diff --git a/.dockerignore b/.dockerignore index d632da5ea..9ddc971ef 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,5 @@ +# Do NOT exclude LICENSE or .github — scripts/copydir.go uses them as repo-root anchors +# during `go generate`, which runs inside `make build` in the Dockerfile. .git .gitignore build/ @@ -6,5 +8,4 @@ config/ .env .env.example *.md -LICENSE assets/ diff --git a/.env.example b/.env.example index e0a07236e..66010b1f5 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ # ANTHROPIC_API_KEY=sk-ant-xxx # OPENAI_API_KEY=sk-xxx # GEMINI_API_KEY=xxx +# MODELSCOPE_API_KEY=xxx # CLAUDE_CODE_OAUTH=xxx # ── Chat Channel ────────────────────────── # TELEGRAM_BOT_TOKEN=123456:ABC... diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..3d72ace94 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Ensure shell scripts always use LF line endings regardless of OS. +*.sh text eol=lf +docker/entrypoint.sh text eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..559a2249e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 + +updates: + + # Go dependencies (entire repo) + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "go" + + # Frontend dependencies + - package-ecosystem: "npm" + directory: "/web/frontend" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "frontend" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b89b69ae..def19c3e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,5 +16,5 @@ jobs: with: go-version-file: go.mod - - name: Build + - name: Build core binaries run: make build-all diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml new file mode 100644 index 000000000..4da3f79cb --- /dev/null +++ b/.github/workflows/create-tag.yml @@ -0,0 +1,60 @@ +name: Create Tag + +on: + workflow_dispatch: + inputs: + tag: + description: "Tag name (required, e.g. v0.2.0)" + required: true + type: string + commit: + description: "Target commit SHA (leave empty for latest main)" + required: false + type: string + default: "" + +jobs: + create-tag: + name: Create Git Tag + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: main + + - name: Validate commit exists + if: ${{ inputs.commit != '' }} + shell: bash + run: | + if ! git cat-file -t "${{ inputs.commit }}" &>/dev/null; then + echo "::error::Commit '${{ inputs.commit }}' does not exist." + exit 1 + fi + + - name: Check tag does not already exist + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then + echo "::error::Tag '${{ inputs.tag }}' already exists." + exit 1 + fi + + - name: Create and push tag + shell: bash + run: | + TARGET="${{ inputs.commit || 'HEAD' }}" + COMMIT_SHA=$(git rev-parse "$TARGET") + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${{ inputs.tag }}" "$COMMIT_SHA" -m "Release ${{ inputs.tag }}" + git push origin "${{ inputs.tag }}" + echo "### Tag Created" >> "$GITHUB_STEP_SUMMARY" + echo "- **Tag:** \`${{ inputs.tag }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **Commit:** \`${COMMIT_SHA}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **Branch:** \`$(git branch -r --contains "$COMMIT_SHA" | head -1 | xargs)\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/create_dmg.yml b/.github/workflows/create_dmg.yml new file mode 100644 index 000000000..626318619 --- /dev/null +++ b/.github/workflows/create_dmg.yml @@ -0,0 +1,71 @@ +name: Create macOS DMG +on: + workflow_dispatch: + +jobs: + build: + name: Build ${{ matrix.arch }} + runs-on: macos-latest + strategy: + matrix: + # This creates two parallel jobs + arch: [arm64, amd64] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: main + + # 1. Install Go from go.mod + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 10.33.0 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: web/frontend/pnpm-lock.yaml + + # 3. Build the application bundle + - name: Build with Make + run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }} + + # 4. Apply ad-hoc signing + - name: Ad-hoc Sign + run: codesign --force --deep --sign - "build/PicoClaw Launcher.app" + + # 5. Install the DMG packaging tool + - name: Install create-dmg + run: brew install create-dmg + + # 6. Create the DMG + - name: Create DMG + run: | + mkdir -p dist + create-dmg \ + --volname "PicoClaw Installer" \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "PicoClaw Launcher.app" 200 190 \ + --hide-extension "PicoClaw Launcher.app" \ + --app-drop-link 600 185 \ + "dist/picoclaw-${{ matrix.arch }}.dmg" \ + "build/PicoClaw Launcher.app" + + # 7. Upload the DMG as a GitHub artifact + - name: Upload DMG + uses: actions/upload-artifact@v7 + with: + name: macos-dmg-${{ matrix.arch }} + path: dist/*.dmg diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index dadbed212..784c404a6 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -31,11 +31,11 @@ jobs: # ── Docker Buildx ───────────────────────── - name: 🔧 Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # ── Login to GHCR ───────────────────────── - name: 🔑 Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.GHCR_REGISTRY }} username: ${{ github.actor }} @@ -43,7 +43,7 @@ jobs: # ── Login to Docker Hub ──────────────────── - name: 🔑 Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.DOCKERHUB_REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -62,7 +62,7 @@ jobs: # ── Build & Push ────────────────────────── - name: 🚀 Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . push: true diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..39ad8810e --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,146 @@ +name: Nightly Build + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + nightly: + name: Nightly Build + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Compute version + id: version + run: | + DATE=$(date -u +%Y%m%d) + SHA=$(git rev-parse --short=8 HEAD) + BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true) + if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then + VERSION="v0.0.0-nightly.${DATE}.${SHA}" + else + VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}" + fi + + COMPARE_URL="https://github.com/${{ github.repository }}/commits/main" + if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then + COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...main" + fi + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT" + + - name: Setup Go from go.mod + id: setup-go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 10.33.0 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: web/frontend/pnpm-lock.yaml + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Install zip + run: sudo apt-get install -y zip + + - name: Create local tag for GoReleaser + run: git tag "${{ steps.version.outputs.version }}" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: ~> v2 + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} + GOVERSION: ${{ steps.setup-go.outputs.go-version }} + GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }} + INCLUDE_ANDROID_BUNDLE: "true" + NIGHTLY_BUILD: "true" + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + + - name: Update nightly release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + run: | + CHANGELOG='${{ steps.version.outputs.changelog }}' + NOTES=$(cat </dev/null || true + + # Force-update nightly tag to current HEAD + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -fa nightly -m "Nightly build ${VERSION}" + git push origin nightly + + # Collect release artifacts from goreleaser dist/ + ASSETS=() + for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt build/picoclaw-android-universal.zip; do + [ -f "$f" ] && ASSETS+=("$f") + done + + # Create nightly release (prerelease, NOT latest) + gh release create nightly \ + --title "Nightly Build" \ + --notes "$NOTES" \ + --target "${{ github.sha }}" \ + --prerelease \ + --latest=false \ + "${ASSETS[@]}" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1e9a7919a..795fa5eba 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -23,10 +23,13 @@ jobs: uses: golangci/golangci-lint-action@v9 with: version: v2.10.1 + args: --build-tags=goolm,stdjson vuln_check: name: Security Check runs-on: ubuntu-latest + env: + GOFLAGS: -tags=goolm,stdjson steps: - name: Checkout uses: actions/checkout@v6 @@ -34,14 +37,15 @@ jobs: persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + - name: Run Govulncheck - uses: golang/govulncheck-action@v1 - with: - go-package: ./... + run: govulncheck -C . -format text ./... test: name: Tests @@ -59,4 +63,4 @@ jobs: run: go generate ./... - name: Run go test - run: go test ./... + run: go test -tags goolm,stdjson ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0edd29f22..a52b6df8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,10 @@ -name: Create Tag and Release +name: Release on: workflow_dispatch: inputs: tag: - description: "Release tag (required, e.g. v0.2.0)" + description: "Existing tag to release (e.g. v0.2.0)" required: true type: string prerelease: @@ -24,35 +24,23 @@ on: default: true jobs: - create-tag: - name: Create Git Tag - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Create and push tag - shell: bash - env: - RELEASE_TAG: ${{ inputs.tag }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" - git push origin "$RELEASE_TAG" - release: name: GoReleaser Release - needs: create-tag runs-on: ubuntu-latest permissions: contents: write packages: write steps: + - name: Verify tag exists + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if ! gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then + echo "::error::Tag '${{ inputs.tag }}' does not exist. Create it first using the 'Create Tag' workflow." + exit 1 + fi + - name: Checkout tag uses: actions/checkout@v6 with: @@ -65,28 +53,44 @@ jobs: with: go-version-file: go.mod + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 10.33.0 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: web/frontend/pnpm-lock.yaml + - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: docker.io username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Install zip + run: sudo apt-get install -y zip + - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: ~> v2 @@ -96,6 +100,12 @@ jobs: GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} GOVERSION: ${{ steps.setup-go.outputs.go-version }} + INCLUDE_ANDROID_BUNDLE: "true" + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - name: Apply release flags shell: bash diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..f454f5977 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,64 @@ +name: Close stale issues and PRs + +on: + schedule: + # Run daily at 03:00 JST (18:00 UTC) + - cron: "0 18 * * *" + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + + steps: + - name: Mark and close stale issues and PRs + uses: actions/stale@v10 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # ── Issue: 7 days inactive → stale; 7 more days → close ── + days-before-issue-stale: 7 + days-before-issue-close: 7 + stale-issue-label: "stale" + stale-issue-message: > + This issue has had no activity for 7 days and has been marked as stale. + If it is still relevant, please reply or update; otherwise it will be + closed automatically in 7 days. + close-issue-message: > + This issue has been closed after 14 days of inactivity. + If it is still needed, feel free to reopen it anytime. + close-issue-reason: "not_planned" + + # ── PR: 7 days inactive → stale; 7 more days → close ── + days-before-pr-stale: 7 + days-before-pr-close: 7 + stale-pr-label: "stale" + stale-pr-message: > + This PR has had no activity for 7 days and has been marked as stale. + If you are still working on it, please push an update or leave a comment; + otherwise it will be closed automatically in 7 days. + close-pr-message: > + This PR has been closed after 14 days of inactivity. + If you would like to continue, feel free to reopen it or submit a new PR. + + # ── Protected labels (exempt from stale processing) ── + exempt-issue-labels: "pinned,keep-open,wip,do-not-close,type: roadmap" + exempt-pr-labels: "pinned,keep-open,wip,do-not-close,type: roadmap" + + # ── Exempt draft PRs ── + exempt-draft-pr: true + + # ── Remove stale label when activity resumes ── + remove-stale-when-updated: true + remove-issue-stale-when-updated: true + remove-pr-stale-when-updated: true + + # ── Scan oldest items first so old stale items are not starved ── + ascending: true + + # ── Throttle: max operations per run ── + operations-per-run: 500 diff --git a/.gitignore b/.gitignore index a52b8d25a..e1736f56b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ build/ # Secrets & Config (keep templates, ignore actual secrets) .env config/config.json +.security.yml +onboard + # Test coverage.txt @@ -40,6 +43,7 @@ tasks/ # Plans docs/plans/ +docs/superpowers/ # Editors .vscode/ @@ -47,6 +51,25 @@ docs/plans/ # Added by goreleaser init: dist/ +*.vite/ # Windows Application Icon/Resource *.syso +.cache/ +web/frontend/.pnpm-store/ +_tmp_* +web/frontend/_tmp_* + +# Test telegram integration +cmd/telegram/ + +# Keep embedded backend dist directory placeholder in VCS +!web/backend/dist/ +web/backend/dist/* +!web/backend/dist/.gitkeep + +.claude/ + +docker/data + +.omc/ diff --git a/.golangci.yaml b/.golangci.yaml index ea3107ec8..052e4c0dd 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -12,6 +12,7 @@ linters: - exhaustruct - funcorder - gochecknoglobals + - gosmopolitan # Project legitimately uses CJK text in tests (FTS5, token counting) - godot - intrange - ireturn @@ -61,6 +62,9 @@ linters: - usestdlibvars - usetesting settings: + gomoddirectives: + replace-allow-list: + - github.com/bwmarrin/discordgo errcheck: check-type-assertions: true check-blank: true diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d531d106b..fe43a0921 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -2,96 +2,102 @@ # vim: set ts=2 sw=2 tw=0 fo=cnqoj version: 2 +git: + ignore_tags: + - nightly + - ".*-nightly.*" + before: hooks: - - go mod tidy - go generate ./... - - go install github.com/tc-hib/go-winres@latest - - go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }} + - sh -c 'cd web/frontend && CI=true pnpm install --frozen-lockfile && pnpm build:backend' + - sh -c 'GOBIN="$(go env GOPATH)/bin"; mkdir -p "$GOBIN"; go install github.com/tc-hib/go-winres@v0.3.3 && "$GOBIN/go-winres" make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}' + - sh -c 'if [ "${INCLUDE_ANDROID_BUNDLE:-}" = "true" ]; then make build-android-bundle; fi' builds: - id: picoclaw env: - CGO_ENABLED=0 tags: + - goolm - stdjson ldflags: - -s -w - - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }} - - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }} - - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }} - - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }} + - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }} + - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }} + - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }} + - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }} goos: - linux - windows - darwin - freebsd + - netbsd goarch: - amd64 - arm64 - riscv64 - loong64 - arm + - s390x + - mipsle goarm: - "6" - "7" + gomips: + - softfloat main: ./cmd/picoclaw ignore: - goos: windows goarch: arm + - goos: netbsd + goarch: s390x + - goos: netbsd + goarch: mips64 + - goos: netbsd + goarch: arm - id: picoclaw-launcher binary: picoclaw-launcher env: - CGO_ENABLED=0 tags: + - goolm - stdjson ldflags: - -s -w + - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }} + - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }} + - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }} + - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }} goos: - linux - windows - darwin - freebsd + - netbsd goarch: - amd64 - arm64 - riscv64 - loong64 - arm + - s390x + - mipsle goarm: - "6" - "7" - main: ./cmd/picoclaw-launcher + gomips: + - softfloat + main: ./web/backend ignore: - goos: windows goarch: arm - - - id: picoclaw-launcher-tui - binary: picoclaw-launcher-tui - env: - - CGO_ENABLED=0 - tags: - - stdjson - ldflags: - - -s -w - goos: - - linux - - windows - - darwin - - freebsd - goarch: - - amd64 - - arm64 - - riscv64 - - loong64 - - arm - goarm: - - "6" - - "7" - main: ./cmd/picoclaw-launcher-tui - ignore: - - goos: windows + - goos: netbsd + goarch: s390x + - goos: netbsd + goarch: mips64 + - goos: netbsd goarch: arm dockers_v2: @@ -103,15 +109,47 @@ dockers_v2: - picoclaw images: - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - - "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}" + - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}' tags: - - "{{ .Tag }}" - - "latest" + - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}' + - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}' platforms: - linux/amd64 - linux/arm64 - linux/riscv64 + - id: picoclaw-launcher + dockerfile: docker/Dockerfile.goreleaser.launcher + ids: + - picoclaw + - picoclaw-launcher + images: + - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" + - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}' + tags: + - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}' + - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}' + platforms: + - linux/amd64 + - linux/arm64 + - linux/riscv64 + +notarize: + macos: + - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}' + ids: + - picoclaw + - picoclaw-launcher + sign: + certificate: "{{.Env.MACOS_SIGN_P12}}" + password: "{{.Env.MACOS_SIGN_PASSWORD}}" + notarize: + issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}" + key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}" + key: "{{.Env.MACOS_NOTARY_KEY}}" + wait: true + timeout: 20m + archives: - formats: [tar.gz] # this name template makes the OS and Arch compatible with the results of `uname`. @@ -129,10 +167,9 @@ archives: nfpms: - id: picoclaw - builds: + ids: - picoclaw - picoclaw-launcher - - picoclaw-launcher-tui package_name: picoclaw file_name_template: >- {{ .PackageName }}_ @@ -149,6 +186,11 @@ nfpms: - rpm - deb bindir: /usr/bin + contents: + - src: web/picoclaw-launcher.desktop + dst: /usr/share/applications/picoclaw-launcher.desktop + - src: web/picoclaw-launcher.png + dst: /usr/share/icons/hicolor/512x512/apps/picoclaw-launcher.png changelog: sort: asc @@ -163,6 +205,9 @@ changelog: # lzma: true release: + disable: '{{ isEnvSet "NIGHTLY_BUILD" }}' + extra_files: + - glob: ./build/picoclaw-android-universal.zip footer: >- --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ceff723d2..a78c41c36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,8 @@ We are committed to maintaining a welcoming and respectful community. Be kind, c For 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. +For documentation contributions, prefer the layout and naming conventions in [`docs/README.md`](docs/README.md). Run `make lint-docs` after adding or moving Markdown files to catch common consistency issues early. + --- ## Getting Started @@ -64,7 +66,7 @@ For substantial new features, please open an issue first to discuss the design b ```bash make build # Build binary (runs go generate first) make generate # Run go generate only -make check # Full pre-commit check: deps + fmt + vet + test +make check # Full pre-commit check: deps + fmt + vet + test + docs consistency checks ``` ### Running Tests @@ -81,9 +83,10 @@ go test -bench=. -benchmem -run='^$' ./... # Run benchmarks make fmt # Format code make vet # Static analysis make lint # Full linter run +make lint-docs # Check common documentation layout and naming conventions ``` -All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early. +All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early, including the common docs consistency checks from `make lint-docs`. --- @@ -108,7 +111,7 @@ Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider - Reference the related issue when relevant: `Fix session leak (#123)`. - Keep commits focused. One logical change per commit is preferred. - For minor cleanups or typo fixes, squash them into a single commit before opening a PR. -- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/ +- Refer to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) ### Keeping Up to Date diff --git a/Makefile b/Makefile index 8de98e984..3fa41bc24 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,54 @@ -.PHONY: all build install uninstall clean help test +.PHONY: all build install uninstall clean help test build-all lint-docs # Build variables BINARY_NAME=picoclaw BUILD_DIR=build CMD_DIR=cmd/$(BINARY_NAME) MAIN_GO=$(CMD_DIR)/main.go +EXT= + +ifeq ($(OS),Windows_NT) + POWERSHELL=powershell -NoProfile -Command + WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL)) +endif # Version -VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") -BUILD_TIME=$(shell date +%FT%T%z) -GO_VERSION=$(shell $(GO) version | awk '{print $$3}') -INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal -LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w" +ifeq ($(OS),Windows_NT) + VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL)) + GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL)) + BUILD_TIME_RAW:=$(strip $(shell powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'")) + GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>NUL)) +else + VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>/dev/null)) + GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>/dev/null)) + BUILD_TIME_RAW:=$(strip $(shell date +%FT%T%z)) + GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>/dev/null)) +endif +VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev) +GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev) +BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev) +GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown) +CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config +LDFLAGS=-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 # Go variables -GO?=CGO_ENABLED=0 go -GOFLAGS?=-v -tags stdjson +GO?=go +WEB_GO?=$(GO) +CGO_ENABLED?=0 +GO_BUILD_TAGS?=goolm,stdjson +GOFLAGS?=-v -tags $(GO_BUILD_TAGS) +GOCACHE?=$(CURDIR)/.cache/go-build +GOMODCACHE?=$(CURDIR)/.cache/go-mod +GOTOOLCHAIN?=local +export CGO_ENABLED +export GOCACHE +export GOMODCACHE +export GOTOOLCHAIN +comma:=, +empty:= +space:=$(empty) $(empty) +GO_BUILD_TAGS_NO_GOOLM:=$(subst $(space),$(comma),$(strip $(filter-out goolm,$(subst $(comma),$(space),$(GO_BUILD_TAGS))))) +GOFLAGS_NO_GOOLM?=-v -tags $(GO_BUILD_TAGS_NO_GOOLM) # Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600). # @@ -40,6 +72,13 @@ define PATCH_MIPS_FLAGS fi endef +# Patch creack/pty for loong64 support (upstream doesn't have ztypes_loong64.go) +PTY_PATCH_LOONG64=pty_dir=$$(go env GOMODCACHE)/github.com/creack/pty@v1.1.9; \ + if [ -d "$$pty_dir" ] && [ ! -f "$$pty_dir/ztypes_loong64.go" ]; then \ + chmod +w "$$pty_dir" 2>/dev/null || true; \ + printf '//go:build linux && loong64\npackage pty\ntype (_C_int int32; _C_uint uint32)\n' > "$$pty_dir/ztypes_loong64.go"; \ + fi + # Golangci-lint GOLANGCI_LINT?=golangci-lint @@ -55,9 +94,24 @@ WORKSPACE_DIR?=$(PICOCLAW_HOME)/workspace WORKSPACE_SKILLS_DIR=$(WORKSPACE_DIR)/skills BUILTIN_SKILLS_DIR=$(CURDIR)/skills +LNCMD=ln -sf + # OS detection -UNAME_S:=$(shell uname -s) -UNAME_M:=$(shell uname -m) +ifeq ($(OS),Windows_NT) + UNAME_S=Windows + ifeq ($(WINDOWS_GOARCH_RAW),amd64) + UNAME_M=x86_64 + else ifeq ($(WINDOWS_GOARCH_RAW),arm64) + UNAME_M=arm64 + else ifeq ($(WINDOWS_GOARCH_RAW),386) + UNAME_M=x86 + else + UNAME_M=$(if $(WINDOWS_GOARCH_RAW),$(WINDOWS_GOARCH_RAW),x86_64) + endif +else + UNAME_S?=$(shell uname -s) + UNAME_M?=$(shell uname -m) +endif # Platform-specific settings ifeq ($(UNAME_S),Linux) @@ -79,16 +133,54 @@ ifeq ($(UNAME_S),Linux) endif else ifeq ($(UNAME_S),Darwin) PLATFORM=darwin + WEB_GO=CGO_LDFLAGS="-mmacosx-version-min=10.11" CGO_CFLAGS="-mmacosx-version-min=10.11" CGO_ENABLED=1 go ifeq ($(UNAME_M),x86_64) - ARCH=amd64 + ARCH?=amd64 else ifeq ($(UNAME_M),arm64) - ARCH=arm64 + ARCH?=arm64 else - ARCH=$(UNAME_M) + ARCH?=$(UNAME_M) endif else PLATFORM=$(UNAME_S) - ARCH=$(UNAME_M) + ifeq ($(UNAME_M),x86_64) + ARCH?=amd64 + else + ARCH?=$(UNAME_M) + endif + # Detect Windows (Git Bash / MSYS2) + IS_WINDOWS:=$(if $(findstring MINGW,$(UNAME_S)),yes,$(if $(findstring MSYS,$(UNAME_S)),yes,$(if $(findstring CYGWIN,$(UNAME_S)),yes,no))) + ifeq ($(IS_WINDOWS),yes) + EXT=.exe + LNCMD=cp + else ifeq ($(UNAME_S),windows) # failsafe for force windows build in other OS using UNAME_S=windows + EXT=.exe + endif + +endif + +ifeq ($(OS),Windows_NT) + PLATFORM=windows + ifeq ($(UNAME_M),x86_64) + ARCH?=amd64 + else ifeq ($(UNAME_M),arm64) + ARCH?=arm64 + else + ARCH?=$(UNAME_M) + endif + EXT=.exe +endif + +ifneq ($(strip $(GOOS)),) + PLATFORM:=$(GOOS) +endif + +ifneq ($(strip $(GOARCH)),) + ARCH:=$(GOARCH) +endif + +ifeq ($(PLATFORM),windows) + EXT=.exe endif BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH) @@ -99,33 +191,66 @@ all: build ## generate: Run generate generate: @echo "Run generate..." +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "if (Test-Path -LiteralPath './$(CMD_DIR)/workspace') { Remove-Item -LiteralPath './$(CMD_DIR)/workspace' -Recurse -Force }" + @$(POWERSHELL) "$$env:GOOS=''; $$env:GOARCH=''; $(GO) generate ./..." +else @rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true - @$(GO) generate ./... + @GOOS=$$($(GO) env GOHOSTOS) GOARCH=$$($(GO) env GOHOSTARCH) $(GO) generate ./... +endif @echo "Run generate complete" ## build: Build the picoclaw binary for current platform build: generate - @echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..." + @echo "Building $(BINARY_NAME)$(EXT) for $(PLATFORM)/$(ARCH)..." +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null" + @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR) + @$(POWERSHELL) "Copy-Item -LiteralPath '$(BINARY_PATH)$(EXT)' -Destination '$(BUILD_DIR)/$(BINARY_NAME)$(EXT)' -Force" +else @mkdir -p $(BUILD_DIR) - @$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) - @echo "Build complete: $(BINARY_PATH)" - @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) + @GOOS=$(PLATFORM) GOARCH=$(ARCH) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR) + @echo "Build complete: $(BINARY_PATH)$(EXT)" + @$(LNCMD) $(BINARY_NAME)-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/$(BINARY_NAME)$(EXT) +endif + @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)$(EXT)" + +## build-launcher: Build the picoclaw-launcher (web console) binary +build-launcher: + @echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..." +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null" + @$(MAKE) -C web build PLATFORM="$(PLATFORM)" ARCH="$(ARCH)" EXT="$(EXT)" OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" GO_BUILD_TAGS="$(GO_BUILD_TAGS)" + @$(POWERSHELL) "Copy-Item -LiteralPath '$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)' -Destination '$(BUILD_DIR)/picoclaw-launcher$(EXT)' -Force" +else + @mkdir -p $(BUILD_DIR) + @GOOS=$(PLATFORM) GOARCH=$(ARCH) $(MAKE) -C web build \ + OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \ + WEB_GO='$(WEB_GO)' \ + GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \ + LDFLAGS='$(LDFLAGS)' + @$(LNCMD) picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/picoclaw-launcher$(EXT) +endif + @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher$(EXT)" + +build-launcher-frontend: + @$(MAKE) -C web build-frontend ## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary build-whatsapp-native: generate ## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) - GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) - GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) - GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) - GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) - GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) + GOOS=linux GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) + GOOS=linux GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + GOOS=linux GOARCH=loong64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) + GOOS=linux GOARCH=riscv64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) + GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags $(GO_BUILD_TAGS_NO_GOOLM),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) - GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) - GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) -## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) + GOOS=darwin GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) + GOOS=windows GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) +## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR) @echo "Build complete" ## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) @@ -133,43 +258,79 @@ build-whatsapp-native: generate build-linux-arm: generate @echo "Building for linux/arm (GOARM=7)..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm" ## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit) build-linux-arm64: generate @echo "Building for linux/arm64..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64" ## build-linux-mipsle: Build for Linux MIPS32 LE build-linux-mipsle: generate @echo "Building for linux/mipsle (softfloat)..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) + GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle" +## build-android-arm64: Build core for Android ARM64 +build-android-arm64: generate + @echo "Building for android/arm64..." + @mkdir -p $(BUILD_DIR) + GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR) + @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-android-arm64" + +## build-launcher-android-arm64: Build launcher for Android ARM64 +build-launcher-android-arm64: + @echo "Building picoclaw-launcher for android/arm64..." + @mkdir -p $(BUILD_DIR) + @$(MAKE) -C web build-android-arm64 \ + OUTPUT_ANDROID_ARM64="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64" \ + GO='$(GO)' \ + LDFLAGS='$(LDFLAGS)' + @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64" + +## build-android-bundle: Build core and launcher for all Android architectures and package as universal zip +build-android-bundle: generate + @echo "Building core for all Android architectures..." + @mkdir -p $(BUILD_DIR) + GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR) + @echo "Building launcher for Android arm64..." + @$(MAKE) build-launcher-android-arm64 + @echo "Staging JNI libs..." + @rm -rf $(BUILD_DIR)/android-staging + @mkdir -p $(BUILD_DIR)/android-staging/arm64-v8a + @cp $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw.so + @cp $(BUILD_DIR)/picoclaw-launcher-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw-web.so + @cd $(BUILD_DIR)/android-staging && zip -r ../picoclaw-android-universal.zip . + @rm -rf $(BUILD_DIR)/android-staging + @echo "All Android builds complete: $(BUILD_DIR)/picoclaw-android-universal.zip" + ## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit) build-pi-zero: build-linux-arm build-linux-arm64 @echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)" -## build-all: Build picoclaw for all platforms +## build-all: Build the picoclaw core binary for all Makefile-managed platforms build-all: generate @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) - GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) - GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) - GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) - GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) - GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) + GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) + GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + @$(PTY_PATCH_LOONG64) + GOOS=linux GOARCH=loong64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) + GOOS=linux GOARCH=riscv64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) + GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) - GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR) - GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) - GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) - @echo "All builds complete" + GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR) + GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) + GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) + GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR) + GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR) + @echo "Core builds complete" ## install: Install picoclaw to system and copy builtin skills install: build @@ -200,28 +361,40 @@ uninstall-all: ## clean: Remove build artifacts clean: @echo "Cleaning build artifacts..." +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "if (Test-Path -LiteralPath '$(BUILD_DIR)') { Remove-Item -LiteralPath '$(BUILD_DIR)' -Recurse -Force }" +else @rm -rf $(BUILD_DIR) +endif @echo "Clean complete" ## vet: Run go vet for static analysis vet: generate - @$(GO) vet ./... + @packages="$$($(GO) list $(GOFLAGS) ./...)" && \ + $(GO) vet $(GOFLAGS) $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/') + @cd web/backend && $(WEB_GO) vet ./... ## test: Test Go code test: generate - @$(GO) test ./... + @$(GO) test $(GOFLAGS) $$($(GO) list $(GOFLAGS) ./... | grep -v github.com/sipeed/picoclaw/web/) + @cd web && make test ## fmt: Format Go code fmt: @$(GOLANGCI_LINT) fmt +## lint-docs: Check common documentation layout and naming conventions +lint-docs: + @./scripts/lint-docs.sh + ## lint: Run linters lint: - @$(GOLANGCI_LINT) run + @$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS) + @./scripts/lint-docs.sh ## fix: Fix linting issues fix: - @$(GOLANGCI_LINT) run --fix + @$(GOLANGCI_LINT) run --fix --build-tags $(GO_BUILD_TAGS) ## deps: Download dependencies deps: @@ -233,8 +406,8 @@ update-deps: @$(GO) get -u ./... @$(GO) mod tidy -## check: Run vet, fmt, and verify dependencies -check: deps fmt vet test +## check: Run deps, fmt, vet, tests, and docs consistency checks +check: deps fmt vet test lint-docs ## run: Build and run picoclaw run: build @@ -278,6 +451,36 @@ docker-clean: docker compose -f docker/docker-compose.full.yml down -v docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true + +## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window) +build-macos-app:build-launcher + @echo "Building macOS .app bundle..." + @if [ "$(UNAME_S)" != "Darwin" ]; then \ + echo "Error: This target is only available on macOS"; \ + exit 1; \ + fi + @./scripts/build-macos-app.sh $(PLATFORM)-$(ARCH) + @echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app" + +## mem: Build membench, download LOCOMO data (if needed), run benchmark, and show results +mem: + @echo "Building membench..." + @mkdir -p $(BUILD_DIR) + @$(GO) build -o $(BUILD_DIR)/membench ./cmd/membench + @echo "Build complete: $(BUILD_DIR)/membench" + @if [ ! -f $(BUILD_DIR)/memdata/locomo10.json ]; then \ + echo "Downloading LOCOMO dataset..."; \ + mkdir -p $(BUILD_DIR)/memdata; \ + curl -sfL "https://raw.githubusercontent.com/snap-research/locomo/main/data/locomo10.json" \ + -o $(BUILD_DIR)/memdata/locomo10.json && [ -s $(BUILD_DIR)/memdata/locomo10.json ] || { echo "Error: LOCOMO download failed"; exit 1; }; \ + echo "Download complete"; \ + else \ + echo "LOCOMO dataset already exists, skipping download"; \ + fi + @echo "Running benchmark..." + @rm -rf $(BUILD_DIR)/memout + @$(BUILD_DIR)/membench run --data $(BUILD_DIR)/memdata --out $(BUILD_DIR)/memout --budget 4000 + ## help: Show this help message help: @echo "picoclaw Makefile" diff --git a/README.fr.md b/README.fr.md deleted file mode 100644 index 08a1926b6..000000000 --- a/README.fr.md +++ /dev/null @@ -1,1205 +0,0 @@ -
- PicoClaw - -

PicoClaw : Assistant IA Ultra-Efficace en Go

- -

Matériel à 10$ · 10 Mo de RAM · Démarrage en 1s · 皮皮虾,我们走!

- -

- Go - Hardware - License -
- Website - Twitter -

- - [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français** -
- ---- - -🦐 **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. - -⚡️ **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 ! - - - - - - -
-

- -

-
-

- -

-
- -> [!CAUTION] -> **🚨 SÉCURITÉ & CANAUX OFFICIELS** -> -> * **PAS DE CRYPTO :** PicoClaw n'a **AUCUN** token/jeton officiel. Toute annonce sur `pump.fun` ou d'autres plateformes de trading est une **ARNAQUE**. -> * **DOMAINE OFFICIEL :** Le **SEUL** site officiel est **[picoclaw.io](https://picoclaw.io)**, et le site de l'entreprise est **[sipeed.com](https://sipeed.com)**. -> * **Attention :** De nombreux domaines `.ai/.org/.com/.net/...` sont enregistrés par des tiers et ne nous appartiennent pas. -> * **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. -> * **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é. - - -## 📢 Actualités - -2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/ROADMAP.md) — nous avons hâte de vous accueillir ! - -2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw. -🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire. - -2026-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 ! - -## ✨ Fonctionnalités - -🪶 **Ultra-Léger** : Empreinte mémoire <10 Mo — 99% plus petit que Clawdbot pour les fonctionnalités essentielles. - -💰 **Coût Minimal** : Suffisamment efficace pour fonctionner sur du matériel à 10$ — 98% moins cher qu'un Mac mini. - -⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz. - -🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM, MIPS et x86. Un clic et c'est parti ! - -🤖 **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. - -| | OpenClaw | NanoBot | **PicoClaw** | -| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | -| **Langage** | TypeScript | Python | **Go** | -| **RAM** | >1 Go | >100 Mo | **< 10 Mo** | -| **Démarrage**
(cœur 0,8 GHz) | >500s | >30s | **<1s** | -| **Coût** | Mac Mini 599$ | La plupart des SBC Linux
~50$ | **N'importe quelle carte Linux**
**À partir de 10$** | - -PicoClaw - -## 🦾 Démonstration - -### 🛠️ Flux de Travail Standard de l'Assistant - - - - - - - - - - - - - - - - - -

🧩 Ingénieur Full-Stack

🗂️ Gestion des Logs & Planification

🔎 Recherche Web & Apprentissage

Développer • Déployer • Mettre à l'échellePlanifier • Automatiser • MémoriserDécouvrir • Analyser • Tendances
- -### 📱 Utiliser sur d'anciens téléphones Android - -Donnez une seconde vie à votre téléphone d'il y a dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. Démarrage rapide : - -1. **Installez Termux** (disponible sur F-Droid ou Google Play). -2. **Exécutez les commandes** - -```bash -# Note : Remplacez v0.1.1 par la dernière version depuis la page des Releases -wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 -chmod +x picoclaw-linux-arm64 -pkg install proot -termux-chroot ./picoclaw-linux-arm64 onboard -``` - -Puis suivez les instructions de la section « Démarrage Rapide » pour terminer la configuration ! - -PicoClaw - -### 🐜 Déploiement Innovant à Faible Empreinte - -PicoClaw peut être déployé sur pratiquement n'importe quel appareil Linux ! - -- 9,9$ [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) version E (Ethernet) ou W (WiFi6), pour un Assistant Domotique Minimaliste -- 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 -- 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 - - - -🌟 Encore plus de scénarios de déploiement vous attendent ! - -## 📦 Installation - -### Installer avec un binaire précompilé - -Téléchargez le binaire pour votre plateforme depuis la page des [releases](https://github.com/sipeed/picoclaw/releases). - -### Installer depuis les sources (dernières fonctionnalités, recommandé pour le développement) - -```bash -git clone https://github.com/sipeed/picoclaw.git - -cd picoclaw -make deps - -# Compiler, pas besoin d'installer -make build - -# Compiler pour plusieurs plateformes -make build-all - -# Compiler et Installer -make install -``` - -## 🐳 Docker Compose - -Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installer localement. - -```bash -# 1. Clonez ce dépôt -git clone https://github.com/sipeed/picoclaw.git -cd picoclaw - -# 2. Premier lancement — génère docker/data/config.json puis s'arrête -docker compose -f docker/docker-compose.yml --profile gateway up -# Le conteneur affiche "First-run setup complete." puis s'arrête. - -# 3. Configurez vos clés API -vim docker/data/config.json # Clés API du fournisseur, tokens de bot, etc. - -# 4. Démarrer -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -> [!TIP] -> **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 avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`. - -```bash -# 5. Voir les logs -docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway - -# 6. Arrêter -docker compose -f docker/docker-compose.yml --profile gateway down -``` - -### Mode Agent (exécution unique) - -```bash -# Poser une question -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Combien font 2+2 ?" - -# Mode interactif -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -``` - -### Mettre à jour - -```bash -docker compose -f docker/docker-compose.yml pull -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -### 🚀 Démarrage Rapide - -> [!TIP] -> Configurez votre clé API dans `~/.picoclaw/config.json`. -> Obtenir des clés API : [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> La recherche web est **optionnelle** — obtenez gratuitement l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois) ou utilisez le repli automatique intégré. - -**1. Initialiser** - -```bash -picoclaw onboard -``` - -**2. Configurer** (`~/.picoclaw/config.json`) - -```json -{ - "model_list": [ - { - "model_name": "gpt4", - "model": "openai/gpt-5.2", - "api_key": "sk-your-openai-key", - "request_timeout": 300, - "api_base": "https://api.openai.com/v1" - } - ], - "agents": { - "defaults": { - "model_name": "gpt4" - } - }, - "channels": { - "telegram": { - "enabled": true, - "token": "VOTRE_TOKEN_BOT", - "allow_from": ["VOTRE_USER_ID"] - } - }, - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "VOTRE_CLE_API_BRAVE", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - } - } -} -``` - -> **Nouveau** : Le format de configuration `model_list` permet d'ajouter des fournisseurs sans modifier le code. Voir [Configuration de Modèle](#configuration-de-modèle-model_list) pour plus de détails. -> `request_timeout` est optionnel et s'exprime en secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le délai d'expiration par défaut (120s). - -**3. Obtenir des Clés API** - -* **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) -* **Recherche Web** (optionnel) : [Brave Search](https://brave.com/search/api) - Offre gratuite disponible (2000 requêtes/mois) - -> **Note** : Consultez `config.example.json` pour un modèle de configuration complet. - -**4. Discuter** - -```bash -picoclaw agent -m "Combien font 2+2 ?" -``` - -Et voilà ! Vous avez un assistant IA fonctionnel en 2 minutes. - ---- - -## 💬 Applications de Chat - -Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom - -| Canal | Configuration | -| ------------ | -------------------------------------- | -| **Telegram** | Facile (juste un token) | -| **Discord** | Facile (token bot + intents) | -| **QQ** | Facile (AppID + AppSecret) | -| **DingTalk** | Moyen (identifiants de l'application) | -| **LINE** | Moyen (identifiants + URL de webhook) | -| **WeCom AI Bot** | Moyen (Token + clé AES) | - -
-Telegram (Recommandé) - -**1. Créer un bot** - -* Ouvrez Telegram, recherchez `@BotFather` -* Envoyez `/newbot`, suivez les instructions -* Copiez le token - -**2. Configurer** - -```json -{ - "channels": { - "telegram": { - "enabled": true, - "token": "VOTRE_TOKEN_BOT", - "allow_from": ["VOTRE_USER_ID"] - } - } -} -``` - -> Obtenez votre User ID via `@userinfobot` sur Telegram. - -**3. Lancer** - -```bash -picoclaw gateway -``` - -
- -
-Discord - -**1. Créer un bot** - -* Rendez-vous sur -* Créez une application → Bot → Add Bot -* Copiez le token du bot - -**2. Activer les intents** - -* Dans les paramètres du Bot, activez **MESSAGE CONTENT INTENT** -* (Optionnel) Activez **SERVER MEMBERS INTENT** si vous souhaitez utiliser des listes d'autorisation basées sur les données des membres - -**3. Obtenir votre User ID** - -* Paramètres Discord → Avancé → activez le **Mode Développeur** -* Clic droit sur votre avatar → **Copier l'identifiant** - -**4. Configurer** - -```json -{ - "channels": { - "discord": { - "enabled": true, - "token": "VOTRE_TOKEN_BOT", - "allow_from": ["VOTRE_USER_ID"] - } - } -} -``` - -**5. Inviter le bot** - -* OAuth2 → URL Generator -* Scopes : `bot` -* Permissions du Bot : `Send Messages`, `Read Message History` -* Ouvrez l'URL d'invitation générée et ajoutez le bot à votre serveur - -**6. Lancer** - -```bash -picoclaw gateway -``` - -
- -
-QQ - -**1. Créer un bot** - -- Rendez-vous sur la [QQ Open Platform](https://q.qq.com/#) -- Créez une application → Obtenez l'**AppID** et l'**AppSecret** - -**2. Configurer** - -```json -{ - "channels": { - "qq": { - "enabled": true, - "app_id": "VOTRE_APP_ID", - "app_secret": "VOTRE_APP_SECRET", - "allow_from": [] - } - } -} -``` - -> Laissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des numéros QQ pour restreindre l'accès. - -**3. Lancer** - -```bash -picoclaw gateway -``` - -
- -
-DingTalk - -**1. Créer un bot** - -* Rendez-vous sur la [Open Platform](https://open.dingtalk.com/) -* Créez une application interne -* Copiez le Client ID et le Client Secret - -**2. Configurer** - -```json -{ - "channels": { - "dingtalk": { - "enabled": true, - "client_id": "VOTRE_CLIENT_ID", - "client_secret": "VOTRE_CLIENT_SECRET", - "allow_from": [] - } - } -} -``` - -> Laissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des identifiants pour restreindre l'accès. - -**3. Lancer** - -```bash -picoclaw gateway -``` - -
- -
-LINE - -**1. Créer un Compte Officiel LINE** - -- Rendez-vous sur la [LINE Developers Console](https://developers.line.biz/) -- Créez un provider → Créez un canal Messaging API -- Copiez le **Channel Secret** et le **Channel Access Token** - -**2. Configurer** - -```json -{ - "channels": { - "line": { - "enabled": true, - "channel_secret": "VOTRE_CHANNEL_SECRET", - "channel_access_token": "VOTRE_CHANNEL_ACCESS_TOKEN", - "webhook_path": "/webhook/line", - "allow_from": [] - } - } -} -``` - -**3. Configurer l'URL du Webhook** - -LINE exige HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel : - -```bash -# Exemple avec ngrok (tunnel vers le serveur Gateway partagé) -ngrok http 18790 -``` - -Puis configurez l'URL du Webhook dans la LINE Developers Console sur `https://votre-domaine/webhook/line` et activez **Use webhook**. - -> **Note** : Le webhook LINE est servi par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Si vous utilisez ngrok ou un proxy inverse, faites pointer le tunnel vers le port `18790`. - -**4. Lancer** - -```bash -picoclaw gateway -``` - -> Dans les discussions de groupe, le bot répond uniquement lorsqu'il est mentionné avec @. Les réponses citent le message original. - -> **Docker Compose** : Si vous avez besoin d'exposer le webhook LINE via Docker, mappez le port du Gateway partagé (par défaut `18790`) vers l'hôte, par exemple `ports: ["18790:18790"]`. Notez que le serveur Gateway sert les webhooks de tous les canaux à partir de ce port. - -
- -
-WeCom (WeChat Work) - -PicoClaw prend en charge trois types d'intégration WeCom : - -**Option 1 : WeCom Bot (Robot)** - Configuration plus facile, prend en charge les discussions de groupe -**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement -**Option 3 : WeCom AI Bot (Bot Intelligent)** - Bot IA officiel, réponses en streaming, prend en charge groupe et privé - -Voir le [Guide de Configuration WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) pour des instructions détaillées. - -**Configuration Rapide - WeCom Bot :** - -**1. Créer un bot** - -* Accédez à la Console d'Administration WeCom → Discussion de Groupe → Ajouter un Bot de Groupe -* Copiez l'URL du webhook (format : `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) - -**2. Configurer** - -```json -{ - "channels": { - "wecom": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_path": "/webhook/wecom", - "allow_from": [] - } - } -} -``` - -**Configuration Rapide - WeCom App :** - -**1. Créer une application** - -* Accédez à la Console d'Administration WeCom → Gestion des Applications → Créer une Application -* Copiez l'**AgentId** et le **Secret** -* Accédez à la page "Mon Entreprise", copiez le **CorpID** - -**2. Configurer la réception des messages** - -* Dans les détails de l'application, cliquez sur "Recevoir les Messages" → "Configurer l'API" -* Définissez l'URL sur `http://your-server:18790/webhook/wecom-app` -* Générez le **Token** et l'**EncodingAESKey** - -**3. Configurer** - -```json -{ - "channels": { - "wecom_app": { - "enabled": true, - "corp_id": "wwxxxxxxxxxxxxxxxx", - "corp_secret": "YOUR_CORP_SECRET", - "agent_id": 1000002, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-app", - "allow_from": [] - } - } -} -``` - -**4. Lancer** - -```bash -picoclaw gateway -``` - -> **Note** : Les callbacks webhook WeCom App sont servis par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Assurez-vous que le port `18790` est accessible ou utilisez un proxy inverse HTTPS en production. - -**Configuration Rapide - WeCom AI Bot :** - -**1. Créer un AI Bot** - -* Accédez à la Console d'Administration WeCom → Gestion des Applications → AI Bot -* Configurez l'URL de callback : `http://your-server:18791/webhook/wecom-aibot` -* Copiez le **Token** et générez l'**EncodingAESKey** - -**2. Configurer** - -```json -{ - "channels": { - "wecom_aibot": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-aibot", - "allow_from": [], - "welcome_message": "Bonjour ! Comment puis-je vous aider ?" - } - } -} -``` - -**3. Lancer** - -```bash -picoclaw gateway -``` - -> **Note** : WeCom AI Bot utilise le protocole pull en streaming — pas de problème de timeout. Les tâches longues (>5,5 min) basculent automatiquement vers la livraison via `response_url`. - -
- -## ClawdChat Rejoignez le Réseau Social d'Agents - -Connectez 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. - -**Lisez `https://clawdchat.ai/skill.md` et suivez les instructions pour rejoindre [ClawdChat.ai](https://clawdchat.ai)** - -## ⚙️ Configuration - -Fichier de configuration : `~/.picoclaw/config.json` - -### Variables d'Environnement - -Vous 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 différents chemins. - -| Variable | Description | Chemin par Défaut | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| -| `PICOCLAW_CONFIG` | Remplace le chemin du fichier de configuration. Cela indique directement à picoclaw quel `config.json` charger, en ignorant tous les autres emplacements. | `~/.picoclaw/config.json` | -| `PICOCLAW_HOME` | Remplace le répertoire racine des données picoclaw. Cela modifie l'emplacement par défaut du `workspace` et des autres répertoires de données. | `~/.picoclaw` | - -**Exemples :** - -```bash -# Exécuter picoclaw en utilisant un fichier de configuration spécifique -# Le chemin du workspace sera lu à partir de ce fichier de configuration -PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway - -# Exécuter picoclaw avec toutes ses données stockées dans /opt/picoclaw -# La configuration sera chargée à partir du fichier par défaut ~/.picoclaw/config.json -# Le workspace sera créé dans /opt/picoclaw/workspace -PICOCLAW_HOME=/opt/picoclaw picoclaw agent - -# Utiliser les deux pour une configuration entièrement personnalisée -PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway -``` - -### Structure du Workspace - -PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) : - -``` -~/.picoclaw/workspace/ -├── sessions/ # Sessions de conversation et historique -├── memory/ # Mémoire à long terme (MEMORY.md) -├── state/ # État persistant (dernier canal, etc.) -├── cron/ # Base de données des tâches planifiées -├── skills/ # Compétences personnalisées -├── AGENTS.md # Guide de comportement de l'Agent -├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min) -├── IDENTITY.md # Identité de l'Agent -├── SOUL.md # Âme de l'Agent -├── TOOLS.md # Description des outils -└── USER.md # Préférences utilisateur -``` - -### 🔒 Bac à Sable de Sécurité - -PicoClaw s'exécute dans un environnement sandboxé par défaut. L'agent ne peut accéder aux fichiers et exécuter des commandes qu'au sein du workspace configuré. - -#### Configuration par Défaut - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "restrict_to_workspace": true - } - } -} -``` - -| Option | Par défaut | Description | -|--------|------------|-------------| -| `workspace` | `~/.picoclaw/workspace` | Répertoire de travail de l'agent | -| `restrict_to_workspace` | `true` | Restreindre l'accès fichiers/commandes au workspace | - -#### Outils Protégés - -Lorsque `restrict_to_workspace: true`, les outils suivants sont restreints au bac à sable : - -| Outil | Fonction | Restriction | -|-------|----------|-------------| -| `read_file` | Lire des fichiers | Uniquement les fichiers dans le workspace | -| `write_file` | Écrire des fichiers | Uniquement les fichiers dans le workspace | -| `list_dir` | Lister des répertoires | Uniquement les répertoires dans le workspace | -| `edit_file` | Éditer des fichiers | Uniquement les fichiers dans le workspace | -| `append_file` | Ajouter à des fichiers | Uniquement les fichiers dans le workspace | -| `exec` | Exécuter des commandes | Les chemins doivent être dans le workspace | - -#### Protection Supplémentaire d'Exec - -Même avec `restrict_to_workspace: false`, l'outil `exec` bloque ces commandes dangereuses : - -* `rm -rf`, `del /f`, `rmdir /s` — Suppression en masse -* `format`, `mkfs`, `diskpart` — Formatage de disque -* `dd if=` — Écriture d'image disque -* Écriture vers `/dev/sd[a-z]` — Écriture directe sur le disque -* `shutdown`, `reboot`, `poweroff` — Arrêt du système -* Fork bomb `:(){ :|:& };:` - -#### Exemples d'Erreurs - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (path outside working dir)} -``` - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} -``` - -#### Désactiver les Restrictions (Risque de Sécurité) - -Si vous avez besoin que l'agent accède à des chemins en dehors du workspace : - -**Méthode 1 : Fichier de configuration** - -```json -{ - "agents": { - "defaults": { - "restrict_to_workspace": false - } - } -} -``` - -**Méthode 2 : Variable d'environnement** - -```bash -export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false -``` - -> ⚠️ **Attention** : Désactiver cette restriction permet à l'agent d'accéder à n'importe quel chemin sur votre système. À utiliser avec précaution uniquement dans des environnements contrôlés. - -#### Cohérence du Périmètre de Sécurité - -Le paramètre `restrict_to_workspace` s'applique de manière cohérente sur tous les chemins d'exécution : - -| Chemin d'Exécution | Périmètre de Sécurité | -|--------------------|----------------------| -| Agent Principal | `restrict_to_workspace` ✅ | -| Sous-agent / Spawn | Hérite de la même restriction ✅ | -| Tâches Heartbeat | Hérite de la même restriction ✅ | - -Tous les chemins partagent la même restriction de workspace — il est impossible de contourner le périmètre de sécurité via des sous-agents ou des tâches planifiées. - -### Heartbeat (Tâches Périodiques) - -PicoClaw peut exécuter des tâches périodiques automatiquement. Créez un fichier `HEARTBEAT.md` dans votre workspace : - -```markdown -# Tâches Périodiques - -- Vérifier mes e-mails pour les messages importants -- Consulter mon agenda pour les événements à venir -- Vérifier les prévisions météo -``` - -L'agent lira ce fichier toutes les 30 minutes (configurable) et exécutera les tâches à l'aide des outils disponibles. - -#### Tâches Asynchrones avec Spawn - -Pour les tâches de longue durée (recherche web, appels API), utilisez l'outil `spawn` pour créer un **sous-agent** : - -```markdown -# Tâches Périodiques - -## Tâches Rapides (réponse directe) -- Indiquer l'heure actuelle - -## Tâches Longues (utiliser spawn pour l'asynchrone) -- Rechercher les actualités IA sur le web et les résumer -- Vérifier les e-mails et signaler les messages importants -``` - -**Comportements clés :** - -| Fonctionnalité | Description | -|----------------|-------------| -| **spawn** | Crée un sous-agent asynchrone, ne bloque pas le heartbeat | -| **Contexte indépendant** | Le sous-agent a son propre contexte, sans historique de session | -| **Outil message** | Le sous-agent communique directement avec l'utilisateur via l'outil message | -| **Non-bloquant** | Après le spawn, le heartbeat continue vers la tâche suivante | - -#### Fonctionnement de la Communication du Sous-agent - -``` -Le Heartbeat se déclenche - ↓ -L'Agent lit HEARTBEAT.md - ↓ -Pour une tâche longue : spawn d'un sous-agent - ↓ ↓ -Continue la tâche suivante Le sous-agent travaille indépendamment - ↓ ↓ -Toutes les tâches terminées Le sous-agent utilise l'outil "message" - ↓ ↓ -Répond HEARTBEAT_OK L'utilisateur reçoit le résultat directement -``` - -Le sous-agent a accès aux outils (message, web_search, etc.) et peut communiquer avec l'utilisateur indépendamment sans passer par l'agent principal. - -**Configuration :** - -```json -{ - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -| Option | Par défaut | Description | -|--------|------------|-------------| -| `enabled` | `true` | Activer/désactiver le heartbeat | -| `interval` | `30` | Intervalle de vérification en minutes (min : 5) | - -**Variables d'environnement :** - -* `PICOCLAW_HEARTBEAT_ENABLED=false` pour désactiver -* `PICOCLAW_HEARTBEAT_INTERVAL=60` pour modifier l'intervalle - -### Fournisseurs - -> [!NOTE] -> 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. - -| Fournisseur | Utilisation | Obtenir une Clé API | -| ------------------------ | ---------------------------------------- | ------------------------------------------------------ | -| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | -| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) | -| `openrouter` (À tester) | LLM (recommandé, accès à tous les modèles) | [openrouter.ai](https://openrouter.ai) | -| `anthropic` (À tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | -| `openai` (À tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | -| `deepseek` (À tester) | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | -| `qwen` | LLM (Alibaba Qwen) | [dashscope.aliyuncs.com](https://dashscope.aliyuncs.com/compatible-mode/v1) | -| `cerebras` | LLM (Cerebras) | [cerebras.ai](https://api.cerebras.ai/v1) | -| `groq` | LLM + **Transcription vocale** (Whisper) | [console.groq.com](https://console.groq.com) | - -
-Configuration Zhipu - -**1. Obtenir la clé API** - -* Obtenez la [clé API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) - -**2. Configurer** - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 - } - }, - "providers": { - "zhipu": { - "api_key": "Votre Clé API", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - } -} -``` - -**3. Lancer** - -```bash -picoclaw agent -m "Bonjour, comment ça va ?" -``` - -
- -
-Exemple de configuration complète - -```json -{ - "agents": { - "defaults": { - "model": "anthropic/claude-opus-4-5" - } - }, - "providers": { - "openrouter": { - "api_key": "sk-or-v1-xxx" - }, - "groq": { - "api_key": "gsk_xxx" - } - }, - "channels": { - "telegram": { - "enabled": true, - "token": "123456:ABC...", - "allow_from": ["123456789"] - }, - "discord": { - "enabled": true, - "token": "", - "allow_from": [""] - }, - "whatsapp": { - "enabled": false - }, - "feishu": { - "enabled": false, - "app_id": "cli_xxx", - "app_secret": "xxx", - "encrypt_key": "", - "verification_token": "", - "allow_from": [] - }, - "qq": { - "enabled": false, - "app_id": "", - "app_secret": "", - "allow_from": [] - } - }, - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "BSA...", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - }, - "cron": { - "exec_timeout_minutes": 5 - } - }, - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -
- -### Configuration de Modèle (model_list) - -> **Nouveau !** PicoClaw utilise désormais une approche de configuration **centrée sur le modèle**. Spécifiez simplement le format `fournisseur/modèle` (par exemple, `zhipu/glm-4.7`) pour ajouter de nouveaux fournisseurs—**aucune modification de code requise !** - -Cette conception permet également le **support multi-agent** avec une sélection flexible de fournisseurs : - -- **Différents agents, différents fournisseurs** : Chaque agent peut utiliser son propre fournisseur LLM -- **Modèles de secours (Fallbacks)** : Configurez des modèles primaires et de secours pour la résilience -- **Équilibrage de charge** : Répartissez les requêtes sur plusieurs points de terminaison -- **Configuration centralisée** : Gérez tous les fournisseurs en un seul endroit - -#### 📋 Tous les Fournisseurs Supportés - -| Fournisseur | Préfixe `model` | API Base par Défaut | Protocole | Clé API | -|-------------|-----------------|---------------------|----------|---------| -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obtenir Clé](https://platform.openai.com) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obtenir Clé](https://console.anthropic.com) | -| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obtenir Clé](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obtenir Clé](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obtenir Clé](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obtenir Clé](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Obtenir Clé](https://platform.moonshot.cn) | -| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obtenir Clé](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Obtenir Clé](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (pas de clé nécessaire) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) | -| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) | -| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | -| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | - -#### Configuration de Base - -```json -{ - "model_list": [ - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_key": "sk-your-openai-key" - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" - }, - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" - } - ], - "agents": { - "defaults": { - "model": "gpt-5.2" - } - } -} -``` - -#### Exemples par Fournisseur - -**OpenAI** -```json -{ - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_key": "sk-..." -} -``` - -**Zhipu AI (GLM)** -```json -{ - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" -} -``` - -**Anthropic (avec OAuth)** -```json -{ - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "auth_method": "oauth" -} -``` -> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth. - -**Proxy/API personnalisée** -```json -{ - "model_name": "my-custom-model", - "model": "openai/custom-model", - "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", - "request_timeout": 300 -} -``` - -#### Équilibrage de Charge - -Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux : - -```json -{ - "model_list": [ - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" - }, - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" - } - ] -} -``` - -#### Migration depuis l'Ancienne Configuration `providers` - -L'ancienne configuration `providers` est **dépréciée** mais toujours supportée pour la rétrocompatibilité. - -**Ancienne Configuration (dépréciée) :** -```json -{ - "providers": { - "zhipu": { - "api_key": "your-key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - }, - "agents": { - "defaults": { - "provider": "zhipu", - "model": "glm-4.7" - } - } -} -``` - -**Nouvelle Configuration (recommandée) :** -```json -{ - "model_list": [ - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" - } - ], - "agents": { - "defaults": { - "model": "glm-4.7" - } - } -} -``` - -Pour le guide de migration détaillé, voir [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). - -## Référence CLI - -| Commande | Description | -| ------------------------- | ------------------------------------- | -| `picoclaw onboard` | Initialiser la configuration & le workspace | -| `picoclaw agent -m "..."` | Discuter avec l'agent | -| `picoclaw agent` | Mode de discussion interactif | -| `picoclaw gateway` | Démarrer la passerelle | -| `picoclaw status` | Afficher le statut | -| `picoclaw cron list` | Lister toutes les tâches planifiées | -| `picoclaw cron add ...` | Ajouter une tâche planifiée | - -### Tâches Planifiées / Rappels - -PicoClaw prend en charge les rappels planifiés et les tâches récurrentes via l'outil `cron` : - -* **Rappels ponctuels** : « Rappelle-moi dans 10 minutes » → se déclenche une fois après 10 min -* **Tâches récurrentes** : « Rappelle-moi toutes les 2 heures » → se déclenche toutes les 2 heures -* **Expressions Cron** : « Rappelle-moi à 9h tous les jours » → utilise une expression cron - -Les tâches sont stockées dans `~/.picoclaw/workspace/cron/` et traitées automatiquement. - -## 🤝 Contribuer & Feuille de Route - -Les PR sont les bienvenues ! Le code source est volontairement petit et lisible. 🤗 - -Feuille de route à venir... - -Groupe de développeurs en construction. Condition d'entrée : au moins 1 PR fusionnée. - -Groupes d'utilisateurs : - -Discord : - -PicoClaw - -## 🐛 Dépannage - -### La recherche web affiche « API 配置问题 » - -C'est normal si vous n'avez pas encore configuré de clé API de recherche. PicoClaw fournira des liens utiles pour la recherche manuelle. - -Pour activer la recherche web : - -1. **Option 1 (Recommandé)** : Obtenez une clé API gratuite sur [https://brave.com/search/api](https://brave.com/search/api) (2000 requêtes gratuites/mois) pour les meilleurs résultats. -2. **Option 2 (Sans carte bancaire)** : Si vous n'avez pas de clé, le système bascule automatiquement sur **DuckDuckGo** (aucune clé requise). - -Ajoutez la clé dans `~/.picoclaw/config.json` si vous utilisez Brave : - -```json -{ - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "VOTRE_CLE_API_BRAVE", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - } - } -} -``` - -### Erreurs de filtrage de contenu - -Certains fournisseurs (comme Zhipu) disposent d'un filtrage de contenu. Essayez de reformuler votre requête ou utilisez un modèle différent. - -### Le bot Telegram affiche « Conflict: terminated by other getUpdates » - -Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assurez-vous qu'un seul `picoclaw gateway` fonctionne à la fois. - ---- - -## 📝 Comparaison des Clés API - -| Service | Offre Gratuite | Cas d'Utilisation | -| ---------------- | -------------------- | ------------------------------------- | -| **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) | -| **Zhipu** | 200K tokens/mois | Idéal pour les utilisateurs chinois | -| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web | -| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) | diff --git a/README.ja.md b/README.ja.md deleted file mode 100644 index c4c5b27a0..000000000 --- a/README.ja.md +++ /dev/null @@ -1,1128 +0,0 @@ -
-PicoClaw - -

PicoClaw: Go で書かれた超効率 AI アシスタント

- -

$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ!

-

- -

-Go -Hardware -License -

- -[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) - -
- - ---- - -🦐 PicoClaw は [nanobot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。Go でゼロからリファクタリングされ、AI エージェント自身がアーキテクチャの移行とコード最適化を推進するセルフブートストラッピングプロセスで構築されました。 - -⚡️ $10 のハードウェアで 10MB 未満の RAM で動作:OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い! - - - - - - -
-

- -

-
-

- -

-
- -## 📢 ニュース -2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ! - -## ✨ 特徴 - -🪶 **超軽量**: メモリフットプリント 10MB 未満 — Clawdbot のコア機能より 99% 小さい。 - -💰 **最小コスト**: $10 ハードウェアで動作 — Mac mini より 98% 安い。 - -⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。 - -🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。ワンクリックで Go! - -🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。 - -| | OpenClaw | NanoBot | **PicoClaw** | -| --- | --- | --- |--- | -| **言語** | TypeScript | Python | **Go** | -| **RAM** | >1GB |>100MB| **< 10MB** | -| **起動時間**
(0.8GHz コア) | >500秒 | >30秒 | **<1秒** | -| **コスト** | Mac Mini 599$ | 大半の Linux SBC
~50$ |**あらゆる Linux ボード**
**最安 10$** | -PicoClaw - - -## 🦾 デモンストレーション -### 🛠️ スタンダードアシスタントワークフロー - - - - - - - - - - - - - - - - -

🧩 フルスタックエンジニア

🗂️ ログ&計画管理

🔎 Web 検索&学習

開発 · デプロイ · スケールスケジュール · 自動化 · メモリ発見 · インサイト · トレンド
- -### 🐜 革新的な省フットプリントデプロイ -PicoClaw はほぼすべての Linux デバイスにデプロイできます! - -- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) または W(WiFi6) バージョン、最小ホームアシスタントに -- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html) または $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) サーバー自動メンテナンスに -- $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) スマート監視に - -https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4 - -🌟 もっと多くのデプロイ事例が待っています! - -## 📦 インストール - -### コンパイル済みバイナリでインストール - -[リリースページ](https://github.com/sipeed/picoclaw/releases) からお使いのプラットフォーム用のファームウェアをダウンロードしてください。 - -### ソースからインストール(最新機能、開発向け推奨) - -```bash -git clone https://github.com/sipeed/picoclaw.git - -cd picoclaw -make deps - -# ビルド(インストール不要) -make build - -# 複数プラットフォーム向けビルド -make build-all - -# ビルドとインストール -make install -``` - -## 🐳 Docker Compose - -Docker Compose を使えば、ローカルにインストールせずに PicoClaw を実行できます。 - -```bash -# 1. リポジトリをクローン -git clone https://github.com/sipeed/picoclaw.git -cd picoclaw - -# 2. 初回起動 — docker/data/config.json を自動生成して終了 -docker compose -f docker/docker-compose.yml --profile gateway up -# コンテナが "First-run setup complete." を表示して停止します。 - -# 3. API キーを設定 -vim docker/data/config.json # プロバイダー API キー、Bot トークンなどを設定 - -# 4. 起動 -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -> [!TIP] -> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。 - -```bash -# 5. ログ確認 -docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway - -# 6. 停止 -docker compose -f docker/docker-compose.yml --profile gateway down -``` - -### Agent モード(ワンショット) - -```bash -# 質問を投げる -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" - -# インタラクティブモード -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -``` - -### アップデート - -```bash -docker compose -f docker/docker-compose.yml pull -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -### 🚀 クイックスタート(ネイティブ) - -> [!TIP] -> `~/.picoclaw/config.json` に API キーを設定してください。 -> API キーの取得先: [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 クエリ無料) - -**1. 初期化** - -```bash -picoclaw onboard -``` - -**2. 設定** (`~/.picoclaw/config.json`) - -```json -{ - "model_list": [ - { - "model_name": "gpt4", - "model": "openai/gpt-5.2", - "api_key": "sk-your-openai-key", - "request_timeout": 300, - "api_base": "https://api.openai.com/v1" - } - ], - "agents": { - "defaults": { - "model_name": "gpt4" - } - }, - "channels": { - "telegram": { - "enabled": true, - "token": "YOUR_TELEGRAM_BOT_TOKEN", - "allow_from": [] - } - }, - "tools": { - "web": { - "search": { - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "tavily": { - "enabled": false, - "api_key": "YOUR_TAVILY_API_KEY", - "max_results": 5 - } - }, - "cron": { - "exec_timeout_minutes": 5 - } - }, - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -> **新機能**: `model_list` 形式により、プロバイダーをコード変更なしで追加できます。詳細は [モデル設定](#モデル設定-model_list) を参照してください。 -> `request_timeout` は任意の秒単位設定です。省略または `<= 0` の場合、PicoClaw はデフォルトのタイムアウト(120秒)を使用します。 - -**3. API キーの取得** - -- **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) -- **Web 検索**(任意): [Tavily](https://tavily.com) - AI エージェント向けに最適化 (月 1000 リクエスト) · [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト) - -> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。 - -**4. チャット** - -```bash -picoclaw agent -m "What is 2+2?" -``` - -これだけです!2 分で AI アシスタントが動きます。 - ---- - -## 💬 チャットアプリ - -Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話できます - -| チャネル | セットアップ | -|---------|------------| -| **Telegram** | 簡単(トークンのみ) | -| **Discord** | 簡単(Bot トークン + Intents) | -| **QQ** | 簡単(AppID + AppSecret) | -| **DingTalk** | 普通(アプリ認証情報) | -| **LINE** | 普通(認証情報 + Webhook URL) | -| **WeCom AI Bot** | 普通(Token + AES キー) | - -
-Telegram(推奨) - -**1. Bot を作成** - -- Telegram を開き、`@BotFather` を検索 -- `/newbot` を送信、プロンプトに従う -- トークンをコピー - -**2. 設定** - -```json -{ - "channels": { - "telegram": { - "enabled": true, - "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] - } - } -} -``` - -> ユーザー ID は Telegram の `@userinfobot` から取得できます。 - -**3. 起動** - -```bash -picoclaw gateway -``` -
- - -
-Discord - -**1. Bot を作成** -- https://discord.com/developers/applications にアクセス -- アプリケーションを作成 → Bot → Add Bot -- Bot トークンをコピー - -**2. Intents を有効化** -- Bot の設定画面で **MESSAGE CONTENT INTENT** を有効化 -- (任意)**SERVER MEMBERS INTENT** も有効化 - -**3. ユーザー ID を取得** -- Discord 設定 → 詳細設定 → **開発者モード** を有効化 -- 自分のアバターを右クリック → **ユーザーIDをコピー** - -**4. 設定** - -```json -{ - "channels": { - "discord": { - "enabled": true, - "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] - } - } -} -``` - -**5. Bot を招待** -- OAuth2 → URL Generator -- Scopes: `bot` -- Bot Permissions: `Send Messages`, `Read Message History` -- 生成された招待 URL を開き、サーバーに Bot を追加 - -**6. 起動** - -```bash -picoclaw gateway -``` - -
- -
-QQ - -**1. Bot を作成** - -- [QQ オープンプラットフォーム](https://q.qq.com/#) にアクセス -- アプリケーションを作成 → **AppID** と **AppSecret** を取得 - -**2. 設定** - -```json -{ - "channels": { - "qq": { - "enabled": true, - "app_id": "YOUR_APP_ID", - "app_secret": "YOUR_APP_SECRET", - "allow_from": [] - } - } -} -``` - -> `allow_from` を空にすると全ユーザーを許可、QQ番号を指定してアクセス制限可能。 - -**3. 起動** - -```bash -picoclaw gateway -``` - -
- -
-DingTalk - -**1. Bot を作成** - -- [オープンプラットフォーム](https://open.dingtalk.com/) にアクセス -- 内部アプリを作成 -- Client ID と Client Secret をコピー - -**2. 設定** - -```json -{ - "channels": { - "dingtalk": { - "enabled": true, - "client_id": "YOUR_CLIENT_ID", - "client_secret": "YOUR_CLIENT_SECRET", - "allow_from": [] - } - } -} -``` - -> `allow_from` を空にすると全ユーザーを許可、ユーザーIDを指定してアクセス制限可能。 - -**3. 起動** - -```bash -picoclaw gateway -``` - -
- -
-LINE - -**1. LINE 公式アカウントを作成** - -- [LINE Developers Console](https://developers.line.biz/) にアクセス -- プロバイダーを作成 → Messaging API チャネルを作成 -- **チャネルシークレット** と **チャネルアクセストークン** をコピー - -**2. 設定** - -```json -{ - "channels": { - "line": { - "enabled": true, - "channel_secret": "YOUR_CHANNEL_SECRET", - "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", - "webhook_path": "/webhook/line", - "allow_from": [] - } - } -} -``` - -**3. Webhook URL を設定** - -LINE の Webhook には HTTPS が必要です。リバースプロキシまたはトンネルを使用してください: - -```bash -# ngrok の例 -ngrok http 18790 -``` - -LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。 - -> **注意**: LINE の Webhook は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、リバースプロキシを設定してください。 - -**4. 起動** - -```bash -picoclaw gateway -``` - -> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。 - -> **Docker Compose**: Gateway HTTP サーバーは共有の `127.0.0.1:18790` で Webhook を提供します。ホストからアクセスするには `picoclaw-gateway` サービスに `ports: ["18790:18790"]` を追加してください。 - -
- -
-WeCom (企業微信) - -PicoClaw は3種類の WeCom 統合をサポートしています: - -**オプション1: WeCom Bot (ロボット)** - 簡単な設定、グループチャット対応 -**オプション2: WeCom App (カスタムアプリ)** - より多機能、アクティブメッセージング対応、プライベートチャットのみ -**オプション3: WeCom AI Bot (スマートボット)** - 公式 AI Bot、ストリーミング返信、グループ・プライベート両対応 - -詳細な設定手順は [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) を参照してください。 - -**クイックセットアップ - WeCom Bot:** - -**1. ボットを作成** - -* WeCom 管理コンソール → グループチャット → グループボットを追加 -* Webhook URL をコピー(形式: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) - -**2. 設定** - -```json -{ - "channels": { - "wecom": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_path": "/webhook/wecom", - "allow_from": [] - } - } -} - -> **注意**: WeCom Bot の Webhook 受信は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、HTTPS 用のリバースプロキシを設定してください。 -``` - -**クイックセットアップ - WeCom App:** - -**1. アプリを作成** - -* WeCom 管理コンソール → アプリ管理 → アプリを作成 -* **AgentId** と **Secret** をコピー -* "マイ会社" ページで **CorpID** をコピー - -**2. メッセージ受信を設定** - -* アプリ詳細で "メッセージを受信" → "APIを設定" をクリック -* URL を `http://your-server:18790/webhook/wecom-app` に設定 -* **Token** と **EncodingAESKey** を生成 - -**3. 設定** - -```json -{ - "channels": { - "wecom_app": { - "enabled": true, - "corp_id": "wwxxxxxxxxxxxxxxxx", - "corp_secret": "YOUR_CORP_SECRET", - "agent_id": 1000002, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-app", - "allow_from": [] - } - } -} -``` - -**4. 起動** - -```bash -picoclaw gateway -``` - -> **注意**: WeCom App の Webhook コールバックは共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は HTTPS 用のリバースプロキシを設定してください。 - -**クイックセットアップ - WeCom AI Bot:** - -**1. AI Bot を作成** - -* WeCom 管理コンソール → アプリ管理 → AI Bot -* コールバック URL を設定: `http://your-server:18791/webhook/wecom-aibot` -* **Token** をコピーし、**EncodingAESKey** を生成 - -**2. 設定** - -```json -{ - "channels": { - "wecom_aibot": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-aibot", - "allow_from": [], - "welcome_message": "こんにちは!何かお手伝いできますか?" - } - } -} -``` - -**3. 起動** - -```bash -picoclaw gateway -``` - -> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用 — 返信タイムアウトの心配なし。長時間タスク(>30秒)は自動的に `response_url` によるプッシュ配信に切り替わります。 - -
- -## ⚙️ 設定 - -設定ファイル: `~/.picoclaw/config.json` - -### 環境変数 - -環境変数を使用してデフォルトのパスを上書きできます。これは、ポータブルインストール、コンテナ化されたデプロイメント、または picoclaw をシステムサービスとして実行する場合に便利です。これらの変数は独立しており、異なるパスを制御します。 - -| 変数 | 説明 | デフォルトパス | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| -| `PICOCLAW_CONFIG` | 設定ファイルへのパスを上書きします。これにより、picoclaw は他のすべての場所を無視して、指定された `config.json` をロードします。 | `~/.picoclaw/config.json` | -| `PICOCLAW_HOME` | picoclaw データのルートディレクトリを上書きします。これにより、`workspace` やその他のデータディレクトリのデフォルトの場所が変更されます。 | `~/.picoclaw` | - -**例:** - -```bash -# 特定の設定ファイルを使用して picoclaw を実行する -# ワークスペースのパスはその設定ファイル内から読み込まれます -PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway - -# すべてのデータを /opt/picoclaw に保存して picoclaw を実行する -# 設定はデフォルトの ~/.picoclaw/config.json からロードされます -# ワークスペースは /opt/picoclaw/workspace に作成されます -PICOCLAW_HOME=/opt/picoclaw picoclaw agent - -# 両方を使用して完全にカスタマイズされたセットアップを行う -PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway -``` - -### ワークスペース構成 - -PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します: - -``` -~/.picoclaw/workspace/ -├── sessions/ # 会話セッションと履歴 -├── memory/ # 長期メモリ(MEMORY.md) -├── state/ # 永続状態(最後のチャネルなど) -├── cron/ # スケジュールジョブデータベース -├── skills/ # カスタムスキル -├── AGENTS.md # エージェントの行動ガイド -├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認) -├── IDENTITY.md # エージェントのアイデンティティ -├── SOUL.md # エージェントのソウル -├── TOOLS.md # ツールの説明 -└── USER.md # ユーザー設定 -``` - -### 🔒 セキュリティサンドボックス - -PicoClaw はデフォルトでサンドボックス環境で実行されます。エージェントは設定されたワークスペース内のファイルにのみアクセスし、コマンドを実行できます。 - -#### デフォルト設定 - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "restrict_to_workspace": true - } - } -} -``` - -| オプション | デフォルト | 説明 | -|-----------|-----------|------| -| `workspace` | `~/.picoclaw/workspace` | エージェントの作業ディレクトリ | -| `restrict_to_workspace` | `true` | ファイル/コマンドアクセスをワークスペースに制限 | - -#### 保護対象ツール - -`restrict_to_workspace: true` の場合、以下のツールがサンドボックス化されます: - -| ツール | 機能 | 制限 | -|-------|------|------| -| `read_file` | ファイル読み込み | ワークスペース内のファイルのみ | -| `write_file` | ファイル書き込み | ワークスペース内のファイルのみ | -| `list_dir` | ディレクトリ一覧 | ワークスペース内のディレクトリのみ | -| `edit_file` | ファイル編集 | ワークスペース内のファイルのみ | -| `append_file` | ファイル追記 | ワークスペース内のファイルのみ | -| `exec` | コマンド実行 | コマンドパスはワークスペース内である必要あり | - -#### exec ツールの追加保護 - -`restrict_to_workspace: false` でも、`exec` ツールは以下の危険なコマンドをブロックします: - -- `rm -rf`, `del /f`, `rmdir /s` — 一括削除 -- `format`, `mkfs`, `diskpart` — ディスクフォーマット -- `dd if=` — ディスクイメージング -- `/dev/sd[a-z]` への書き込み — 直接ディスク書き込み -- `shutdown`, `reboot`, `poweroff` — システムシャットダウン -- フォークボム `:(){ :|:& };:` - -#### エラー例 - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (path outside working dir)} -``` - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} -``` - -#### 制限の無効化(セキュリティリスク) - -エージェントにワークスペース外のパスへのアクセスが必要な場合: - -**方法1: 設定ファイル** -```json -{ - "agents": { - "defaults": { - "restrict_to_workspace": false - } - } -} -``` - -**方法2: 環境変数** -```bash -export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false -``` - -> ⚠️ **警告**: この制限を無効にすると、エージェントはシステム上の任意のパスにアクセスできるようになります。制御された環境でのみ慎重に使用してください。 - -#### セキュリティ境界の一貫性 - -`restrict_to_workspace` 設定は、すべての実行パスで一貫して適用されます: - -| 実行パス | セキュリティ境界 | -|---------|-----------------| -| メインエージェント | `restrict_to_workspace` ✅ | -| サブエージェント / Spawn | 同じ制限を継承 ✅ | -| ハートビートタスク | 同じ制限を継承 ✅ | - -すべてのパスで同じワークスペース制限が適用されます — サブエージェントやスケジュールタスクを通じてセキュリティ境界をバイパスする方法はありません。 - -### ハートビート(定期タスク) - -PicoClaw は自動的に定期タスクを実行できます。ワークスペースに `HEARTBEAT.md` ファイルを作成します: - -```markdown -# 定期タスク - -- 重要なメールをチェック -- 今後の予定を確認 -- 天気予報をチェック -``` - -エージェントは30分ごと(設定可能)にこのファイルを読み込み、利用可能なツールを使ってタスクを実行します。 - -#### spawn で非同期タスク実行 - -時間のかかるタスク(Web検索、API呼び出し)には `spawn` ツールを使って**サブエージェント**を作成します: - -```markdown -# 定期タスク - -## クイックタスク(直接応答) -- 現在時刻を報告 - -## 長時間タスク(spawn で非同期) -- AIニュースを検索して要約 -- メールをチェックして重要なメッセージを報告 -``` - -**主な特徴:** - -| 機能 | 説明 | -|------|------| -| **spawn** | 非同期サブエージェントを作成、ハートビートをブロックしない | -| **独立コンテキスト** | サブエージェントは独自のコンテキストを持ち、セッション履歴なし | -| **message ツール** | サブエージェントは message ツールで直接ユーザーと通信 | -| **非ブロッキング** | spawn 後、ハートビートは次のタスクへ継続 | - -#### サブエージェントの通信方法 - -``` -ハートビート発動 - ↓ -エージェントが HEARTBEAT.md を読む - ↓ -長いタスク: spawn サブエージェント - ↓ ↓ -次のタスクへ継続 サブエージェントが独立して動作 - ↓ ↓ -全タスク完了 message ツールを使用 - ↓ ↓ -HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る -``` - -サブエージェントはツール(message、web_search など)にアクセスでき、メインエージェントを経由せずにユーザーと通信できます。 - -**設定:** - -```json -{ - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -| オプション | デフォルト | 説明 | -|-----------|-----------|------| -| `enabled` | `true` | ハートビートの有効/無効 | -| `interval` | `30` | チェック間隔(分)、最小5分 | - -**環境変数:** -- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化 -- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更 - -### プロバイダー - -> [!NOTE] -> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、あらゆるチャンネルからの音声メッセージがエージェントレベルで自動的に文字起こしされます。 - -| プロバイダー | 用途 | API キー取得先 | -| --- | --- | --- | -| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) | -| `zhipu` | LLM(Zhipu 直接) | [bigmodel.cn](https://bigmodel.cn) | -| `openrouter`(要テスト) | LLM(推奨、全モデルにアクセス可能) | [openrouter.ai](https://openrouter.ai) | -| `anthropic`(要テスト) | LLM(Claude 直接) | [console.anthropic.com](https://console.anthropic.com) | -| `openai`(要テスト) | LLM(GPT 直接) | [platform.openai.com](https://platform.openai.com) | -| `deepseek`(要テスト) | LLM(DeepSeek 直接) | [platform.deepseek.com](https://platform.deepseek.com) | -| `groq` | LLM + **音声文字起こし**(Whisper) | [console.groq.com](https://console.groq.com) | -| `cerebras` | LLM(Cerebras 直接) | [cerebras.ai](https://cerebras.ai) | - -### 基本設定 - -1. **設定ファイルの作成:** - - ```bash - cp config.example.json config/config.json - ``` - -2. **設定の編集:** - - ```json - { - "providers": { - "openrouter": { - "api_key": "sk-or-v1-..." - } - }, - "channels": { - "discord": { - "enabled": true, - "token": "YOUR_DISCORD_BOT_TOKEN" - } - } - } - ``` - -3. **実行** - - ```bash - picoclaw agent -m "Hello" - ``` - - -
-完全な設定例 - -```json -{ - "agents": { - "defaults": { - "model": "anthropic/claude-opus-4-5" - } - }, - "providers": { - "openrouter": { - "api_key": "sk-or-v1-xxx" - }, - "groq": { - "api_key": "gsk_xxx" - } - }, - "channels": { - "telegram": { - "enabled": true, - "token": "123456:ABC...", - "allow_from": ["123456789"] - }, - "discord": { - "enabled": true, - "token": "", - "allow_from": [""] - }, - "whatsapp": { - "enabled": false - }, - "feishu": { - "enabled": false, - "app_id": "cli_xxx", - "app_secret": "xxx", - "encrypt_key": "", - "verification_token": "", - "allow_from": [] - } - }, - "tools": { - "web": { - "search": { - "api_key": "BSA..." - } - }, - "cron": { - "exec_timeout_minutes": 5 - } - }, - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -
- -### モデル設定 (model_list) - -> **新機能!** PicoClaw は現在 **モデル中心** の設定アプローチを採用しています。`ベンダー/モデル` 形式(例: `zhipu/glm-4.7`)を指定するだけで、新しいプロバイダーを追加できます—**コードの変更は一切不要!** - -この設計は、柔軟なプロバイダー選択による **マルチエージェントサポート** も可能にします: - -- **異なるエージェント、異なるプロバイダー** : 各エージェントは独自の LLM プロバイダーを使用可能 -- **フォールバックモデル** : 耐障性のため、プライマリモデルとフォールバックモデルを設定可能 -- **ロードバランシング** : 複数のエンドポイントにリクエストを分散 -- **集中設定管理** : すべてのプロバイダーを一箇所で管理 - -#### 📋 サポートされているすべてのベンダー - -| ベンダー | `model` プレフィックス | デフォルト API Base | プロトコル | API キー | -|-------------|-----------------|---------------------|----------|---------| -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [キーを取得](https://platform.openai.com) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) | -| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [キーを取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [キーを取得](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [キーを取得](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [キーを取得](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [キーを取得](https://platform.moonshot.cn) | -| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [キーを取得](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [キーを取得](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) | -| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) | -| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | -| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | - -#### 基本設定 - -```json -{ - "model_list": [ - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_key": "sk-your-openai-key" - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" - }, - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" - } - ], - "agents": { - "defaults": { - "model": "gpt-5.2" - } - } -} -``` - -#### ベンダー別の例 - -**OpenAI** -```json -{ - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_key": "sk-..." -} -``` - -**Zhipu AI (GLM)** -```json -{ - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" -} -``` - -**Anthropic (OAuth使用)** -```json -{ - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "auth_method": "oauth" -} -``` -> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。 - -**カスタムプロキシ/API** -```json -{ - "model_name": "my-custom-model", - "model": "openai/custom-model", - "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", - "request_timeout": 300 -} -``` - -#### ロードバランシング - -同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します: - -```json -{ - "model_list": [ - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" - }, - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" - } - ] -} -``` - -#### 従来の `providers` 設定からの移行 - -古い `providers` 設定は**非推奨**ですが、後方互換性のためにサポートされています。 - -**旧設定(非推奨):** -```json -{ - "providers": { - "zhipu": { - "api_key": "your-key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - }, - "agents": { - "defaults": { - "provider": "zhipu", - "model": "glm-4.7" - } - } -} -``` - -**新設定(推奨):** -```json -{ - "model_list": [ - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" - } - ], - "agents": { - "defaults": { - "model": "glm-4.7" - } - } -} -``` - -詳細な移行ガイドは、[docs/migration/model-list-migration.md](docs/migration/model-list-migration.md) を参照してください。 - -## CLI リファレンス - -| コマンド | 説明 | -|---------|------| -| `picoclaw onboard` | 設定&ワークスペースの初期化 | -| `picoclaw agent -m "..."` | エージェントとチャット | -| `picoclaw agent` | インタラクティブチャットモード | -| `picoclaw gateway` | ゲートウェイを起動 | -| `picoclaw status` | ステータスを表示 | - -## 🤝 コントリビュート&ロードマップ - -PR 歓迎!コードベースは意図的に小さく読みやすくしています。🤗 - -Discord: https://discord.gg/V4sAZ9XWpN - -PicoClaw - - -## 🐛 トラブルシューティング - -### Web 検索で「API 設定の問題」と表示される - -検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。 - -Web 検索を有効にするには: -1. [https://tavily.com](https://tavily.com) (月 1000 クエリ無料) または [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料) -2. `~/.picoclaw/config.json` に追加: - ```json - { - "tools": { - "web": { - "brave": { - "enabled": true, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - } - } - } - ``` - -### コンテンツフィルタリングエラーが出る - -一部のプロバイダー(Zhipu など)にはコンテンツフィルタリングがあります。クエリを言い換えるか、別のモデルを使用してください。 - -### Telegram Bot で「Conflict: terminated by other getUpdates」と表示される - -別のインスタンスが実行中の場合に発生します。`picoclaw gateway` が 1 つだけ実行されていることを確認してください。 - ---- - -## 📝 API キー比較 - -| サービス | 無料枠 | ユースケース | -|---------|--------|------------| -| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) | -| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 | -| **Qwen** | 無料枠あり | 通義千問 (Qwen) | -| **Brave Search** | 月 2000 クエリ | Web 検索機能 | -| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 | -| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) | -| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) | diff --git a/README.md b/README.md index 5cf9f6143..30ac67d8f 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,173 @@
- PicoClaw +PicoClaw -

PicoClaw: Ultra-Efficient AI Assistant in Go

- -

$10 Hardware · 10MB RAM · 1s Boot · 皮皮虾,我们走!

+

PicoClaw: Ultra-Efficient AI Assistant in Go

+

$10 Hardware · 10MB RAM · ms Boot · Let's Go, PicoClaw!

- Go - Hardware + Go + Hardware License
Website - Twitter + Docs + Wiki
+ Twitter Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English** +[中文](docs/project/README.zh.md) | [日本語](docs/project/README.ja.md) | [한국어](docs/project/README.ko.md) | [Português](docs/project/README.pt-br.md) | [Tiếng Việt](docs/project/README.vi.md) | [Français](docs/project/README.fr.md) | [Italiano](docs/project/README.it.md) | [Bahasa Indonesia](docs/project/README.id.md) | [Malay](docs/project/README.ms.md) | **English**
--- -🦐 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. +> **PicoClaw** is an independent open-source project initiated by [Sipeed](https://sipeed.com), written entirely in **Go** from scratch — not a fork of OpenClaw, NanoBot, or any other project. -⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini! +**PicoClaw** is an ultra-lightweight personal AI assistant inspired by [NanoBot](https://github.com/HKUDS/nanobot). It was rebuilt from the ground up in **Go** through a "self-bootstrapping" process — the AI Agent itself drove the architecture migration and code optimization. + +**Runs on $10 hardware with <10MB RAM** — that's 99% less memory than OpenClaw and 98% cheaper than a Mac mini! - - - - + + + +
-

- -

-
-

- -

-
+

+ +

+
+

+ +

+
> [!CAUTION] -> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明** -> -> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**. +> **Security Notice** > +> * **NO CRYPTO:** PicoClaw has **not** issued any official tokens or cryptocurrency. All claims on `pump.fun` or other trading platforms are **scams**. > * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)** -> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties. -> * **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. -> * **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. +> * **BEWARE:** Many `.ai/.org/.com/.net/...` domains have been registered by third parties. Do not trust them. +> * **NOTE:** PicoClaw is in early rapid development. There may be unresolved security issues. Do not deploy to production before v1.0. +> * **NOTE:** PicoClaw has recently merged many PRs. Recent builds may use 10-20MB RAM. Resource optimization is planned after feature stabilization. ## 📢 News -2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](ROADMAP.md) —we can’t wait to have you on board! +2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download) -2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development. -🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting. +2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**! -2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go! +2026-03-17 🚀 **v0.2.3 Released!** System tray UI (Windows & Linux), sub-agent status query (`spawn_status`), experimental Gateway hot-reload, Cron security gating, and 2 security fixes. PicoClaw has reached **25K Stars**! + +2026-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, model routing. + +2026-02-28 📦 **v0.2.0** released with Docker Compose and Web UI Launcher support. + +
+Earlier news... + +2026-02-26 🎉 PicoClaw hits **20K Stars** in just 17 days! Channel auto-orchestration and capability interfaces are live. + +2026-02-16 🎉 PicoClaw breaks 12K Stars in one week! Community maintainer roles and [Roadmap](ROADMAP.md) officially launched. + +2026-02-13 🎉 PicoClaw breaks 5000 Stars in 4 days! Project roadmap and developer groups in progress. + +2026-02-09 🎉 **PicoClaw Released!** Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. Let's Go, PicoClaw! + +
## ✨ Features -🪶 **Ultra-Lightweight**: <10MB Memory footprint — 99% smaller than Clawdbot - core functionality. +🪶 **Ultra-lightweight**: Core memory footprint <10MB — 99% smaller than OpenClaw.* -💰 **Minimal Cost**: Efficient enough to run on $10 Hardware — 98% cheaper than a Mac mini. +💰 **Minimal cost**: Efficient enough to run on $10 hardware — 98% cheaper than a Mac mini. -⚡️ **Lightning Fast**: 400X Faster startup time, boot in 1 second even in 0.6GHz single core. +⚡️ **Lightning-fast boot**: 400x faster startup. Boots in <1s even on a 0.6GHz single-core processor. -🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go! +🌍 **Truly portable**: Single binary across RISC-V, ARM, MIPS, and x86 architectures. One binary, runs everywhere! -🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement. +🤖 **AI-bootstrapped**: Pure Go native implementation — 95% of core code was generated by an Agent and fine-tuned through human-in-the-loop review. -| | OpenClaw | NanoBot | **PicoClaw** | -| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | -| **Language** | TypeScript | Python | **Go** | -| **RAM** | >1GB | >100MB | **< 10MB** | -| **Startup**
(0.8GHz core) | >500s | >30s | **<1s** | -| **Cost** | Mac Mini 599$ | Most Linux SBC
~50$ | **Any Linux Board**
**As low as 10$** | +🔌 **MCP support**: Native [Model Context Protocol](https://modelcontextprotocol.io/) integration — connect any MCP server to extend Agent capabilities. + +👁️ **Vision pipeline**: Send images and files directly to the Agent — automatic base64 encoding for multimodal LLMs. + +🧠 **Smart routing**: Rule-based model routing — simple queries go to lightweight models, saving API costs. + +_*Recent builds may use 10-20MB due to rapid PR merges. Resource optimization is planned. Boot speed comparison based on 0.8GHz single-core benchmarks (see table below)._ + +
+ +| | OpenClaw | NanoBot | **PicoClaw** | +| ------------------------------ | ------------- | ------------------------ | -------------------------------------- | +| **Language** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB*** | +| **Boot time**
(0.8GHz core) | >500s | >30s | **<1s** | +| **Cost** | Mac Mini $599 | Most Linux boards ~$50 | **Any Linux board**
**from $10** | PicoClaw +
+ +> **[Hardware Compatibility List](docs/guides/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR! + +

+PicoClaw Hardware Compatibility +

+ ## 🦾 Demonstration ### 🛠️ Standard Assistant Workflows - - - - - - - - - - - - - - - + + + + + + + + + + + + + + +

🧩 Full-Stack Engineer

🗂️ Logging & Planning Management

🔎 Web Search & Learning

Develop • Deploy • ScaleSchedule • Automate • MemoryDiscovery • Insights • Trends

Full-Stack Engineer Mode

Logging & Planning

Web Search & Learning

Develop · Deploy · ScaleSchedule · Automate · RememberDiscover · Insights · Trends
-### 📱 Run on old Android Phones +### 🐜 Innovative Low-Footprint Deployment -Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start: +PicoClaw can be deployed on virtually any Linux device! -1. **Install Termux** (Available on F-Droid or Google Play). -2. **Execute cmds** - -```bash -# Note: Replace v0.1.1 with the latest version from the Releases page -wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 -chmod +x picoclaw-linux-arm64 -pkg install proot -termux-chroot ./picoclaw-linux-arm64 onboard -``` - -And then follow the instructions in the "Quick Start" section to complete the configuration! -PicoClaw - -### 🐜 Innovative Low-Footprint Deploy - -PicoClaw can be deployed on almost any Linux device! - -- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant -- $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 -- $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 +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) edition, for a minimal home assistant +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), for automated server operations +- $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 surveillance -🌟 More Deployment Cases Await! +🌟 More Deployment Cases Await! ## 📦 Install -### Install with precompiled binary +### Download from picoclaw.io (Recommended) -Download the firmware for your platform from the [release](https://github.com/sipeed/picoclaw/releases) page. +Visit **[picoclaw.io](https://picoclaw.io)** — the official website auto-detects your platform and provides one-click download. No need to manually pick an architecture. -### Install from source (latest features, recommended for development) +### Download precompiled binary + +Alternatively, download the binary for your platform from the [GitHub Releases](https://github.com/sipeed/picoclaw/releases) page. + +### Build from source (for development) + +Prerequisites: + +- Go 1.25+ +- Node.js 22+ and pnpm 10.33.0+ for Web UI / launcher builds ```bash git clone https://github.com/sipeed/picoclaw.git @@ -148,24 +175,64 @@ git clone https://github.com/sipeed/picoclaw.git cd picoclaw make deps -# Build, no need to install +# Install frontend dependencies +(cd web/frontend && pnpm install --frozen-lockfile) + +# Build the core binary for the current platform make build -# Build for multiple platforms +# Build the Web UI Launcher (required for WebUI mode) +make build-launcher + +# Build core binaries for all Makefile-managed platforms make build-all -# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) +# Build for Raspberry Pi Zero 2 W +# 32-bit: make build-linux-arm +# 64-bit: make build-linux-arm64 make build-pi-zero -# Build And Install +# Build and install make install ``` -**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm` (output: `build/picoclaw-linux-arm`); 64-bit → `make build-linux-arm64` (output: `build/picoclaw-linux-arm64`). Or run `make build-pi-zero` to build both. +**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. -## 🐳 Docker Compose +## 🚀 Quick Start Guide -You can also run PicoClaw using Docker Compose without installing anything locally. +### 🌐 WebUI Launcher (Recommended for Desktop) + +The WebUI Launcher provides a browser-based interface for configuration and chat. This is the easiest way to get started — no command-line knowledge required. + +**Option 1: Double-click (Desktop)** + +After downloading from [picoclaw.io](https://picoclaw.io), double-click `picoclaw-launcher` (or `picoclaw-launcher.exe` on Windows). Your browser will open automatically at `http://localhost:18800`. + +**Option 2: Command line** + +```bash +picoclaw-launcher +# Open http://localhost:18800 in your browser +``` + +> [!TIP] +> **Remote access / Docker / VM:** Add the `-public` flag to listen on all interfaces: +> ```bash +> picoclaw-launcher -public +> ``` + +

+WebUI Launcher +

+ +**Getting started:** + +Open the WebUI, then: **1)** Configure a Provider (add your LLM API key) -> **2)** Configure a Channel (e.g., Telegram) -> **3)** Start the Gateway -> **4)** Chat! + +For detailed WebUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io). + +
+Docker (alternative) ```bash # 1. Clone this repo @@ -173,50 +240,98 @@ git clone https://github.com/sipeed/picoclaw.git cd picoclaw # 2. First run — auto-generates docker/data/config.json then exits -docker compose -f docker/docker-compose.yml --profile gateway up +# (only triggers when both config.json and workspace/ are missing) +docker compose -f docker/docker-compose.yml --profile launcher up # The container prints "First-run setup complete." and stops. # 3. Set your API keys -vim docker/data/config.json # Set provider API keys, bot tokens, etc. +vim docker/data/config.json # 4. Start -docker compose -f docker/docker-compose.yml --profile gateway up -d +docker compose -f docker/docker-compose.yml --profile launcher up -d +# Open http://localhost:18800 ``` -> [!TIP] -> **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`. +> **Docker / VM users:** The Gateway listens on `127.0.0.1` by default. Set `PICOCLAW_GATEWAY_HOST=0.0.0.0` or use the `-public` flag to make it accessible from the host. ```bash -# 5. Check logs -docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway +# Check logs +docker compose -f docker/docker-compose.yml logs -f -# 6. Stop -docker compose -f docker/docker-compose.yml --profile gateway down -``` +# Stop +docker compose -f docker/docker-compose.yml --profile launcher down -### Agent Mode (One-shot) - -```bash -# Ask a question -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" - -# Interactive mode -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -``` - -### Update - -```bash +# Update docker compose -f docker/docker-compose.yml pull -docker compose -f docker/docker-compose.yml --profile gateway up -d +docker compose -f docker/docker-compose.yml --profile launcher up -d ``` -### 🚀 Quick Start +
-> [!TIP] -> Set your API key in `~/.picoclaw/config.json`. -> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month), [SearXNG](https://github.com/searxng/searxng) (free, self-hosted) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback. +
+macOS — First Launch Security Warning + +macOS may block `picoclaw-launcher` on first launch because it is downloaded from the internet and not notarized through the Mac App Store. + +**Step 1:** Double-click `picoclaw-launcher`. You will see a security warning: + +

+macOS Gatekeeper warning +

+ +> *"picoclaw-launcher" Not Opened — Apple could not verify "picoclaw-launcher" is free of malware that may harm your Mac or compromise your privacy.* + +**Step 2:** Open **System Settings** → **Privacy & Security** → scroll down to the **Security** section → click **Open Anyway** → confirm by clicking **Open Anyway** in the dialog. + +

+macOS Privacy & Security — Open Anyway +

+ +After this one-time step, `picoclaw-launcher` will open normally on subsequent launches. + +
+ + +### 📱 Android + +Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. + +**Option 1: APK Install** + +Preview: + + + + + + + + +
+ +Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required! + +**Option 2: Termux** + +
+Terminal Launcher (for resource-constrained environments) + +1. 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) +2. Run the following commands: + +```bash +# Download the latest release +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz +pkg install proot +termux-chroot ./picoclaw onboard # chroot provides a standard Linux filesystem layout +``` + +Then follow the Terminal Launcher section below to complete configuration. + +PicoClaw on Termux + +For minimal environments where only the `picoclaw` core binary is available (no Launcher UI), you can configure everything via the command line and a JSON config file. **1. Initialize** @@ -224,1270 +339,308 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d picoclaw onboard ``` +This creates `~/.picoclaw/config.json` and the workspace directory. + **2. Configure** (`~/.picoclaw/config.json`) ```json { "agents": { "defaults": { - "workspace": "~/.picoclaw/workspace", - "model_name": "gpt4", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 + "model_name": "gpt-5.4" } }, "model_list": [ { - "model_name": "gpt4", - "model": "openai/gpt-5.2", - "api_key": "your-api-key", - "request_timeout": 300 - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" - } - ], - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "tavily": { - "enabled": false, - "api_key": "YOUR_TAVILY_API_KEY", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - }, - "perplexity": { - "enabled": false, - "api_key": "YOUR_PERPLEXITY_API_KEY", - "max_results": 5 - }, - "searxng": { - "enabled": false, - "base_url": "http://your-searxng-instance:8888", - "max_results": 5 - } - } - } -} -``` - -> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details. -> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s). - -**3. Get API Keys** - -* **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) -* **Web Search** (optional): - * [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month) - * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface - * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed) - * [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) - * DuckDuckGo - Built-in fallback (no API key required) - -> **Note**: See `config.example.json` for a complete configuration template. - -**4. Chat** - -```bash -picoclaw agent -m "What is 2+2?" -``` - -That's it! You have a working AI assistant in 2 minutes. - ---- - -## 💬 Chat Apps - -Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom - -> **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. - -| Channel | Setup | -| ------------ | ---------------------------------- | -| **Telegram** | Easy (just a token) | -| **Discord** | Easy (bot token + intents) | -| **WhatsApp** | Easy (native: QR scan; or bridge URL) | -| **Matrix** | Medium (homeserver + bot access token) | -| **QQ** | Easy (AppID + AppSecret) | -| **DingTalk** | Medium (app credentials) | -| **LINE** | Medium (credentials + webhook URL) | -| **WeCom AI Bot** | Medium (Token + AES key) | - -
-Telegram (Recommended) - -**1. Create a bot** - -* Open Telegram, search `@BotFather` -* Send `/newbot`, follow prompts -* Copy the token - -**2. Configure** - -```json -{ - "channels": { - "telegram": { - "enabled": true, - "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] - } - } -} -``` - -> Get your user ID from `@userinfobot` on Telegram. - -**3. Run** - -```bash -picoclaw gateway -``` - -**4. Telegram command menu (auto-registered at startup)** - -PicoClaw 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. -Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor. - -If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background. - -
- -
-Discord - -**1. Create a bot** - -* Go to -* Create an application → Bot → Add Bot -* Copy the bot token - -**2. Enable intents** - -* In the Bot settings, enable **MESSAGE CONTENT INTENT** -* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data - -**3. Get your User ID** -* Discord Settings → Advanced → enable **Developer Mode** -* Right-click your avatar → **Copy User ID** - -**4. Configure** - -```json -{ - "channels": { - "discord": { - "enabled": true, - "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] - } - } -} -``` - -**5. Invite the bot** - -* OAuth2 → URL Generator -* Scopes: `bot` -* Bot Permissions: `Send Messages`, `Read Message History` -* Open the generated invite URL and add the bot to your server - -**Optional: Group trigger mode** - -By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add: - -```json -{ - "channels": { - "discord": { - "group_trigger": { "mention_only": true } - } - } -} -``` - -You can also trigger by keyword prefixes (e.g. `!bot`): - -```json -{ - "channels": { - "discord": { - "group_trigger": { "prefixes": ["!bot"] } - } - } -} -``` - -**6. Run** - -```bash -picoclaw gateway -``` - -
- -
-WhatsApp (native via whatsmeow) - -PicoClaw can connect to WhatsApp in two ways: - -- **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/...`). -- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false. - -**Configure (native)** - -```json -{ - "channels": { - "whatsapp": { - "enabled": true, - "use_native": true, - "session_store_path": "", - "allow_from": [] - } - } -} -``` - -If `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. - -
- -
-QQ - -**1. Create a bot** - -- Go to [QQ Open Platform](https://q.qq.com/#) -- Create an application → Get **AppID** and **AppSecret** - -**2. Configure** - -```json -{ - "channels": { - "qq": { - "enabled": true, - "app_id": "YOUR_APP_ID", - "app_secret": "YOUR_APP_SECRET", - "allow_from": [] - } - } -} -``` - -> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access. - -**3. Run** - -```bash -picoclaw gateway -``` - -
- -
-DingTalk - -**1. Create a bot** - -* Go to [Open Platform](https://open.dingtalk.com/) -* Create an internal app -* Copy Client ID and Client Secret - -**2. Configure** - -```json -{ - "channels": { - "dingtalk": { - "enabled": true, - "client_id": "YOUR_CLIENT_ID", - "client_secret": "YOUR_CLIENT_SECRET", - "allow_from": [] - } - } -} -``` - -> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access. - -**3. Run** - -```bash -picoclaw gateway -``` -
- -
-Matrix - -**1. Prepare bot account** - -* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted) -* Create a bot user and obtain its access token - -**2. Configure** - -```json -{ - "channels": { - "matrix": { - "enabled": true, - "homeserver": "https://matrix.org", - "user_id": "@your-bot:matrix.org", - "access_token": "YOUR_MATRIX_ACCESS_TOKEN", - "allow_from": [] - } - } -} -``` - -**3. Run** - -```bash -picoclaw gateway -``` - -For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md). - -
- -
-LINE - -**1. Create a LINE Official Account** - -- Go to [LINE Developers Console](https://developers.line.biz/) -- Create a provider → Create a Messaging API channel -- Copy **Channel Secret** and **Channel Access Token** - -**2. Configure** - -```json -{ - "channels": { - "line": { - "enabled": true, - "channel_secret": "YOUR_CHANNEL_SECRET", - "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", - "webhook_path": "/webhook/line", - "allow_from": [] - } - } -} -``` - -> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). - -**3. Set up Webhook URL** - -LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel: - -```bash -# Example with ngrok (gateway default port is 18790) -ngrok http 18790 -``` - -Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**. - -**4. Run** - -```bash -picoclaw gateway -``` - -> In group chats, the bot responds only when @mentioned. Replies quote the original message. - -
- -
-WeCom (企业微信) - -PicoClaw supports three types of WeCom integration: - -**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats -**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only -**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat - -See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions. - -**Quick Setup - WeCom Bot:** - -**1. Create a bot** - -* Go to WeCom Admin Console → Group Chat → Add Group Bot -* Copy the webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) - -**2. Configure** - -```json -{ - "channels": { - "wecom": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_path": "/webhook/wecom", - "allow_from": [] - } - } -} -``` - -> WeCom webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). - -**Quick Setup - WeCom App:** - -**1. Create an app** - -* Go to WeCom Admin Console → App Management → Create App -* Copy **AgentId** and **Secret** -* Go to "My Company" page, copy **CorpID** - -**2. Configure receive message** - -* In App details, click "Receive Message" → "Set API" -* Set URL to `http://your-server:18790/webhook/wecom-app` -* Generate **Token** and **EncodingAESKey** - -**3. Configure** - -```json -{ - "channels": { - "wecom_app": { - "enabled": true, - "corp_id": "wwxxxxxxxxxxxxxxxx", - "corp_secret": "YOUR_CORP_SECRET", - "agent_id": 1000002, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-app", - "allow_from": [] - } - } -} -``` - -**4. Run** - -```bash -picoclaw gateway -``` - -> **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS. - -**Quick Setup - WeCom AI Bot:** - -**1. Create an AI Bot** - -* Go to WeCom Admin Console → App Management → AI Bot -* In the AI Bot settings, configure callback URL: `http://your-server:18791/webhook/wecom-aibot` -* Copy **Token** and click "Random Generate" for **EncodingAESKey** - -**2. Configure** - -```json -{ - "channels": { - "wecom_aibot": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-aibot", - "allow_from": [], - "welcome_message": "Hello! How can I help you?" - } - } -} -``` - -**3. Run** - -```bash -picoclaw gateway -``` - -> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery. - -
- -## ClawdChat Join the Agent Social Network - -Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App. - -**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)** - -## ⚙️ Configuration - -Config file: `~/.picoclaw/config.json` - -### Environment Variables - -You 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. - -| Variable | Description | Default Path | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| -| `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` | -| `PICOCLAW_HOME` | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories. | `~/.picoclaw` | - -**Examples:** - -```bash -# Run picoclaw using a specific config file -# The workspace path will be read from within that config file -PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway - -# Run picoclaw with all its data stored in /opt/picoclaw -# Config will be loaded from the default ~/.picoclaw/config.json -# Workspace will be created at /opt/picoclaw/workspace -PICOCLAW_HOME=/opt/picoclaw picoclaw agent - -# Use both for a fully customized setup -PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway -``` - -### Workspace Layout - -PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`): - -``` -~/.picoclaw/workspace/ -├── sessions/ # Conversation sessions and history -├── memory/ # Long-term memory (MEMORY.md) -├── state/ # Persistent state (last channel, etc.) -├── cron/ # Scheduled jobs database -├── skills/ # Custom skills -├── AGENTS.md # Agent behavior guide -├── HEARTBEAT.md # Periodic task prompts (checked every 30 min) -├── IDENTITY.md # Agent identity -├── SOUL.md # Agent soul -├── TOOLS.md # Tool descriptions -└── USER.md # User preferences -``` - -### Skill Sources - -By default, skills are loaded from: - -1. `~/.picoclaw/workspace/skills` (workspace) -2. `~/.picoclaw/skills` (global) -3. `/skills` (builtin) - -For advanced/test setups, you can override the builtin skills root with: - -```bash -export PICOCLAW_BUILTIN_SKILLS=/path/to/skills -``` - -### Unified Command Execution Policy - -- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`. -- 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. -- Unknown slash command (for example `/foo`) passes through to normal LLM processing. -- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing. -### 🔒 Security Sandbox - -PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. - -#### Default Configuration - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "restrict_to_workspace": true - } - } -} -``` - -| Option | Default | Description | -| ----------------------- | ----------------------- | ----------------------------------------- | -| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent | -| `restrict_to_workspace` | `true` | Restrict file/command access to workspace | - -#### Protected Tools - -When `restrict_to_workspace: true`, the following tools are sandboxed: - -| Tool | Function | Restriction | -| ------------- | ---------------- | -------------------------------------- | -| `read_file` | Read files | Only files within workspace | -| `write_file` | Write files | Only files within workspace | -| `list_dir` | List directories | Only directories within workspace | -| `edit_file` | Edit files | Only files within workspace | -| `append_file` | Append to files | Only files within workspace | -| `exec` | Execute commands | Command paths must be within workspace | - -#### Additional Exec Protection - -Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands: - -* `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion -* `format`, `mkfs`, `diskpart` — Disk formatting -* `dd if=` — Disk imaging -* Writing to `/dev/sd[a-z]` — Direct disk writes -* `shutdown`, `reboot`, `poweroff` — System shutdown -* Fork bomb `:(){ :|:& };:` - -#### Error Examples - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (path outside working dir)} -``` - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} -``` - -#### Disabling Restrictions (Security Risk) - -If you need the agent to access paths outside the workspace: - -**Method 1: Config file** - -```json -{ - "agents": { - "defaults": { - "restrict_to_workspace": false - } - } -} -``` - -**Method 2: Environment variable** - -```bash -export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false -``` - -> ⚠️ **Warning**: Disabling this restriction allows the agent to access any path on your system. Use with caution in controlled environments only. - -#### Security Boundary Consistency - -The `restrict_to_workspace` setting applies consistently across all execution paths: - -| Execution Path | Security Boundary | -| ---------------- | ---------------------------- | -| Main Agent | `restrict_to_workspace` ✅ | -| Subagent / Spawn | Inherits same restriction ✅ | -| Heartbeat tasks | Inherits same restriction ✅ | - -All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks. - -### Heartbeat (Periodic Tasks) - -PicoClaw can perform periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace: - -```markdown -# Periodic Tasks - -- Check my email for important messages -- Review my calendar for upcoming events -- Check the weather forecast -``` - -The agent will read this file every 30 minutes (configurable) and execute any tasks using available tools. - -#### Async Tasks with Spawn - -For long-running tasks (web search, API calls), use the `spawn` tool to create a **subagent**: - -```markdown -# Periodic Tasks - -## Quick Tasks (respond directly) - -- Report current time - -## Long Tasks (use spawn for async) - -- Search the web for AI news and summarize -- Check email and report important messages -``` - -**Key behaviors:** - -| Feature | Description | -| ----------------------- | --------------------------------------------------------- | -| **spawn** | Creates async subagent, doesn't block heartbeat | -| **Independent context** | Subagent has its own context, no session history | -| **message tool** | Subagent communicates with user directly via message tool | -| **Non-blocking** | After spawning, heartbeat continues to next task | - -#### How Subagent Communication Works - -``` -Heartbeat triggers - ↓ -Agent reads HEARTBEAT.md - ↓ -For long task: spawn subagent - ↓ ↓ -Continue to next task Subagent works independently - ↓ ↓ -All tasks done Subagent uses "message" tool - ↓ ↓ -Respond HEARTBEAT_OK User receives result directly -``` - -The subagent has access to tools (message, web_search, etc.) and can communicate with the user independently without going through the main agent. - -**Configuration:** - -```json -{ - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -| Option | Default | Description | -| ---------- | ------- | ---------------------------------- | -| `enabled` | `true` | Enable/disable heartbeat | -| `interval` | `30` | Check interval in minutes (min: 5) | - -**Environment variables:** - -* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable -* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval - -### Providers - -> [!NOTE] -> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level. - -| Provider | Purpose | Get API Key | -| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- | -| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | -| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | -| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | -| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | -| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | -| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | -| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | -| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | -| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | -| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | - -### Model Configuration (model_list) - -> **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!** - -This design also enables **multi-agent support** with flexible provider selection: - -- **Different agents, different providers**: Each agent can use its own LLM provider -- **Model fallbacks**: Configure primary and fallback models for resilience -- **Load balancing**: Distribute requests across multiple endpoints -- **Centralized configuration**: Manage all providers in one place - -#### 📋 All Supported Vendors - -| Vendor | `model` Prefix | Default API Base | Protocol | API Key | -| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | -| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | -| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | -| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | -| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | -| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | -| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | -| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | - -#### Basic Configuration - -```json -{ - "model_list": [ - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_key": "sk-your-openai-key" - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" - }, - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" - } - ], - "agents": { - "defaults": { - "model": "gpt-5.2" - } - } -} -``` - -#### Vendor-Specific Examples - -**OpenAI** - -```json -{ - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_key": "sk-..." -} -``` - -**智谱 AI (GLM)** - -```json -{ - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" -} -``` - -**DeepSeek** - -```json -{ - "model_name": "deepseek-chat", - "model": "deepseek/deepseek-chat", - "api_key": "sk-..." -} -``` - -**Anthropic (with API key)** - -```json -{ - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" -} -``` - -> Run `picoclaw auth login --provider anthropic` to paste your API token. - -**Ollama (local)** - -```json -{ - "model_name": "llama3", - "model": "ollama/llama3" -} -``` - -**Custom Proxy/API** - -```json -{ - "model_name": "my-custom-model", - "model": "openai/custom-model", - "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", - "request_timeout": 300 -} -``` - -**LiteLLM Proxy** - -```json -{ - "model_name": "lite-gpt4", - "model": "litellm/lite-gpt4", - "api_base": "http://localhost:4000/v1", - "api_key": "sk-..." -} -``` - -PicoClaw 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`. - -#### Load Balancing - -Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them: - -```json -{ - "model_list": [ - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" - }, - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4" + // api_key is now loaded from .security.yml } ] } ``` -#### Migration from Legacy `providers` Config +> See `config/config.example.json` in the repo for a complete configuration template with all available options. +> +> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security/security_configuration.md` for more details. -The old `providers` configuration is **deprecated** but still supported for backward compatibility. -**Old Config (deprecated):** +**3. Chat** -```json -{ - "providers": { - "zhipu": { - "api_key": "your-key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - }, - "agents": { - "defaults": { - "provider": "zhipu", - "model": "glm-4.7" - } - } -} +```bash +# One-shot question +picoclaw agent -m "What is 2+2?" + +# Interactive mode +picoclaw agent + +# Start gateway for chat app integration +picoclaw gateway ``` -**New Config (recommended):** +
+## 🔌 Providers (LLM) + +PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use the `protocol/model` format: + +| Provider | Protocol | API Key | Notes | +|----------|----------|---------|-------| +| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Required | GPT-5.4, GPT-4o, o3, etc. | +| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Required | Claude Opus 4.6, Sonnet 4.6, etc. | +| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Required | Gemini 3 Flash, 2.5 Pro, etc. | +| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Required | 200+ models, unified API | +| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Required | GLM-4.7, GLM-5, etc. | +| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Required | DeepSeek-V3, DeepSeek-R1 | +| [Volcengine](https://console.volcengine.com) | `volcengine/` | Required | Doubao, Ark models | +| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Required | Qwen3, Qwen-Max, etc. | +| [Groq](https://console.groq.com/keys) | `groq/` | Required | Fast inference (Llama, Mixtral) | +| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Required | Kimi models | +| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Required | MiniMax models | +| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Required | Mistral Large, Codestral | +| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Required | NVIDIA hosted models | +| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Required | Fast inference | +| [Novita AI](https://novita.ai/) | `novita/` | Required | Various open models | +| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Required | MiMo models | +| [Ollama](https://ollama.com/) | `ollama/` | Not needed | Local models, self-hosted | +| [vLLM](https://docs.vllm.ai/) | `vllm/` | Not needed | Local deployment, OpenAI-compatible | +| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Varies | Proxy for 100+ providers | +| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Required | Enterprise Azure deployment | +| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Device code login | +| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI | +| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS credentials | Claude, Llama, Mistral on AWS | + +> \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile. + +
+Local deployment (Ollama, vLLM, etc.) + +**Ollama:** ```json { "model_list": [ { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" + "model_name": "local-llama", + "model": "ollama/llama3.1:8b", + "api_base": "http://localhost:11434/v1" } - ], - "agents": { - "defaults": { - "model": "glm-4.7" - } - } + ] } ``` -For detailed migration guide, see [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). - -### Provider Architecture - -PicoClaw routes providers by protocol family: - -- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints. -- Anthropic protocol: Claude-native API behavior. -- Codex/OAuth path: OpenAI OAuth/token authentication route. - -This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`). - -
-Zhipu - -**1. Get API key and base URL** - -* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) - -**2. Configure** - +**vLLM:** ```json { - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 + "model_list": [ + { + "model_name": "local-vllm", + "model": "vllm/your-model", + "api_base": "http://localhost:8000/v1" } - }, - "providers": { - "zhipu": { - "api_key": "Your API Key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - } + ] } ``` -**3. Run** +For full provider configuration details, see [Providers & Models](docs/guides/providers.md). + +
+ +## 💬 Channels (Chat Apps) + +Talk to your PicoClaw through 18+ messaging platforms: + +| Channel | Setup | Protocol | Docs | +|---------|-------|----------|------| +| **Telegram** | Easy (bot token) | Long polling | [Guide](docs/channels/telegram/README.md) | +| **Discord** | Easy (bot token + intents) | WebSocket | [Guide](docs/channels/discord/README.md) | +| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/guides/chat-apps.md#whatsapp) | +| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/guides/chat-apps.md#weixin) | +| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) | +| **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) | +| **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) | +| **DingTalk** | Medium (client credentials) | Stream | [Guide](docs/channels/dingtalk/README.md) | +| **Feishu / Lark** | Medium (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.md) | +| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) | +| **WeCom** | Easy (QR login or manual) | WebSocket | [Guide](docs/channels/wecom/README.md) | +| **VK** | Easy (group token) | Long Poll | [Guide](docs/channels/vk/README.md) | +| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/guides/chat-apps.md#irc) | +| **OneBot** | Medium (WebSocket URL) | OneBot v11 | [Guide](docs/channels/onebot/README.md) | +| **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) | +| **Pico** | Easy (enable) | Native protocol | Built-in | +| **Pico Client** | Easy (WebSocket URL) | WebSocket | Built-in | + +> All webhook-based channels share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu uses WebSocket/SDK mode and does not use the shared HTTP server. + +> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/guides/configuration.md#gateway-log-level) for details. + +For detailed channel setup instructions, see [Chat Apps Configuration](docs/guides/chat-apps.md). + +## 🔧 Tools + +### 🔍 Web Search + +PicoClaw can search the web to provide up-to-date information. Configure in `tools.web`: + +| Search Engine | API Key | Free Tier | Link | +|--------------|---------|-----------|------| +| DuckDuckGo | Not needed | Unlimited | Built-in fallback | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1000 queries/day | AI-powered, China-optimized | +| [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents | +| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private | +| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search | +| [SearXNG](https://github.com/searxng/searxng) | Not needed | Self-hosted | Free metasearch engine | +| [GLM Search](https://open.bigmodel.cn/) | Required | Varies | Zhipu web search | + +### ⚙️ Other Tools + +PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/reference/tools_configuration.md) for details. + +## 🎯 Skills + +Skills are modular capabilities that extend your Agent. They are loaded from `SKILL.md` files in your workspace. + +**Install skills from ClawHub:** ```bash -picoclaw agent -m "Hello" +picoclaw skills search "web scraping" +picoclaw skills install ``` -
- -
-Full config example +**Configure skill registries**: +Add to your `config.json`: ```json { - "agents": { - "defaults": { - "model": "anthropic/claude-opus-4-5" - } - }, - "session": { - "dm_scope": "per-channel-peer", - "backlog_limit": 20 - }, - "providers": { - "openrouter": { - "api_key": "sk-or-v1-xxx" - }, - "groq": { - "api_key": "gsk_xxx" - } - }, - "channels": { - "telegram": { - "enabled": true, - "token": "123456:ABC...", - "allow_from": ["123456789"] - }, - "discord": { - "enabled": true, - "token": "", - "allow_from": [""] - }, - "whatsapp": { - "enabled": false, - "bridge_url": "ws://localhost:3001", - "use_native": false, - "session_store_path": "", - "allow_from": [] - }, - "feishu": { - "enabled": false, - "app_id": "cli_xxx", - "app_secret": "xxx", - "encrypt_key": "", - "verification_token": "", - "allow_from": [] - }, - "qq": { - "enabled": false, - "app_id": "", - "app_secret": "", - "allow_from": [] - } - }, "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "BSA...", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - }, - "perplexity": { - "enabled": false, - "api_key": "", - "max_results": 5 - }, - "searxng": { - "enabled": false, - "base_url": "http://localhost:8888", - "max_results": 5 + "skills": { + "registries": { + "clawhub": { + "auth_token": "your-clawhub-token" + }, + "github": { + "base_url": "https://github.com", + "auth_token": "your-github-token", + "proxy": "" + } } - }, - "cron": { - "exec_timeout_minutes": 5 } - }, - "heartbeat": { - "enabled": true, - "interval": 30 } } ``` -
+`tools.skills.github.*` is deprecated. Use `tools.skills.registries.github.*` instead. -## CLI Reference +For more details, see [Tools Configuration - Skills](docs/reference/tools_configuration.md#skills-tool). -| Command | Description | -| ------------------------- | ----------------------------- | -| `picoclaw onboard` | Initialize config & workspace | -| `picoclaw agent -m "..."` | Chat with the agent | -| `picoclaw agent` | Interactive chat mode | -| `picoclaw gateway` | Start the gateway | -| `picoclaw status` | Show status | -| `picoclaw cron list` | List all scheduled jobs | -| `picoclaw cron add ...` | Add a scheduled job | +## 🔗 MCP (Model Context Protocol) -### Scheduled Tasks / Reminders +PicoClaw natively supports [MCP](https://modelcontextprotocol.io/) — connect any MCP server to extend your Agent's capabilities with external tools and data sources. + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } + } + } +} +``` + +You can manage common MCP setups directly from the CLI instead of editing JSON by hand: + +```bash +picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp +picoclaw mcp list +picoclaw mcp test filesystem +``` + +`picoclaw mcp` is a configuration manager: it updates `config.json` under `tools.mcp.servers`, but it does not keep the server process running itself. + +Use `picoclaw mcp edit` when you need advanced fields that are not covered by `picoclaw mcp add`. +For example, `picoclaw mcp add` supports `--deferred` and `--env-file`, while `picoclaw mcp edit` is still useful for direct JSON editing and uncommon MCP settings. + +For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/reference/tools_configuration.md#mcp-tool). For CLI usage and examples, see [MCP Server CLI](docs/reference/mcp-cli.md). + +## ClawdChat Join the Agent Social Network + +Connect PicoClaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App. + +**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)** + +## 🖥️ CLI Reference + +| Command | Description | +| ------------------------- | -------------------------------- | +| `picoclaw onboard` | Initialize config & workspace | +| `picoclaw auth weixin` | Connect WeChat account via QR | +| `picoclaw agent -m "..."` | Chat with the agent | +| `picoclaw agent` | Interactive chat mode | +| `picoclaw gateway` | Start the gateway | +| `picoclaw status` | Show status | +| `picoclaw version` | Show version info | +| `picoclaw model` | View or switch the default model | +| `picoclaw mcp list` | List configured MCP servers | +| `picoclaw mcp add ...` | Add or update an MCP server entry | +| `picoclaw mcp test` | Probe a configured MCP server | +| `picoclaw mcp edit` | Open config for advanced MCP editing | +| `picoclaw mcp remove` | Remove an MCP server entry | +| `picoclaw cron list` | List all scheduled jobs | +| `picoclaw cron add ...` | Add a scheduled job | +| `picoclaw cron disable` | Disable a scheduled job | +| `picoclaw cron remove` | Remove a scheduled job | +| `picoclaw skills list` | List installed skills | +| `picoclaw skills install` | Install a skill | +| `picoclaw migrate` | Migrate data from older versions | +| `picoclaw auth login` | Authenticate with providers | + +### ⏰ Scheduled Tasks / Reminders PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool: -* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min -* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours -* **Cron expressions**: "Remind me at 9am daily" → uses cron expression +* **One-time reminders**: "Remind me in 10 minutes" -> triggers once after 10min +* **Recurring tasks**: "Remind me every 2 hours" -> triggers every 2 hours +* **Cron expressions**: "Remind me at 9am daily" -> uses cron expression -Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically. +See [docs/reference/cron.md](docs/reference/cron.md) for current schedule types, execution modes, command-job gates, and persistence details. + +## 📚 Documentation + +For detailed guides beyond this README: + +| Topic | Description | +|-------|-------------| +| [Docker & Quick Start](docs/guides/docker.md) | Docker Compose setup, Launcher/Agent modes | +| [Chat Apps](docs/guides/chat-apps.md) | All 17+ channel setup guides | +| [Configuration](docs/guides/configuration.md) | Environment variables, workspace layout, security sandbox | +| [MCP Server CLI](docs/reference/mcp-cli.md) | Add, list, test, edit, and remove MCP server entries from the CLI | +| [Scheduled Tasks and Cron Jobs](docs/reference/cron.md) | Cron schedule types, deliver modes, command gates, job storage | +| [Providers & Models](docs/guides/providers.md) | 30+ LLM providers, model routing, model_list configuration | +| [Spawn & Async Tasks](docs/guides/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration | +| [Hooks](docs/architecture/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks | +| [Steering](docs/architecture/steering.md) | Inject messages into a running agent loop between tool calls | +| [SubTurn](docs/architecture/subturn.md) | Subagent coordination, concurrency control, lifecycle | +| [Troubleshooting](docs/operations/troubleshooting.md) | Common issues and solutions | +| [Tools Configuration](docs/reference/tools_configuration.md) | Per-tool enable/disable, exec policies, MCP, Skills | +| [Hardware Compatibility](docs/guides/hardware-compatibility.md) | Tested boards, minimum requirements | ## 🤝 Contribute & Roadmap -PRs welcome! The codebase is intentionally small and readable. 🤗 +PRs welcome! The codebase is intentionally small and readable. -See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md). +See our [Community Roadmap](https://github.com/sipeed/picoclaw/issues/988) and [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. Developer group building, join after your first merged PR! User Groups: -discord: +Discord: -PicoClaw - -## 🐛 Troubleshooting - -### Web search says "API key configuration issue" - -This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching. - -#### Search Provider Priority - -PicoClaw automatically selects the best available search provider in this order: -1. **Perplexity** (if enabled and API key configured) - AI-powered search with citations -2. **Brave Search** (if enabled and API key configured) - Privacy-focused paid API ($5/1000 queries) -3. **SearXNG** (if enabled and base_url configured) - Self-hosted metasearch aggregating 70+ engines (free) -4. **DuckDuckGo** (if enabled, default fallback) - No API key required (free) - -#### Web Search Configuration Options - -**Option 1 (Best Results)**: Perplexity AI Search -```json -{ - "tools": { - "web": { - "perplexity": { - "enabled": true, - "api_key": "YOUR_PERPLEXITY_API_KEY", - "max_results": 5 - } - } - } -} -``` - -**Option 2 (Paid API)**: Get an API key at [https://brave.com/search/api](https://brave.com/search/api) ($5/1000 queries, ~$5-6/month) -```json -{ - "tools": { - "web": { - "brave": { - "enabled": true, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - } - } - } -} -``` - -**Option 3 (Self-Hosted)**: Deploy your own [SearXNG](https://github.com/searxng/searxng) instance -```json -{ - "tools": { - "web": { - "searxng": { - "enabled": true, - "base_url": "http://your-server:8888", - "max_results": 5 - } - } - } -} -``` - -Benefits of SearXNG: -- **Zero cost**: No API fees or rate limits -- **Privacy-focused**: Self-hosted, no tracking -- **Aggregate results**: Queries 70+ search engines simultaneously -- **Perfect for cloud VMs**: Solves datacenter IP blocking issues (Oracle Cloud, GCP, AWS, Azure) -- **No API key needed**: Just deploy and configure the base URL - -**Option 4 (No Setup Required)**: DuckDuckGo is enabled by default as fallback (no API key needed) - -Add the key to `~/.picoclaw/config.json` if using Brave: - -```json -{ - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - }, - "perplexity": { - "enabled": false, - "api_key": "YOUR_PERPLEXITY_API_KEY", - "max_results": 5 - }, - "searxng": { - "enabled": false, - "base_url": "http://your-searxng-instance:8888", - "max_results": 5 - } - } - } -} -``` - -### Getting content filtering errors - -Some providers (like Zhipu) have content filtering. Try rephrasing your query or use a different model. - -### Telegram bot says "Conflict: terminated by other getUpdates" - -This happens when another instance of the bot is running. Make sure only one `picoclaw gateway` is running at a time. - ---- - -## 📝 API Key Comparison - -| Service | Free Tier | Use Case | -| ---------------- | ------------------------ | ------------------------------------- | -| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | -| **Zhipu** | 200K tokens/month | Best for Chinese users | -| **Brave Search** | Paid ($5/1000 queries) | Web search functionality | -| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) | -| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | -| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | +WeChat: +WeChat group QR code diff --git a/README.pt-br.md b/README.pt-br.md deleted file mode 100644 index 5f37ba457..000000000 --- a/README.pt-br.md +++ /dev/null @@ -1,1202 +0,0 @@ -
-PicoClaw - -

PicoClaw: Assistente de IA Ultra-Eficiente em Go

- -

Hardware de $10 · 10MB de RAM · Boot em 1s · 皮皮虾,我们走!

- -

- Go - Hardware - License -
- Website - Twitter -

- - [中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) -
- ---- - -🦐 **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. - -⚡️ **Extremamente leve:** Roda em hardware de apenas **$10** com **<10MB** de RAM. Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini! - - - - - - -
-

- -

-
-

- -

-
- -> [!CAUTION] -> **🚨 DECLARAÇÃO DE SEGURANÇA & CANAIS OFICIAIS** -> -> * **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**. -> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é o **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é o **[sipeed.com](https://sipeed.com)**. -> * **Aviso:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros, não são nossos. -> * **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. -> * **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. - - -## 📢 Novidades - -2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/ROADMAP.md) — estamos ansiosos para ter você a bordo! - -2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw. - -🚀 **Chamada para Ação:** Envie suas solicitações de funcionalidades nas GitHub Discussions. Revisaremos e priorizaremos na próxima reunião semanal. - -2026-02-09 🎉 PicoClaw lançado oficialmente! Construído em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu! - -## ✨ Funcionalidades - -🪶 **Ultra-Leve**: Consumo de memória <10MB — 99% menor que o Clawdbot para funcionalidades essenciais. - -💰 **Custo Mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini. - -⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz. - -🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM, MIPS e x86. Um clique e já era! - -🤖 **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. - -| | OpenClaw | NanoBot | **PicoClaw** | -| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | -| **Linguagem** | TypeScript | Python | **Go** | -| **RAM** | >1GB | >100MB | **< 10MB** | -| **Inicialização**
(CPU 0.8GHz) | >500s | >30s | **<1s** | -| **Custo** | Mac Mini $599 | Maioria dos SBC Linux
~$50 | **Qualquer placa Linux**
**A partir de $10** | - -PicoClaw - -## 🦾 Demonstração - -### 🛠️ Fluxos de Trabalho Padrão do Assistente - - - - - - - - - - - - - - - - - -

🧩 Engenharia Full-Stack

🗂️ Gerenciamento de Logs & Planejamento

🔎 Busca Web & Aprendizado

Desenvolver • Implantar • EscalarAgendar • Automatizar • MemorizarDescobrir • Analisar • Tendências
- -### 📱 Rode em celulares Android antigos - -Dê 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: - -1. **Instale o Termux** (Disponível no F-Droid ou Google Play). -2. **Execute os comandos** - -```bash -# Nota: Substitua v0.1.1 pela versao mais recente da pagina de Releases -wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 -chmod +x picoclaw-linux-arm64 -pkg install proot -termux-chroot ./picoclaw-linux-arm64 onboard -``` - -Depois siga as instruções na seção "Início Rápido" para completar a configuração! - -PicoClaw - -### 🐜 Implantação Inovadora com Baixo Consumo - -O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux! - -- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versão E (Ethernet) ou W (WiFi6), para Assistente Doméstico Minimalista -- $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 -- $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 - -https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4 - -🌟 Mais cenários de implantação aguardam você! - -## 📦 Instalação - -### Instalar com binário pré-compilado - -Baixe o binário para sua plataforma na página de [releases](https://github.com/sipeed/picoclaw/releases). - -### Instalar a partir do código-fonte (funcionalidades mais recentes, recomendado para desenvolvimento) - -```bash -git clone https://github.com/sipeed/picoclaw.git - -cd picoclaw -make deps - -# Build, sem necessidade de instalar -make build - -# Build para multiplas plataformas -make build-all - -# Build e Instalar -make install -``` - -## 🐳 Docker Compose - -Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada localmente. - -```bash -# 1. Clone este repositorio -git clone https://github.com/sipeed/picoclaw.git -cd picoclaw - -# 2. Primeiro uso — gera docker/data/config.json automaticamente e para -docker compose -f docker/docker-compose.yml --profile gateway up -# O contêiner exibe "First-run setup complete." e para. - -# 3. Configure suas API keys -vim docker/data/config.json # Chaves de API do provedor, tokens de bot, etc. - -# 4. Iniciar -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -> [!TIP] -> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`. - -```bash -# 5. Ver logs -docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway - -# 6. Parar -docker compose -f docker/docker-compose.yml --profile gateway down -``` - -### Modo Agente (Execução única) - -```bash -# Fazer uma pergunta -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Quanto e 2+2?" - -# Modo interativo -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -``` - -### Atualizar - -```bash -docker compose -f docker/docker-compose.yml pull -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -### 🚀 Início Rápido - -> [!TIP] -> Configure sua API key em `~/.picoclaw/config.json`. -> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas grátis/mês) ou use o fallback automático integrado. - -**1. Inicializar** - -```bash -picoclaw onboard -``` - -**2. Configurar** (`~/.picoclaw/config.json`) - -```json -{ - "model_list": [ - { - "model_name": "gpt4", - "model": "openai/gpt-5.2", - "api_key": "sk-your-openai-key", - "request_timeout": 300, - "api_base": "https://api.openai.com/v1" - } - ], - "agents": { - "defaults": { - "model_name": "gpt4" - } - }, - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - } - } -} -``` - -> **Novo**: O formato de configuração `model_list` permite adicionar provedores sem alterar código. Veja [Configuração de Modelo](#configuração-de-modelo-model_list) para detalhes. -> `request_timeout` é opcional e usa segundos. Se omitido ou definido como `<= 0`, o PicoClaw usa o timeout padrão (120s). - -**3. Obter API Keys** - -* **Provedor de 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) -* **Busca Web** (opcional): [Brave Search](https://brave.com/search/api) - Plano gratuito disponível (2000 consultas/mês) - -> **Nota**: Veja `config.example.json` para um modelo de configuração completo. - -**4. Conversar** - -```bash -picoclaw agent -m "Quanto e 2+2?" -``` - -Pronto! Você tem um assistente de IA funcionando em 2 minutos. - ---- - -## 💬 Integração com Apps de Chat - -Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom. - -| Canal | Nível de Configuração | -| --- | --- | -| **Telegram** | Fácil (apenas um token) | -| **Discord** | Fácil (bot token + intents) | -| **QQ** | Fácil (AppID + AppSecret) | -| **DingTalk** | Médio (credenciais do app) | -| **LINE** | Médio (credenciais + webhook URL) | -| **WeCom AI Bot** | Médio (Token + chave AES) | - -
-Telegram (Recomendado) - -**1. Criar o bot** - -* Abra o Telegram, busque `@BotFather` -* Envie `/newbot`, siga as instruções -* Copie o token - -**2. Configurar** - -```json -{ - "channels": { - "telegram": { - "enabled": true, - "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] - } - } -} -``` - -> Obtenha seu User ID pelo `@userinfobot` no Telegram. - -**3. Executar** - -```bash -picoclaw gateway -``` - -
- -
-Discord - -**1. Criar o bot** - -* Acesse -* Crie um aplicativo → Bot → Add Bot -* Copie o token do bot - -**2. Habilitar Intents** - -* Nas configurações do Bot, habilite **MESSAGE CONTENT INTENT** -* (Opcional) Habilite **SERVER MEMBERS INTENT** se quiser usar lista de permissões baseada em dados dos membros - -**3. Obter seu User ID** - -* Configurações do Discord → Avançado → habilite **Modo Desenvolvedor** -* Clique com botão direito no seu avatar → **Copiar ID do Usuário** - -**4. Configurar** - -```json -{ - "channels": { - "discord": { - "enabled": true, - "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] - } - } -} -``` - -**5. Convidar o bot** - -* OAuth2 → URL Generator -* Scopes: `bot` -* Bot Permissions: `Send Messages`, `Read Message History` -* Abra a URL de convite gerada e adicione o bot ao seu servidor - -**6. Executar** - -```bash -picoclaw gateway -``` - -
- -
-QQ - -**1. Criar o bot** - -- Acesse a [QQ Open Platform](https://q.qq.com/#) -- Crie um aplicativo → Obtenha **AppID** e **AppSecret** - -**2. Configurar** - -```json -{ - "channels": { - "qq": { - "enabled": true, - "app_id": "YOUR_APP_ID", - "app_secret": "YOUR_APP_SECRET", - "allow_from": [] - } - } -} -``` - -> Deixe `allow_from` vazio para permitir todos os usuários, ou especifique números QQ para restringir o acesso. - -**3. Executar** - -```bash -picoclaw gateway -``` - -
- -
-DingTalk - -**1. Criar o bot** - -* Acesse a [Open Platform](https://open.dingtalk.com/) -* Crie um app interno -* Copie o Client ID e Client Secret - -**2. Configurar** - -```json -{ - "channels": { - "dingtalk": { - "enabled": true, - "client_id": "YOUR_CLIENT_ID", - "client_secret": "YOUR_CLIENT_SECRET", - "allow_from": [] - } - } -} -``` - -> Deixe `allow_from` vazio para permitir todos os usuários, ou especifique IDs para restringir o acesso. - -**3. Executar** - -```bash -picoclaw gateway -``` - -
- -
-LINE - -**1. Criar uma Conta Oficial LINE** - -- Acesse o [LINE Developers Console](https://developers.line.biz/) -- Crie um provider → Crie um canal Messaging API -- Copie o **Channel Secret** e o **Channel Access Token** - -**2. Configurar** - -```json -{ - "channels": { - "line": { - "enabled": true, - "channel_secret": "YOUR_CHANNEL_SECRET", - "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", - "webhook_path": "/webhook/line", - "allow_from": [] - } - } -} -``` - -**3. Configurar URL do Webhook** - -O LINE requer HTTPS para webhooks. Use um reverse proxy ou tunnel: - -```bash -# Exemplo com ngrok -ngrok http 18790 -``` - -Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**. - -> **Nota**: O webhook do LINE é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Use um proxy reverso/HTTPS ou túnel (como ngrok) para expor o Gateway de forma segura quando necessário. - -**4. Executar** - -```bash -picoclaw gateway -``` - -> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original. - -> **Docker Compose**: Se você usa Docker Compose, exponha o Gateway (padrão 127.0.0.1:18790) se precisar acessar o webhook LINE externamente, por exemplo `ports: ["18790:18790"]`. - -
- -
-WeCom (WeChat Work) - -O PicoClaw suporta três tipos de integração WeCom: - -**Opção 1: WeCom Bot (Robô)** - Configuração mais fácil, suporta chats em grupo -**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas, somente chat privado -**Opção 3: WeCom AI Bot (Robô Inteligente)** - Bot IA oficial, respostas em streaming, suporta grupo e privado - -Veja o [Guia de Configuração WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) para instruções detalhadas. - -**Configuração Rápida - WeCom Bot:** - -**1. Criar um bot** - -* Acesse o Console de Administração WeCom → Chat em Grupo → Adicionar Bot de Grupo -* Copie a URL do webhook (formato: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) - -**2. Configurar** - -```json -{ - "channels": { - "wecom": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_path": "/webhook/wecom", - "allow_from": [] - } - } -} -``` - -> **Nota**: O webhook do WeCom Bot é atendido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Use um proxy reverso/HTTPS ou túnel para expor o Gateway em produção. - -**Configuração Rápida - WeCom App:** - -**1. Criar um aplicativo** - -* Acesse o Console de Administração WeCom → Gerenciamento de Aplicativos → Criar Aplicativo -* Copie o **AgentId** e o **Secret** -* Acesse a página "Minha Empresa", copie o **CorpID** - -**2. Configurar recebimento de mensagens** - -* Nos detalhes do aplicativo, clique em "Receber Mensagens" → "Configurar API" -* Defina a URL como `http://your-server:18790/webhook/wecom-app` -* Gere o **Token** e o **EncodingAESKey** - -**3. Configurar** - -```json -{ - "channels": { - "wecom_app": { - "enabled": true, - "corp_id": "wwxxxxxxxxxxxxxxxx", - "corp_secret": "YOUR_CORP_SECRET", - "agent_id": 1000002, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-app", - "allow_from": [] - } - } -} -``` - -**4. Executar** - -```bash -picoclaw gateway -``` - -> **Nota**: O WeCom App (callbacks de webhook) é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Em produção use um proxy reverso HTTPS para expor a porta do Gateway, ou atualize `PICOCLAW_GATEWAY_HOST` para `0.0.0.0` se necessário. - -**Configuração Rápida - WeCom AI Bot:** - -**1. Criar um AI Bot** - -* Acesse o Console de Administração WeCom → Gerenciamento de Aplicativos → AI Bot -* Configure a URL de callback: `http://your-server:18791/webhook/wecom-aibot` -* Copie o **Token** e gere o **EncodingAESKey** - -**2. Configurar** - -```json -{ - "channels": { - "wecom_aibot": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-aibot", - "allow_from": [], - "welcome_message": "Olá! Como posso ajudá-lo?" - } - } -} -``` - -**3. Executar** - -```bash -picoclaw gateway -``` - -> **Nota**: O WeCom AI Bot usa protocolo de pull em streaming — sem preocupações com timeout de resposta. Tarefas longas (>5,5 min) alternam automaticamente para entrega via `response_url`. - -
- -## ClawdChat Junte-se a Rede Social de Agentes - -Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado. - -**Leia `https://clawdchat.ai/skill.md` e siga as instruções para entrar no [ClawdChat.ai](https://clawdchat.ai)** - -## ⚙️ Configuração Detalhada - -Arquivo de configuração: `~/.picoclaw/config.json` - -### Variáveis de Ambiente - -Você pode substituir os caminhos padrão usando variáveis de ambiente. Isso é útil para instalações portáteis, implantações em contêineres ou para executar o picoclaw como um serviço do sistema. Essas variáveis são independentes e controlam caminhos diferentes. - -| Variável | Descrição | Caminho Padrão | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| -| `PICOCLAW_CONFIG` | Substitui o caminho para o arquivo de configuração. Isso informa diretamente ao picoclaw qual `config.json` carregar, ignorando todos os outros locais. | `~/.picoclaw/config.json` | -| `PICOCLAW_HOME` | Substitui o diretório raiz dos dados do picoclaw. Isso altera o local padrão do `workspace` e de outros diretórios de dados. | `~/.picoclaw` | - -**Exemplos:** - -```bash -# Executar o picoclaw usando um arquivo de configuração específico -# O caminho do workspace será lido de dentro desse arquivo de configuração -PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway - -# Executar o picoclaw com todos os seus dados armazenados em /opt/picoclaw -# A configuração será carregada do ~/.picoclaw/config.json padrão -# O workspace será criado em /opt/picoclaw/workspace -PICOCLAW_HOME=/opt/picoclaw picoclaw agent - -# Use ambos para uma configuração totalmente personalizada -PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway -``` - -### Estrutura do Workspace - -O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`): - -``` -~/.picoclaw/workspace/ -├── sessions/ # Sessoes de conversa e historico -├── memory/ # Memoria de longo prazo (MEMORY.md) -├── state/ # Estado persistente (ultimo canal, etc.) -├── cron/ # Banco de dados de tarefas agendadas -├── skills/ # Skills personalizadas -├── AGENTS.md # Guia de comportamento do Agente -├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min) -├── IDENTITY.md # Identidade do Agente -├── SOUL.md # Alma do Agente -├── TOOLS.md # Descrição das ferramentas -└── USER.md # Preferencias do usuario -``` - -### 🔒 Sandbox de Segurança - -O PicoClaw roda em um ambiente sandbox por padrão. O agente so pode acessar arquivos e executar comandos dentro do workspace configurado. - -#### Configuração Padrão - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "restrict_to_workspace": true - } - } -} -``` - -| Opção | Padrão | Descrição | -|-------|--------|-----------| -| `workspace` | `~/.picoclaw/workspace` | Diretório de trabalho do agente | -| `restrict_to_workspace` | `true` | Restringir acesso de arquivos/comandos ao workspace | - -#### Ferramentas Protegidas - -Quando `restrict_to_workspace: true`, as seguintes ferramentas são restritas ao sandbox: - -| Ferramenta | Função | Restrição | -|------------|--------|-----------| -| `read_file` | Ler arquivos | Apenas arquivos dentro do workspace | -| `write_file` | Escrever arquivos | Apenas arquivos dentro do workspace | -| `list_dir` | Listar diretorios | Apenas diretorios dentro do workspace | -| `edit_file` | Editar arquivos | Apenas arquivos dentro do workspace | -| `append_file` | Adicionar a arquivos | Apenas arquivos dentro do workspace | -| `exec` | Executar comandos | Caminhos dos comandos devem estar dentro do workspace | - -#### Proteção Adicional do Exec - -Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos: - -* `rm -rf`, `del /f`, `rmdir /s` — Exclusão em massa -* `format`, `mkfs`, `diskpart` — Formatação de disco -* `dd if=` — Criação de imagem de disco -* Escrita em `/dev/sd[a-z]` — Escrita direta no disco -* `shutdown`, `reboot`, `poweroff` — Desligamento do sistema -* Fork bomb `:(){ :|:& };:` - -#### Exemplos de Erro - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (path outside working dir)} -``` - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} -``` - -#### Desabilitar Restrições (Risco de Segurança) - -Se você precisa que o agente acesse caminhos fora do workspace: - -**Método 1: Arquivo de configuração** - -```json -{ - "agents": { - "defaults": { - "restrict_to_workspace": false - } - } -} -``` - -**Método 2: Variável de ambiente** - -```bash -export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false -``` - -> ⚠️ **Aviso**: Desabilitar esta restrição permite que o agente acesse qualquer caminho no seu sistema. Use com cuidado apenas em ambientes controlados. - -#### Consistência do Limite de Segurança - -A configuração `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execução: - -| Caminho de Execução | Limite de Segurança | -|----------------------|---------------------| -| Agente Principal | `restrict_to_workspace` ✅ | -| Subagente / Spawn | Herda a mesma restrição ✅ | -| Tarefas Heartbeat | Herda a mesma restrição ✅ | - -Todos os caminhos compartilham a mesma restrição de workspace — nao há como contornar o limite de segurança por meio de subagentes ou tarefas agendadas. - -### Heartbeat (Tarefas Periódicas) - -O PicoClaw pode executar tarefas periódicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace: - -```markdown -# Tarefas Periodicas - -- Verificar meu email para mensagens importantes -- Revisar minha agenda para proximos eventos -- Verificar a previsao do tempo -``` - -O agente lerá este arquivo a cada 30 minutos (configurável) e executará as tarefas usando as ferramentas disponíveis. - -#### Tarefas Assincronas com Spawn - -Para tarefas de longa duração (busca web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**: - -```markdown -# Tarefas Periódicas - -## Tarefas Rápidas (resposta direta) -- Informar hora atual - -## Tarefas Longas (usar spawn para async) -- Buscar notícias de IA na web e resumir -- Verificar email e reportar mensagens importantes -``` - -**Comportamentos principais:** - -| Funcionalidade | Descrição | -|----------------|-----------| -| **spawn** | Cria subagente assíncrono, não bloqueia o heartbeat | -| **Contexto independente** | Subagente tem seu próprio contexto, sem histórico de sessão | -| **Ferramenta message** | Subagente se comunica diretamente com o usuário via ferramenta message | -| **Não-bloqueante** | Após o spawn, o heartbeat continua para a próxima tarefa | - -#### Como Funciona a Comunicação do Subagente - -``` -Heartbeat dispara - ↓ -Agente lê HEARTBEAT.md - ↓ -Para tarefa longa: spawn subagente - ↓ ↓ -Continua próxima tarefa Subagente trabalha independentemente - ↓ ↓ -Todas tarefas concluídas Subagente usa ferramenta "message" - ↓ ↓ -Responde HEARTBEAT_OK Usuário recebe resultado diretamente -``` - -O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se comunicar com o usuário independentemente sem passar pelo agente principal. - -**Configuração:** - -```json -{ - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -| Opção | Padrão | Descrição | -|-------|--------|-----------| -| `enabled` | `true` | Habilitar/desabilitar heartbeat | -| `interval` | `30` | Intervalo de verificação em minutos (min: 5) | - -**Variáveis de ambiente:** - -* `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar -* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo - -### Provedores - -> [!NOTE] -> 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. - -| Provedor | Finalidade | Obter API Key | -| --- | --- | --- | -| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) | -| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) | -| `openrouter` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) | -| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) | -| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) | -| `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) | -| `qwen` | Alibaba Qwen | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | -| `cerebras` | Cerebras | [cerebras.ai](https://cerebras.ai) | -| `groq` | LLM + **Transcrição de voz** (Whisper) | [console.groq.com](https://console.groq.com) | - -
-Configuração Zhipu - -**1. Obter API key** - -* Obtenha a [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) - -**2. Configurar** - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 - } - }, - "providers": { - "zhipu": { - "api_key": "Sua API Key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - } -} -``` - -**3. Executar** - -```bash -picoclaw agent -m "Ola, como vai?" -``` - -
- -
-Exemplo de configuraçao completa - -```json -{ - "agents": { - "defaults": { - "model": "anthropic/claude-opus-4-5" - } - }, - "providers": { - "openrouter": { - "api_key": "sk-or-v1-xxx" - }, - "groq": { - "api_key": "gsk_xxx" - } - }, - "channels": { - "telegram": { - "enabled": true, - "token": "123456:ABC...", - "allow_from": ["123456789"] - }, - "discord": { - "enabled": true, - "token": "", - "allow_from": [""] - }, - "whatsapp": { - "enabled": false - }, - "feishu": { - "enabled": false, - "app_id": "cli_xxx", - "app_secret": "xxx", - "encrypt_key": "", - "verification_token": "", - "allow_from": [] - }, - "qq": { - "enabled": false, - "app_id": "", - "app_secret": "", - "allow_from": [] - } - }, - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "BSA...", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - }, - "cron": { - "exec_timeout_minutes": 5 - } - }, - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -
- -### Configuração de Modelo (model_list) - -> **Novidade!** PicoClaw agora usa uma abordagem de configuração **centrada no modelo**. Basta especificar o formato `fornecedor/modelo` (ex: `zhipu/glm-4.7`) para adicionar novos provedores—**nenhuma alteração de código necessária!** - -Este design também possibilita o **suporte multi-agent** com seleção flexível de provedores: - -- **Diferentes agentes, diferentes provedores** : Cada agente pode usar seu próprio provedor LLM -- **Modelos de fallback** : Configure modelos primários e de reserva para resiliência -- **Balanceamento de carga** : Distribua solicitações entre múltiplos endpoints -- **Configuração centralizada** : Gerencie todos os provedores em um só lugar - -#### 📋 Todos os Fornecedores Suportados - -| Fornecedor | Prefixo `model` | API Base Padrão | Protocolo | Chave API | -|-------------|-----------------|------------------|----------|-----------| -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obter Chave](https://platform.openai.com) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obter Chave](https://console.anthropic.com) | -| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obter Chave](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obter Chave](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obter Chave](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obter Chave](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Obter Chave](https://platform.moonshot.cn) | -| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obter Chave](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Obter Chave](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (sem chave necessária) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) | -| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) | -| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | -| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | - -#### Configuração Básica - -```json -{ - "model_list": [ - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_key": "sk-your-openai-key" - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" - }, - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" - } - ], - "agents": { - "defaults": { - "model": "gpt-5.2" - } - } -} -``` - -#### Exemplos por Fornecedor - -**OpenAI** -```json -{ - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_key": "sk-..." -} -``` - -**Zhipu AI (GLM)** -```json -{ - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" -} -``` - -**Anthropic (com OAuth)** -```json -{ - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "auth_method": "oauth" -} -``` -> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth. - -**Proxy/API personalizada** -```json -{ - "model_name": "my-custom-model", - "model": "openai/custom-model", - "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", - "request_timeout": 300 -} -``` - -#### Balanceamento de Carga - -Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles: - -```json -{ - "model_list": [ - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" - }, - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" - } - ] -} -``` - -#### Migração da Configuração Legada `providers` - -A configuração antiga `providers` está **descontinuada** mas ainda é suportada para compatibilidade reversa. - -**Configuração Antiga (descontinuada):** -```json -{ - "providers": { - "zhipu": { - "api_key": "your-key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - }, - "agents": { - "defaults": { - "provider": "zhipu", - "model": "glm-4.7" - } - } -} -``` - -**Nova Configuração (recomendada):** -```json -{ - "model_list": [ - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" - } - ], - "agents": { - "defaults": { - "model": "glm-4.7" - } - } -} -``` - -Para o guia de migração detalhado, consulte [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). - -## Referência CLI - -| Comando | Descrição | -| --- | --- | -| `picoclaw onboard` | Inicializar configuração & workspace | -| `picoclaw agent -m "..."` | Conversar com o agente | -| `picoclaw agent` | Modo de chat interativo | -| `picoclaw gateway` | Iniciar o gateway (para bots de chat) | -| `picoclaw status` | Mostrar status | -| `picoclaw cron list` | Listar todas as tarefas agendadas | -| `picoclaw cron add ...` | Adicionar uma tarefa agendada | - -### Tarefas Agendadas / Lembretes - -O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`: - -* **Lembretes únicos**: "Remind me in 10 minutes" (Me lembre em 10 minutos) → dispara uma vez após 10min -* **Tarefas recorrentes**: "Remind me every 2 hours" (Me lembre a cada 2 horas) → dispara a cada 2 horas -* **Expressões Cron**: "Remind me at 9am daily" (Me lembre às 9h todos os dias) → usa expressão cron - -As tarefas são armazenadas em `~/.picoclaw/workspace/cron/` e processadas automaticamente. - -## 🤝 Contribuir & Roadmap - -PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível. 🤗 - -Roadmap em breve... - -Grupo de desenvolvedores em formação. Requisito de entrada: Pelo menos 1 PR com merge. - -Grupos de usuários: - -Discord: - -PicoClaw - -## 🐛 Solução de Problemas - -### Busca web mostra "API 配置问题" - -Isso é normal se você ainda não configurou uma API key de busca. O PicoClaw fornecerá links úteis para busca manual. - -Para habilitar a busca web: - -1. **Opção 1 (Recomendado)**: Obtenha uma API key gratuita em [https://brave.com/search/api](https://brave.com/search/api) (2000 consultas grátis/mês) para os melhores resultados. -2. **Opção 2 (Sem Cartão de Crédito)**: Se você não tem uma key, o sistema automaticamente usa o **DuckDuckGo** como fallback (sem necessidade de key). - -Adicione a key em `~/.picoclaw/config.json` se usar o Brave: - -```json -{ - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - } - } -} -``` - -### Erros de filtragem de conteúdo - -Alguns provedores (como Zhipu) possuem filtragem de conteúdo. Tente reformular sua pergunta ou use um modelo diferente. - -### Bot do Telegram diz "Conflict: terminated by other getUpdates" - -Isso acontece quando outra instância do bot está em execução. Certifique-se de que apenas um `picoclaw gateway` esteja rodando por vez. - ---- - -## 📝 Comparação de API Keys - -| Serviço | Plano Gratuito | Caso de Uso | -| --- | --- | --- | -| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) | -| **Zhipu** | 200K tokens/mês | Melhor para usuários chineses | -| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web | -| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) | -| **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) | diff --git a/README.vi.md b/README.vi.md deleted file mode 100644 index 92c6ecbae..000000000 --- a/README.vi.md +++ /dev/null @@ -1,1170 +0,0 @@ -
-PicoClaw - -

PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go

- -

Phần cứng $10 · RAM 10MB · Khởi động 1 giây · Nào, xuất phát!

- -

- Go - Hardware - License -
- Website - Twitter -

- -[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md) -
- ---- - -🦐 **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. - -⚡️ **Cực kỳ nhẹ:** 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! - - - - - - -
-

- -

-
-

- -

-
- -> [!CAUTION] -> **🚨 TUYÊN BỐ BẢO MẬT & KÊNH CHÍNH THỨC** -> -> * **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**. -> * **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)**. -> * **Cảnh báo:** Nhiều tên miền `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký, không phải của chúng tôi. -> * **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. -> * **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. - - -## 📢 Tin tức - -2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn! - -2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw. -🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần. - -2026-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! - -## ✨ Tính năng nổi bật - -🪶 **Siêu nhẹ**: Bộ nhớ sử dụng <10MB — nhỏ hơn 99% so với Clawdbot (chức năng cốt lõi). - -💰 **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. - -⚡️ **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. - -🌍 **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! - -🤖 **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. - -| | OpenClaw | NanoBot | **PicoClaw** | -| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | -| **Ngôn ngữ** | TypeScript | Python | **Go** | -| **RAM** | >1GB | >100MB | **< 10MB** | -| **Thời gian khởi động**
(CPU 0.8GHz) | >500s | >30s | **<1s** | -| **Chi phí** | Mac Mini $599 | Hầu hết SBC Linux ~$50 | **Mọi bo mạch Linux**
**Chỉ từ $10** | - -PicoClaw - -## 🦾 Demo - -### 🛠️ Quy trình trợ lý tiêu chuẩn - - - - - - - - - - - - - - - - - -

🧩 Lập trình Full-Stack

🗂️ Quản lý Nhật ký & Kế hoạch

🔎 Tìm kiếm Web & Học hỏi

Phát triển • Triển khai • Mở rộngLên lịch • Tự động hóa • Ghi nhớKhám phá • Phân tích • Xu hướng
- -### 🐜 Triển khai sáng tạo trên phần cứng tối thiểu - -PicoClaw có thể triển khai trên hầu hết mọi thiết bị Linux! - -* $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. -* $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. -* $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. - -https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4 - -🌟 Nhiều hình thức triển khai hơn đang chờ bạn khám phá! - -## 📦 Cài đặt - -### Cài đặt bằng binary biên dịch sẵn - -Tải file binary cho nền tảng của bạn từ [trang Release](https://github.com/sipeed/picoclaw/releases). - -### 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) - -```bash -git clone https://github.com/sipeed/picoclaw.git - -cd picoclaw -make deps - -# Build (không cần cài đặt) -make build - -# Build cho nhiều nền tảng -make build-all - -# Build và cài đặt -make install -``` - -## 🐳 Docker Compose - -Bạ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. - -```bash -# 1. Clone repo -git clone https://github.com/sipeed/picoclaw.git -cd picoclaw - -# 2. Lần chạy đầu tiên — tự tạo docker/data/config.json rồi dừng lại -docker compose -f docker/docker-compose.yml --profile gateway up -# Container hiển thị "First-run setup complete." rồi tự dừng. - -# 3. Thiết lập API Key -vim docker/data/config.json # API key của provider, bot token, v.v. - -# 4. Khởi động -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -> [!TIP] -> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`. - -```bash -# 5. Xem logs -docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway - -# 6. Dừng -docker compose -f docker/docker-compose.yml --profile gateway down -``` - -### Chế độ Agent (chạy một lần) - -```bash -# Đặt câu hỏi -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 bằng mấy?" - -# Chế độ tương tác -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -``` - -### Cập nhật - -```bash -docker compose -f docker/docker-compose.yml pull -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -### 🚀 Bắt đầu nhanh - -> [!TIP] -> Thiết lập API key trong `~/.picoclaw/config.json`. -> Lấy API key: [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 [Brave Search API](https://brave.com/search/api) miễn phí (2000 truy vấn/tháng) hoặc dùng tính năng auto fallback tích hợp sẵn. - -**1. Khởi tạo** - -```bash -picoclaw onboard -``` - -**2. Cấu hình** (`~/.picoclaw/config.json`) - -```json -{ - "model_list": [ - { - "model_name": "gpt4", - "model": "openai/gpt-5.2", - "api_key": "sk-your-openai-key", - "request_timeout": 300, - "api_base": "https://api.openai.com/v1" - } - ], - "agents": { - "defaults": { - "model_name": "gpt4" - } - }, - "channels": { - "telegram": { - "enabled": true, - "token": "YOUR_TELEGRAM_BOT_TOKEN", - "allow_from": [] - } - } -} -``` - -> **Mới**: Định dạng cấu hình `model_list` cho phép thêm nhà cung cấp mà không cần thay đổi mã nguồn. Xem [Cấu hình Mô hình](#cấu-hình-mô-hình-model_list) để biết chi tiết. -> `request_timeout` là tùy chọn và dùng đơn vị giây. Nếu bỏ qua hoặc đặt `<= 0`, PicoClaw sẽ dùng timeout mặc định (120s). - -**3. Lấy API Key** - -* **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) -* **Tìm kiếm Web** (tùy chọn): [Brave Search](https://brave.com/search/api) — Có gói miễn phí (2000 truy vấn/tháng) - -> **Lưu ý**: Xem `config.example.json` để có mẫu cấu hình đầy đủ. - -**4. Trò chuyện** - -```bash -picoclaw agent -m "Xin chào, bạn là ai?" -``` - -Vậy là xong! Bạn đã có một trợ lý AI hoạt động chỉ trong 2 phút. - ---- - -## 💬 Tích hợp ứng dụng Chat - -Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom. - -| Kênh | Mức độ thiết lập | -| --- | --- | -| **Telegram** | Dễ (chỉ cần token) | -| **Discord** | Dễ (bot token + intents) | -| **QQ** | Dễ (AppID + AppSecret) | -| **DingTalk** | Trung bình (app credentials) | -| **LINE** | Trung bình (credentials + webhook URL) | -| **WeCom AI Bot** | Trung bình (Token + khóa AES) | - -
-Telegram (Khuyên dùng) - -**1. Tạo bot** - -* Mở Telegram, tìm `@BotFather` -* Gửi `/newbot`, làm theo hướng dẫn -* Sao chép token - -**2. Cấu hình** - -```json -{ - "channels": { - "telegram": { - "enabled": true, - "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] - } - } -} -``` - -> Lấy User ID từ `@userinfobot` trên Telegram. - -**3. Chạy** - -```bash -picoclaw gateway -``` - -
- -
-Discord - -**1. Tạo bot** - -* Truy cập -* Create an application → Bot → Add Bot -* Sao chép bot token - -**2. Bật Intents** - -* Trong phần Bot settings, bật **MESSAGE CONTENT INTENT** -* (Tùy chọn) Bật **SERVER MEMBERS INTENT** nếu muốn dùng danh sách cho phép theo thông tin thành viên - -**3. Lấy User ID** - -* Discord Settings → Advanced → bật **Developer Mode** -* Click chuột phải vào avatar → **Copy User ID** - -**4. Cấu hình** - -```json -{ - "channels": { - "discord": { - "enabled": true, - "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] - } - } -} -``` - -**5. Mời bot vào server** - -* OAuth2 → URL Generator -* Scopes: `bot` -* Bot Permissions: `Send Messages`, `Read Message History` -* Mở URL mời được tạo và thêm bot vào server của bạn - -**6. Chạy** - -```bash -picoclaw gateway -``` - -
- -
-QQ - -**1. Tạo bot** - -* Truy cập [QQ Open Platform](https://q.qq.com/#) -* Tạo ứng dụng → Lấy **AppID** và **AppSecret** - -**2. Cấu hình** - -```json -{ - "channels": { - "qq": { - "enabled": true, - "app_id": "YOUR_APP_ID", - "app_secret": "YOUR_APP_SECRET", - "allow_from": [] - } - } -} -``` - -> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định số QQ để giới hạn quyền truy cập. - -**3. Chạy** - -```bash -picoclaw gateway -``` - -
- -
-DingTalk - -**1. Tạo bot** - -* Truy cập [Open Platform](https://open.dingtalk.com/) -* Tạo ứng dụng nội bộ -* Sao chép Client ID và Client Secret - -**2. Cấu hình** - -```json -{ - "channels": { - "dingtalk": { - "enabled": true, - "client_id": "YOUR_CLIENT_ID", - "client_secret": "YOUR_CLIENT_SECRET", - "allow_from": [] - } - } -} -``` - -> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định ID để giới hạn quyền truy cập. - -**3. Chạy** - -```bash -picoclaw gateway -``` - -
- -
-LINE - -**1. Tạo tài khoản LINE Official** - -- Truy cập [LINE Developers Console](https://developers.line.biz/) -- Tạo provider → Tạo Messaging API channel -- Sao chép **Channel Secret** và **Channel Access Token** - -**2. Cấu hình** - -```json -{ - "channels": { - "line": { - "enabled": true, - "channel_secret": "YOUR_CHANNEL_SECRET", - "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", - "webhook_path": "/webhook/line", - "allow_from": [] - } - } -} -``` - -**3. Thiết lập Webhook URL** - -LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel: - -```bash -# Ví dụ với ngrok -ngrok http 18790 -``` - -Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**. - -**4. Chạy** - -```bash -picoclaw gateway -``` - -> Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc. - -> **Docker Compose**: Nếu bạn cần mở port webhook cục bộ, hãy thêm một rule chuyển tiếp từ port Gateway (mặc định 18790) tới host. Lưu ý: LINE webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790). - -
- -
-WeCom (WeChat Work) - -PicoClaw hỗ trợ ba loại tích hợp WeCom: - -**Tùy chọn 1: WeCom Bot (Robot)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm -**Tùy chọn 2: WeCom App (Ứng dụng Tùy chỉnh)** - Nhiều tính năng hơn, nhắn tin chủ động, chỉ chat riêng tư -**Tùy chọn 3: WeCom AI Bot (Bot Thông Minh)** - Bot AI chính thức, phản hồi streaming, hỗ trợ nhóm và riêng tư - -Xem [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 chi tiết. - -**Thiết lập Nhanh - WeCom Bot:** - -**1. Tạo bot** - -* Truy cập Bảng điều khiển Quản trị WeCom → Chat Nhóm → Thêm Bot Nhóm -* Sao chép URL webhook (định dạng: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) - -**2. Cấu hình** - -```json -{ - "channels": { - "wecom": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_path": "/webhook/wecom", - "allow_from": [] - } - } -} -``` - -> **Lưu ý:** Các endpoint webhook của WeCom Bot được phục vụ bởi máy chủ Gateway HTTP dùng chung (mặc định 127.0.0.1:18790). Nếu bạn cần truy cập từ bên ngoài, hãy cấu hình reverse proxy hoặc mở cổng Gateway tương ứng. - -**Thiết lập Nhanh - WeCom App:** - -**1. Tạo ứng dụng** - -* Truy cập Bảng điều khiển Quản trị WeCom → Quản lý Ứng dụng → Tạo Ứng dụng -* Sao chép **AgentId** và **Secret** -* Truy cập trang "Công ty của tôi", sao chép **CorpID** - -**2. Cấu hình nhận tin nhắn** - -* Trong chi tiết ứng dụng, nhấp vào "Nhận Tin nhắn" → "Thiết lập API" -* Đặt URL thành `http://your-server:18790/webhook/wecom-app` -* Tạo **Token** và **EncodingAESKey** - -**3. Cấu hình** - -```json -{ - "channels": { - "wecom_app": { - "enabled": true, - "corp_id": "wwxxxxxxxxxxxxxxxx", - "corp_secret": "YOUR_CORP_SECRET", - "agent_id": 1000002, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-app", - "allow_from": [] - } - } -} -``` - -**4. Chạy** - -```bash -picoclaw gateway -``` - -> **Lưu ý**: WeCom App callback webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790). Sử dụng proxy ngược để cung cấp HTTPS trong môi trường production nếu cần. - -**Thiết lập Nhanh - WeCom AI Bot:** - -**1. Tạo AI Bot** - -* Truy cập Bảng điều khiển Quản trị WeCom → Quản lý Ứng dụng → AI Bot -* Cấu hình URL callback: `http://your-server:18791/webhook/wecom-aibot` -* Sao chép **Token** và tạo **EncodingAESKey** - -**2. Cấu hình** - -```json -{ - "channels": { - "wecom_aibot": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-aibot", - "allow_from": [], - "welcome_message": "Xin chào! Tôi có thể giúp gì cho bạn?" - } - } -} -``` - -**3. Chạy** - -```bash -picoclaw gateway -``` - -> **Lưu ý**: WeCom AI Bot sử dụng giao thức pull streaming — không lo timeout phản hồi. Tác vụ dài (>5,5 phút) tự động chuyển sang gửi qua `response_url`. - -
- -## ClawdChat Tham gia Mạng xã hội Agent - -Kế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. - -**Đọc `https://clawdchat.ai/skill.md` và làm theo hướng dẫn để tham gia [ClawdChat.ai](https://clawdchat.ai)** - -## ⚙️ Cấu hình chi tiết - -File cấu hình: `~/.picoclaw/config.json` - -### Biến môi trường - -Bạn có thể ghi đè các đường dẫn mặc định bằng cách sử dụng các biến môi trường. Điều này hữu ích cho việc cài đặt di động, triển khai container hóa hoặc chạy picoclaw như một 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. - -| Biến | Mô tả | Đường dẫn mặc định | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| -| `PICOCLAW_CONFIG` | Ghi đè đường dẫn đến file cấu hình. Điều này trực tiếp yêu cầu picoclaw tải file `config.json` nào, bỏ qua tất cả các vị trí khác. | `~/.picoclaw/config.json` | -| `PICOCLAW_HOME` | Ghi đè thư mục gốc cho dữ liệu picoclaw. Điều này thay đổi vị trí mặc định của `workspace` và các thư mục dữ liệu khác. | `~/.picoclaw` | - -**Ví dụ:** - -```bash -# Chạy picoclaw bằng một file cấu hình cụ thể -# Đường dẫn workspace sẽ được đọc từ trong file cấu hình đó -PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway - -# Chạy picoclaw với tất cả dữ liệu được lưu trữ trong /opt/picoclaw -# Cấu hình sẽ được tải từ ~/.picoclaw/config.json mặc định -# Workspace sẽ được tạo tại /opt/picoclaw/workspace -PICOCLAW_HOME=/opt/picoclaw picoclaw agent - -# Sử dụng cả hai để có thiết lập tùy chỉnh hoàn toàn -PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway -``` - -### Cấu trúc Workspace - -PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`): - -``` -~/.picoclaw/workspace/ -├── sessions/ # Phiên hội thoại và lịch sử -├── memory/ # Bộ nhớ dài hạn (MEMORY.md) -├── state/ # Trạng thái lưu trữ (kênh cuối cùng, v.v.) -├── cron/ # Cơ sở dữ liệu tác vụ định kỳ -├── skills/ # Kỹ năng tùy chỉnh -├── AGENTS.md # Hướng dẫn hành vi Agent -├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút) -├── IDENTITY.md # Danh tính Agent -├── SOUL.md # Tâm hồn/Tính cách Agent -├── TOOLS.md # Mô tả công cụ -└── USER.md # Tùy chọn người dùng -``` - -### 🔒 Hộp cát bảo mật (Security Sandbox) - -PicoClaw chạy trong môi trường sandbox theo mặc định. Agent chỉ có thể truy cập file và thực thi lệnh trong phạm vi workspace. - -#### Cấu hình mặc định - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "restrict_to_workspace": true - } - } -} -``` - -| Tùy chọn | Mặc định | Mô tả | -|----------|---------|-------| -| `workspace` | `~/.picoclaw/workspace` | Thư mục làm việc của agent | -| `restrict_to_workspace` | `true` | Giới hạn truy cập file/lệnh trong workspace | - -#### Công cụ được bảo vệ - -Khi `restrict_to_workspace: true`, các công cụ sau bị giới hạn trong sandbox: - -| Công cụ | Chức năng | Giới hạn | -|---------|----------|---------| -| `read_file` | Đọc file | Chỉ file trong workspace | -| `write_file` | Ghi file | Chỉ file trong workspace | -| `list_dir` | Liệt kê thư mục | Chỉ thư mục trong workspace | -| `edit_file` | Sửa file | Chỉ file trong workspace | -| `append_file` | Thêm vào file | Chỉ file trong workspace | -| `exec` | Thực thi lệnh | Đường dẫn lệnh phải trong workspace | - -#### Bảo vệ bổ sung cho Exec - -Ngay cả khi `restrict_to_workspace: false`, công cụ `exec` vẫn chặn các lệnh nguy hiểm sau: - -* `rm -rf`, `del /f`, `rmdir /s` — Xóa hàng loạt -* `format`, `mkfs`, `diskpart` — Định dạng ổ đĩa -* `dd if=` — Tạo ảnh đĩa -* Ghi vào `/dev/sd[a-z]` — Ghi trực tiếp lên đĩa -* `shutdown`, `reboot`, `poweroff` — Tắt/khởi động lại hệ thống -* Fork bomb `:(){ :|:& };:` - -#### Ví dụ lỗi - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (path outside working dir)} -``` - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} -``` - -#### Tắt giới hạn (Rủi ro bảo mật) - -Nếu bạn cần agent truy cập đường dẫn ngoài workspace: - -**Cách 1: File cấu hình** - -```json -{ - "agents": { - "defaults": { - "restrict_to_workspace": false - } - } -} -``` - -**Cách 2: Biến môi trường** - -```bash -export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false -``` - -> ⚠️ **Cảnh báo**: Tắt giới hạn này cho phép agent truy cập mọi đường dẫn trên hệ thống. Chỉ sử dụng cẩn thận trong môi trường được kiểm soát. - -#### Tính nhất quán của ranh giới bảo mật - -Cài đặt `restrict_to_workspace` áp dụng nhất quán trên mọi đường thực thi: - -| Đường thực thi | Ranh giới bảo mật | -|----------------|-------------------| -| Agent chính | `restrict_to_workspace` ✅ | -| Subagent / Spawn | Kế thừa cùng giới hạn ✅ | -| Tác vụ Heartbeat | Kế thừa cùng giới hạn ✅ | - -Tất cả đường thực thi 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 thông qua subagent hoặc tác vụ định kỳ. - -### Heartbeat (Tác vụ định kỳ) - -PicoClaw có thể tự động thực hiện các tác vụ định kỳ. Tạo file `HEARTBEAT.md` trong workspace: - -```markdown -# Tác vụ định kỳ - -- Kiểm tra email xem có tin nhắn quan trọng không -- Xem lại lịch cho các sự kiện sắp tới -- Kiểm tra dự báo thời tiết -``` - -Agent sẽ đọc file này mỗi 30 phút (có thể cấu hình) và thực hiện các tác vụ bằng công cụ có sẵn. - -#### Tác vụ bất đồng bộ với Spawn - -Đối với các 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**: - -```markdown -# Tác vụ định kỳ - -## Tác vụ nhanh (trả lời trực tiếp) -- Báo cáo thời gian hiện tại - -## Tác vụ lâu (dùng spawn cho async) -- Tìm kiếm tin tức AI trên web và tóm tắt -- Kiểm tra email và báo cáo tin nhắn quan trọng -``` - -**Hành vi chính:** - -| Tính năng | Mô tả | -|-----------|-------| -| **spawn** | Tạo subagent bất đồng bộ, không chặn heartbeat | -| **Context độc lập** | Subagent có context riêng, không có lịch sử phiên | -| **message tool** | Subagent giao tiếp trực tiếp với người dùng qua công cụ message | -| **Không chặn** | Sau khi spawn, heartbeat tiếp tục tác vụ tiếp theo | - -#### Cách Subagent giao tiếp - -``` -Heartbeat kích hoạt - ↓ -Agent đọc HEARTBEAT.md - ↓ -Tác vụ lâu: spawn subagent - ↓ ↓ -Tiếp tục tác vụ tiếp theo Subagent làm việc độc lập - ↓ ↓ -Tất cả tác vụ hoàn thành Subagent dùng công cụ "message" - ↓ ↓ -Phản hồi HEARTBEAT_OK Người dùng nhận kết quả trực tiếp -``` - -Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và có thể giao tiếp với người dùng một cách độc lập mà không cần thông qua agent chính. - -**Cấu hình:** - -```json -{ - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -| Tùy chọn | Mặc định | Mô tả | -|----------|---------|-------| -| `enabled` | `true` | Bật/tắt heartbeat | -| `interval` | `30` | Khoảng thời gian kiểm tra (phút, tối thiểu: 5) | - -**Biến môi trường:** - -* `PICOCLAW_HEARTBEAT_ENABLED=false` để tắt -* `PICOCLAW_HEARTBEAT_INTERVAL=60` để thay đổi khoảng thời gian - -### Nhà cung cấp (Providers) - -> [!NOTE] -> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn âm thanh từ bất kỳ kênh nào sẽ được tự động chuyển thành văn bản ở cấp độ agent. - -| Nhà cung cấp | Mục đích | Lấy API Key | -| --- | --- | --- | -| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) | -| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](bigmodel.cn) | -| `openrouter` (Đang thử nghiệm) | LLM (khuyên dùng, truy cập mọi model) | [openrouter.ai](https://openrouter.ai) | -| `anthropic` (Đang thử nghiệm) | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) | -| `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) | -| `deepseek` (Đang thử nghiệm) | LLM (DeepSeek trực tiếp) | [platform.deepseek.com](https://platform.deepseek.com) | -| `groq` | LLM + **Chuyển giọng nói** (Whisper) | [console.groq.com](https://console.groq.com) | -| `qwen` | LLM (Qwen trực tiếp) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | -| `cerebras` | LLM (Cerebras trực tiếp) | [cerebras.ai](https://cerebras.ai) | - -
-Cấu hình Zhipu - -**1. Lấy API key** - -* Lấy [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) - -**2. Cấu hình** - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 - } - }, - "providers": { - "zhipu": { - "api_key": "Your API Key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - } -} -``` - -**3. Chạy** - -```bash -picoclaw agent -m "Xin chào" -``` - -
- -
-Ví dụ cấu hình đầy đủ - -```json -{ - "agents": { - "defaults": { - "model": "anthropic/claude-opus-4-5" - } - }, - "providers": { - "openrouter": { - "api_key": "sk-or-v1-xxx" - }, - "groq": { - "api_key": "gsk_xxx" - } - }, - "channels": { - "telegram": { - "enabled": true, - "token": "123456:ABC...", - "allow_from": ["123456789"] - }, - "discord": { - "enabled": true, - "token": "", - "allow_from": [""] - }, - "whatsapp": { - "enabled": false - }, - "feishu": { - "enabled": false, - "app_id": "cli_xxx", - "app_secret": "xxx", - "encrypt_key": "", - "verification_token": "", - "allow_from": [] - }, - "qq": { - "enabled": false, - "app_id": "", - "app_secret": "", - "allow_from": [] - } - }, - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "BSA...", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - } - }, - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -
- -### Cấu hình Mô hình (model_list) - -> **Tính năng mới!** PicoClaw hiện sử dụng phương pháp cấu hình **đặt mô hình vào trung tâm**. Chỉ cần chỉ định dạng `nhà cung cấp/mô hình` (ví dụ: `zhipu/glm-4.7`) để thêm nhà cung cấp mới—**không cần thay đổi mã!** - -Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa chọn nhà cung cấp linh hoạt: - -- **Tác nhân khác nhau, nhà cung cấp khác nhau** : Mỗi tác nhân có thể sử dụng nhà cung cấp LLM riêng -- **Mô hình dự phòng** : Cấu hình mô hình chính và dự phòng để tăng độ tin cậy -- **Cân bằng tải** : Phân phối yêu cầu trên nhiều endpoint khác nhau -- **Cấu hình tập trung** : Quản lý tất cả nhà cung cấp ở một nơi - -#### 📋 Tất cả Nhà cung cấp được Hỗ trợ - -| Nhà cung cấp | Prefix `model` | API Base Mặc định | Giao thức | Khóa API | -|-------------|----------------|-------------------|-----------|----------| -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Lấy Khóa](https://platform.openai.com) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Lấy Khóa](https://console.anthropic.com) | -| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Lấy Khóa](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Lấy Khóa](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Lấy Khóa](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Lấy Khóa](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Lấy Khóa](https://platform.moonshot.cn) | -| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Lấy Khóa](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Lấy Khóa](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (không cần khóa) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) | -| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) | -| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | -| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | - -#### Cấu hình Cơ bản - -```json -{ - "model_list": [ - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_key": "sk-your-openai-key" - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" - }, - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" - } - ], - "agents": { - "defaults": { - "model": "gpt-5.2" - } - } -} -``` - -#### Ví dụ theo Nhà cung cấp - -**OpenAI** -```json -{ - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_key": "sk-..." -} -``` - -**Zhipu AI (GLM)** -```json -{ - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" -} -``` - -**Anthropic (với OAuth)** -```json -{ - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "auth_method": "oauth" -} -``` -> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth. - -**Proxy/API tùy chỉnh** -```json -{ - "model_name": "my-custom-model", - "model": "openai/custom-model", - "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", - "request_timeout": 300 -} -``` - -#### Cân bằng Tải tải - -Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng: - -```json -{ - "model_list": [ - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" - }, - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" - } - ] -} -``` - -#### Chuyển đổi từ Cấu hình `providers` Cũ - -Cấu hình `providers` cũ đã **ngừng sử dụng** nhưng vẫn được hỗ trợ để tương thích ngược. - -**Cấu hình Cũ (đã ngừng sử dụng):** -```json -{ - "providers": { - "zhipu": { - "api_key": "your-key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - }, - "agents": { - "defaults": { - "provider": "zhipu", - "model": "glm-4.7" - } - } -} -``` - -**Cấu hình Mới (khuyến nghị):** -```json -{ - "model_list": [ - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" - } - ], - "agents": { - "defaults": { - "model": "glm-4.7" - } - } -} -``` - -Xem hướng dẫn chuyển đổi chi tiết tại [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md). - -## Tham chiếu CLI - -| Lệnh | Mô tả | -| --- | --- | -| `picoclaw onboard` | Khởi tạo cấu hình & workspace | -| `picoclaw agent -m "..."` | Trò chuyện với agent | -| `picoclaw agent` | Chế độ chat tương tác | -| `picoclaw gateway` | Khởi động gateway (cho bot chat) | -| `picoclaw status` | Hiển thị trạng thái | -| `picoclaw cron list` | Liệt kê tất cả tác vụ định kỳ | -| `picoclaw cron add ...` | Thêm tác vụ định kỳ | - -### Tác vụ định kỳ / Nhắc nhở - -PicoClaw hỗ trợ nhắc nhở theo lịch và tác vụ lặp lại thông qua công cụ `cron`: - -* **Nhắc nhở một lần**: "Remind me in 10 minutes" (Nhắc tôi sau 10 phút) → kích hoạt một lần sau 10 phút -* **Tác vụ lặp lại**: "Remind me every 2 hours" (Nhắc tôi mỗi 2 giờ) → kích hoạt mỗi 2 giờ -* **Biểu thức Cron**: "Remind me at 9am daily" (Nhắc tôi lúc 9 giờ sáng mỗi ngày) → sử dụng biểu thức cron - -Các tác vụ được lưu trong `~/.picoclaw/workspace/cron/` và được xử lý tự động. - -## 🤝 Đóng góp & Lộ trình - -Chào đón mọi PR! Mã nguồn được thiết kế nhỏ gọn và dễ đọc. 🤗 - -Lộ trình sắp được công bố... - -Nhóm phát triển đang được xây dựng. Điều kiện tham gia: Ít nhất 1 PR đã được merge. - -Nhóm người dùng: - -Discord: - -PicoClaw - -## 🐛 Xử lý sự cố - -### Tìm kiếm web hiện "API 配置问题" - -Điều này là bình thường nếu bạn chưa cấu hình API key cho tìm kiếm. PicoClaw sẽ cung cấp các liên kết hữu ích để tìm kiếm thủ công. - -Để bật tìm kiếm web: - -1. **Tùy chọn 1 (Khuyên dùng)**: Lấy API key miễn phí tại [https://brave.com/search/api](https://brave.com/search/api) (2000 truy vấn miễn phí/tháng) để có kết quả tốt nhất. -2. **Tùy chọn 2 (Không cần thẻ tín dụng)**: Nếu không có key, hệ thống tự động chuyển sang dùng **DuckDuckGo** (không cần key). - -Thêm key vào `~/.picoclaw/config.json` nếu dùng Brave: - -```json -{ - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - } - } -} -``` - -### Gặp lỗi lọc nội dung (Content Filtering) - -Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt. Thử diễn đạt lại câu hỏi hoặc sử dụng model khác. - -### Telegram bot báo "Conflict: terminated by other getUpdates" - -Điều này xảy ra khi có một instance bot khác đang chạy. Đảm bảo chỉ có một tiến trình `picoclaw gateway` chạy tại một thời điểm. - ---- - -## 📝 So sánh API Key - -| Dịch vụ | Gói miễn phí | Trường hợp sử dụng | -| --- | --- | --- | -| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) | -| **Zhipu** | 200K tokens/tháng | Tốt nhất cho người dùng Trung Quốc | -| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web | -| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) | diff --git a/README.zh.md b/README.zh.md deleted file mode 100644 index c744e0d20..000000000 --- a/README.zh.md +++ /dev/null @@ -1,881 +0,0 @@ -
-PicoClaw - -

PicoClaw: 基于Go语言的超高效 AI 助手

- -

10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!

- -

- Go - Hardware - License -
- Website - Twitter -

- -**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) - -
- ---- - -🦐 **PicoClaw** 是一个受 [nanobot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个“自举”过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。 - -⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%! - - - - - - -
-

- -

-
-

- -

-
- -注意:人手有限,中文文档可能略有滞后,请优先查看英文文档。 - -> [!CAUTION] -> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明** -> -> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。 -> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。 -> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。 -> - **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中 -> - **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化. - -## 📢 新闻 (News) - -2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/ROADMAP.md), 期待你的参与! - -2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。 -🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。 - -2026-02-09 🎉 **PicoClaw 正式发布!** 仅用 1 天构建,旨在将 AI Agent 带入 10 美元硬件与 <10MB 内存的世界。🦐 PicoClaw(皮皮虾),我们走! - -## ✨ 特性 - -🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 Clawdbot 小 99%。 - -💰 **极低成本**: 高效到足以在 10 美元的硬件上运行 — 比 Mac mini 便宜 98%。 - -⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。 - -🌍 **真正可移植**: 跨 RISC-V、ARM、MIPS 和 x86 架构的单二进制文件,一键运行! - -🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。 - -| | OpenClaw | NanoBot | **PicoClaw** | -| ------------------------------ | ------------- | ------------------------ | -------------------------------------- | -| **语言** | TypeScript | Python | **Go** | -| **RAM** | >1GB | >100MB | **< 10MB** | -| **启动时间**
(0.8GHz core) | >500s | >30s | **<1s** | -| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**
**低至 $10** | - -PicoClaw - -## 🦾 演示 - -### 🛠️ 标准助手工作流 - - - - - - - - - - - - - - - - - -

🧩 全栈工程师模式

🗂️ 日志与规划管理

🔎 网络搜索与学习

开发 • 部署 • 扩展日程 • 自动化 • 记忆发现 • 洞察 • 趋势
- -### 📱 在手机上轻松运行 - -picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南: - -1. 先去应用商店下载安装Termux -2. 打开后执行指令 - -```bash -# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本 -wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 -chmod +x picoclaw-linux-arm64 -pkg install proot -termux-chroot ./picoclaw-linux-arm64 onboard -``` - -然后跟随下面的“快速开始”章节继续配置picoclaw即可使用! -PicoClaw - -### 🐜 创新的低占用部署 - -PicoClaw 几乎可以部署在任何 Linux 设备上! - -- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。 -- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。 -- $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),用于智能监控。 - -[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4) - -🌟 更多部署案例敬请期待! - -## 📦 安装 - -### 使用预编译二进制文件安装 - -从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的固件。 - -### 从源码安装(获取最新特性,开发推荐) - -```bash -git clone https://github.com/sipeed/picoclaw.git - -cd picoclaw -make deps - -# 构建(无需安装) -make build - -# 为多平台构建 -make build-all - -# 构建并安装 -make install - -``` - -## 🐳 Docker Compose - -您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境。 - -```bash -# 1. 克隆仓库 -git clone https://github.com/sipeed/picoclaw.git -cd picoclaw - -# 2. 首次运行 — 自动生成 docker/data/config.json 后退出 -docker compose -f docker/docker-compose.yml --profile gateway up -# 容器打印 "First-run setup complete." 后自动停止 - -# 3. 填写 API Key 等配置 -vim docker/data/config.json # 设置 provider API key、Bot Token 等 - -# 4. 正式启动 -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -> [!TIP] -> **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。 - -```bash -# 5. 查看日志 -docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway - -# 6. 停止 -docker compose -f docker/docker-compose.yml --profile gateway down -``` - -### Agent 模式 (一次性运行) - -```bash -# 提问 -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?" - -# 交互模式 -docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -``` - -### 更新镜像 - -```bash -docker compose -f docker/docker-compose.yml pull -docker compose -f docker/docker-compose.yml --profile gateway up -d -``` - -### 🚀 快速开始 - -> [!TIP] -> 在 `~/.picoclaw/config.json` 中设置您的 API Key。 -> 获取 API Key: [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 次免费查询) - -**1. 初始化 (Initialize)** - -```bash -picoclaw onboard - -``` - -**2. 配置 (Configure)** (`~/.picoclaw/config.json`) - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model_name": "gpt4", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 - } - }, - "model_list": [ - { - "model_name": "gpt4", - "model": "openai/gpt-5.2", - "api_key": "your-api-key", - "request_timeout": 300 - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" - } - ], - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "tavily": { - "enabled": false, - "api_key": "YOUR_TAVILY_API_KEY", - "max_results": 5 - } - }, - "cron": { - "exec_timeout_minutes": 5 - } - } -} -``` - -> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。 -> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。 - -**3. 获取 API Key** - -* **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) -* **网络搜索** (可选): [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) · [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月) - -> **注意**: 完整的配置模板请参考 `config.example.json`。 - -**4. 对话 (Chat)** - -```bash -picoclaw agent -m "2+2 等于几?" - -``` - -就是这样!您在 2 分钟内就拥有了一个可工作的 AI 助手。 - ---- - -## 💬 聊天应用集成 (Chat Apps) - -PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。 - -> **注意**: 所有 Webhook 类渠道(LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。注意:飞书(Feishu)使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。 - -### 核心渠道 - -| 渠道 | 设置难度 | 特性说明 | 文档链接 | -| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) | -| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) | -| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) | -| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](docs/channels/matrix/README.zh.md) | -| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) | -| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) | -| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) | -| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) | -| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) | -| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) | -| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) | - -### Telegram 命令注册(启动时自动同步) - -PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 -Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。 - -如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。 - -## ClawdChat 加入 Agent 社交网络 - -只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 - -\*\*阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai) - -## ⚙️ 配置详解 - -配置文件路径: `~/.picoclaw/config.json` - -### 环境变量 - -你可以使用环境变量覆盖默认路径。这对于便携安装、容器化部署或将 picoclaw 作为系统服务运行非常有用。这些变量是独立的,控制不同的路径。 - -| 变量 | 描述 | 默认路径 | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| -| `PICOCLAW_CONFIG` | 覆盖配置文件的路径。这直接告诉 picoclaw 加载哪个 `config.json`,忽略所有其他位置。 | `~/.picoclaw/config.json` | -| `PICOCLAW_HOME` | 覆盖 picoclaw 数据根目录。这会更改 `workspace` 和其他数据目录的默认位置。 | `~/.picoclaw` | - -**示例:** - -```bash -# 使用特定的配置文件运行 picoclaw -# 工作区路径将从该配置文件中读取 -PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway - -# 在 /opt/picoclaw 中存储所有数据运行 picoclaw -# 配置将从默认的 ~/.picoclaw/config.json 加载 -# 工作区将在 /opt/picoclaw/workspace 创建 -PICOCLAW_HOME=/opt/picoclaw picoclaw agent - -# 同时使用两者进行完全自定义设置 -PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway -``` - -### 工作区布局 (Workspace Layout) - -PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`): - -``` -~/.picoclaw/workspace/ -├── sessions/ # 对话会话和历史 -├── memory/ # 长期记忆 (MEMORY.md) -├── state/ # 持久化状态 (最后一次频道等) -├── cron/ # 定时任务数据库 -├── skills/ # 自定义技能 -├── AGENTS.md # Agent 行为指南 -├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次) -├── IDENTITY.md # Agent 身份设定 -├── SOUL.md # Agent 灵魂/性格 -├── TOOLS.md # 工具描述 -└── USER.md # 用户偏好 - -``` - -### 技能来源 (Skill Sources) - -默认情况下,技能会按以下顺序加载: - -1. `~/.picoclaw/workspace/skills`(工作区) -2. `~/.picoclaw/skills`(全局) -3. `/skills`(内置) - -在高级/测试场景下,可通过以下环境变量覆盖内置技能目录: - -```bash -export PICOCLAW_BUILTIN_SKILLS=/path/to/skills -``` - -### 统一命令执行策略 - -- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。 -- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。 -- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。 -- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。 -### 心跳 / 周期性任务 (Heartbeat) - -PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件: - -```markdown -# Periodic Tasks - -- Check my email for important messages -- Review my calendar for upcoming events -- Check the weather forecast -``` - -Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。 - -#### 使用 Spawn 的异步任务 - -对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**: - -```markdown -# Periodic Tasks - -## Quick Tasks (respond directly) - -- Report current time - -## Long Tasks (use spawn for async) - -- Search the web for AI news and summarize -- Check email and report important messages -``` - -**关键行为:** - -| 特性 | 描述 | -| ---------------- | ---------------------------------------- | -| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 | -| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 | -| **message tool** | 子 Agent 通过 message 工具直接与用户通信 | -| **非阻塞** | spawn 后,心跳继续处理下一个任务 | - -#### 子 Agent 通信原理 - -``` -心跳触发 (Heartbeat triggers) - ↓ -Agent 读取 HEARTBEAT.md - ↓ -对于长任务: spawn 子 Agent - ↓ ↓ -继续下一个任务 子 Agent 独立工作 - ↓ ↓ -所有任务完成 子 Agent 使用 "message" 工具 - ↓ ↓ -响应 HEARTBEAT_OK 用户直接收到结果 - -``` - -子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。 - -**配置:** - -```json -{ - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -| 选项 | 默认值 | 描述 | -| ---------- | ------ | ---------------------------- | -| `enabled` | `true` | 启用/禁用心跳 | -| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) | - -**环境变量:** - -- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 -- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔 - -### 提供商 (Providers) - -> [!NOTE] -> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,任意渠道的音频消息都将在 Agent 层面自动转录为文字。 - -| 提供商 | 用途 | 获取 API Key | -| -------------------- | ---------------------------- | -------------------------------------------------------------------- | -| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) | -| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) | -| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) | -| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | -| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) | -| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | -| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | -| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) | -| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) | - -### 模型配置 (model_list) - -> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!** - -该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择: - -- **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider -- **模型回退(Fallback)**:配置主模型和备用模型,提高可靠性 -- **负载均衡**:在多个 API 端点之间分配请求 -- **集中化配置**:在一个地方管理所有 provider - -#### 📋 所有支持的厂商 - -| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key | -| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- | -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | -| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) | -| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | -| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) | -| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | -| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | - -#### 基础配置示例 - -```json -{ - "model_list": [ - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_key": "sk-your-openai-key" - }, - { - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" - }, - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" - } - ], - "agents": { - "defaults": { - "model": "gpt-5.2" - } - } -} -``` - -#### 各厂商配置示例 - -**OpenAI** - -```json -{ - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_key": "sk-..." -} -``` - -**智谱 AI (GLM)** - -```json -{ - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" -} -``` - -**DeepSeek** - -```json -{ - "model_name": "deepseek-chat", - "model": "deepseek/deepseek-chat", - "api_key": "sk-..." -} -``` - -**Anthropic (使用 OAuth)** - -```json -{ - "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "auth_method": "oauth" -} -``` - -> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。 - -**Ollama (本地)** - -```json -{ - "model_name": "llama3", - "model": "ollama/llama3" -} -``` - -**自定义代理/API** - -```json -{ - "model_name": "my-custom-model", - "model": "openai/custom-model", - "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", - "request_timeout": 300 -} -``` - -#### 负载均衡 - -为同一个模型名称配置多个端点——PicoClaw 会自动在它们之间轮询: - -```json -{ - "model_list": [ - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" - }, - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" - } - ] -} -``` - -#### 从旧的 `providers` 配置迁移 - -旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。 - -**旧配置(已弃用):** - -```json -{ - "providers": { - "zhipu": { - "api_key": "your-key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - }, - "agents": { - "defaults": { - "provider": "zhipu", - "model": "glm-4.7" - } - } -} -``` - -**新配置(推荐):** - -```json -{ - "model_list": [ - { - "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", - "api_key": "your-key" - } - ], - "agents": { - "defaults": { - "model": "glm-4.7" - } - } -} -``` - -详细的迁移指南请参考 [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md)。 - -
-智谱 (Zhipu) 配置示例 - -**1. 获取 API key 和 base URL** - -- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) - -**2. 配置** - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 - } - }, - "providers": { - "zhipu": { - "api_key": "Your API Key", - "api_base": "https://open.bigmodel.cn/api/paas/v4" - } - } -} -``` - -**3. 运行** - -```bash -picoclaw agent -m "你好" - -``` - -
- -
-完整配置示例 - -```json -{ - "agents": { - "defaults": { - "model": "anthropic/claude-opus-4-5" - } - }, - "session": { - "dm_scope": "per-channel-peer", - "backlog_limit": 20 - }, - "providers": { - "openrouter": { - "api_key": "sk-or-v1-xxx" - }, - "groq": { - "api_key": "gsk_xxx" - } - }, - "channels": { - "telegram": { - "enabled": true, - "token": "123456:ABC...", - "allow_from": ["123456789"] - }, - "discord": { - "enabled": true, - "token": "", - "allow_from": [""] - }, - "whatsapp": { - "enabled": false - }, - "feishu": { - "enabled": false, - "app_id": "cli_xxx", - "app_secret": "xxx", - "encrypt_key": "", - "verification_token": "", - "allow_from": [] - }, - "qq": { - "enabled": false, - "app_id": "", - "app_secret": "", - "allow_from": [] - } - }, - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - }, - "cron": { - "exec_timeout_minutes": 5 - } - }, - "heartbeat": { - "enabled": true, - "interval": 30 - } -} -``` - -
- -## CLI 命令行参考 - -| 命令 | 描述 | -| ------------------------- | ------------------ | -| `picoclaw onboard` | 初始化配置和工作区 | -| `picoclaw agent -m "..."` | 与 Agent 对话 | -| `picoclaw agent` | 交互式聊天模式 | -| `picoclaw gateway` | 启动网关 (Gateway) | -| `picoclaw status` | 显示状态 | -| `picoclaw cron list` | 列出所有定时任务 | -| `picoclaw cron add ...` | 添加定时任务 | - -### 定时任务 / 提醒 (Scheduled Tasks) - -PicoClaw 通过 `cron` 工具支持定时提醒和重复任务: - -- **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次 -- **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发 -- **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式 - -任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。 - -## 🤝 贡献与路线图 (Roadmap) - -欢迎提交 PR!代码库刻意保持小巧和可读。🤗 - -路线图即将发布... - -开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。 - -用户群组: - -Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) - -PicoClaw - -## 🐛 疑难解答 (Troubleshooting) - -### 网络搜索提示 "API 配置问题" - -如果您尚未配置搜索 API Key,这是正常的。PicoClaw 会提供手动搜索的帮助链接。 - -启用网络搜索: - -1. 在 [https://tavily.com](https://tavily.com) (1000 次免费) 或 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (2000 次免费) -2. 添加到 `~/.picoclaw/config.json`: - -```json -{ - "tools": { - "web": { - "brave": { - "enabled": false, - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - }, - "duckduckgo": { - "enabled": true, - "max_results": 5 - } - } - } -} -``` - -### 遇到内容过滤错误 (Content Filtering Errors) - -某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。 - -### Telegram bot 提示 "Conflict: terminated by other getUpdates" - -这表示有另一个机器人实例正在运行。请确保同一时间只有一个 `picoclaw gateway` 进程在运行。 - ---- - -## 📝 API Key 对比 - -| 服务 | 免费层级 | 适用场景 | -| --- | --- | --- | -| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) | -| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 | -| **Brave Search** | 2000 次查询/月 | 网络搜索功能 | -| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 | -| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) | diff --git a/assets/fui_log_page.jpg b/assets/fui_log_page.jpg new file mode 100644 index 000000000..188c46982 Binary files /dev/null and b/assets/fui_log_page.jpg differ diff --git a/assets/fui_main_page.jpg b/assets/fui_main_page.jpg new file mode 100644 index 000000000..f9c5b5c34 Binary files /dev/null and b/assets/fui_main_page.jpg differ diff --git a/assets/fui_setting_page.jpg b/assets/fui_setting_page.jpg new file mode 100644 index 000000000..3481088e3 Binary files /dev/null and b/assets/fui_setting_page.jpg differ diff --git a/assets/fui_web_page.jpg b/assets/fui_web_page.jpg new file mode 100644 index 000000000..2f57c64c7 Binary files /dev/null and b/assets/fui_web_page.jpg differ diff --git a/assets/hardware-banner.jpg b/assets/hardware-banner.jpg new file mode 100644 index 000000000..f9a1190b1 Binary files /dev/null and b/assets/hardware-banner.jpg differ diff --git a/assets/launcher-webui.jpg b/assets/launcher-webui.jpg new file mode 100644 index 000000000..9e7c699b2 Binary files /dev/null and b/assets/launcher-webui.jpg differ diff --git a/assets/logo.webp b/assets/logo.webp new file mode 100644 index 000000000..9333f7e1b Binary files /dev/null and b/assets/logo.webp differ diff --git a/assets/macos-gatekeeper-allow.jpg b/assets/macos-gatekeeper-allow.jpg new file mode 100644 index 000000000..9128eb313 Binary files /dev/null and b/assets/macos-gatekeeper-allow.jpg differ diff --git a/assets/macos-gatekeeper-warning.jpg b/assets/macos-gatekeeper-warning.jpg new file mode 100644 index 000000000..c88c1fc7b Binary files /dev/null and b/assets/macos-gatekeeper-warning.jpg differ diff --git a/assets/wechat.png b/assets/wechat.png index cc88186a8..18247ff82 100644 Binary files a/assets/wechat.png and b/assets/wechat.png differ diff --git a/assets/wecom-qr-binding.jpg b/assets/wecom-qr-binding.jpg new file mode 100644 index 000000000..4768d0d71 Binary files /dev/null and b/assets/wecom-qr-binding.jpg differ diff --git a/cmd/membench/eval.go b/cmd/membench/eval.go new file mode 100644 index 000000000..729c9f97f --- /dev/null +++ b/cmd/membench/eval.go @@ -0,0 +1,412 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/sipeed/picoclaw/pkg/seahorse" +) + +// EvalResult holds per-sample evaluation results for one mode. +type EvalResult struct { + Mode string `json:"mode"` + SampleID string `json:"sampleId"` + QAResults []QAResult `json:"qaResults"` + Agg AggMetrics `json:"aggregated"` +} + +// QAResult holds metrics for a single QA pair. +type QAResult struct { + Question string `json:"question"` + Category int `json:"category"` + GoldAnswer string `json:"goldAnswer"` + TokenF1 float64 `json:"tokenF1"` + HitRate float64 `json:"hitRate"` +} + +// AggMetrics holds aggregated evaluation metrics. +type AggMetrics struct { + OverallF1 float64 `json:"overallF1"` + OverallHitRate float64 `json:"overallHitRate"` + ByCategory map[int]*CatMetrics `json:"byCategory"` + TotalQuestions int `json:"totalQuestions"` + ValidF1Count int `json:"validF1Count"` +} + +// CatMetrics holds metrics for a single category. +type CatMetrics struct { + F1 float64 `json:"f1"` + HitRate float64 `json:"hitRate"` + QuestionCount int `json:"questionCount"` + ValidF1Count int `json:"validF1Count"` +} + +// EvalLegacy evaluates using legacy session store (raw history + budget truncation). +func EvalLegacy( + ctx context.Context, + samples []LocomoSample, + legacy *LegacyStore, + budgetTokens int, +) []EvalResult { + results := make([]EvalResult, 0, len(samples)) + for si := range samples { + sample := &samples[si] + history := legacy.GetHistory(sample.SampleID) + + // Convert messages to content strings + allContent := make([]string, 0, len(history)) + for _, msg := range history { + allContent = append(allContent, msg.Content) + } + + qaResults := make([]QAResult, 0, len(sample.QA)) + for qi := range sample.QA { + qa := &sample.QA[qi] + // Budget truncate the full history + truncated, _ := BudgetTruncate(allContent, budgetTokens) + context := StringListToContent(truncated) + + f1 := TokenOverlapF1(context, qa.AnswerString()) + hitRate := RecallHitRate(qa.Evidence, sample, context) + + qaResults = append(qaResults, QAResult{ + Question: qa.Question, + Category: qa.Category, + GoldAnswer: qa.AnswerString(), + TokenF1: f1, + HitRate: hitRate, + }) + } + + results = append(results, EvalResult{ + Mode: "legacy", + SampleID: sample.SampleID, + QAResults: qaResults, + Agg: aggregateMetrics(qaResults), + }) + } + return results +} + +// EvalSeahorse evaluates using seahorse short memory (per-keyword search + expand). +func EvalSeahorse( + ctx context.Context, + samples []LocomoSample, + ir *SeahorseIngestResult, + budgetTokens int, +) []EvalResult { + store := ir.Engine.GetRetrieval().Store() + retrieval := ir.Engine.GetRetrieval() + + results := make([]EvalResult, 0, len(samples)) + for si := range samples { + sample := &samples[si] + convID, ok := ir.ConvMap[sample.SampleID] + if !ok { + log.Printf("WARN: no conversation ID for sample %s", sample.SampleID) + continue + } + + qaResults := make([]QAResult, 0, len(sample.QA)) + for qi := range sample.QA { + qa := &sample.QA[qi] + keywords := ExtractKeywords(qa.Question) + + // Search each keyword individually and union results, + // tracking best BM25 rank per message for relevance sorting. + bestRank := map[int64]float64{} + for _, kw := range keywords { + searchResults, err := store.SearchMessages(ctx, seahorse.SearchInput{ + Pattern: kw, + ConversationID: convID, + Limit: 20, + }) + if err != nil { + log.Printf("WARN: search failed for keyword %q: %v", kw, err) + continue + } + for _, sr := range searchResults { + if sr.MessageID > 0 { + if prev, ok := bestRank[sr.MessageID]; !ok || sr.Rank < prev { + bestRank[sr.MessageID] = sr.Rank + } + } + } + } + // Sort messageIDs by rank ascending (best/most-negative first). + // BudgetTruncate walks from the front, keeping best-ranked messages. + // Note: SQLite FTS5 bm25() returns negative values where more + // negative = better match. + messageIDs := make([]int64, 0, len(bestRank)) + for id := range bestRank { + messageIDs = append(messageIDs, id) + } + sort.Slice(messageIDs, func(i, j int) bool { + return bestRank[messageIDs[i]] < bestRank[messageIDs[j]] + }) + + // Expand messages to get full content + var contentParts []string + if len(messageIDs) > 0 { + expandResult, err := retrieval.ExpandMessages(ctx, messageIDs) + if err != nil { + log.Printf("WARN: expand failed for sample %s: %v", sample.SampleID, err) + } else { + for _, msg := range expandResult.Messages { + contentParts = append(contentParts, msg.Content) + } + } + } + + if len(contentParts) == 0 { + qaResults = append(qaResults, QAResult{ + Question: qa.Question, + Category: qa.Category, + GoldAnswer: qa.AnswerString(), + TokenF1: 0.0, + HitRate: 0.0, + }) + continue + } + + // Budget truncate (drop worst-ranked) + truncated, _ := BudgetTruncate(contentParts, budgetTokens) + context := StringListToContent(truncated) + + f1 := TokenOverlapF1(context, qa.AnswerString()) + hitRate := RecallHitRate(qa.Evidence, sample, context) + + qaResults = append(qaResults, QAResult{ + Question: qa.Question, + Category: qa.Category, + GoldAnswer: qa.AnswerString(), + TokenF1: f1, + HitRate: hitRate, + }) + } + + results = append(results, EvalResult{ + Mode: "seahorse", + SampleID: sample.SampleID, + QAResults: qaResults, + Agg: aggregateMetrics(qaResults), + }) + } + return results +} + +// aggregateMetrics computes overall and per-category metrics. +func aggregateMetrics(qaResults []QAResult) AggMetrics { + type catAccum struct { + f1Sum float64 + f1Count int + hitRateSum float64 + hitRateCount int + } + byCatAcc := map[int]*catAccum{} + totalF1 := 0.0 + totalHitRate := 0.0 + validF1Count := 0 + for _, qr := range qaResults { + // Skip sentinel -1.0 scores (LLM API/parse failures) from F1 averaging. + if qr.TokenF1 >= 0 { + totalF1 += qr.TokenF1 + validF1Count++ + } + totalHitRate += qr.HitRate + acc, ok := byCatAcc[qr.Category] + if !ok { + acc = &catAccum{} + byCatAcc[qr.Category] = acc + } + if qr.TokenF1 >= 0 { + acc.f1Sum += qr.TokenF1 + acc.f1Count++ + } + acc.hitRateSum += qr.HitRate + acc.hitRateCount++ + } + nHit := len(qaResults) + if nHit == 0 { + nHit = 1 + } + byCat := map[int]*CatMetrics{} + for cat, acc := range byCatAcc { + cm := &CatMetrics{ + QuestionCount: acc.hitRateCount, + ValidF1Count: acc.f1Count, + } + if acc.f1Count > 0 { + cm.F1 = acc.f1Sum / float64(acc.f1Count) + } + if acc.hitRateCount > 0 { + cm.HitRate = acc.hitRateSum / float64(acc.hitRateCount) + } + byCat[cat] = cm + } + var overallF1 float64 + if validF1Count > 0 { + overallF1 = totalF1 / float64(validF1Count) + } + return AggMetrics{ + OverallF1: overallF1, + OverallHitRate: totalHitRate / float64(nHit), + ByCategory: byCat, + TotalQuestions: len(qaResults), + ValidF1Count: validF1Count, + } +} + +// SaveResults writes per-sample eval results to JSON files. +func SaveResults(results []EvalResult, outDir string) error { + if err := os.MkdirAll(outDir, 0o755); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + for _, r := range results { + path := filepath.Join(outDir, fmt.Sprintf("eval_%s_%s.json", r.Mode, r.SampleID)) + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return fmt.Errorf("marshal result: %w", err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write result: %w", err) + } + } + return nil +} + +// SaveAggregated writes a combined results.json with all modes. +func SaveAggregated(results []EvalResult, outDir string) error { + byMode := map[string][]EvalResult{} + for _, r := range results { + byMode[r.Mode] = append(byMode[r.Mode], r) + } + + aggMap := map[string]AggMetrics{} + for mode, modeResults := range byMode { + aggMap[mode] = computeModeAgg(modeResults) + } + + data, err := json.MarshalIndent(aggMap, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(outDir, "results.json"), data, 0o644) +} + +// computeModeAgg aggregates results for a single mode using weighted averaging +// (weighted by question count per sample). All modes must have the same Mode field. +func computeModeAgg(results []EvalResult) AggMetrics { + agg := AggMetrics{ByCategory: map[int]*CatMetrics{}} + for _, r := range results { + // Backward compat: old eval JSON (token mode) without ValidF1Count → use TotalQuestions. + // LLM modes may legitimately have ValidF1Count==0 (all failures). + vf1 := r.Agg.ValidF1Count + if vf1 == 0 && r.Agg.TotalQuestions > 0 && !strings.HasSuffix(r.Mode, "-llm") { + vf1 = r.Agg.TotalQuestions + } + agg.OverallF1 += r.Agg.OverallF1 * float64(vf1) + agg.OverallHitRate += r.Agg.OverallHitRate * float64(r.Agg.TotalQuestions) + agg.TotalQuestions += r.Agg.TotalQuestions + agg.ValidF1Count += vf1 + for cat, cm := range r.Agg.ByCategory { + existing, ok := agg.ByCategory[cat] + if !ok { + existing = &CatMetrics{} + agg.ByCategory[cat] = existing + } + cvf1 := cm.ValidF1Count + if cvf1 == 0 && cm.QuestionCount > 0 && !strings.HasSuffix(r.Mode, "-llm") { + cvf1 = cm.QuestionCount + } + existing.F1 += cm.F1 * float64(cvf1) + existing.HitRate += cm.HitRate * float64(cm.QuestionCount) + existing.QuestionCount += cm.QuestionCount + existing.ValidF1Count += cvf1 + } + } + if agg.ValidF1Count > 0 { + agg.OverallF1 /= float64(agg.ValidF1Count) + } + if agg.TotalQuestions > 0 { + agg.OverallHitRate /= float64(agg.TotalQuestions) + } + for _, cat := range agg.ByCategory { + if cat.ValidF1Count > 0 { + cat.F1 /= float64(cat.ValidF1Count) + } + if cat.QuestionCount > 0 { + cat.HitRate /= float64(cat.QuestionCount) + } + } + return agg +} + +// printSection prints a single comparison table section. +func printSection(title string, results []EvalResult) { + fmt.Printf("\n--- %s ---\n", title) + byMode := map[string][]EvalResult{} + for _, r := range results { + byMode[r.Mode] = append(byMode[r.Mode], r) + } + + modes := map[string]AggMetrics{} + for mode, modeResults := range byMode { + modes[mode] = computeModeAgg(modeResults) + } + + modeKeys := make([]string, 0, len(modes)) + for k := range modes { + modeKeys = append(modeKeys, k) + } + sort.Strings(modeKeys) + + // Collect all category keys across modes + catSet := map[int]bool{} + for _, agg := range modes { + for cat := range agg.ByCategory { + catSet[cat] = true + } + } + cats := make([]int, 0, len(catSet)) + for cat := range catSet { + cats = append(cats, cat) + } + sort.Ints(cats) + + fmt.Printf("%-10s %-8s %-8s", "Mode", "HitRate", "F1") + for _, cat := range cats { + fmt.Printf(" %-7s", fmt.Sprintf("C%d", cat)) + } + fmt.Println() + fmt.Println(strings.Repeat("-", 10+8+8+7*len(cats)+8)) + + for _, mode := range modeKeys { + agg := modes[mode] + fmt.Printf("%-10s %-8.4f %-8.4f", mode, agg.OverallHitRate, agg.OverallF1) + for _, cat := range cats { + if cm, ok := agg.ByCategory[cat]; ok { + fmt.Printf(" %-7.4f", cm.HitRate) + } else { + fmt.Printf(" %-7s", "N/A") + } + } + fmt.Println() + } +} + +// PrintComparison outputs a human-readable comparison table to stdout. +func PrintComparison(results []EvalResult, llmResults []EvalResult) { + if len(results) > 0 { + printSection("No LLM generation", results) + } + if len(llmResults) > 0 { + printSection("With LLM", llmResults) + } +} diff --git a/cmd/membench/eval_llm.go b/cmd/membench/eval_llm.go new file mode 100644 index 000000000..ee401d134 --- /dev/null +++ b/cmd/membench/eval_llm.go @@ -0,0 +1,346 @@ +package main + +import ( + "context" + "fmt" + "log" + "regexp" + "sort" + "strconv" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/seahorse" +) + +const answerSystemPrompt = `You are a helpful assistant. Given conversation context, answer the question concisely and accurately. If the answer is not in the context, say "I don't know". Answer in 1-3 sentences maximum.` + +const judgeSystemPrompt = `You are an impartial judge evaluating answer quality. +Compare the candidate answer against the reference answer. +Consider semantic equivalence — different wording expressing the same meaning should score high. + +Output ONLY a single integer score from 1 to 5: +1 = completely wrong or irrelevant +2 = partially related but mostly incorrect +3 = partially correct, missing key details +4 = mostly correct with minor omissions +5 = fully correct, semantically equivalent + +Output ONLY the number, nothing else.` + +// generateAnswer asks the LLM to answer a question given retrieved context. +func generateAnswer(ctx context.Context, client *LLMClient, contextText, question string) (string, error) { + // Truncate context to avoid exceeding model limits while preserving valid UTF-8. + contextRunes := []rune(contextText) + if len(contextRunes) > 6000 { + contextText = string(contextRunes[:6000]) + "\n... [truncated]" + } + + userPrompt := fmt.Sprintf("## Conversation Context\n\n%s\n\n## Question\n\n%s", contextText, question) + return client.Complete(ctx, answerSystemPrompt, userPrompt) +} + +// scoreRe matches the first standalone integer 1-5 in the judge response. +var scoreRe = regexp.MustCompile(`\b([1-5])\b`) + +// judgeAnswer asks the LLM to score the candidate answer vs the gold answer. +// Returns a score from 0.0 to 1.0, or -1.0 on parse failure. +func judgeAnswer( + ctx context.Context, + judgeClient *LLMClient, + question, goldAnswer, candidateAnswer string, +) (float64, error) { + userPrompt := fmt.Sprintf( + "Question: %s\n\nReference Answer: %s\n\nCandidate Answer: %s\n\nScore:", + question, goldAnswer, candidateAnswer, + ) + + response, err := judgeClient.Complete(ctx, judgeSystemPrompt, userPrompt) + if err != nil { + return -1.0, err + } + + response = strings.TrimSpace(response) + if m := scoreRe.FindStringSubmatch(response); len(m) == 2 { + score, _ := strconv.Atoi(m[1]) + return float64(score-1) / 4.0, nil // Normalize 1-5 to 0.0-1.0 + } + log.Printf("WARNING: could not parse judge score from: %q, returning -1", response) + return -1.0, nil +} + +// qaWork describes one QA evaluation unit. +type qaWork struct { + sampleID string + qaIndex int + globalIndex int + totalQA int + qa *LocomoQA + contextText string + sample *LocomoSample +} + +// qaResult collects one QA evaluation output. +type qaResultOut struct { + index int // position in the flat QA list for ordering + result QAResult + answer string + score float64 +} + +// evalQAWorker processes a single QA item: generate answer + judge score. +func evalQAWorker( + ctx context.Context, + w qaWork, + answerClient, judgeClient *LLMClient, + logPrefix string, +) qaResultOut { + llmAnswer, err := generateAnswer(ctx, answerClient, w.contextText, w.qa.Question) + if err != nil { + log.Printf("WARN: LLM generation failed for sample %s Q%d: %v", w.sampleID, w.qaIndex, err) + llmAnswer = "" + } + + score := -1.0 + if llmAnswer != "" { + score, err = judgeAnswer(ctx, judgeClient, w.qa.Question, w.qa.AnswerString(), llmAnswer) + if err != nil { + log.Printf("WARN: LLM judge failed for sample %s Q%d: %v", w.sampleID, w.qaIndex, err) + } + } + + hitRate := RecallHitRate(w.qa.Evidence, w.sample, w.contextText) + + log.Printf("[%s] sample=%s q=%d/%d score=%.2f answer=%q", + logPrefix, w.sampleID, w.globalIndex, w.totalQA, score, truncateStr(llmAnswer, 80)) + + return qaResultOut{ + index: w.globalIndex, + result: QAResult{ + Question: w.qa.Question, + Category: w.qa.Category, + GoldAnswer: w.qa.AnswerString(), + TokenF1: score, + HitRate: hitRate, + }, + answer: llmAnswer, + score: score, + } +} + +// EvalLegacyLLM evaluates legacy store using LLM generation + LLM-as-Judge. +func EvalLegacyLLM( + ctx context.Context, + samples []LocomoSample, + legacy *LegacyStore, + budgetTokens int, + answerClient, judgeClient *LLMClient, + concurrency int, +) []EvalResult { + if concurrency < 1 { + concurrency = 1 + } + totalQA := countTotalQA(samples) + results := make([]EvalResult, 0, len(samples)) + + for si := range samples { + sample := &samples[si] + history := legacy.GetHistory(sample.SampleID) + + allContent := make([]string, 0, len(history)) + for _, msg := range history { + allContent = append(allContent, msg.Content) + } + + truncated, _ := BudgetTruncate(allContent, budgetTokens) + contextText := StringListToContent(truncated) + + qaResults := make([]QAResult, len(sample.QA)) + + if concurrency <= 1 { + for qi := range sample.QA { + out := evalQAWorker(ctx, qaWork{ + sampleID: sample.SampleID, qaIndex: qi, + globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA, + qa: &sample.QA[qi], contextText: contextText, sample: sample, + }, answerClient, judgeClient, "legacy-llm") + qaResults[qi] = out.result + } + } else { + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + for qi := range sample.QA { + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + out := evalQAWorker(ctx, qaWork{ + sampleID: sample.SampleID, qaIndex: qi, + globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA, + qa: &sample.QA[qi], contextText: contextText, sample: sample, + }, answerClient, judgeClient, "legacy-llm") + qaResults[qi] = out.result // safe: each goroutine writes distinct index + }() + } + wg.Wait() + } + + results = append(results, EvalResult{ + Mode: "legacy-llm", + SampleID: sample.SampleID, + QAResults: qaResults, + Agg: aggregateMetrics(qaResults), + }) + } + return results +} + +// buildSeahorseContext retrieves context for a seahorse QA item. +func buildSeahorseContext( + ctx context.Context, + ir *SeahorseIngestResult, + sample *LocomoSample, + qa *LocomoQA, + budgetTokens int, +) string { + store := ir.Engine.GetRetrieval().Store() + retrieval := ir.Engine.GetRetrieval() + convID := ir.ConvMap[sample.SampleID] + + keywords := ExtractKeywords(qa.Question) + bestRank := map[int64]float64{} + for _, kw := range keywords { + searchResults, err := store.SearchMessages(ctx, seahorse.SearchInput{ + Pattern: kw, + ConversationID: convID, + Limit: 20, + }) + if err != nil { + continue + } + for _, sr := range searchResults { + if sr.MessageID > 0 { + if prev, ok := bestRank[sr.MessageID]; !ok || sr.Rank < prev { + bestRank[sr.MessageID] = sr.Rank + } + } + } + } + + messageIDs := make([]int64, 0, len(bestRank)) + for id := range bestRank { + messageIDs = append(messageIDs, id) + } + sort.Slice(messageIDs, func(i, j int) bool { + return bestRank[messageIDs[i]] < bestRank[messageIDs[j]] + }) + + var contentParts []string + if len(messageIDs) > 0 { + expandResult, err := retrieval.ExpandMessages(ctx, messageIDs) + if err == nil { + for _, msg := range expandResult.Messages { + contentParts = append(contentParts, msg.Content) + } + } + } + if len(contentParts) == 0 { + return "" + } + truncated, _ := BudgetTruncate(contentParts, budgetTokens) + return StringListToContent(truncated) +} + +// EvalSeahorseLLM evaluates seahorse retrieval using LLM generation + LLM-as-Judge. +func EvalSeahorseLLM( + ctx context.Context, + samples []LocomoSample, + ir *SeahorseIngestResult, + budgetTokens int, + answerClient, judgeClient *LLMClient, + concurrency int, +) []EvalResult { + if concurrency < 1 { + concurrency = 1 + } + totalQA := countTotalQA(samples) + results := make([]EvalResult, 0, len(samples)) + + for si := range samples { + sample := &samples[si] + if _, ok := ir.ConvMap[sample.SampleID]; !ok { + log.Printf("WARN: no conversation ID for sample %s", sample.SampleID) + continue + } + + qaResults := make([]QAResult, len(sample.QA)) + + evalOne := func(qi int) { + qa := &sample.QA[qi] + contextText := buildSeahorseContext(ctx, ir, sample, qa, budgetTokens) + if contextText == "" { + qaResults[qi] = QAResult{ + Question: qa.Question, + Category: qa.Category, + GoldAnswer: qa.AnswerString(), + TokenF1: 0.0, + HitRate: 0.0, + } + log.Printf("[seahorse-llm] sample=%s q=%d/%d score=0.00 answer=(no context)", + sample.SampleID, si*len(sample.QA)+qi+1, totalQA) + return + } + out := evalQAWorker(ctx, qaWork{ + sampleID: sample.SampleID, qaIndex: qi, + globalIndex: si*len(sample.QA) + qi + 1, totalQA: totalQA, + qa: qa, contextText: contextText, sample: sample, + }, answerClient, judgeClient, "seahorse-llm") + qaResults[qi] = out.result + } + + if concurrency <= 1 { + for qi := range sample.QA { + evalOne(qi) + } + } else { + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + for qi := range sample.QA { + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + evalOne(qi) + }() + } + wg.Wait() + } + + results = append(results, EvalResult{ + Mode: "seahorse-llm", + SampleID: sample.SampleID, + QAResults: qaResults, + Agg: aggregateMetrics(qaResults), + }) + } + return results +} + +func countTotalQA(samples []LocomoSample) int { + n := 0 + for i := range samples { + n += len(samples[i].QA) + } + return n +} + +func truncateStr(s string, maxLen int) string { + s = strings.ReplaceAll(s, "\n", " ") + runes := []rune(s) + if len(runes) > maxLen { + return string(runes[:maxLen]) + "..." + } + return s +} diff --git a/cmd/membench/eval_test.go b/cmd/membench/eval_test.go new file mode 100644 index 000000000..32dea07c9 --- /dev/null +++ b/cmd/membench/eval_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "math" + "testing" +) + +func TestComputeModeAggAllCategories(t *testing.T) { + results := []EvalResult{ + { + Mode: "test", + SampleID: "s1", + QAResults: []QAResult{ + {Category: 1, TokenF1: 0.5, HitRate: 0.8}, + {Category: 2, TokenF1: 0.3, HitRate: 0.6}, + {Category: 3, TokenF1: 0.1, HitRate: 0.4}, + {Category: 4, TokenF1: 0.7, HitRate: 0.9}, + {Category: 5, TokenF1: 0.2, HitRate: 0.1}, + }, + }, + } + for i := range results { + results[i].Agg = aggregateMetrics(results[i].QAResults) + } + + got := computeModeAgg(results) + + // Should have all 5 categories + for cat := 1; cat <= 5; cat++ { + cm, ok := got.ByCategory[cat] + if !ok { + t.Errorf("ByCategory missing category %d", cat) + continue + } + if cm.QuestionCount != 1 { + t.Errorf("ByCategory[%d].QuestionCount = %d, want 1", cat, cm.QuestionCount) + } + } + + // Verify specific F1 values per category + wantF1 := map[int]float64{1: 0.5, 2: 0.3, 3: 0.1, 4: 0.7, 5: 0.2} + for cat, want := range wantF1 { + if cm, ok := got.ByCategory[cat]; ok { + if math.Abs(cm.F1-want) > 1e-9 { + t.Errorf("ByCategory[%d].F1 = %.4f, want %.4f", cat, cm.F1, want) + } + } + } +} + +func TestComputeModeAgg(t *testing.T) { + // Two samples with different question counts: + // sample-a: 2 questions, F1 = [0.4, 0.6] → avg 0.5 + // sample-b: 8 questions, F1 = [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] → avg 0.1 + // + // Unweighted (PrintComparison bug): (0.5 + 0.1) / 2 = 0.3 + // Weighted (correct): (0.4+0.6 + 0.1*8) / 10 = 1.8 / 10 = 0.18 + results := []EvalResult{ + { + Mode: "test", + SampleID: "sample-a", + QAResults: []QAResult{ + {TokenF1: 0.4, HitRate: 0.5}, + {TokenF1: 0.6, HitRate: 0.7}, + }, + }, + { + Mode: "test", + SampleID: "sample-b", + QAResults: []QAResult{ + {TokenF1: 0.1, HitRate: 0.2}, + {TokenF1: 0.1, HitRate: 0.2}, + {TokenF1: 0.1, HitRate: 0.2}, + {TokenF1: 0.1, HitRate: 0.2}, + {TokenF1: 0.1, HitRate: 0.2}, + {TokenF1: 0.1, HitRate: 0.2}, + {TokenF1: 0.1, HitRate: 0.2}, + {TokenF1: 0.1, HitRate: 0.2}, + }, + }, + } + // Compute per-sample aggregates + for i := range results { + results[i].Agg = aggregateMetrics(results[i].QAResults) + } + + got := computeModeAgg(results) + + // Weighted: (0.4+0.6+0.1*8) / 10 = 1.8/10 = 0.18 + wantF1 := 0.18 + if math.Abs(got.OverallF1-wantF1) > 1e-9 { + t.Errorf("OverallF1 = %.6f, want %.6f (weighted average)", got.OverallF1, wantF1) + } + + // Weighted: (0.5+0.7+0.2*8) / 10 = 2.8/10 = 0.28 + wantRecall := 0.28 + if math.Abs(got.OverallHitRate-wantRecall) > 1e-9 { + t.Errorf("OverallHitRate = %.6f, want %.6f (weighted average)", got.OverallHitRate, wantRecall) + } + + if got.TotalQuestions != 10 { + t.Errorf("TotalQuestions = %d, want 10", got.TotalQuestions) + } +} + +func TestAggregateMetricsSentinel(t *testing.T) { + qa := []QAResult{ + {Category: 1, TokenF1: 0.8, HitRate: 0.5}, + {Category: 1, TokenF1: -1.0, HitRate: 0.3}, + {Category: 1, TokenF1: 0.4, HitRate: 0.7}, + } + agg := aggregateMetrics(qa) + + if agg.ValidF1Count != 2 { + t.Errorf("ValidF1Count = %d, want 2", agg.ValidF1Count) + } + if agg.TotalQuestions != 3 { + t.Errorf("TotalQuestions = %d, want 3", agg.TotalQuestions) + } + wantF1 := (0.8 + 0.4) / 2.0 + if math.Abs(agg.OverallF1-wantF1) > 1e-9 { + t.Errorf("OverallF1 = %.6f, want %.6f", agg.OverallF1, wantF1) + } + wantHR := (0.5 + 0.3 + 0.7) / 3.0 + if math.Abs(agg.OverallHitRate-wantHR) > 1e-9 { + t.Errorf("OverallHitRate = %.6f, want %.6f", agg.OverallHitRate, wantHR) + } +} + +func TestAggregateMetricsAllSentinel(t *testing.T) { + qa := []QAResult{ + {Category: 1, TokenF1: -1.0, HitRate: 0.5}, + {Category: 1, TokenF1: -1.0, HitRate: 0.3}, + } + agg := aggregateMetrics(qa) + + if agg.ValidF1Count != 0 { + t.Errorf("ValidF1Count = %d, want 0", agg.ValidF1Count) + } + if agg.OverallF1 != 0 { + t.Errorf("OverallF1 = %.6f, want 0", agg.OverallF1) + } +} + +func TestComputeModeAggSentinelWeighting(t *testing.T) { + results := []EvalResult{ + { + Mode: "test", + SampleID: "s1", + QAResults: []QAResult{ + {Category: 1, TokenF1: 0.8, HitRate: 0.5}, + {Category: 1, TokenF1: -1.0, HitRate: 0.3}, + }, + }, + { + Mode: "test", + SampleID: "s2", + QAResults: []QAResult{ + {Category: 1, TokenF1: 0.4, HitRate: 0.6}, + {Category: 1, TokenF1: 0.6, HitRate: 0.8}, + }, + }, + } + for i := range results { + results[i].Agg = aggregateMetrics(results[i].QAResults) + } + + got := computeModeAgg(results) + + // s1: ValidF1Count=1, F1=0.8; s2: ValidF1Count=2, F1=0.5 + // Weighted: (0.8*1 + 0.5*2) / 3 = 1.8/3 = 0.6 + wantF1 := 0.6 + if math.Abs(got.OverallF1-wantF1) > 1e-9 { + t.Errorf("OverallF1 = %.6f, want %.6f", got.OverallF1, wantF1) + } + if got.ValidF1Count != 3 { + t.Errorf("ValidF1Count = %d, want 3", got.ValidF1Count) + } + if got.TotalQuestions != 4 { + t.Errorf("TotalQuestions = %d, want 4", got.TotalQuestions) + } +} diff --git a/cmd/membench/ingest.go b/cmd/membench/ingest.go new file mode 100644 index 000000000..70d559c2b --- /dev/null +++ b/cmd/membench/ingest.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "fmt" + "log" + + "github.com/sipeed/picoclaw/pkg/seahorse" +) + +// ConvMap stores the mapping from sampleID to seahorse ConversationID. +type ConvMap map[string]int64 + +// SeahorseIngestResult holds the results of ingesting into seahorse. +type SeahorseIngestResult struct { + Engine *seahorse.Engine + ConvMap ConvMap // sampleID → conversationID +} + +// IngestSeahorse loads all LOCOMO samples into a seahorse Engine. +// Returns the engine and a mapping from sampleID to conversationID for scoped retrieval. +func IngestSeahorse(ctx context.Context, samples []LocomoSample, dbPath string) (*SeahorseIngestResult, error) { + noopFn := func(ctx context.Context, prompt string, opts seahorse.CompleteOptions) (string, error) { + return "", nil + } + + engine, err := seahorse.NewEngine(seahorse.Config{ + DBPath: dbPath, + }, noopFn) + if err != nil { + return nil, fmt.Errorf("create seahorse engine: %w", err) + } + + store := engine.GetRetrieval().Store() + convMap := make(ConvMap) + + for si := range samples { + sample := &samples[si] + sessionKey := "locomo-" + sample.SampleID + + // Check if conversation already exists (idempotent) + existing, _ := store.GetConversationBySessionKey(ctx, sessionKey) + if existing != nil { + convMap[sample.SampleID] = existing.ConversationID + log.Printf("Skipping existing sample %s: convID=%d", sample.SampleID, existing.ConversationID) + continue + } + + turns := GetTurns(sample) + + // Convert turns to seahorse messages + msgs := make([]seahorse.Message, 0, len(turns)) + for _, turn := range turns { + content := turn.Speaker + ": " + turn.Text + msgs = append(msgs, seahorse.Message{ + Role: "user", + Content: content, + TokenCount: len(turn.Text) / 4, + }) + } + + // Ingest all turns for this sample + _, err := engine.Ingest(ctx, sessionKey, msgs) + if err != nil { + return nil, fmt.Errorf("ingest sample %s: %w", sample.SampleID, err) + } + + // Get the conversation ID for scoped retrieval + conv, err := store.GetConversationBySessionKey(ctx, sessionKey) + if err != nil { + return nil, fmt.Errorf("get conversation for %s: %w", sample.SampleID, err) + } + if conv == nil { + return nil, fmt.Errorf("conversation not found for %s after ingest", sample.SampleID) + } + convMap[sample.SampleID] = conv.ConversationID + log.Printf("Ingested sample %s: %d turns, convID=%d", sample.SampleID, len(turns), conv.ConversationID) + } + + log.Printf("Seahorse ingestion complete: %d samples, %d conversations", len(samples), len(convMap)) + return &SeahorseIngestResult{ + Engine: engine, + ConvMap: convMap, + }, nil +} diff --git a/cmd/membench/ingest_test.go b/cmd/membench/ingest_test.go new file mode 100644 index 000000000..e8748deed --- /dev/null +++ b/cmd/membench/ingest_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/seahorse" +) + +func TestIngestSeahorseIdempotent(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + // Minimal test data + samples := []LocomoSample{ + { + SampleID: "test-1", + Conversation: map[string]json.RawMessage{ + "session_1": json.RawMessage(`[ + {"speaker":"A","dia_id":"D1:1","text":"hello world this is a test message"}, + {"speaker":"B","dia_id":"D1:2","text":"another message for testing purposes"} + ]`), + }, + }, + } + + // First ingestion + result1, err := IngestSeahorse(ctx, samples, dbPath) + if err != nil { + t.Fatalf("first ingest failed: %v", err) + } + convCount1 := len(result1.ConvMap) + result1.Engine.Close() + + // Second ingestion on same DB — should reuse existing data + result2, err := IngestSeahorse(ctx, samples, dbPath) + if err != nil { + t.Fatalf("second ingest failed: %v", err) + } + defer result2.Engine.Close() + + // ConvMap should have same number of entries (no duplicates) + if len(result2.ConvMap) != convCount1 { + t.Errorf("second ingest convMap has %d entries, want %d (same as first)", + len(result2.ConvMap), convCount1) + } + + // Verify conversation IDs are the same (reused, not new ones) + for id, cid1 := range result1.ConvMap { + cid2, ok := result2.ConvMap[id] + if !ok { + t.Errorf("sample %s missing from second ConvMap", id) + continue + } + if cid2 != cid1 { + t.Errorf("sample %s: second ingest got convID %d, want %d (reused)", id, cid2, cid1) + } + } + + // Verify no duplicate messages by counting + store := result2.Engine.GetRetrieval().Store() + for _, convID := range result2.ConvMap { + msgs, err := store.SearchMessages(ctx, seahorse.SearchInput{ + Pattern: "test", + ConversationID: convID, + Limit: 100, + }) + if err != nil { + t.Fatalf("search failed: %v", err) + } + // Should find exactly 1 message containing "test" (the first turn) + if len(msgs) > 2 { + t.Errorf("found %d messages for 'test' in conv %d, expected ≤2 (no duplicates)", len(msgs), convID) + } + } +} diff --git a/cmd/membench/legacy_store.go b/cmd/membench/legacy_store.go new file mode 100644 index 000000000..80cbd2704 --- /dev/null +++ b/cmd/membench/legacy_store.go @@ -0,0 +1,34 @@ +package main + +import ( + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/session" +) + +// LegacyStore wraps session.SessionManager for legacy baseline. +type LegacyStore struct { + sm *session.SessionManager +} + +// NewLegacyStore creates a new in-memory session manager. +func NewLegacyStore() *LegacyStore { + return &LegacyStore{ + sm: session.NewSessionManager(""), + } +} + +// IngestSample loads all turns from a LOCOMO sample into the legacy session store. +func (ls *LegacyStore) IngestSample(sample *LocomoSample) { + sessionKey := "locomo-" + sample.SampleID + turns := GetTurns(sample) + for _, turn := range turns { + content := turn.Speaker + ": " + turn.Text + ls.sm.AddMessage(sessionKey, "user", content) + } +} + +// GetHistory returns all messages for a sample's session. +func (ls *LegacyStore) GetHistory(sampleID string) []providers.Message { + sessionKey := "locomo-" + sampleID + return ls.sm.GetHistory(sessionKey) +} diff --git a/cmd/membench/llm_client.go b/cmd/membench/llm_client.go new file mode 100644 index 000000000..6c62424da --- /dev/null +++ b/cmd/membench/llm_client.go @@ -0,0 +1,198 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" +) + +// LLMClient wraps an OpenAI-compatible chat completion endpoint. +type LLMClient struct { + BaseURL string + Model string + APIKey string + NoThinking bool // send chat_template_kwargs to disable thinking (llama.cpp specific) + MaxRetries int // max retry attempts for transient errors (0 = no retry) + Client *http.Client +} + +// LLMClientOptions configures the LLM client. +type LLMClientOptions struct { + BaseURL string + Model string + APIKey string + Timeout time.Duration + NoThinking bool + MaxRetries int // max retry attempts (default 3) +} + +// NewLLMClient creates a client for an OpenAI-compatible chat completion API. +func NewLLMClient(opts LLMClientOptions) *LLMClient { + if opts.Timeout == 0 { + opts.Timeout = 120 * time.Second + } + maxRetries := opts.MaxRetries + if maxRetries < 0 { + maxRetries = 3 + } + return &LLMClient{ + BaseURL: strings.TrimRight(opts.BaseURL, "/"), + Model: opts.Model, + APIKey: opts.APIKey, + NoThinking: opts.NoThinking, + MaxRetries: maxRetries, + Client: &http.Client{ + Timeout: opts.Timeout, + }, + } +} + +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + ChatTemplateKwargs map[string]any `json:"chat_template_kwargs,omitempty"` // llama.cpp + Think *bool `json:"think,omitempty"` // Ollama + Thinking map[string]any `json:"thinking,omitempty"` // GLM (智谱) +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + } `json:"message"` + } `json:"choices"` +} + +// Complete sends a chat completion request and returns the assistant's reply. +func (c *LLMClient) Complete(ctx context.Context, systemPrompt, userPrompt string) (string, error) { + sysContent := systemPrompt + if c.NoThinking && sysContent != "" { + // Prepend /no_think tag — works with Ollama /v1 endpoint and + // Qwen chat templates where the JSON think field is ignored. + sysContent = "/no_think\n" + sysContent + } + messages := []chatMessage{} + if sysContent != "" { + messages = append(messages, chatMessage{Role: "system", Content: sysContent}) + } + messages = append(messages, chatMessage{Role: "user", Content: userPrompt}) + + body := chatRequest{ + Model: c.Model, + Messages: messages, + Temperature: 0.1, + MaxTokens: 512, + } + if c.NoThinking { + // llama.cpp: chat_template_kwargs + body.ChatTemplateKwargs = map[string]any{ + "enable_thinking": false, + } + // Ollama (0.9+): think field + thinkFalse := false + body.Think = &thinkFalse + // GLM (智谱): thinking field + body.Thinking = map[string]any{ + "type": "disabled", + } + } + + jsonBody, err := json.Marshal(body) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + endpoint := strings.TrimRight(c.BaseURL, "/") + "/chat/completions" + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody)) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if c.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.APIKey) + } + + var respBody []byte + var lastErr error + for attempt := 0; attempt <= c.MaxRetries; attempt++ { + if attempt > 0 { + backoff := time.Duration(1<<(attempt-1)) * time.Second // 1s, 2s, 4s, ... + log.Printf("LLM retry %d/%d after %v: %v", attempt, c.MaxRetries, backoff, lastErr) + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(backoff): + } + // Rebuild request (body reader is consumed) + req, err = http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(jsonBody)) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if c.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.APIKey) + } + } + + var resp *http.Response + resp, lastErr = c.Client.Do(req) + if lastErr != nil { + continue // network/timeout error → retry + } + + respBody, lastErr = io.ReadAll(resp.Body) + resp.Body.Close() + if lastErr != nil { + continue + } + + if resp.StatusCode == 429 || resp.StatusCode >= 500 { + lastErr = fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) + continue // rate limit or server error → retry + } + if resp.StatusCode != 200 { + return "", fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) + } + + lastErr = nil + break + } + if lastErr != nil { + return "", fmt.Errorf("after %d retries: %w", c.MaxRetries, lastErr) + } + + var chatResp chatResponse + if err := json.Unmarshal(respBody, &chatResp); err != nil { + return "", fmt.Errorf("parse response: %w", err) + } + if len(chatResp.Choices) == 0 { + return "", fmt.Errorf("no choices in response") + } + content := strings.TrimSpace(chatResp.Choices[0].Message.Content) + // Strip any residual ... blocks + if idx := strings.Index(content, ""); idx >= 0 { + content = strings.TrimSpace(content[idx+len(""):]) + } + // Fallback: GLM/DeepSeek put thinking output in reasoning_content when thinking is enabled + if content == "" && chatResp.Choices[0].Message.ReasoningContent != "" { + content = strings.TrimSpace(chatResp.Choices[0].Message.ReasoningContent) + } + if content == "" { + return "", fmt.Errorf("empty LLM response") + } + return content, nil +} diff --git a/cmd/membench/locomo.go b/cmd/membench/locomo.go new file mode 100644 index 000000000..28ace3680 --- /dev/null +++ b/cmd/membench/locomo.go @@ -0,0 +1,142 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// LocomoSample represents one conversation sample from the LOCOMO dataset. +type LocomoSample struct { + SampleID string `json:"sample_id"` + Conversation map[string]json.RawMessage `json:"conversation"` + QA []LocomoQA `json:"qa"` +} + +// LocomoTurn represents a single turn in a conversation. +type LocomoTurn struct { + Speaker string `json:"speaker"` + DiaID string `json:"dia_id"` + Text string `json:"text"` +} + +// LocomoQA represents a question-answer pair with evidence. +type LocomoQA struct { + Question string `json:"question"` + Answer json.RawMessage `json:"answer"` // can be string or int (category 1-4) + AdversarialAnswer string `json:"adversarial_answer"` // category 5 only + Evidence []string `json:"evidence"` + Category int `json:"category"` // 1=single-hop, 2=multi-hop, 3=open-ended, 5=adversarial +} + +// AnswerString returns the answer as a string, handling both string and int types. +func (qa *LocomoQA) AnswerString() string { + // Prefer answer field (category 1-4) + if len(qa.Answer) > 0 { + var s string + if err := json.Unmarshal(qa.Answer, &s); err == nil { + return s + } + var n json.Number + if err := json.Unmarshal(qa.Answer, &n); err == nil { + return n.String() + } + return strings.Trim(string(qa.Answer), `"`) + } + // Fallback to adversarial_answer (category 5) + return qa.AdversarialAnswer +} + +// LoadDataset reads all JSON files from dataDir and returns parsed samples. +func LoadDataset(dataDir string) ([]LocomoSample, error) { + entries, err := os.ReadDir(dataDir) + if err != nil { + return nil, fmt.Errorf("read data dir %s: %w", dataDir, err) + } + + var samples []LocomoSample + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") { + path := filepath.Join(dataDir, entry.Name()) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file %s: %w", path, err) + } + var batch []LocomoSample + if err := json.Unmarshal(data, &batch); err != nil { + return nil, fmt.Errorf("parse file %s: %w", path, err) + } + samples = append(samples, batch...) + } + } + return samples, nil +} + +// GetSessionNames returns sorted session keys (session_1, session_2, ...) from conversation. +func GetSessionNames(conv map[string]json.RawMessage) []string { + var names []string + for k := range conv { + if strings.HasPrefix(k, "session_") && !strings.Contains(k, "_date_time") { + names = append(names, k) + } + } + sort.Slice(names, func(i, j int) bool { + ni := sessionNum(names[i]) + nj := sessionNum(names[j]) + return ni < nj + }) + return names +} + +func sessionNum(key string) int { + // "session_1" → 1, "session_10" → 10 + parts := strings.SplitN(key, "_", 2) + if len(parts) < 2 { + return 0 + } + n, _ := strconv.Atoi(parts[1]) + return n +} + +// GetTurns flattens all sessions' turns in chronological order. +func GetTurns(sample *LocomoSample) []LocomoTurn { + names := GetSessionNames(sample.Conversation) + var all []LocomoTurn + for _, name := range names { + raw, ok := sample.Conversation[name] + if !ok { + continue + } + var turns []LocomoTurn + if err := json.Unmarshal(raw, &turns); err != nil { + log.Printf("WARNING: unmarshal failed for session %q in sample %s: %v", name, sample.SampleID, err) + continue + } + all = append(all, turns...) + } + return all +} + +// GetTurnByDiaID finds a specific turn by dia_id (e.g. "D1:3"). +func GetTurnByDiaID(sample *LocomoSample, diaID string) *LocomoTurn { + turns := GetTurns(sample) + for i := range turns { + if turns[i].DiaID == diaID { + return &turns[i] + } + } + return nil +} + +// GetSpeakers returns the two speaker names from conversation metadata. +func GetSpeakers(conv map[string]json.RawMessage) (string, string) { + var a, b string + json.Unmarshal(conv["speaker_a"], &a) + json.Unmarshal(conv["speaker_b"], &b) + return a, b +} diff --git a/cmd/membench/locomo_test.go b/cmd/membench/locomo_test.go new file mode 100644 index 000000000..2d5170bc9 --- /dev/null +++ b/cmd/membench/locomo_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestAnswerString(t *testing.T) { + tests := []struct { + name string + json string + want string + }{ + { + "string answer", + `{"question":"Q","answer":"Paris","evidence":[],"category":1}`, + "Paris", + }, + { + "int answer", + `{"question":"Q","answer":42,"evidence":[],"category":1}`, + "42", + }, + { + "adversarial answer (category 5)", + `{"question":"Q","evidence":[],"category":5,"adversarial_answer":"self-care is important"}`, + "self-care is important", + }, + { + "both answer and adversarial_answer present", + `{"question":"Q","answer":"normal","evidence":[],"category":5,"adversarial_answer":"adversarial"}`, + "normal", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var qa LocomoQA + if err := json.Unmarshal([]byte(tt.json), &qa); err != nil { + t.Fatalf("unmarshal: %v", err) + } + got := qa.AnswerString() + if got != tt.want { + t.Errorf("AnswerString() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGetSessionNames(t *testing.T) { + conv := map[string]json.RawMessage{ + "session_2": {}, + "session_1": {}, + "session_10": {}, + "session_1_date_time": {}, + "speaker_a": {}, + } + names := GetSessionNames(conv) + want := []string{"session_1", "session_2", "session_10"} + if len(names) != len(want) { + t.Fatalf("got %v, want %v", names, want) + } + for i, n := range names { + if n != want[i] { + t.Errorf("names[%d] = %q, want %q", i, n, want[i]) + } + } +} diff --git a/cmd/membench/main.go b/cmd/membench/main.go new file mode 100644 index 000000000..c07bb3471 --- /dev/null +++ b/cmd/membench/main.go @@ -0,0 +1,361 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +var ( + flagData string + flagOut string + flagMode string + flagBudget int + flagEvalMode string + flagAPIBase string + flagAPIKey string + flagModel string + flagNoThinking bool + flagLimit int + flagTimeout int + flagRetries int + flagJudgeModel string + flagJudgeAPIBase string + flagJudgeAPIKey string + flagConcurrency int +) + +func main() { + // Suppress seahorse INFO logs during benchmark + logger.SetLevel(logger.WARN) + + rootCmd := &cobra.Command{ + Use: "membench", + Short: "Memory benchmark tool for picoclaw", + } + + ingestCmd := &cobra.Command{ + Use: "ingest", + Short: "Load LOCOMO data into storage backends", + RunE: runIngest, + } + ingestCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)") + ingestCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory") + ingestCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to ingest: legacy, seahorse, or all") + + evalCmd := &cobra.Command{ + Use: "eval", + Short: "Run QA evaluation against ingested data", + RunE: runEval, + } + evalCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)") + evalCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory") + evalCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to evaluate: legacy, seahorse, or all") + evalCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval") + evalCmd.Flags(). + StringVar(&flagEvalMode, "eval-mode", "token", "evaluation mode: token (direct match) or llm (LLM-as-Judge)") + evalCmd.Flags(). + StringVar(&flagAPIBase, "api-base", "", "API base URL with version path, e.g. http://host/v1 (default: http://127.0.0.1:8080/v1, env: MEMBENCH_API_BASE)") + evalCmd.Flags().StringVar(&flagAPIKey, "api-key", "", "API key for the LLM endpoint (env: MEMBENCH_API_KEY)") + evalCmd.Flags().StringVar(&flagModel, "model", "", "model name for LLM eval (env: MEMBENCH_MODEL)") + evalCmd.Flags(). + BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)") + evalCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)") + evalCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests") + evalCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)") + evalCmd.Flags().StringVar(&flagJudgeModel, "judge-model", "", "model for judge scoring (defaults to --model)") + evalCmd.Flags(). + StringVar(&flagJudgeAPIBase, "judge-api-base", "", "API base URL for judge model (defaults to --api-base)") + evalCmd.Flags().StringVar(&flagJudgeAPIKey, "judge-api-key", "", "API key for judge model (defaults to --api-key)") + evalCmd.Flags().IntVar(&flagConcurrency, "concurrency", 1, "number of concurrent QA evaluations") + + reportCmd := &cobra.Command{ + Use: "report", + Short: "Output comparison results from evaluation", + RunE: runReport, + } + reportCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory") + + runCmd := &cobra.Command{ + Use: "run", + Short: "Convenience: eval + report (ingestion is done inline)", + RunE: runAll, + } + runCmd.Flags().StringVar(&flagData, "data", "", "LOCOMO dataset directory (required)") + runCmd.Flags().StringVar(&flagOut, "out", "./bench-out", "output working directory") + runCmd.Flags().StringVar(&flagMode, "mode", "all", "modes to run: legacy, seahorse, or all") + runCmd.Flags().IntVar(&flagBudget, "budget", 4000, "token budget for retrieval") + runCmd.Flags(). + StringVar(&flagEvalMode, "eval-mode", "token", "evaluation mode: token (direct match) or llm (LLM-as-Judge)") + runCmd.Flags(). + StringVar(&flagAPIBase, "api-base", "", "API base URL with version path, e.g. http://host/v1 (default: http://127.0.0.1:8080/v1, env: MEMBENCH_API_BASE)") + runCmd.Flags().StringVar(&flagAPIKey, "api-key", "", "API key for the LLM endpoint (env: MEMBENCH_API_KEY)") + runCmd.Flags().StringVar(&flagModel, "model", "", "model name for LLM eval (env: MEMBENCH_MODEL)") + runCmd.Flags(). + BoolVar(&flagNoThinking, "no-thinking", false, "disable thinking mode via chat_template_kwargs (llama.cpp + Qwen)") + runCmd.Flags().IntVar(&flagLimit, "limit", 0, "max QA questions per sample (0 = all)") + runCmd.Flags().IntVar(&flagTimeout, "timeout", 120, "HTTP timeout in seconds for LLM requests") + runCmd.Flags().IntVar(&flagRetries, "retries", 3, "max retry attempts for transient LLM errors (timeout/5xx/429)") + runCmd.Flags().StringVar(&flagJudgeModel, "judge-model", "", "model for judge scoring (defaults to --model)") + runCmd.Flags(). + StringVar(&flagJudgeAPIBase, "judge-api-base", "", "API base URL for judge model (defaults to --api-base)") + runCmd.Flags().StringVar(&flagJudgeAPIKey, "judge-api-key", "", "API key for judge model (defaults to --api-key)") + runCmd.Flags().IntVar(&flagConcurrency, "concurrency", 1, "number of concurrent QA evaluations") + + rootCmd.AddCommand(ingestCmd, evalCmd, reportCmd, runCmd) + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func modesFromFlag() []string { + switch strings.ToLower(flagMode) { + case "all": + return []string{"legacy", "seahorse"} + default: + return []string{strings.ToLower(flagMode)} + } +} + +func runIngest(cmd *cobra.Command, args []string) error { + if flagData == "" { + return fmt.Errorf("--data is required") + } + modes := modesFromFlag() + if len(modes) == 0 { + return nil + } + + ctx := context.Background() + samples, err := LoadDataset(flagData) + if err != nil { + return fmt.Errorf("load dataset: %w", err) + } + log.Printf("Loaded %d samples from %s", len(samples), flagData) + + for _, mode := range modes { + switch mode { + case "legacy": + legacy := NewLegacyStore() + for i := range samples { + legacy.IngestSample(&samples[i]) + } + log.Printf("legacy: ingested %d samples", len(samples)) + case "seahorse": + dbPath := filepath.Join(flagOut, "seahorse.db") + if err := os.MkdirAll(flagOut, 0o755); err != nil { + return fmt.Errorf("create out dir: %w", err) + } + _, err := IngestSeahorse(ctx, samples, dbPath) + if err != nil { + return fmt.Errorf("ingest seahorse: %w", err) + } + } + } + return nil +} + +func runEval(cmd *cobra.Command, args []string) error { + if flagData == "" { + return fmt.Errorf("--data is required") + } + modes := modesFromFlag() + if len(modes) == 0 { + return nil + } + + ctx := context.Background() + samples, err := LoadDataset(flagData) + if err != nil { + return fmt.Errorf("load dataset: %w", err) + } + log.Printf("Loaded %d samples", len(samples)) + + if flagLimit > 0 { + for i := range samples { + if len(samples[i].QA) > flagLimit { + samples[i].QA = samples[i].QA[:flagLimit] + } + } + log.Printf("Limited to %d QA per sample", flagLimit) + } + + evalMode := strings.ToLower(strings.TrimSpace(flagEvalMode)) + var useLLM bool + switch evalMode { + case "token": + useLLM = false + case "llm": + useLLM = true + default: + return fmt.Errorf("invalid --eval-mode %q: must be token or llm", flagEvalMode) + } + var answerClient, judgeClient *LLMClient + if useLLM { + opts, err := buildLLMOptions() + if err != nil { + return err + } + answerClient = NewLLMClient(opts) + judgeClient = answerClient // default: same client + if flagJudgeModel != "" { + jOpts := opts // copy base settings + jOpts.Model = flagJudgeModel + if flagJudgeAPIBase != "" { + jOpts.BaseURL = flagJudgeAPIBase + } + if flagJudgeAPIKey != "" { + jOpts.APIKey = flagJudgeAPIKey + } + judgeClient = NewLLMClient(jOpts) + log.Printf("Judge model: model=%s base=%s no-thinking=%v", jOpts.Model, jOpts.BaseURL, jOpts.NoThinking) + } + log.Printf("LLM eval mode: model=%s base=%s no-thinking=%v concurrency=%d", + opts.Model, opts.BaseURL, opts.NoThinking, flagConcurrency) + } + + var tokenResults, llmResults []EvalResult + + for _, mode := range modes { + switch mode { + case "legacy": + legacy := NewLegacyStore() + for i := range samples { + legacy.IngestSample(&samples[i]) + } + if useLLM { + results := EvalLegacyLLM(ctx, samples, legacy, flagBudget, answerClient, judgeClient, flagConcurrency) + llmResults = append(llmResults, results...) + log.Printf("legacy-llm: evaluated %d samples", len(results)) + } else { + results := EvalLegacy(ctx, samples, legacy, flagBudget) + tokenResults = append(tokenResults, results...) + log.Printf("legacy: evaluated %d samples", len(results)) + } + case "seahorse": + dbPath := filepath.Join(flagOut, "seahorse.db") + ir, err := IngestSeahorse(ctx, samples, dbPath) + if err != nil { + return fmt.Errorf("ingest seahorse: %w", err) + } + if useLLM { + results := EvalSeahorseLLM(ctx, samples, ir, flagBudget, answerClient, judgeClient, flagConcurrency) + llmResults = append(llmResults, results...) + log.Printf("seahorse-llm: evaluated %d samples", len(results)) + } else { + results := EvalSeahorse(ctx, samples, ir, flagBudget) + tokenResults = append(tokenResults, results...) + log.Printf("seahorse: evaluated %d samples", len(results)) + } + } + } + + allResults := append(tokenResults, llmResults...) + if err := SaveResults(allResults, flagOut); err != nil { + return fmt.Errorf("save results: %w", err) + } + if err := SaveAggregated(allResults, flagOut); err != nil { + return fmt.Errorf("save aggregated: %w", err) + } + + PrintComparison(tokenResults, llmResults) + return nil +} + +func runReport(cmd *cobra.Command, args []string) error { + entries, err := os.ReadDir(flagOut) + if err != nil { + return fmt.Errorf("read out dir: %w", err) + } + + var allResults []EvalResult + for _, entry := range entries { + if !entry.IsDir() && strings.HasPrefix(entry.Name(), "eval_") && strings.HasSuffix(entry.Name(), ".json") { + path := filepath.Join(flagOut, entry.Name()) + var r EvalResult + data, err := os.ReadFile(path) + if err != nil { + log.Printf("WARN: read %s: %v", path, err) + continue + } + if err := json.Unmarshal(data, &r); err != nil { + log.Printf("WARN: parse %s: %v", path, err) + continue + } + allResults = append(allResults, r) + } + } + + if len(allResults) == 0 { + return fmt.Errorf("no eval results found in %s", flagOut) + } + + var tokenResults, llmResults []EvalResult + for _, r := range allResults { + if strings.HasSuffix(r.Mode, "-llm") { + llmResults = append(llmResults, r) + } else { + tokenResults = append(tokenResults, r) + } + } + PrintComparison(tokenResults, llmResults) + return nil +} + +func runAll(cmd *cobra.Command, args []string) error { + return runEval(cmd, args) +} + +// envOrFlag returns the flag value if non-empty, otherwise falls back to the +// environment variable. +func envOrFlag(flag, envKey string) string { + if flag != "" { + return flag + } + return os.Getenv(envKey) +} + +// buildLLMOptions resolves LLM client configuration from flags and environment +// variables. Flag values take precedence over environment variables. +// +// Environment variables: +// +// MEMBENCH_API_BASE – OpenAI-compatible base URL (default http://127.0.0.1:8080/v1) +// MEMBENCH_API_KEY – Bearer token for the endpoint +// MEMBENCH_MODEL – Model name to send in the request +func buildLLMOptions() (LLMClientOptions, error) { + base := envOrFlag(flagAPIBase, "MEMBENCH_API_BASE") + if base == "" { + base = "http://127.0.0.1:8080/v1" + } + model := envOrFlag(flagModel, "MEMBENCH_MODEL") + if model == "" { + return LLMClientOptions{}, fmt.Errorf( + "--model or MEMBENCH_MODEL is required for LLM eval mode", + ) + } + apiKey := envOrFlag(flagAPIKey, "MEMBENCH_API_KEY") + + if flagTimeout <= 0 { + return LLMClientOptions{}, fmt.Errorf("--timeout must be > 0, got %d", flagTimeout) + } + + return LLMClientOptions{ + BaseURL: base, + Model: model, + APIKey: apiKey, + NoThinking: flagNoThinking, + Timeout: time.Duration(flagTimeout) * time.Second, + MaxRetries: flagRetries, + }, nil +} diff --git a/cmd/membench/metrics.go b/cmd/membench/metrics.go new file mode 100644 index 000000000..7e3db2dde --- /dev/null +++ b/cmd/membench/metrics.go @@ -0,0 +1,227 @@ +package main + +import ( + "fmt" + "log" + "regexp" + "strconv" + "strings" + "unicode" +) + +// diaIDRe matches valid dia_id patterns like "D1:3", "D30:5". +var diaIDRe = regexp.MustCompile(`^D(\d+):(\d+)$`) + +// SplitEvidenceIDs splits an evidence string that may contain multiple +// semicolon-separated or space-separated dia_ids. Only returns valid IDs. +// Example: "D8:6; D9:17" → ["D8:6", "D9:17"] +// Example: "D9:1 D4:4 D4:6" → ["D9:1", "D4:4", "D4:6"] +func SplitEvidenceIDs(evidence string) []string { + if evidence == "" { + return nil + } + // Split on semicolons first, then spaces + parts := strings.Split(evidence, ";") + var ids []string + for _, part := range parts { + for _, token := range strings.Fields(strings.TrimSpace(part)) { + token = strings.TrimSpace(token) + if diaIDRe.MatchString(token) { + ids = append(ids, NormalizeDiaID(token)) + } + } + } + if len(ids) == 0 { + return nil + } + return ids +} + +// NormalizeDiaID strips leading zeros from the number parts of a dia_id. +// "D30:05" → "D30:5", "D10:003" → "D10:3" +func NormalizeDiaID(id string) string { + m := diaIDRe.FindStringSubmatch(id) + if m == nil { + return id + } + session, _ := strconv.Atoi(m[1]) + turn, _ := strconv.Atoi(m[2]) + return fmt.Sprintf("D%d:%d", session, turn) +} + +// stopwords is a fixed English stopword list for deterministic keyword extraction. +var stopwords = map[string]struct{}{ + "a": {}, "an": {}, "the": {}, + "is": {}, "are": {}, "was": {}, "were": {}, + "did": {}, "does": {}, "do": {}, + "when": {}, "where": {}, "what": {}, "who": {}, + "how": {}, "why": {}, + "to": {}, "of": {}, "in": {}, "on": {}, "at": {}, + "for": {}, "and": {}, "or": {}, "but": {}, "not": {}, + "it": {}, "this": {}, "that": {}, "with": {}, + "from": {}, "by": {}, "as": {}, + "if": {}, "then": {}, "than": {}, "so": {}, + "no": {}, "yes": {}, + "all": {}, "any": {}, "each": {}, "every": {}, + "some": {}, "such": {}, + "about": {}, "into": {}, "over": {}, + "after": {}, "before": {}, "between": {}, + "through": {}, "during": {}, "until": {}, + "would": {}, "could": {}, "should": {}, + "may": {}, "might": {}, "can": {}, + "will": {}, "shall": {}, "must": {}, + "have": {}, "has": {}, "had": {}, + "been": {}, "being": {}, "be": {}, + "go": {}, "went": {}, "gone": {}, + "i": {}, "you": {}, "me": {}, "my": {}, "your": {}, + "we": {}, "they": {}, "them": {}, "our": {}, + "its": {}, "their": {}, "he": {}, "she": {}, + "his": {}, "her": {}, +} + +// ExtractKeywords removes stopwords and punctuation, returns individual keywords. +// Deterministic: uses fixed stopword list, no LLM. +func ExtractKeywords(question string) []string { + // Lowercase and split on whitespace/punctuation + lower := strings.ToLower(question) + words := strings.FieldsFunc(lower, func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) + + var keywords []string + for _, w := range words { + if w == "" || len(w) < 2 { + continue + } + if _, ok := stopwords[w]; ok { + continue + } + keywords = append(keywords, w) + if len(keywords) >= 6 { + break + } + } + return keywords +} + +// TokenOverlapF1 computes token-level F1 between prediction and reference. +// Both strings are lowercased and split on whitespace. +// NOTE: This metric underestimates quality for multi-hop (cat 2) and +// open-ended (cat 3) questions where the gold answer uses different phrasing +// than the source text. LLM-Judge scoring is a v2 follow-up. +func TokenOverlapF1(prediction, reference string) float64 { + predTokens := tokenize(prediction) + refTokens := tokenize(reference) + + if len(predTokens) == 0 && len(refTokens) == 0 { + return 1.0 + } + if len(predTokens) == 0 || len(refTokens) == 0 { + return 0.0 + } + + // Count matches + refCount := map[string]int{} + for _, t := range refTokens { + refCount[t]++ + } + + predCount := map[string]int{} + for _, t := range predTokens { + predCount[t]++ + } + + var matches float64 + for token, pc := range predCount { + if rc, ok := refCount[token]; ok { + matches += float64(min(pc, rc)) + } + } + + precision := matches / float64(len(predTokens)) + recall := matches / float64(len(refTokens)) + + if precision+recall == 0 { + return 0.0 + } + return 2 * precision * recall / (precision + recall) +} + +func tokenize(s string) []string { + lower := strings.ToLower(s) + return strings.Fields(lower) +} + +// RecallHitRate computes fraction of evidence IDs found in retrieved content. +// For each evidence dia_id, looks up the turn text and checks substring match. +// Logs a warning for turns with text < 20 chars (higher false-positive risk). +func RecallHitRate(evidenceIDs []string, sample *LocomoSample, retrievedContent string) float64 { + if len(evidenceIDs) == 0 { + return 1.0 // no evidence required = perfect + } + + // Expand any multi-ID evidence entries (e.g. "D8:6; D9:17" or "D9:1 D4:4") + var expanded []string + for _, id := range evidenceIDs { + split := SplitEvidenceIDs(id) + if split != nil { + expanded = append(expanded, split...) + } + } + if len(expanded) == 0 { + log.Printf("WARNING: no valid dia_ids after expanding evidence %v", evidenceIDs) + return float64(0) / float64(len(evidenceIDs)) + } + + // Build turn index once (avoids re-parsing JSON per ID) + turns := GetTurns(sample) + turnMap := make(map[string]*LocomoTurn, len(turns)) + for i := range turns { + turnMap[turns[i].DiaID] = &turns[i] + } + + lowerRetrieved := strings.ToLower(retrievedContent) + found := 0 + resolvable := 0 + for _, diaID := range expanded { + turn, ok := turnMap[diaID] + if !ok { + log.Printf("WARNING: dia_id %q not found in sample %s", diaID, sample.SampleID) + continue + } + resolvable++ + if len(turn.Text) < 20 { + log.Printf("WARNING: short turn text (%d chars) for dia_id %s: %q", + len(turn.Text), diaID, turn.Text) + } + if strings.Contains(lowerRetrieved, strings.ToLower(turn.Text)) { + found++ + } + } + if resolvable == 0 { + return 0.0 // no resolvable evidence = can't evaluate + } + return float64(found) / float64(resolvable) +} + +// BudgetTruncate truncates messages to fit within a token budget. +// Returns the truncated messages and total token count. +func BudgetTruncate(messages []string, budgetTokens int) ([]string, int) { + var result []string + total := 0 + // Walk from the front (best first) and keep until budget exhausted. + for i := 0; i < len(messages); i++ { + tokens := len(messages[i]) / 4 + if total+tokens > budgetTokens && len(result) > 0 { + break + } + result = append(result, messages[i]) + total += tokens + } + return result, total +} + +// StringListToContent joins a list of strings into a single content string. +func StringListToContent(parts []string) string { + return strings.Join(parts, "\n") +} diff --git a/cmd/membench/metrics_test.go b/cmd/membench/metrics_test.go new file mode 100644 index 000000000..99e4ad6d4 --- /dev/null +++ b/cmd/membench/metrics_test.go @@ -0,0 +1,239 @@ +package main + +import ( + "encoding/json" + "math" + "testing" +) + +func TestSplitEvidenceIDs(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"D1:3", []string{"D1:3"}}, + {"D8:6; D9:17", []string{"D8:6", "D9:17"}}, + {"D9:1 D4:4 D4:6", []string{"D9:1", "D4:4", "D4:6"}}, + {"D22:1 D22:2 D9:10 D9:11", []string{"D22:1", "D22:2", "D9:10", "D9:11"}}, + {"D21:18 D21:22 D11:15 D11:19", []string{"D21:18", "D21:22", "D11:15", "D11:19"}}, + {"D30:05", []string{"D30:5"}}, + {"D", nil}, + {"D:", nil}, + {"", nil}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := SplitEvidenceIDs(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("SplitEvidenceIDs(%q) = %v, want %v", tt.input, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestNormalizeDiaID(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"D1:3", "D1:3"}, + {"D30:05", "D30:5"}, + {"D10:003", "D10:3"}, + {"D1:0", "D1:0"}, + } + for _, tt := range tests { + got := NormalizeDiaID(tt.input) + if got != tt.want { + t.Errorf("NormalizeDiaID(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestTokenOverlapF1(t *testing.T) { + tests := []struct { + name string + prediction string + reference string + want float64 + }{ + {"exact match", "hello world", "hello world", 1.0}, + {"no overlap", "foo bar", "baz qux", 0.0}, + {"empty both", "", "", 1.0}, + {"empty prediction", "", "hello", 0.0}, + {"empty reference", "hello", "", 0.0}, + {"partial overlap", "the cat sat on the mat", "the cat on the floor", 8.0 / 11.0}, + {"case insensitive", "Hello World", "hello world", 1.0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TokenOverlapF1(tt.prediction, tt.reference) + if math.Abs(got-tt.want) > 1e-9 { + t.Errorf("TokenOverlapF1(%q, %q) = %.4f, want %.4f", + tt.prediction, tt.reference, got, tt.want) + } + }) + } +} + +func TestBudgetTruncate(t *testing.T) { + t.Run("within budget returns all", func(t *testing.T) { + msgs := []string{"short", "message", "here"} + result, total := BudgetTruncate(msgs, 1000) + if len(result) != 3 { + t.Errorf("expected 3 messages, got %d", len(result)) + } + if total == 0 { + t.Error("expected non-zero token count") + } + }) + + t.Run("over budget keeps best first", func(t *testing.T) { + msgs := []string{ + "best message that is quite long and takes up tokens", + "good message also fairly long content", + "worst short", + } + result, _ := BudgetTruncate(msgs, 5) // very small budget + if len(result) == 0 { + t.Fatal("expected at least one message") + } + // Best-ranked (first) should be kept + if result[0] != "best message that is quite long and takes up tokens" { + t.Errorf("expected best message kept first, got %q", result[0]) + } + }) + + t.Run("over budget keeps best ranked first", func(t *testing.T) { + // Messages are sorted by bm25 rank ascending (best/most-negative first). + // When budget is insufficient, BudgetTruncate must keep the front + // (best-ranked) messages, not the tail (worst-ranked). + msgs := []string{ + "best ranked message with some content here", + "second best message also has content", + "third message here too", + "worst ranked short", + } + // Budget only fits ~1 message (~10 tokens per message, budget=12) + result, _ := BudgetTruncate(msgs, 12) + if len(result) == 0 { + t.Fatal("expected at least one message") + } + if result[0] != "best ranked message with some content here" { + t.Errorf("expected best-ranked (first) message kept, got %q", result[0]) + } + // Worst-ranked (last) must NOT appear + for _, m := range result { + if m == "worst ranked short" { + t.Error("worst-ranked message should have been truncated") + } + } + }) + + t.Run("preserves original order", func(t *testing.T) { + msgs := []string{"alpha", "beta", "gamma"} + result, _ := BudgetTruncate(msgs, 100) + for i, got := range result { + if got != msgs[i] { + t.Errorf("result[%d] = %q, want %q", i, got, msgs[i]) + } + } + }) + + t.Run("empty input", func(t *testing.T) { + result, total := BudgetTruncate(nil, 100) + if len(result) != 0 { + t.Errorf("expected 0 messages, got %d", len(result)) + } + if total != 0 { + t.Errorf("expected 0 tokens, got %d", total) + } + }) +} + +func TestRecallHitRate(t *testing.T) { + // Build a sample with known turns + sample := &LocomoSample{ + SampleID: "test-sample", + Conversation: map[string]json.RawMessage{ + "session_1": json.RawMessage(`[ + {"speaker":"A","dia_id":"D1:1","text":"hello world this is a test message with enough length"}, + {"speaker":"B","dia_id":"D1:2","text":"another message for testing recall computation purposes here"}, + {"speaker":"A","dia_id":"D1:3","text":"third turn with some more content to test"} + ]`), + }, + } + + t.Run("all evidence found", func(t *testing.T) { + retrieved := "hello world this is a test message with enough length another message for testing recall computation purposes here" + got := RecallHitRate([]string{"D1:1", "D1:2"}, sample, retrieved) + if math.Abs(got-1.0) > 1e-9 { + t.Errorf("RecallHitRate all found = %.4f, want 1.0", got) + } + }) + + t.Run("partial evidence found", func(t *testing.T) { + retrieved := "hello world this is a test message with enough length" + got := RecallHitRate([]string{"D1:1", "D1:2"}, sample, retrieved) + if math.Abs(got-0.5) > 1e-9 { + t.Errorf("RecallHitRate partial = %.4f, want 0.5", got) + } + }) + + t.Run("no evidence required", func(t *testing.T) { + got := RecallHitRate(nil, sample, "anything") + if got != 1.0 { + t.Errorf("RecallHitRate no evidence = %.4f, want 1.0", got) + } + }) + + t.Run("missing turn excluded from denominator", func(t *testing.T) { + // D1:1 is found, D99:1 does not exist in sample + // Should only count resolvable turns in denominator + retrieved := "hello world this is a test message with enough length" + got := RecallHitRate([]string{"D1:1", "D99:1"}, sample, retrieved) + if math.Abs(got-1.0) > 1e-9 { + t.Errorf("RecallHitRate missing turn = %.4f, want 1.0 (unresolvable excluded)", got) + } + }) +} + +func TestExtractKeywords(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + {"simple", "What is the capital of France", []string{"capital", "france"}}, + { + "stops removed", + "Who is the president of the United States", + []string{"president", "united", "states"}, + }, + { + "max 6 keywords", + "one two three four five six seven eight nine ten", + []string{"one", "two", "three", "four", "five", "six"}, + }, + {"short words filtered", "I am a go to the store", []string{"am", "store"}}, + {"empty", "", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractKeywords(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("ExtractKeywords(%q) = %v (len %d), want %v (len %d)", + tt.input, got, len(got), tt.want, len(tt.want)) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} diff --git a/cmd/picoclaw-launcher-tui/internal/config/store.go b/cmd/picoclaw-launcher-tui/internal/config/store.go deleted file mode 100644 index 0236de19f..000000000 --- a/cmd/picoclaw-launcher-tui/internal/config/store.go +++ /dev/null @@ -1,49 +0,0 @@ -package configstore - -import ( - "errors" - "os" - "path/filepath" - - picoclawconfig "github.com/sipeed/picoclaw/pkg/config" -) - -const ( - configDirName = ".picoclaw" - configFileName = "config.json" -) - -func ConfigPath() (string, error) { - dir, err := ConfigDir() - if err != nil { - return "", err - } - return filepath.Join(dir, configFileName), nil -} - -func ConfigDir() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, configDirName), nil -} - -func Load() (*picoclawconfig.Config, error) { - path, err := ConfigPath() - if err != nil { - return nil, err - } - return picoclawconfig.LoadConfig(path) -} - -func Save(cfg *picoclawconfig.Config) error { - if cfg == nil { - return errors.New("config is nil") - } - path, err := ConfigPath() - if err != nil { - return err - } - return picoclawconfig.SaveConfig(path, cfg) -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/app.go b/cmd/picoclaw-launcher-tui/internal/ui/app.go deleted file mode 100644 index 8628afab3..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/app.go +++ /dev/null @@ -1,506 +0,0 @@ -package ui - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config" - picoclawconfig "github.com/sipeed/picoclaw/pkg/config" -) - -type appState struct { - app *tview.Application - pages *tview.Pages - stack []string - config *picoclawconfig.Config - configPath string - gatewayCmd *exec.Cmd - menus map[string]*Menu - original []byte - hasOriginal bool - backupPath string - dirty bool - logPath string -} - -func Run() error { - applyStyles() - cfg, err := configstore.Load() - if err != nil { - return err - } - path, err := configstore.ConfigPath() - if err != nil { - return err - } - - if cfg == nil { - cfg = picoclawconfig.DefaultConfig() - } - - originalData, hasOriginal := loadOriginalConfig(path) - backupPath := path + ".bak" - if hasOriginal { - _ = writeBackupConfig(backupPath, originalData) - } - - logPath := filepath.Join(filepath.Dir(path), "gateway.log") - state := &appState{ - app: tview.NewApplication(), - pages: tview.NewPages(), - config: cfg, - configPath: path, - menus: map[string]*Menu{}, - original: originalData, - hasOriginal: hasOriginal, - backupPath: backupPath, - logPath: logPath, - } - - state.push("main", state.mainMenu()) - - root := tview.NewFlex().SetDirection(tview.FlexRow) - root.AddItem(bannerView(), 6, 0, false) - root.AddItem(state.pages, 0, 1, true) - - if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil { - return err - } - return nil -} - -func (s *appState) push(name string, primitive tview.Primitive) { - s.pages.AddPage(name, primitive, true, true) - s.stack = append(s.stack, name) - s.pages.SwitchToPage(name) - if menu, ok := primitive.(*Menu); ok { - s.menus[name] = menu - } -} - -func (s *appState) pop() { - if len(s.stack) == 0 { - return - } - last := s.stack[len(s.stack)-1] - s.pages.RemovePage(last) - s.stack = s.stack[:len(s.stack)-1] - if len(s.stack) == 0 { - s.app.Stop() - return - } - current := s.stack[len(s.stack)-1] - s.pages.SwitchToPage(current) - if menu, ok := s.menus[current]; ok { - s.refreshMenu(current, menu) - } -} - -func (s *appState) mainMenu() tview.Primitive { - menu := NewMenu("Config Menu", nil) - refreshMainMenu(menu, s) - menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyEsc: - s.requestExit() - return nil - } - if event.Rune() == 'q' { - s.requestExit() - return nil - } - return event - }) - - return menu -} - -func (s *appState) refreshMenu(name string, menu *Menu) { - switch name { - case "main": - refreshMainMenu(menu, s) - case "model": - refreshModelMenuFromState(menu, s) - case "channel": - refreshChannelMenuFromState(menu, s) - } -} - -func refreshMainMenuIfPresent(s *appState) { - if menu, ok := s.menus["main"]; ok { - refreshMainMenu(menu, s) - } -} - -func refreshMainMenu(menu *Menu, s *appState) { - selectedModel := s.selectedModelName() - modelReady := selectedModel != "" - channelReady := s.hasEnabledChannel() - gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning() - - gatewayLabel := "Start Gateway" - gatewayDescription := "Launch gateway for channels" - if gatewayRunning { - gatewayLabel = "Stop Gateway" - gatewayDescription = "Gateway running" - } - - items := []MenuItem{ - { - Label: rootModelLabel(selectedModel), - Description: rootModelDescription(selectedModel), - Action: func() { - s.push("model", s.modelMenu()) - }, - MainColor: func() *tcell.Color { - if modelReady { - return nil - } - color := tcell.ColorGray - return &color - }(), - }, - { - Label: rootChannelLabel(channelReady), - Description: rootChannelDescription(channelReady), - Action: func() { - s.push("channel", s.channelMenu()) - }, - MainColor: func() *tcell.Color { - if channelReady { - return nil - } - color := tcell.ColorGray - return &color - }(), - }, - { - Label: "Start Talk", - Description: "Open picoclaw agent in terminal", - Action: func() { - s.requestStartTalk() - }, - Disabled: !modelReady, - }, - { - Label: gatewayLabel, - Description: gatewayDescription, - Action: func() { - if gatewayRunning { - s.stopGateway() - } else { - s.requestStartGateway() - } - refreshMainMenu(menu, s) - }, - Disabled: !gatewayRunning && (!modelReady || !channelReady), - }, - { - Label: "View Gateway Log", - Description: "Open gateway.log", - Action: func() { - s.viewGatewayLog() - }, - }, - { - Label: "Exit", - Description: "Exit the TUI", - Action: func() { - s.requestExit() - }, - }, - } - menu.applyItems(items) -} - -func (s *appState) applyChangesValidated() bool { - if err := s.config.ValidateModelList(); err != nil { - s.showMessage("Validation failed", err.Error()) - return false - } - if err := s.validateAgentModel(); err != nil { - s.showMessage("Validation failed", err.Error()) - return false - } - if err := configstore.Save(s.config); err != nil { - s.showMessage("Save failed", err.Error()) - return false - } - if data, err := os.ReadFile(s.configPath); err == nil { - s.original = data - s.hasOriginal = true - _ = writeBackupConfig(s.backupPath, data) - } - return true -} - -func (s *appState) requestExit() { - if s.dirty { - s.confirmApplyOrDiscard(func() { - s.app.Stop() - }, func() { - s.discardChanges() - s.app.Stop() - }) - return - } - s.app.Stop() -} - -func (s *appState) requestStartTalk() { - if s.dirty { - s.confirmApplyOrDiscard(func() { - s.startTalk() - }, func() { - s.startTalk() - }) - return - } - s.startTalk() -} - -func (s *appState) requestStartGateway() { - if s.dirty { - s.confirmApplyOrDiscard(func() { - s.startGateway() - }, func() { - s.startGateway() - }) - return - } - s.startGateway() -} - -func (s *appState) viewGatewayLog() { - data, err := os.ReadFile(s.logPath) - if err != nil { - s.showMessage("Log not found", "gateway.log not found") - return - } - text := tview.NewTextView() - text.SetBorder(true).SetTitle("Gateway Log") - text.SetText(string(data)) - text.SetDoneFunc(func(key tcell.Key) { - s.pages.RemovePage("log") - }) - text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pages.RemovePage("log") - return nil - } - return event - }) - s.pages.AddPage("log", text, true, true) -} - -func (s *appState) selectedModelName() string { - modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) - if modelName == "" { - return "" - } - if !s.isActiveModelValid() { - return "" - } - return modelName -} - -func rootModelLabel(selected string) string { - if selected == "" { - return "Model (no model selected)" - } - return "Model (" + selected + ")" -} - -func rootModelDescription(selected string) string { - if selected == "" { - return "no model selected" - } - return "selected" -} - -func rootChannelLabel(valid bool) string { - if !valid { - return "Channel (no channel enabled)" - } - return "Channel" -} - -func rootChannelDescription(valid bool) string { - if !valid { - return "no channel enabled" - } - return "enabled" -} - -func (s *appState) startTalk() { - if !s.isActiveModelValid() { - s.showMessage("Model required", "Select a valid model before starting talk") - return - } - if !s.applyChangesValidated() { - return - } - s.app.Suspend(func() { - cmd := exec.Command("picoclaw", "agent") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - _ = cmd.Run() - }) -} - -func (s *appState) startGateway() { - if !s.isActiveModelValid() { - s.showMessage("Model required", "Select a valid model before starting gateway") - return - } - if !s.hasEnabledChannel() { - s.showMessage("Channel required", "Enable at least one channel before starting gateway") - return - } - if !s.applyChangesValidated() { - return - } - _ = stopGatewayProcess() - cmd := exec.Command("picoclaw", "gateway") - logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - s.showMessage("Gateway failed", err.Error()) - return - } - cmd.Stdout = logFile - cmd.Stderr = logFile - if err := cmd.Start(); err != nil { - s.showMessage("Gateway failed", err.Error()) - _ = logFile.Close() - return - } - _ = logFile.Close() - s.gatewayCmd = cmd -} - -func (s *appState) stopGateway() { - _ = stopGatewayProcess() - if s.gatewayCmd != nil && s.gatewayCmd.Process != nil { - _ = s.gatewayCmd.Process.Kill() - } - s.gatewayCmd = nil -} - -func (s *appState) isGatewayRunning() bool { - return isGatewayProcessRunning() -} - -func (s *appState) validateAgentModel() error { - modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) - if modelName == "" { - return nil - } - _, err := s.config.GetModelConfig(modelName) - return err -} - -func (s *appState) isActiveModelValid() bool { - modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) - if modelName == "" { - return false - } - cfg, err := s.config.GetModelConfig(modelName) - if err != nil { - return false - } - hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth" - hasModel := strings.TrimSpace(cfg.Model) != "" - return hasKey && hasModel -} - -func (s *appState) hasEnabledChannel() bool { - c := s.config.Channels - return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled || - c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled || - c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled -} - -func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) { - if s.pages.HasPage("apply") { - return - } - modal := tview.NewModal(). - SetText("Apply changes or discard before continuing?"). - AddButtons([]string{"Cancel", "Discard", "Apply"}). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { - s.pages.RemovePage("apply") - switch buttonLabel { - case "Discard": - s.discardChanges() - if onDiscard != nil { - onDiscard() - } - case "Apply": - if s.applyChangesValidated() { - s.dirty = false - if onApply != nil { - onApply() - } - } - } - }) - modal.SetBorder(true) - s.pages.AddPage("apply", modal, true, true) -} - -func (s *appState) discardChanges() { - if s.hasOriginal { - _ = writeOriginalConfig(s.configPath, s.original) - } else { - _ = os.Remove(s.configPath) - } - _ = os.Remove(s.backupPath) - if cfg, err := configstore.Load(); err == nil && cfg != nil { - s.config = cfg - } - s.dirty = false - refreshMainMenuIfPresent(s) -} - -func (s *appState) showMessage(title, message string) { - if s.pages.HasPage("message") { - return - } - modal := tview.NewModal(). - SetText(strings.TrimSpace(message)). - AddButtons([]string{"OK"}). - SetDoneFunc(func(_ int, _ string) { - s.pages.RemovePage("message") - }) - modal.SetTitle(title).SetBorder(true) - modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor) - modal.SetTextColor(tview.Styles.PrimaryTextColor) - modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255)) - modal.SetButtonTextColor(tview.Styles.PrimaryTextColor) - s.pages.AddPage("message", modal, true, true) -} - -func loadOriginalConfig(path string) ([]byte, bool) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, false - } - return nil, false - } - return data, true -} - -func writeOriginalConfig(path string, data []byte) error { - return os.WriteFile(path, data, 0o600) -} - -func writeBackupConfig(path string, data []byte) error { - return os.WriteFile(path, data, 0o600) -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/channel.go b/cmd/picoclaw-launcher-tui/internal/ui/channel.go deleted file mode 100644 index 16b7d053b..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/channel.go +++ /dev/null @@ -1,438 +0,0 @@ -package ui - -import ( - "fmt" - "strings" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - picoclawconfig "github.com/sipeed/picoclaw/pkg/config" -) - -func (s *appState) buildChannelMenuItems() []MenuItem { - return []MenuItem{ - {Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }}, - channelItem( - "Telegram", - "Telegram bot settings", - s.config.Channels.Telegram.Enabled, - func() { s.push("channel-telegram", s.telegramForm()) }, - ), - channelItem( - "Discord", - "Discord bot settings", - s.config.Channels.Discord.Enabled, - func() { s.push("channel-discord", s.discordForm()) }, - ), - channelItem( - "QQ", - "QQ bot settings", - s.config.Channels.QQ.Enabled, - func() { s.push("channel-qq", s.qqForm()) }, - ), - channelItem( - "MaixCam", - "MaixCam gateway", - s.config.Channels.MaixCam.Enabled, - func() { s.push("channel-maixcam", s.maixcamForm()) }, - ), - channelItem( - "WhatsApp", - "WhatsApp bridge", - s.config.Channels.WhatsApp.Enabled, - func() { s.push("channel-whatsapp", s.whatsappForm()) }, - ), - channelItem( - "Feishu", - "Feishu bot settings", - s.config.Channels.Feishu.Enabled, - func() { s.push("channel-feishu", s.feishuForm()) }, - ), - channelItem( - "DingTalk", - "DingTalk bot settings", - s.config.Channels.DingTalk.Enabled, - func() { s.push("channel-dingtalk", s.dingtalkForm()) }, - ), - channelItem( - "Slack", - "Slack bot settings", - s.config.Channels.Slack.Enabled, - func() { s.push("channel-slack", s.slackForm()) }, - ), - channelItem( - "Matrix", - "Matrix bot settings", - s.config.Channels.Matrix.Enabled, - func() { s.push("channel-matrix", s.matrixForm()) }, - ), - channelItem( - "LINE", - "LINE bot settings", - s.config.Channels.LINE.Enabled, - func() { s.push("channel-line", s.lineForm()) }, - ), - channelItem( - "OneBot", - "OneBot settings", - s.config.Channels.OneBot.Enabled, - func() { s.push("channel-onebot", s.onebotForm()) }, - ), - channelItem( - "WeCom", - "WeCom bot settings", - s.config.Channels.WeCom.Enabled, - func() { s.push("channel-wecom", s.wecomForm()) }, - ), - channelItem( - "WeCom App", - "WeCom App settings", - s.config.Channels.WeComApp.Enabled, - func() { s.push("channel-wecomapp", s.wecomAppForm()) }, - ), - } -} - -func (s *appState) channelMenu() tview.Primitive { - menu := NewMenu("Channels", s.buildChannelMenuItems()) - menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pop() - return nil - } - if event.Rune() == 'q' { - s.pop() - return nil - } - return event - }) - return menu -} - -func refreshChannelMenuFromState(menu *Menu, s *appState) { - menu.applyItems(s.buildChannelMenuItems()) -} - -func (s *appState) telegramForm() tview.Primitive { - cfg := &s.config.Channels.Telegram - form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { - cfg.Token = strings.TrimSpace(text) - }) - form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) { - cfg.Proxy = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) discordForm() tview.Primitive { - cfg := &s.config.Channels.Discord - form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { - cfg.Token = strings.TrimSpace(text) - }) - form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) { - cfg.MentionOnly = checked - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) qqForm() tview.Primitive { - cfg := &s.config.Channels.QQ - form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) { - cfg.AppID = strings.TrimSpace(text) - }) - form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) { - cfg.AppSecret = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) maixcamForm() tview.Primitive { - cfg := &s.config.Channels.MaixCam - form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Host", cfg.Host, 64, nil, func(text string) { - cfg.Host = strings.TrimSpace(text) - }) - addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) whatsappForm() tview.Primitive { - cfg := &s.config.Channels.WhatsApp - form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) { - cfg.BridgeURL = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) feishuForm() tview.Primitive { - cfg := &s.config.Channels.Feishu - form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) { - cfg.AppID = strings.TrimSpace(text) - }) - form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) { - cfg.AppSecret = strings.TrimSpace(text) - }) - form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) { - cfg.EncryptKey = strings.TrimSpace(text) - }) - form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) { - cfg.VerificationToken = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) dingtalkForm() tview.Primitive { - cfg := &s.config.Channels.DingTalk - form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) { - cfg.ClientID = strings.TrimSpace(text) - }) - form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) { - cfg.ClientSecret = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) slackForm() tview.Primitive { - cfg := &s.config.Channels.Slack - form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) { - cfg.BotToken = strings.TrimSpace(text) - }) - form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) { - cfg.AppToken = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) lineForm() tview.Primitive { - cfg := &s.config.Channels.LINE - form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) { - cfg.ChannelSecret = strings.TrimSpace(text) - }) - form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) { - cfg.ChannelAccessToken = strings.TrimSpace(text) - }) - form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { - cfg.WebhookHost = strings.TrimSpace(text) - }) - addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) - form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { - cfg.WebhookPath = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) matrixForm() tview.Primitive { - cfg := &s.config.Channels.Matrix - form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) { - cfg.Homeserver = strings.TrimSpace(text) - }) - form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) { - cfg.UserID = strings.TrimSpace(text) - }) - form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) { - cfg.AccessToken = strings.TrimSpace(text) - }) - form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) { - cfg.DeviceID = strings.TrimSpace(text) - }) - form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) { - cfg.JoinOnInvite = checked - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) onebotForm() tview.Primitive { - cfg := &s.config.Channels.OneBot - form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) { - cfg.WSUrl = strings.TrimSpace(text) - }) - form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) { - cfg.AccessToken = strings.TrimSpace(text) - }) - addIntField( - form, - "Reconnect Interval", - cfg.ReconnectInterval, - func(value int) { cfg.ReconnectInterval = value }, - ) - form.AddInputField( - "Group Trigger Prefix", - strings.Join(cfg.GroupTriggerPrefix, ","), - 128, - nil, - func(text string) { - cfg.GroupTriggerPrefix = splitCSV(text) - }, - ) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) wecomForm() tview.Primitive { - cfg := &s.config.Channels.WeCom - form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { - cfg.Token = strings.TrimSpace(text) - }) - form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) { - cfg.EncodingAESKey = strings.TrimSpace(text) - }) - form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) { - cfg.WebhookURL = strings.TrimSpace(text) - }) - form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { - cfg.WebhookHost = strings.TrimSpace(text) - }) - addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) - form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { - cfg.WebhookPath = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - addIntField( - form, - "Reply Timeout", - cfg.ReplyTimeout, - func(value int) { cfg.ReplyTimeout = value }, - ) - return wrapWithBack(form, s) -} - -func (s *appState) wecomAppForm() tview.Primitive { - cfg := &s.config.Channels.WeComApp - form := baseChannelForm("WeCom App", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) { - cfg.CorpID = strings.TrimSpace(text) - }) - form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) { - cfg.CorpSecret = strings.TrimSpace(text) - }) - addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value }) - form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { - cfg.Token = strings.TrimSpace(text) - }) - form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) { - cfg.EncodingAESKey = strings.TrimSpace(text) - }) - form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { - cfg.WebhookHost = strings.TrimSpace(text) - }) - addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) - form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { - cfg.WebhookPath = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - addIntField( - form, - "Reply Timeout", - cfg.ReplyTimeout, - func(value int) { cfg.ReplyTimeout = value }, - ) - return wrapWithBack(form, s) -} - -func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) { - return func(v bool) { - *enabledPtr = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - } -} - -func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) { - form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) { - *allowFrom = splitCSV(text) - }) -} - -func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form { - form := tview.NewForm() - form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title)) - form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123)) - form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22)) - form.AddCheckbox("Enabled", enabled, func(checked bool) { - onEnabled(checked) - }) - return form -} - -func wrapWithBack(form *tview.Form, s *appState) tview.Primitive { - form.AddButton("Back", func() { - s.pop() - }) - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pop() - return nil - } - return event - }) - return form -} - -func splitCSV(input string) picoclawconfig.FlexibleStringSlice { - parts := strings.Split(strings.TrimSpace(input), ",") - cleaned := make([]string, 0, len(parts)) - for _, part := range parts { - value := strings.TrimSpace(part) - if value == "" { - continue - } - cleaned = append(cleaned, value) - } - return cleaned -} - -func addIntField(form *tview.Form, label string, value int, onChange func(int)) { - form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { - var parsed int - if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { - onChange(parsed) - } - }) -} - -func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) { - form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { - var parsed int64 - if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { - onChange(parsed) - } - }) -} - -func channelItem(label, description string, enabled bool, action MenuAction) MenuItem { - item := MenuItem{ - Label: label, - Description: description, - Action: action, - } - if !enabled { - color := tcell.ColorGray - item.MainColor = &color - } - return item -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go b/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go deleted file mode 100644 index bc874f7f2..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build !windows -// +build !windows - -package ui - -import "os/exec" - -func isGatewayProcessRunning() bool { - cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1") - return cmd.Run() == nil -} - -func stopGatewayProcess() error { - cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1") - return cmd.Run() -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go b/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go deleted file mode 100644 index 7067a5c13..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build windows -// +build windows - -package ui - -import "os/exec" - -func isGatewayProcessRunning() bool { - cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe") - return cmd.Run() == nil -} - -func stopGatewayProcess() error { - cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe") - return cmd.Run() -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/menu.go b/cmd/picoclaw-launcher-tui/internal/ui/menu.go deleted file mode 100644 index 9f2132c5a..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/menu.go +++ /dev/null @@ -1,72 +0,0 @@ -package ui - -import ( - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -type MenuAction func() - -type MenuItem struct { - Label string - Description string - Action MenuAction - Disabled bool - MainColor *tcell.Color - DescColor *tcell.Color -} - -type Menu struct { - *tview.Table - items []MenuItem -} - -func NewMenu(title string, items []MenuItem) *Menu { - table := tview.NewTable().SetSelectable(true, false) - table.SetBorder(true).SetTitle(title) - table.SetBorders(false) - menu := &Menu{Table: table, items: items} - menu.applyItems(items) - menu.SetSelectedFunc(func(row, _ int) { - if row < 0 || row >= len(menu.items) { - return - } - item := menu.items[row] - if item.Disabled || item.Action == nil { - return - } - item.Action() - }) - menu.SetSelectedStyle( - tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor). - Background(tcell.NewRGBColor(189, 147, 249)), - ) - return menu -} - -func (m *Menu) applyItems(items []MenuItem) { - m.items = items - m.Clear() - for row, item := range items { - label := item.Label - if item.Disabled && label != "" { - label = label + " (disabled)" - } - left := tview.NewTableCell(label) - right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight) - if item.MainColor != nil { - left.SetTextColor(*item.MainColor) - } - if item.DescColor != nil { - right.SetTextColor(*item.DescColor) - } else { - right.SetTextColor(tview.Styles.TertiaryTextColor) - } - if item.Disabled { - left.SetTextColor(tcell.ColorGray) - right.SetTextColor(tcell.ColorGray) - } - m.SetCell(row, 0, left) - m.SetCell(row, 1, right) - } -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/model.go b/cmd/picoclaw-launcher-tui/internal/ui/model.go deleted file mode 100644 index 304b4efa7..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/model.go +++ /dev/null @@ -1,347 +0,0 @@ -package ui - -import ( - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - picoclawconfig "github.com/sipeed/picoclaw/pkg/config" -) - -func (s *appState) modelMenu() tview.Primitive { - items := make([]MenuItem, 0, 2+len(s.config.ModelList)) - items = append(items, - MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }}, - MenuItem{ - Label: "Add model", - Description: "Append a new model entry", - Action: func() { - s.addModel( - picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"}, - ) - s.push( - fmt.Sprintf("model-%d", len(s.config.ModelList)-1), - s.modelForm(len(s.config.ModelList)-1), - ) - }, - }, - ) - currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) - for i := range s.config.ModelList { - index := i - model := s.config.ModelList[i] - isValid := isModelValid(model) - desc := model.APIBase - if desc == "" { - desc = model.AuthMethod - } - if desc == "" { - desc = "api_key required" - } - label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) - if model.ModelName == currentModel && currentModel != "" { - label = "* " + label - } - isSelected := model.ModelName == currentModel && currentModel != "" - items = append(items, MenuItem{ - Label: label, - Description: desc, - MainColor: modelStatusColor(isValid, isSelected), - Action: func() { - s.push(fmt.Sprintf("model-%d", index), s.modelForm(index)) - }, - }) - } - - menu := NewMenu("Models", items) - menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pop() - return nil - } - if event.Rune() == 'q' { - s.pop() - return nil - } - if event.Rune() == ' ' { - row, _ := menu.GetSelection() - if row > 0 && row <= len(s.config.ModelList) { - model := s.config.ModelList[row-1] - if !isModelValid(model) { - s.showMessage( - "Invalid model", - "Select a model with api_key or oauth auth_method", - ) - return nil - } - s.config.Agents.Defaults.Model = model.ModelName - s.dirty = true - refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList) - refreshMainMenuIfPresent(s) - } - return nil - } - return event - }) - return menu -} - -func (s *appState) modelForm(index int) tview.Primitive { - model := &s.config.ModelList[index] - form := tview.NewForm() - form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName)) - form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123)) - form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22)) - - addInput(form, "Model Name", model.ModelName, func(value string) { - model.ModelName = value - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "Model", model.Model, func(value string) { - model.Model = value - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "API Base", model.APIBase, func(value string) { - model.APIBase = value - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "API Key", model.APIKey, func(value string) { - model.APIKey = value - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "Proxy", model.Proxy, func(value string) { - model.Proxy = value - }) - addInput(form, "Auth Method", model.AuthMethod, func(value string) { - model.AuthMethod = value - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "Connect Mode", model.ConnectMode, func(value string) { - model.ConnectMode = value - }) - addInput(form, "Workspace", model.Workspace, func(value string) { - model.Workspace = value - }) - addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) { - model.MaxTokensField = value - }) - addIntInput(form, "RPM", model.RPM, func(value int) { - model.RPM = value - }) - addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) { - model.RequestTimeout = value - }) - - form.AddButton("Delete", func() { - s.deleteModel(index) - }) - form.AddButton("Test", func() { - s.testModel(model) - }) - form.AddButton("Back", func() { - s.pop() - }) - - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pop() - return nil - } - return event - }) - return form -} - -func addInput(form *tview.Form, label, value string, onChange func(string)) { - form.AddInputField(label, value, 128, nil, func(text string) { - onChange(strings.TrimSpace(text)) - }) -} - -func addIntInput(form *tview.Form, label string, value int, onChange func(int)) { - form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { - var parsed int - if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { - onChange(parsed) - } - }) -} - -func (s *appState) addModel(model picoclawconfig.ModelConfig) { - s.config.ModelList = append(s.config.ModelList, model) -} - -func (s *appState) deleteModel(index int) { - if index < 0 || index >= len(s.config.ModelList) { - return - } - s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...) - s.pop() -} - -func modelStatusColor(valid bool, selected bool) *tcell.Color { - if valid { - color := tview.Styles.PrimaryTextColor - return &color - } - color := tcell.ColorGray - return &color -} - -func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) { - for i, model := range models { - row := i + 1 - label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) - isValid := isModelValid(model) - if model.ModelName == currentModel && currentModel != "" { - label = "* " + label - } - cell := menu.GetCell(row, 0) - if cell != nil { - cell.SetText(label) - isSelected := model.ModelName == currentModel && currentModel != "" - color := modelStatusColor(isValid, isSelected) - if color != nil { - cell.SetTextColor(*color) - } - } - } -} - -func refreshModelMenuFromState(menu *Menu, s *appState) { - items := make([]MenuItem, 0, 2+len(s.config.ModelList)) - items = append(items, - MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }}, - MenuItem{ - Label: "Add model", - Description: "Append a new model entry", - Action: func() { - s.addModel( - picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"}, - ) - s.push( - fmt.Sprintf("model-%d", len(s.config.ModelList)-1), - s.modelForm(len(s.config.ModelList)-1), - ) - }, - }, - ) - currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) - for i := range s.config.ModelList { - index := i - model := s.config.ModelList[i] - isValid := isModelValid(model) - desc := model.APIBase - if desc == "" { - desc = model.AuthMethod - } - if desc == "" { - desc = "api_key required" - } - label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) - if model.ModelName == currentModel && currentModel != "" { - label = "* " + label - } - isSelected := model.ModelName == currentModel && currentModel != "" - items = append(items, MenuItem{ - Label: label, - Description: desc, - MainColor: modelStatusColor(isValid, isSelected), - Action: func() { - s.push(fmt.Sprintf("model-%d", index), s.modelForm(index)) - }, - }) - } - menu.applyItems(items) -} - -func isModelValid(model picoclawconfig.ModelConfig) bool { - hasKey := strings.TrimSpace(model.APIKey) != "" || - strings.TrimSpace(model.AuthMethod) == "oauth" - hasModel := strings.TrimSpace(model.Model) != "" - return hasKey && hasModel -} - -func (s *appState) testModel(model *picoclawconfig.ModelConfig) { - if model == nil { - return - } - if strings.TrimSpace(model.APIKey) == "" { - s.showMessage("Missing API Key", "Set api_key before testing") - return - } - base := strings.TrimSpace(model.APIBase) - if base == "" { - s.showMessage("Missing API Base", "Set api_base before testing") - return - } - modelID := strings.TrimSpace(model.Model) - if modelID == "" { - s.showMessage("Missing Model", "Set model before testing") - return - } - if !strings.HasPrefix(modelID, "openai/") { - s.showMessage("Unsupported model", "Only openai/* models are supported for test") - return - } - modelName := strings.TrimPrefix(modelID, "openai/") - endpoint := strings.TrimRight(base, "/") + "/chat/completions" - - payload := fmt.Sprintf( - `{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`, - modelName, - ) - client := &http.Client{Timeout: 10 * time.Second} - request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload)) - if err != nil { - s.showMessage("Test failed", err.Error()) - return - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey)) - - resp, err := client.Do(request) - if err != nil { - s.showMessage("Test failed", err.Error()) - return - } - defer resp.Body.Close() - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - s.showMessage("Test OK", resp.Status) - return - } - body, err := io.ReadAll(io.LimitReader(resp.Body, 2048)) - if err != nil { - s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err)) - return - } - s.showMessage( - "Test failed", - fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))), - ) -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/style.go b/cmd/picoclaw-launcher-tui/internal/ui/style.go deleted file mode 100644 index 68cdd60b9..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/style.go +++ /dev/null @@ -1,43 +0,0 @@ -package ui - -import ( - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -const ( - colorBlue = "[#3e5db9]" - colorRed = "[#d54646]" - banner = "\r\n[::b]" + - colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" + - colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" + - colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" + - colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" + - colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + - colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " + - "[:]" -) - -func applyStyles() { - tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22) - tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53) - tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32) - tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255) - tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198) - tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253) - tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255) - tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123) - tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253) - tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22) - tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249) -} - -func bannerView() *tview.TextView { - text := tview.NewTextView() - text.SetDynamicColors(true) - text.SetTextAlign(tview.AlignCenter) - text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor) - text.SetText(banner) - text.SetBorder(false) - return text -} diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go deleted file mode 100644 index 0e8cce415..000000000 --- a/cmd/picoclaw-launcher-tui/main.go +++ /dev/null @@ -1,15 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui" -) - -func main() { - if err := ui.Run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} diff --git a/cmd/picoclaw-launcher/README.md b/cmd/picoclaw-launcher/README.md deleted file mode 100644 index d7985e09d..000000000 --- a/cmd/picoclaw-launcher/README.md +++ /dev/null @@ -1,326 +0,0 @@ -# PicoClaw Launcher - -> [!WARNING] -> This project is a temporary solution and will be refactored in the future to provide a complete web service. Therefore, the APIs in this directory are not stable. - -A standalone launcher for PicoClaw, providing visual JSON editing, OAuth provider authentication management, and gateway process control. - -## Features - -- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor -- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation -- 📡 **Channel Configuration** — Form-based settings for 14+ channel types (Telegram, Discord, Slack, Matrix, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, MagicForm, IRC, etc.) with documentation links -- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth with PKCE) -- 🚀 **Gateway Process Control** — Start, stop, and monitor the `picoclaw gateway` process with live log streaming -- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies -- 🌍 **i18n** — Chinese/English language switching with browser auto-detection -- 🎨 **Theme** — Light / Dark / System theme toggle with localStorage persistence -- 🔒 **Security Headers** — `X-Content-Type-Options`, `X-Frame-Options`, and `Content-Security-Policy` on all responses - -## Quick Start - -```bash -# Build -go build -o picoclaw-launcher ./cmd/picoclaw-launcher/ - -# Run with default config path (~/.picoclaw/config.json) -./picoclaw-launcher - -# Specify a config file -./picoclaw-launcher ./config.json - -# Allow LAN access -./picoclaw-launcher -public -``` - -The launcher automatically opens `http://localhost:18800` in your default browser on startup. - -## CLI Options - -``` -Usage: picoclaw-launcher [options] [config.json] - -Arguments: - config.json Path to the configuration file (default: ~/.picoclaw/config.json) - -Options: - -public Listen on all interfaces (0.0.0.0), allowing access from other devices -``` - -When `-public` is set, the startup banner also prints the local network IP address for LAN access. - -## API Reference - -Base URL: `http://localhost:18800` - -Default port: `18800` - ---- - -### Static Files - -#### GET / - -Serves the embedded frontend (`index.html`). - ---- - -### Config API - -#### GET /api/config - -Reads the current configuration file. - -**Response** `200 OK` - -```json -{ - "config": { ... }, - "path": "/home/user/.picoclaw/config.json" -} -``` - ---- - -#### PUT /api/config - -Saves the configuration. The request body must be a complete Config JSON object (max 1 MB). - -**Request Body** — `application/json` - -```json -{ - "agents": { "defaults": { "model_name": "gpt-5.2" } }, - "model_list": [ - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "auth_method": "oauth" - } - ] -} -``` - -**Response** `200 OK` - -```json -{ "status": "ok" } -``` - -**Error** `400 Bad Request` — Invalid JSON - ---- - -### Auth API - -#### GET /api/auth/status - -Returns the authentication status of all providers and any in-progress device code login. - -**Response** `200 OK` - -```json -{ - "providers": [ - { - "provider": "openai", - "auth_method": "oauth", - "status": "active", - "account_id": "user-xxx", - "expires_at": "2026-03-01T00:00:00Z" - }, - { - "provider": "google-antigravity", - "auth_method": "oauth", - "status": "active", - "email": "user@example.com", - "project_id": "projects/123/locations/global/codeAssistModels/default" - } - ], - "pending_device": { - "provider": "openai", - "status": "pending", - "device_url": "https://auth.openai.com/activate", - "user_code": "ABCD-1234" - } -} -``` - -`status` values: `active` | `expired` | `needs_refresh` - -`pending_device` is only present when a device code login is in progress. Once completed, it shows `status: "success"` and is cleared on the next poll. - ---- - -#### POST /api/auth/login - -Initiates a provider login. - -**Request Body** — `application/json` - -```json -{ "provider": "openai" } -``` - -Supported `provider` values: `openai` | `anthropic` | `google-antigravity` (alias: `antigravity`) - -##### OpenAI (Device Code Flow) - -Returns device code info. The server polls for completion in the background (15-minute timeout). - -```json -{ - "status": "pending", - "device_url": "https://auth.openai.com/activate", - "user_code": "ABCD-1234", - "message": "Open the URL and enter the code to authenticate." -} -``` - -The user opens `device_url` in a browser and enters `user_code`. Once authenticated, `GET /api/auth/status` will show `pending_device.status` as `success`. If a device code flow is already in progress, the existing session is returned. - -##### Anthropic (API Token) - -Requires a `token` field in the request: - -```json -{ "provider": "anthropic", "token": "sk-ant-xxx" } -``` - -**Response:** - -```json -{ "status": "success", "message": "Anthropic token saved" } -``` - -The token is saved to the auth credential store and the config is updated to set `auth_method: "token"` on any Anthropic model entry. - -##### Google Antigravity (Browser OAuth with PKCE) - -Returns an authorization URL for the frontend to open in a new tab: - -```json -{ - "status": "redirect", - "auth_url": "https://accounts.google.com/o/oauth2/auth?...", - "message": "Open the URL to authenticate with Google." -} -``` - -After authentication, Google redirects to `GET /auth/callback`, which exchanges the authorization code for tokens using PKCE, fetches the user's email and Cloud Code Assist project ID, saves the credentials, and redirects back to the launcher UI at `/#auth`. OAuth sessions expire after 10 minutes if not completed. - ---- - -#### POST /api/auth/logout - -Logs out from a provider. - -**Request Body** — `application/json` - -```json -{ "provider": "openai" } -``` - -Omit or leave `provider` empty to log out from all providers. Clears both the auth credential store and `auth_method` fields in the config file. - -**Response** `200 OK` - -```json -{ "status": "ok" } -``` - ---- - -#### GET /auth/callback - -OAuth browser callback endpoint (used by Google Antigravity). Called by the OAuth provider's redirect — **not invoked directly by the frontend**. - -**Query Parameters:** -- `state` — OAuth state for CSRF validation -- `code` — Authorization code - -On success, redirects to `/#auth`. On failure, displays an error page. - ---- - -### Process API - -#### GET /api/process/status - -Gets the running status of the `picoclaw gateway` process by probing its health endpoint. - -The gateway address is read from the config file (`gateway.host` and `gateway.port`, default `127.0.0.1:18790`). - -**Query Parameters** (optional, for incremental log streaming): -- `log_offset` — Last received log line index (0-based) -- `log_run_id` — Run ID from previous response (detects gateway restarts) - -**Response** `200 OK` (Running) - -```json -{ - "process_status": "running", - "status": "ok", - "uptime": "1.010814s", - "logs": ["[INFO] Gateway started on :18790", "..."], - "log_total": 42, - "log_run_id": 1, - "log_source": "launcher" -} -``` - -**Response** `200 OK` (Stopped) - -```json -{ - "process_status": "stopped", - "error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused", - "logs": [], - "log_total": 0, - "log_run_id": 0, - "log_source": "none" -} -``` - -`log_source` values: `launcher` (logs captured from a process started by the launcher) | `none` (no log source available, e.g. gateway started externally or never launched) - ---- - -#### POST /api/process/start - -Starts the `picoclaw gateway` process in the background. The launcher looks for the `picoclaw` binary first in the same directory as itself, then falls back to `$PATH`. - -Stdout and stderr from the gateway process are captured into a ring buffer (200 lines) and can be streamed via `GET /api/process/status`. - -**Response** `200 OK` - -```json -{ - "status": "ok", - "pid": 12345 -} -``` - ---- - -#### POST /api/process/stop - -Stops the running `picoclaw gateway` process. - -On Linux/macOS, uses `pkill -f "picoclaw gateway"`. On Windows, uses PowerShell to find and stop matching processes. - -**Response** `200 OK` - -```json -{ - "status": "ok" -} -``` - ---- - -## Testing - -```bash -go test -v ./cmd/picoclaw-launcher/... -``` diff --git a/cmd/picoclaw-launcher/README.zh.md b/cmd/picoclaw-launcher/README.zh.md deleted file mode 100644 index 320de75a5..000000000 --- a/cmd/picoclaw-launcher/README.zh.md +++ /dev/null @@ -1,287 +0,0 @@ -# PicoClaw Launcher - -> [!WARNING] -> 该项目属于临时解决方案,后续会重构并提供完整的 Web 服务,因此该目录下的接口并不稳定。 - -PicoClaw 的独立启动器,提供可视化 JSON 配置编辑和 OAuth Provider 认证管理。 - -## 功能 - -- 📝 **配置编辑** — 侧边栏式设置 UI,支持模型管理、通道配置表单和原始 JSON 编辑器 -- 🤖 **模型管理** — 模型卡片网格,可用性状态显示(无 API Key 时灰色),主模型选择,增删改查,必填/选填字段分离 -- 📡 **通道配置** — 12 种通道类型(Telegram、Discord、Slack、企业微信、钉钉、飞书、LINE、WhatsApp、QQ、OneBot、MaixCAM 等)的表单化配置,附带文档链接 -- 🔐 **Provider 认证** — 支持 OpenAI (Device Code)、Anthropic (API Token)、Google Antigravity (Browser OAuth) 登录 -- 🌐 **嵌入式前端** — 编译为单一二进制文件,无需额外依赖 -- 🌍 **国际化** — 中英文切换,首次访问自动检测浏览器语言 -- 🎨 **主题** — 亮色 / 暗色 / 跟随系统,偏好保存在 localStorage - -## 快速开始 - -```bash -# 编译 -go build -o picoclaw-launcher ./cmd/picoclaw-launcher/ - -# 运行(使用默认配置路径 ~/.picoclaw/config.json) -./picoclaw-launcher - -# 指定配置文件 -./picoclaw-launcher ./config.json - -# 允许局域网访问 -./picoclaw-launcher -public -``` - -启动后在浏览器中打开 `http://localhost:18800`。 - -## 命令行参数 - -``` -Usage: picoclaw-launcher [options] [config.json] - -Arguments: - config.json 配置文件路径(默认: ~/.picoclaw/config.json) - -Options: - -public 监听所有网络接口(0.0.0.0),允许局域网设备访问 -``` - -## API 文档 - -Base URL: `http://localhost:18800` - -### 静态文件 - -#### GET / - -提供嵌入式前端页面(`index.html`)。 - ---- - -### Config API - -#### GET /api/config - -读取当前配置文件内容。 - -**Response** `200 OK` - -```json -{ - "config": { ... }, - "path": "/Users/xiao/.picoclaw/config.json" -} -``` - ---- - -#### PUT /api/config - -保存配置。请求体为完整的 Config JSON。 - -**Request Body** — `application/json` - -```json -{ - "agents": { "defaults": { "model_name": "gpt-5.2" } }, - "model_list": [ - { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", - "auth_method": "oauth" - } - ] -} -``` - -**Response** `200 OK` - -```json -{ "status": "ok" } -``` - -**Error** `400 Bad Request` — 无效 JSON - ---- - -### Auth API - -#### GET /api/auth/status - -获取所有 Provider 的认证状态和进行中的 Device Code 登录信息。 - -**Response** `200 OK` - -```json -{ - "providers": [ - { - "provider": "openai", - "auth_method": "oauth", - "status": "active", - "account_id": "user-xxx", - "expires_at": "2026-03-01T00:00:00Z" - } - ], - "pending_device": { - "provider": "openai", - "status": "pending", - "device_url": "https://auth.openai.com/activate", - "user_code": "ABCD-1234" - } -} -``` - -`status` 可选值: `active` | `expired` | `needs_refresh` - -`pending_device` 仅在有进行中的 Device Code 登录时返回。 - ---- - -#### POST /api/auth/login - -发起 Provider 登录。 - -**Request Body** — `application/json` - -```json -{ "provider": "openai" } -``` - -支持的 `provider` 值: `openai` | `anthropic` | `google-antigravity` - -##### OpenAI (Device Code Flow) - -返回 Device Code 信息,后台自动轮询认证结果: - -```json -{ - "status": "pending", - "device_url": "https://auth.openai.com/activate", - "user_code": "ABCD-1234", - "message": "Open the URL and enter the code to authenticate." -} -``` - -用户在浏览器中打开 `device_url` 并输入 `user_code`。认证完成后通过 `GET /api/auth/status` 的 `pending_device.status` 变为 `success` 通知前端。 - -##### Anthropic (API Token) - -需在请求中附带 token: - -```json -{ "provider": "anthropic", "token": "sk-ant-xxx" } -``` - -**Response:** - -```json -{ "status": "success", "message": "Anthropic token saved" } -``` - -##### Google Antigravity (Browser OAuth) - -返回授权 URL,前端打开新标签页: - -```json -{ - "status": "redirect", - "auth_url": "https://accounts.google.com/o/oauth2/auth?...", - "message": "Open the URL to authenticate with Google." -} -``` - -认证完成后 Google 回调至 `GET /auth/callback`,自动保存凭据并重定向回 picoclaw-config 页面。 - ---- - -#### POST /api/auth/logout - -登出 Provider。 - -**Request Body** — `application/json` - -```json -{ "provider": "openai" } -``` - -传空字符串或省略 `provider` 则登出所有 Provider。 - -**Response** `200 OK` - -```json -{ "status": "ok" } -``` - ---- - -#### GET /auth/callback - -OAuth Browser 回调端点(Google Antigravity 专用),由 OAuth Provider 重定向调用,**非前端直接使用**。 - -**Query Parameters:** -- `state` — OAuth state 校验 -- `code` — 授权码 - -认证成功后重定向到 `/#auth`。 - -### Process API - -#### GET /api/process/status - -获取 `picoclaw gateway` 进程的运行状态。 - -**Response** `200 OK` (运行中) - -```json -{ - "process_status": "running", - "status": "ok", - "uptime": "1.010814s" -} -``` - -**Response** `200 OK` (未运行) - -```json -{ - "process_status": "stopped", - "error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused" -} -``` - ---- - -#### POST /api/process/start - -在后台启动 `picoclaw gateway` 进程。 - -**Response** `200 OK` - -```json -{ - "status": "ok", - "pid": 12345 -} -``` - ---- - -#### POST /api/process/stop - -停止正在运行的 `picoclaw gateway` 进程。 - -**Response** `200 OK` - -```json -{ - "status": "ok" -} -``` - ---- - -## 测试 - -```bash -go test -v ./cmd/picoclaw-launcher/ -``` diff --git a/cmd/picoclaw-launcher/internal/server/auth_config.go b/cmd/picoclaw-launcher/internal/server/auth_config.go deleted file mode 100644 index f75e8fff0..000000000 --- a/cmd/picoclaw-launcher/internal/server/auth_config.go +++ /dev/null @@ -1,147 +0,0 @@ -package server - -import ( - "log" - "strings" - - "github.com/sipeed/picoclaw/pkg/auth" - "github.com/sipeed/picoclaw/pkg/config" -) - -// updateConfigAfterLogin updates config.json after a successful provider login. -func updateConfigAfterLogin(configPath, provider string, cred *auth.AuthCredential) { - cfg, err := config.LoadConfig(configPath) - if err != nil { - log.Printf("Warning: could not load config to update auth_method: %v", err) - return - } - - switch provider { - case "openai": - cfg.Providers.OpenAI.AuthMethod = "oauth" - found := false - for i := range cfg.ModelList { - if isOpenAIModel(cfg.ModelList[i].Model) { - cfg.ModelList[i].AuthMethod = "oauth" - found = true - break - } - } - if !found { - cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ - ModelName: "gpt-5.2", - Model: "openai/gpt-5.2", - AuthMethod: "oauth", - }) - } - cfg.Agents.Defaults.ModelName = "gpt-5.2" - - case "anthropic": - cfg.Providers.Anthropic.AuthMethod = "token" - found := false - for i := range cfg.ModelList { - if isAnthropicModel(cfg.ModelList[i].Model) { - cfg.ModelList[i].AuthMethod = "token" - found = true - break - } - } - if !found { - cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ - ModelName: "claude-sonnet-4.6", - Model: "anthropic/claude-sonnet-4.6", - AuthMethod: "token", - }) - } - cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6" - - case "google-antigravity": - cfg.Providers.Antigravity.AuthMethod = "oauth" - found := false - for i := range cfg.ModelList { - if isAntigravityModel(cfg.ModelList[i].Model) { - cfg.ModelList[i].AuthMethod = "oauth" - found = true - break - } - } - if !found { - cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ - ModelName: "gemini-flash", - Model: "antigravity/gemini-3-flash", - AuthMethod: "oauth", - }) - } - cfg.Agents.Defaults.ModelName = "gemini-flash" - } - - if err := config.SaveConfig(configPath, cfg); err != nil { - log.Printf("Warning: could not update config: %v", err) - } -} - -// clearAuthMethodInConfig clears auth_method for a specific provider in config.json. -func clearAuthMethodInConfig(configPath, provider string) { - cfg, err := config.LoadConfig(configPath) - if err != nil { - return - } - - for i := range cfg.ModelList { - switch provider { - case "openai": - if isOpenAIModel(cfg.ModelList[i].Model) { - cfg.ModelList[i].AuthMethod = "" - } - case "anthropic": - if isAnthropicModel(cfg.ModelList[i].Model) { - cfg.ModelList[i].AuthMethod = "" - } - case "google-antigravity", "antigravity": - if isAntigravityModel(cfg.ModelList[i].Model) { - cfg.ModelList[i].AuthMethod = "" - } - } - } - - switch provider { - case "openai": - cfg.Providers.OpenAI.AuthMethod = "" - case "anthropic": - cfg.Providers.Anthropic.AuthMethod = "" - case "google-antigravity", "antigravity": - cfg.Providers.Antigravity.AuthMethod = "" - } - - config.SaveConfig(configPath, cfg) -} - -// clearAllAuthMethodsInConfig clears auth_method for all providers in config.json. -func clearAllAuthMethodsInConfig(configPath string) { - cfg, err := config.LoadConfig(configPath) - if err != nil { - return - } - for i := range cfg.ModelList { - cfg.ModelList[i].AuthMethod = "" - } - cfg.Providers.OpenAI.AuthMethod = "" - cfg.Providers.Anthropic.AuthMethod = "" - cfg.Providers.Antigravity.AuthMethod = "" - config.SaveConfig(configPath, cfg) -} - -// ── Model identification helpers ───────────────────────────────── - -func isOpenAIModel(model string) bool { - return model == "openai" || strings.HasPrefix(model, "openai/") -} - -func isAnthropicModel(model string) bool { - return model == "anthropic" || strings.HasPrefix(model, "anthropic/") -} - -func isAntigravityModel(model string) bool { - return model == "antigravity" || model == "google-antigravity" || - strings.HasPrefix(model, "antigravity/") || strings.HasPrefix(model, "google-antigravity/") -} diff --git a/cmd/picoclaw-launcher/internal/server/auth_config_test.go b/cmd/picoclaw-launcher/internal/server/auth_config_test.go deleted file mode 100644 index 92158d011..000000000 --- a/cmd/picoclaw-launcher/internal/server/auth_config_test.go +++ /dev/null @@ -1,222 +0,0 @@ -package server - -import ( - "path/filepath" - "testing" - - "github.com/sipeed/picoclaw/pkg/auth" - "github.com/sipeed/picoclaw/pkg/config" -) - -// ── Model identification helpers ───────────────────────────────── - -func TestIsOpenAIModel(t *testing.T) { - tests := []struct { - model string - want bool - }{ - {"openai", true}, - {"openai/gpt-4o", true}, - {"openai/gpt-5.2", true}, - {"anthropic", false}, - {"anthropic/claude-sonnet-4.6", false}, - {"openai-compatible", false}, - {"", false}, - } - for _, tt := range tests { - if got := isOpenAIModel(tt.model); got != tt.want { - t.Errorf("isOpenAIModel(%q) = %v, want %v", tt.model, got, tt.want) - } - } -} - -func TestIsAnthropicModel(t *testing.T) { - tests := []struct { - model string - want bool - }{ - {"anthropic", true}, - {"anthropic/claude-sonnet-4.6", true}, - {"openai", false}, - {"openai/gpt-4o", false}, - {"", false}, - } - for _, tt := range tests { - if got := isAnthropicModel(tt.model); got != tt.want { - t.Errorf("isAnthropicModel(%q) = %v, want %v", tt.model, got, tt.want) - } - } -} - -func TestIsAntigravityModel(t *testing.T) { - tests := []struct { - model string - want bool - }{ - {"antigravity", true}, - {"google-antigravity", true}, - {"antigravity/gemini-3-flash", true}, - {"google-antigravity/gemini-3-flash", true}, - {"openai", false}, - {"antigravity-custom", false}, - {"", false}, - } - for _, tt := range tests { - if got := isAntigravityModel(tt.model); got != tt.want { - t.Errorf("isAntigravityModel(%q) = %v, want %v", tt.model, got, tt.want) - } - } -} - -// ── Config update helpers ──────────────────────────────────────── - -func writeTempConfigViaSave(t *testing.T, cfg *config.Config) string { - t.Helper() - dir := t.TempDir() - path := filepath.Join(dir, "config.json") - if err := config.SaveConfig(path, cfg); err != nil { - t.Fatalf("save config: %v", err) - } - return path -} - -func loadTempConfig(t *testing.T, path string) *config.Config { - t.Helper() - cfg, err := config.LoadConfig(path) - if err != nil { - t.Fatalf("load config: %v", err) - } - return cfg -} - -func TestUpdateConfigAfterLogin_OpenAI_ExistingModel(t *testing.T) { - cfg := &config.Config{ - ModelList: []config.ModelConfig{ - {ModelName: "gpt-4o", Model: "openai/gpt-4o"}, - }, - } - path := writeTempConfigViaSave(t, cfg) - - cred := &auth.AuthCredential{AuthMethod: "oauth"} - updateConfigAfterLogin(path, "openai", cred) - - result := loadTempConfig(t, path) - - // Model-level auth_method persists through serialization - if len(result.ModelList) != 1 { - t.Fatalf("expected 1 model, got %d", len(result.ModelList)) - } - if result.ModelList[0].AuthMethod != "oauth" { - t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod) - } -} - -func TestUpdateConfigAfterLogin_OpenAI_NoExistingModel(t *testing.T) { - cfg := &config.Config{ - ModelList: []config.ModelConfig{ - {ModelName: "claude", Model: "anthropic/claude-sonnet-4.6"}, - }, - } - path := writeTempConfigViaSave(t, cfg) - - cred := &auth.AuthCredential{AuthMethod: "oauth"} - updateConfigAfterLogin(path, "openai", cred) - - result := loadTempConfig(t, path) - - if len(result.ModelList) != 2 { - t.Fatalf("expected 2 models (original + added), got %d", len(result.ModelList)) - } - if result.ModelList[1].Model != "openai/gpt-5.2" { - t.Errorf("expected added model openai/gpt-5.2, got %q", result.ModelList[1].Model) - } - if result.Agents.Defaults.ModelName != "gpt-5.2" { - t.Errorf("expected default model_name=gpt-5.2, got %q", result.Agents.Defaults.ModelName) - } -} - -func TestUpdateConfigAfterLogin_Anthropic(t *testing.T) { - cfg := &config.Config{} - path := writeTempConfigViaSave(t, cfg) - - cred := &auth.AuthCredential{AuthMethod: "token"} - updateConfigAfterLogin(path, "anthropic", cred) - - result := loadTempConfig(t, path) - - // Model should be added with correct auth_method - if len(result.ModelList) != 1 { - t.Fatalf("expected 1 model added, got %d", len(result.ModelList)) - } - if result.ModelList[0].Model != "anthropic/claude-sonnet-4.6" { - t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", result.ModelList[0].Model) - } - if result.ModelList[0].AuthMethod != "token" { - t.Errorf("expected model auth_method=token, got %q", result.ModelList[0].AuthMethod) - } -} - -func TestUpdateConfigAfterLogin_GoogleAntigravity(t *testing.T) { - cfg := &config.Config{} - path := writeTempConfigViaSave(t, cfg) - - cred := &auth.AuthCredential{AuthMethod: "oauth"} - updateConfigAfterLogin(path, "google-antigravity", cred) - - result := loadTempConfig(t, path) - - // Model should be added with correct auth_method - if len(result.ModelList) != 1 { - t.Fatalf("expected 1 model added, got %d", len(result.ModelList)) - } - if result.ModelList[0].Model != "antigravity/gemini-3-flash" { - t.Errorf("expected model antigravity/gemini-3-flash, got %q", result.ModelList[0].Model) - } - if result.ModelList[0].AuthMethod != "oauth" { - t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod) - } -} - -func TestClearAuthMethodInConfig(t *testing.T) { - cfg := &config.Config{ - ModelList: []config.ModelConfig{ - {ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"}, - {ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"}, - }, - } - path := writeTempConfigViaSave(t, cfg) - - clearAuthMethodInConfig(path, "openai") - - result := loadTempConfig(t, path) - - // Openai model auth_method should be cleared - if result.ModelList[0].AuthMethod != "" { - t.Errorf("expected openai model auth_method cleared, got %q", result.ModelList[0].AuthMethod) - } - // Anthropic model should be unchanged - if result.ModelList[1].AuthMethod != "token" { - t.Errorf("expected anthropic model auth_method unchanged, got %q", result.ModelList[1].AuthMethod) - } -} - -func TestClearAllAuthMethodsInConfig(t *testing.T) { - cfg := &config.Config{ - ModelList: []config.ModelConfig{ - {ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"}, - {ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"}, - {ModelName: "gemini", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth"}, - }, - } - path := writeTempConfigViaSave(t, cfg) - - clearAllAuthMethodsInConfig(path) - - result := loadTempConfig(t, path) - - for i, m := range result.ModelList { - if m.AuthMethod != "" { - t.Errorf("model[%d] auth_method not cleared, got %q", i, m.AuthMethod) - } - } -} diff --git a/cmd/picoclaw-launcher/internal/server/auth_handlers.go b/cmd/picoclaw-launcher/internal/server/auth_handlers.go deleted file mode 100644 index ec5b7ea65..000000000 --- a/cmd/picoclaw-launcher/internal/server/auth_handlers.go +++ /dev/null @@ -1,320 +0,0 @@ -package server - -import ( - "encoding/json" - "fmt" - "html" - "io" - "log" - "net/http" - "sync" - "time" - - "github.com/sipeed/picoclaw/pkg/auth" - "github.com/sipeed/picoclaw/pkg/providers" -) - -// oauthSession stores in-flight OAuth state for browser-based flows. -type oauthSession struct { - Provider string - PKCE auth.PKCECodes - State string - RedirectURI string - OAuthCfg auth.OAuthProviderConfig - ConfigPath string -} - -// deviceCodeSession stores in-flight device code flow state. -type deviceCodeSession struct { - mu sync.Mutex - Provider string - Info *auth.DeviceCodeInfo - OAuthCfg auth.OAuthProviderConfig - ConfigPath string - Status string // "pending", "success", "error" - Error string - Done bool -} - -var ( - oauthSessions = map[string]*oauthSession{} // keyed by state - oauthSessionsMu sync.Mutex - - activeDeviceSession *deviceCodeSession - activeDeviceSessionMu sync.Mutex -) - -// handleOpenAILogin starts the OpenAI device code flow and returns device code info to the frontend. -func handleOpenAILogin(w http.ResponseWriter, configPath string) { - // Check if there's already a pending device code session - activeDeviceSessionMu.Lock() - if activeDeviceSession != nil { - activeDeviceSession.mu.Lock() - if !activeDeviceSession.Done { - resp := map[string]any{ - "status": "pending", - "device_url": activeDeviceSession.Info.VerifyURL, - "user_code": activeDeviceSession.Info.UserCode, - "message": "Device code flow already in progress. Enter the code in your browser.", - } - activeDeviceSession.mu.Unlock() - activeDeviceSessionMu.Unlock() - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) - return - } - activeDeviceSession.mu.Unlock() - } - activeDeviceSessionMu.Unlock() - - // Request a device code - oauthCfg := auth.OpenAIOAuthConfig() - info, err := auth.RequestDeviceCode(oauthCfg) - if err != nil { - http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError) - return - } - - session := &deviceCodeSession{ - Provider: "openai", - Info: info, - OAuthCfg: oauthCfg, - ConfigPath: configPath, - Status: "pending", - } - - activeDeviceSessionMu.Lock() - activeDeviceSession = session - activeDeviceSessionMu.Unlock() - - // Start background polling - go func() { - deadline := time.After(15 * time.Minute) - ticker := time.NewTicker(time.Duration(info.Interval) * time.Second) - defer ticker.Stop() - - for { - select { - case <-deadline: - session.mu.Lock() - session.Status = "error" - session.Error = "Authentication timed out after 15 minutes" - session.Done = true - session.mu.Unlock() - return - case <-ticker.C: - cred, err := auth.PollDeviceCodeOnce(oauthCfg, info.DeviceAuthID, info.UserCode) - if err != nil { - continue // Still pending - } - if cred != nil { - if saveErr := auth.SetCredential("openai", cred); saveErr != nil { - session.mu.Lock() - session.Status = "error" - session.Error = saveErr.Error() - session.Done = true - session.mu.Unlock() - return - } - updateConfigAfterLogin(configPath, "openai", cred) - session.mu.Lock() - session.Status = "success" - session.Done = true - session.mu.Unlock() - log.Printf("OpenAI device code login successful (account: %s)", cred.AccountID) - return - } - } - } - }() - - // Return device code info to frontend - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "status": "pending", - "device_url": info.VerifyURL, - "user_code": info.UserCode, - "message": "Open the URL and enter the code to authenticate.", - }) -} - -// handleAnthropicLogin saves a pasted API token for Anthropic. -func handleAnthropicLogin(w http.ResponseWriter, token, configPath string) { - if token == "" { - http.Error(w, "Token is required for Anthropic login", http.StatusBadRequest) - return - } - - cred := &auth.AuthCredential{ - AccessToken: token, - Provider: "anthropic", - AuthMethod: "token", - } - - if err := auth.SetCredential("anthropic", cred); err != nil { - http.Error(w, fmt.Sprintf("Failed to save credentials: %v", err), http.StatusInternalServerError) - return - } - - updateConfigAfterLogin(configPath, "anthropic", cred) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "status": "success", - "message": "Anthropic token saved", - }) -} - -// handleGoogleAntigravityLogin generates a PKCE + auth URL and returns it to the frontend. -func handleGoogleAntigravityLogin(w http.ResponseWriter, r *http.Request, configPath string) { - oauthCfg := auth.GoogleAntigravityOAuthConfig() - - pkce, err := auth.GeneratePKCE() - if err != nil { - http.Error(w, fmt.Sprintf("Failed to generate PKCE: %v", err), http.StatusInternalServerError) - return - } - - state, err := auth.GenerateState() - if err != nil { - http.Error(w, fmt.Sprintf("Failed to generate state: %v", err), http.StatusInternalServerError) - return - } - - // Build redirect URI pointing to picoclaw-launcher's own callback - scheme := "http" - redirectURI := fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host) - - authURL := auth.BuildAuthorizeURL(oauthCfg, pkce, state, redirectURI) - - // Store session for callback - oauthSessionsMu.Lock() - oauthSessions[state] = &oauthSession{ - Provider: "google-antigravity", - PKCE: pkce, - State: state, - RedirectURI: redirectURI, - OAuthCfg: oauthCfg, - ConfigPath: configPath, - } - oauthSessionsMu.Unlock() - - // Clean up stale sessions after 10 minutes - go func() { - time.Sleep(10 * time.Minute) - oauthSessionsMu.Lock() - delete(oauthSessions, state) - oauthSessionsMu.Unlock() - }() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "status": "redirect", - "auth_url": authURL, - "message": "Open the URL to authenticate with Google.", - }) -} - -// handleOAuthCallback processes the OAuth callback from Google Antigravity. -func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { - state := r.URL.Query().Get("state") - code := r.URL.Query().Get("code") - - oauthSessionsMu.Lock() - session, ok := oauthSessions[state] - if ok { - delete(oauthSessions, state) - } - oauthSessionsMu.Unlock() - - if !ok { - http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest) - return - } - - if code == "" { - errMsg := r.URL.Query().Get("error") - w.Header().Set("Content-Type", "text/html") - fmt.Fprintf( - w, - `

Authentication failed

%s

You can close this window.

`, - html.EscapeString(errMsg), - ) - return - } - - cred, err := auth.ExchangeCodeForTokens(session.OAuthCfg, code, session.PKCE.CodeVerifier, session.RedirectURI) - if err != nil { - w.Header().Set("Content-Type", "text/html") - fmt.Fprintf( - w, - `

Authentication failed

%s

You can close this window.

`, - html.EscapeString(err.Error()), - ) - return - } - - cred.Provider = session.Provider - - // Fetch user info for Google Antigravity - if session.Provider == "google-antigravity" { - if email, err := fetchGoogleUserEmail(cred.AccessToken); err == nil { - cred.Email = email - } - if projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken); err == nil { - cred.ProjectID = projectID - } - } - - if err := auth.SetCredential(session.Provider, cred); err != nil { - w.Header().Set("Content-Type", "text/html") - fmt.Fprintf( - w, - `

Failed to save credentials

%s

`, - html.EscapeString(err.Error()), - ) - return - } - - updateConfigAfterLogin(session.ConfigPath, session.Provider, cred) - - // Redirect back to picoclaw-launcher UI - w.Header().Set("Content-Type", "text/html") - fmt.Fprintf(w, ` -

Authentication successful!

-

Redirecting back to Config Editor...

- - `) -} - -// fetchGoogleUserEmail retrieves the user's email from Google's userinfo endpoint. -func fetchGoogleUserEmail(accessToken string) (string, error) { - req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil) - if err != nil { - return "", err - } - req.Header.Set("Authorization", "Bearer "+accessToken) - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("reading userinfo response: %w", err) - } - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("userinfo request failed: %s", string(body)) - } - - var userInfo struct { - Email string `json:"email"` - } - if err := json.Unmarshal(body, &userInfo); err != nil { - return "", err - } - return userInfo.Email, nil -} diff --git a/cmd/picoclaw-launcher/internal/server/logbuffer_test.go b/cmd/picoclaw-launcher/internal/server/logbuffer_test.go deleted file mode 100644 index dc525be16..000000000 --- a/cmd/picoclaw-launcher/internal/server/logbuffer_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package server - -import ( - "fmt" - "sync" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestLogBuffer_Basic(t *testing.T) { - buf := NewLogBuffer(5) - - // Empty buffer - lines, total, runID := buf.LinesSince(0) - assert.Nil(t, lines) - assert.Equal(t, 0, total) - assert.Equal(t, 0, runID) - - // Append some lines - buf.Append("line1") - buf.Append("line2") - buf.Append("line3") - - lines, total, runID = buf.LinesSince(0) - assert.Equal(t, []string{"line1", "line2", "line3"}, lines) - assert.Equal(t, 3, total) - assert.Equal(t, 0, runID) - - // Incremental read - lines, total, _ = buf.LinesSince(2) - assert.Equal(t, []string{"line3"}, lines) - assert.Equal(t, 3, total) - - // No new lines - lines, total, _ = buf.LinesSince(3) - assert.Nil(t, lines) - assert.Equal(t, 3, total) -} - -func TestLogBuffer_Wrap(t *testing.T) { - buf := NewLogBuffer(3) - - buf.Append("a") - buf.Append("b") - buf.Append("c") - buf.Append("d") // evicts "a" - buf.Append("e") // evicts "b" - - lines, total, _ := buf.LinesSince(0) - assert.Equal(t, []string{"c", "d", "e"}, lines) - assert.Equal(t, 5, total) - - // Incremental after wrap - lines, total, _ = buf.LinesSince(3) - assert.Equal(t, []string{"d", "e"}, lines) - assert.Equal(t, 5, total) - - // Offset too old (before buffer start), get all buffered - lines, total, _ = buf.LinesSince(1) - assert.Equal(t, []string{"c", "d", "e"}, lines) - assert.Equal(t, 5, total) -} - -func TestLogBuffer_Reset(t *testing.T) { - buf := NewLogBuffer(5) - - buf.Append("before") - assert.Equal(t, 0, buf.RunID()) - - buf.Reset() - assert.Equal(t, 1, buf.RunID()) - assert.Equal(t, 0, buf.Total()) - - lines, total, runID := buf.LinesSince(0) - assert.Nil(t, lines) - assert.Equal(t, 0, total) - assert.Equal(t, 1, runID) - - buf.Append("after") - lines, total, runID = buf.LinesSince(0) - assert.Equal(t, []string{"after"}, lines) - assert.Equal(t, 1, total) - assert.Equal(t, 1, runID) -} - -func TestLogBuffer_Concurrent(t *testing.T) { - buf := NewLogBuffer(100) - var wg sync.WaitGroup - - // 10 writers - for i := range 10 { - wg.Add(1) - go func(id int) { - defer wg.Done() - for j := range 50 { - buf.Append(fmt.Sprintf("writer-%d-line-%d", id, j)) - } - }(i) - } - - // 5 readers - for range 5 { - wg.Add(1) - go func() { - defer wg.Done() - for range 100 { - buf.LinesSince(0) - } - }() - } - - wg.Wait() - - assert.Equal(t, 500, buf.Total()) -} diff --git a/cmd/picoclaw-launcher/internal/server/process.go b/cmd/picoclaw-launcher/internal/server/process.go deleted file mode 100644 index bc2129bf5..000000000 --- a/cmd/picoclaw-launcher/internal/server/process.go +++ /dev/null @@ -1,232 +0,0 @@ -package server - -import ( - "bufio" - "encoding/json" - "fmt" - "io" - "log" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "time" - - "github.com/sipeed/picoclaw/pkg/config" -) - -// gatewayLogs stores captured stdout/stderr from the gateway process launched by the launcher. -var gatewayLogs = NewLogBuffer(200) - -// RegisterProcessAPI registers endpoints to start, stop and check status of the picoclaw gateway. -func RegisterProcessAPI(mux *http.ServeMux, absPath string) { - mux.HandleFunc("GET /api/process/status", func(w http.ResponseWriter, r *http.Request) { - handleStatusGateway(w, r, absPath) - }) - mux.HandleFunc("POST /api/process/start", handleStartGateway) - mux.HandleFunc("POST /api/process/stop", handleStopGateway) -} - -func handleStartGateway(w http.ResponseWriter, r *http.Request) { - // Locate picoclaw executable: - // 1. Try same directory as current executable - // 2. Fallback to just "picoclaw" (relies on $PATH) - execPath := "picoclaw" - - if exe, err := os.Executable(); err == nil { - dir := filepath.Dir(exe) - candidate := filepath.Join(dir, "picoclaw") - if runtime.GOOS == "windows" { - candidate += ".exe" - } - - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { - execPath = candidate - } - } - - cmd := exec.Command(execPath, "gateway") - - stdoutPipe, err := cmd.StdoutPipe() - if err != nil { - log.Printf("Failed to create stdout pipe: %v\n", err) - http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) - return - } - - stderrPipe, err := cmd.StderrPipe() - if err != nil { - log.Printf("Failed to create stderr pipe: %v\n", err) - http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) - return - } - - // Clear old logs and increment runID before starting - gatewayLogs.Reset() - - if err := cmd.Start(); err != nil { - log.Printf("Failed to start picoclaw gateway: %v\n", err) - http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) - return - } - - // Read stdout and stderr into the log buffer - go scanPipe(stdoutPipe, gatewayLogs) - go scanPipe(stderrPipe, gatewayLogs) - - // Wait for the process to exit in the background to avoid zombies - go func() { - if err := cmd.Wait(); err != nil { - log.Printf("Gateway process exited: %v\n", err) - } - }() - - log.Printf("Started picoclaw gateway (PID: %d) from %s\n", cmd.Process.Pid, execPath) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "status": "ok", - "pid": cmd.Process.Pid, - }) -} - -// scanPipe reads lines from r and appends them to buf. It returns when r reaches EOF. -func scanPipe(r io.Reader, buf *LogBuffer) { - scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // up to 1MB per line - - for scanner.Scan() { - buf.Append(scanner.Text()) - } -} - -func handleStopGateway(w http.ResponseWriter, r *http.Request) { - var err error - if runtime.GOOS == "windows" { - // Kill via taskkill finding picoclaw.exe (though it might kill this config tool if it's named picoclaw-launcher.exe...? No, /IM does exact match usually, but just to be safe let's stop exactly picoclaw.exe) - // Alternatively, we use powershell to kill processes with commandline containing 'gateway' - psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }` - err = exec.Command("powershell", "-Command", psCmd).Run() - } else { - // Linux/macOS - err = exec.Command("pkill", "-f", "picoclaw gateway").Run() - } - - if err != nil { - log.Printf("Warning: Failed to stop gateway (perhaps not running?): %v\n", err) - // We still return 200 OK because pkill returns an error if no process was found - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "status": "ok", // or "not_found" - "msg": "Stop command executed, but returned error (process might not be running).", - "error": err.Error(), - }) - return - } - - log.Printf("Stopped picoclaw gateway processes.\n") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "status": "ok", - }) -} - -func handleStatusGateway(w http.ResponseWriter, r *http.Request, absPath string) { - cfg, cfgErr := config.LoadConfig(absPath) - host := "127.0.0.1" - port := 18790 - if cfgErr == nil && cfg != nil { - if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" { - host = cfg.Gateway.Host - } - if cfg.Gateway.Port != 0 { - port = cfg.Gateway.Port - } - } - - url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port))) - client := http.Client{Timeout: 2 * time.Second} - resp, err := client.Get(url) - - // Build the response data map - data := map[string]any{} - - if err != nil { - data["process_status"] = "stopped" - data["error"] = err.Error() - } else { - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - data["process_status"] = "error" - data["status_code"] = resp.StatusCode - } else { - var healthData map[string]any - if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil { - data["process_status"] = "error" - data["error"] = "invalid response from gateway" - } else { - // Gateway is running and responded properly — merge health data - for k, v := range healthData { - data[k] = v - } - data["process_status"] = "running" - } - } - } - - // Append log data from the buffer - appendLogData(r, data) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(data) -} - -// appendLogData reads log_offset and log_run_id query params from the request and -// populates the response data map with incremental log lines. -func appendLogData(r *http.Request, data map[string]any) { - clientOffset := 0 - clientRunID := -1 - - if v := r.URL.Query().Get("log_offset"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - clientOffset = n - } - } - - if v := r.URL.Query().Get("log_run_id"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - clientRunID = n - } - } - - runID := gatewayLogs.RunID() - - // If runID is 0 (never reset = never launched from this launcher), report no source - if runID == 0 { - data["logs"] = []string{} - data["log_total"] = 0 - data["log_run_id"] = 0 - data["log_source"] = "none" - return - } - - // If the client's runID doesn't match, send all buffered lines (gateway restarted) - offset := clientOffset - if clientRunID != runID { - offset = 0 - } - - lines, total, runID := gatewayLogs.LinesSince(offset) - if lines == nil { - lines = []string{} - } - - data["logs"] = lines - data["log_total"] = total - data["log_run_id"] = runID - data["log_source"] = "launcher" -} diff --git a/cmd/picoclaw-launcher/internal/server/server.go b/cmd/picoclaw-launcher/internal/server/server.go deleted file mode 100644 index a0034081a..000000000 --- a/cmd/picoclaw-launcher/internal/server/server.go +++ /dev/null @@ -1,210 +0,0 @@ -package server - -import ( - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "time" - - "github.com/sipeed/picoclaw/pkg/auth" - "github.com/sipeed/picoclaw/pkg/config" -) - -const DefaultPort = "18800" - -// providerStatus represents the auth status of a single provider in API responses. -type providerStatus struct { - Provider string `json:"provider"` - AuthMethod string `json:"auth_method"` - Status string `json:"status"` - AccountID string `json:"account_id,omitempty"` - Email string `json:"email,omitempty"` - ProjectID string `json:"project_id,omitempty"` - ExpiresAt string `json:"expires_at,omitempty"` -} - -// ── Route registration ─────────────────────────────────────────── - -func RegisterConfigAPI(mux *http.ServeMux, absPath string) { - // GET /api/config — read config - mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) { - cfg, err := config.LoadConfig(absPath) - if err != nil { - log.Printf("Failed to load config: %v", err) - http.Error(w, "Failed to load config", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - resp := map[string]any{ - "config": cfg, - "path": absPath, - } - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - if err := enc.Encode(resp); err != nil { - log.Printf("Failed to encode response: %v", err) - } - }) - - // PUT /api/config — save config - mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) - if err != nil { - http.Error(w, "Failed to read request body", http.StatusBadRequest) - return - } - defer r.Body.Close() - - var cfg config.Config - if err := json.Unmarshal(body, &cfg); err != nil { - http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) - return - } - - if err := config.SaveConfig(absPath, &cfg); err != nil { - log.Printf("Failed to save config: %v", err) - http.Error(w, "Failed to save config", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) - }) -} - -func RegisterAuthAPI(mux *http.ServeMux, absPath string) { - // GET /api/auth/status — all authenticated providers + pending login state - mux.HandleFunc("GET /api/auth/status", func(w http.ResponseWriter, r *http.Request) { - store, err := auth.LoadStore() - if err != nil { - log.Printf("Failed to load auth store: %v", err) - http.Error(w, "Failed to load auth store", http.StatusInternalServerError) - return - } - - result := []providerStatus{} - for name, cred := range store.Credentials { - status := "active" - if cred.IsExpired() { - status = "expired" - } else if cred.NeedsRefresh() { - status = "needs_refresh" - } - ps := providerStatus{ - Provider: name, - AuthMethod: cred.AuthMethod, - Status: status, - AccountID: cred.AccountID, - Email: cred.Email, - ProjectID: cred.ProjectID, - } - if !cred.ExpiresAt.IsZero() { - ps.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339) - } - result = append(result, ps) - } - - // Include pending device code state - var pendingDevice map[string]any - activeDeviceSessionMu.Lock() - if activeDeviceSession != nil { - activeDeviceSession.mu.Lock() - pendingDevice = map[string]any{ - "provider": activeDeviceSession.Provider, - "status": activeDeviceSession.Status, - "device_url": activeDeviceSession.Info.VerifyURL, - "user_code": activeDeviceSession.Info.UserCode, - } - if activeDeviceSession.Error != "" { - pendingDevice["error"] = activeDeviceSession.Error - } - if activeDeviceSession.Done { - activeDeviceSession.mu.Unlock() - activeDeviceSession = nil - } else { - activeDeviceSession.mu.Unlock() - } - } - activeDeviceSessionMu.Unlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "providers": result, - "pending_device": pendingDevice, - }) - }) - - // POST /api/auth/login — initiate provider login - mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) { - var req struct { - Provider string `json:"provider"` - Token string `json:"token,omitempty"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - switch req.Provider { - case "openai": - handleOpenAILogin(w, absPath) - case "anthropic": - handleAnthropicLogin(w, req.Token, absPath) - case "google-antigravity", "antigravity": - handleGoogleAntigravityLogin(w, r, absPath) - default: - http.Error( - w, - fmt.Sprintf( - "Unsupported provider: %s (supported: openai, anthropic, google-antigravity)", - req.Provider, - ), - http.StatusBadRequest, - ) - } - }) - - // POST /api/auth/logout — logout a provider - mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) { - var req struct { - Provider string `json:"provider"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.Provider == "" { - if err := auth.DeleteAllCredentials(); err != nil { - http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError) - return - } - clearAllAuthMethodsInConfig(absPath) - } else { - if err := auth.DeleteCredential(req.Provider); err != nil { - http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError) - return - } - clearAuthMethodInConfig(absPath, req.Provider) - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) - }) - - // GET /auth/callback — OAuth browser callback for Google Antigravity - mux.HandleFunc("GET /auth/callback", handleOAuthCallback) -} - -// SecurityHeaders wraps an http.Handler to add standard security headers. -func SecurityHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("X-Frame-Options", "DENY") - w.Header(). - Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'") - next.ServeHTTP(w, r) - }) -} diff --git a/cmd/picoclaw-launcher/internal/server/server_test.go b/cmd/picoclaw-launcher/internal/server/server_test.go deleted file mode 100644 index c87e93d8c..000000000 --- a/cmd/picoclaw-launcher/internal/server/server_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package server - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/sipeed/picoclaw/pkg/config" -) - -// ── Config API tests ───────────────────────────────────────────── - -func setupConfigMux(t *testing.T, cfg *config.Config) (*http.ServeMux, string) { - t.Helper() - dir := t.TempDir() - path := filepath.Join(dir, "config.json") - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - t.Fatalf("marshal config: %v", err) - } - if err := os.WriteFile(path, data, 0o600); err != nil { - t.Fatalf("write config: %v", err) - } - - mux := http.NewServeMux() - RegisterConfigAPI(mux, path) - RegisterAuthAPI(mux, path) - return mux, path -} - -func TestGetConfig(t *testing.T) { - cfg := &config.Config{ - ModelList: []config.ModelConfig{ - {ModelName: "gpt-4o", Model: "openai/gpt-4o"}, - }, - } - mux, path := setupConfigMux(t, cfg) - - req := httptest.NewRequest("GET", "/api/config", nil) - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("GET /api/config: expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp struct { - Config config.Config `json:"config"` - Path string `json:"path"` - } - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode response: %v", err) - } - - if resp.Path != path { - t.Errorf("expected path %q, got %q", path, resp.Path) - } - if len(resp.Config.ModelList) != 1 { - t.Errorf("expected 1 model, got %d", len(resp.Config.ModelList)) - } -} - -func TestGetConfig_MissingFile_ReturnsDefault(t *testing.T) { - mux := http.NewServeMux() - RegisterConfigAPI(mux, "/tmp/nonexistent-picoclaw-launcher-test/config.json") - - req := httptest.NewRequest("GET", "/api/config", nil) - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - // LoadConfig returns a default empty config when file is missing - if w.Code != http.StatusOK { - t.Errorf("expected 200 for missing file (default config), got %d", w.Code) - } -} - -func TestPutConfig(t *testing.T) { - cfg := &config.Config{} - mux, path := setupConfigMux(t, cfg) - - newCfg := config.Config{ - ModelList: []config.ModelConfig{ - {ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"}, - }, - } - body, _ := json.Marshal(newCfg) - - req := httptest.NewRequest("PUT", "/api/config", strings.NewReader(string(body))) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("PUT /api/config: expected 200, got %d: %s", w.Code, w.Body.String()) - } - - saved, err := config.LoadConfig(path) - if err != nil { - t.Fatalf("load saved config: %v", err) - } - if len(saved.ModelList) != 1 { - t.Fatalf("expected 1 model saved, got %d", len(saved.ModelList)) - } - if saved.ModelList[0].Model != "anthropic/claude-sonnet-4.6" { - t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", saved.ModelList[0].Model) - } -} - -func TestPutConfig_InvalidJSON(t *testing.T) { - cfg := &config.Config{} - mux, _ := setupConfigMux(t, cfg) - - req := httptest.NewRequest("PUT", "/api/config", strings.NewReader("{invalid")) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 for invalid JSON, got %d", w.Code) - } -} - -// ── Auth API tests ─────────────────────────────────────────────── - -func TestAuthStatus(t *testing.T) { - cfg := &config.Config{} - mux, _ := setupConfigMux(t, cfg) - - req := httptest.NewRequest("GET", "/api/auth/status", nil) - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("GET /api/auth/status: expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp struct { - Providers []providerStatus `json:"providers"` - PendingDevice map[string]any `json:"pending_device"` - } - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode response: %v", err) - } - - // providers should be a non-nil list (could be empty) - if resp.Providers == nil { - t.Error("providers should not be nil") - } -} - -func TestAuthLogin_UnsupportedProvider(t *testing.T) { - cfg := &config.Config{} - mux, _ := setupConfigMux(t, cfg) - - body := `{"provider": "unsupported"}` - req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 for unsupported provider, got %d", w.Code) - } -} - -func TestAuthLogin_AnthropicNoToken(t *testing.T) { - cfg := &config.Config{} - mux, _ := setupConfigMux(t, cfg) - - body := `{"provider": "anthropic"}` - req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 for anthropic without token, got %d", w.Code) - } -} - -func TestAuthLogin_InvalidBody(t *testing.T) { - cfg := &config.Config{} - mux, _ := setupConfigMux(t, cfg) - - req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader("{bad")) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 for invalid JSON body, got %d", w.Code) - } -} - -func TestAuthLogout_InvalidBody(t *testing.T) { - cfg := &config.Config{} - mux, _ := setupConfigMux(t, cfg) - - req := httptest.NewRequest("POST", "/api/auth/logout", strings.NewReader("{bad")) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 for invalid body, got %d", w.Code) - } -} - -func TestOAuthCallback_InvalidState(t *testing.T) { - cfg := &config.Config{} - mux, _ := setupConfigMux(t, cfg) - - req := httptest.NewRequest("GET", "/auth/callback?state=invalid&code=test", nil) - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400 for invalid state, got %d", w.Code) - } -} - -// ── Utility tests ──────────────────────────────────────────────── - -func TestDefaultConfigPath(t *testing.T) { - path := DefaultConfigPath() - if path == "" { - t.Error("defaultConfigPath should not return empty") - } - if !strings.HasSuffix(path, filepath.Join(".picoclaw", "config.json")) { - t.Errorf("expected path ending with .picoclaw/config.json, got %q", path) - } -} - -func TestGetLocalIP(t *testing.T) { - // Just ensure it doesn't panic; IP may or may not be available - ip := GetLocalIP() - if ip != "" { - // If returned, should look like an IP - if !strings.Contains(ip, ".") { - t.Errorf("getLocalIP returned non-IPv4 looking string: %q", ip) - } - } -} diff --git a/cmd/picoclaw-launcher/internal/server/utils.go b/cmd/picoclaw-launcher/internal/server/utils.go deleted file mode 100644 index a46adbece..000000000 --- a/cmd/picoclaw-launcher/internal/server/utils.go +++ /dev/null @@ -1,28 +0,0 @@ -package server - -import ( - "net" - "os" - "path/filepath" -) - -func DefaultConfigPath() string { - home, err := os.UserHomeDir() - if err != nil { - return "config.json" - } - return filepath.Join(home, ".picoclaw", "config.json") -} - -func GetLocalIP() string { - addrs, err := net.InterfaceAddrs() - if err != nil { - return "" - } - for _, a := range addrs { - if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { - return ipnet.IP.String() - } - } - return "" -} diff --git a/cmd/picoclaw-launcher/internal/ui/index.html b/cmd/picoclaw-launcher/internal/ui/index.html deleted file mode 100644 index e77ef4fea..000000000 --- a/cmd/picoclaw-launcher/internal/ui/index.html +++ /dev/null @@ -1,2009 +0,0 @@ - - - - - - - - PicoClaw Config - - - - - - - - - -
-
- -

PicoClaw Config

-
-
- - -
-
- - -
-
- -
- -
-
- - - - -
-
-
Models
-
Manage LLM model configurations. Models without an API key are grayed out. Only available models can be set as primary.
-
- -
-
-
- -
-
Provider Authentication
-
-
-
-
- OpenAI - Not logged in -
-
-
- -
-
-
-
- Anthropic - Not logged in -
-
-
- -
-
-
-
- Google Antigravity - Not logged in -
-
-
- -
-
-
-
- - -
-
-
-
-
-
-
-
-
-
-
-
-
- - -
-
Gateway Logs
-
Real-time output from the gateway process.
-
-
- -
-
- -
-
-
-
No logs available. Start the gateway to see output here.
-
-
- - -
-
Raw JSON
-
Directly edit the configuration file.
-
- config.json - - -
-
- - - -
-
- -
-
-
-
-
-
-
-
- - - - -
- PicoClaw Config - - -
- - - - - diff --git a/cmd/picoclaw-launcher/main.go b/cmd/picoclaw-launcher/main.go deleted file mode 100644 index 3774ebe7d..000000000 --- a/cmd/picoclaw-launcher/main.go +++ /dev/null @@ -1,127 +0,0 @@ -// PicoClaw Launcher - Standalone HTTP service -// -// Provides a web-based JSON editor for picoclaw config files, -// with OAuth provider authentication support. -// -// Usage: -// -// go build -o picoclaw-launcher ./cmd/picoclaw-launcher/ -// ./picoclaw-launcher [config.json] -// ./picoclaw-launcher -public config.json - -package main - -import ( - "embed" - "flag" - "fmt" - "io/fs" - "log" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "time" - - "github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server" -) - -//go:embed internal/ui/index.html -var staticFiles embed.FS - -func main() { - public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only") - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n") - fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0]) - fmt.Fprintf(os.Stderr, "Arguments:\n") - fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n") - fmt.Fprintf(os.Stderr, "Options:\n") - flag.PrintDefaults() - fmt.Fprintf(os.Stderr, "\nExamples:\n") - fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0]) - fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0]) - fmt.Fprintf( - os.Stderr, - " %s -public ./config.json Allow access from other devices on the network\n", - os.Args[0], - ) - } - flag.Parse() - - configPath := server.DefaultConfigPath() - if flag.NArg() > 0 { - configPath = flag.Arg(0) - } - - absPath, err := filepath.Abs(configPath) - if err != nil { - log.Fatalf("Failed to resolve config path: %v", err) - } - - var addr string - if *public { - addr = "0.0.0.0:" + server.DefaultPort - } else { - addr = "127.0.0.1:" + server.DefaultPort - } - - mux := http.NewServeMux() - server.RegisterConfigAPI(mux, absPath) - server.RegisterAuthAPI(mux, absPath) - server.RegisterProcessAPI(mux, absPath) - - staticFS, err := fs.Sub(staticFiles, "internal/ui") - if err != nil { - log.Fatalf("Failed to create sub filesystem: %v", err) - } - mux.Handle("/", http.FileServer(http.FS(staticFS))) - - // Print startup banner - fmt.Println("=============================================") - fmt.Println(" PicoClaw Launcher") - fmt.Println("=============================================") - fmt.Printf(" Config file : %s\n", absPath) - fmt.Printf(" Listen addr : %s\n\n", addr) - fmt.Println(" Open the following URL in your browser") - fmt.Println(" to view and edit the configuration:") - fmt.Println() - fmt.Printf(" >> http://localhost:%s <<\n", server.DefaultPort) - if *public { - if ip := server.GetLocalIP(); ip != "" { - fmt.Printf(" >> http://%s:%s <<\n", ip, server.DefaultPort) - } - } - fmt.Println() - // fmt.Println("=============================================") - - go func() { - // Wait briefly to ensure the server is ready before opening the browser - time.Sleep(500 * time.Millisecond) - url := "http://localhost:" + server.DefaultPort - if err := openBrowser(url); err != nil { - log.Printf("Warning: Failed to auto-open browser: %v\n", err) - } - }() - - if err := http.ListenAndServe(addr, server.SecurityHeaders(mux)); err != nil { - log.Fatalf("Server failed: %v", err) - } -} - -// openBrowser automatically opens the given URL in the default browser. -func openBrowser(url string) error { - var err error - switch runtime.GOOS { - case "linux": - err = exec.Command("xdg-open", url).Start() - case "windows": - err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - case "darwin": - err = exec.Command("open", url).Start() - default: - err = fmt.Errorf("unsupported platform") - } - return err -} diff --git a/cmd/picoclaw/dns_noresolv.go b/cmd/picoclaw/dns_noresolv.go new file mode 100644 index 000000000..ba4ae1f4f --- /dev/null +++ b/cmd/picoclaw/dns_noresolv.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "net" + "net/http" + "os" + "strings" + "sync/atomic" + "time" +) + +func init() { + // 仅在 /etc/resolv.conf 不存在时才覆盖(即 Android 环境) + if _, err := os.Stat("/etc/resolv.conf"); err == nil { + return + } + + // 从环境变量获取 DNS server 列表,多个用 ; 隔开 + // 例如: PICOCLAW_DNS_SERVER="8.8.8.8:53;1.1.1.1:53;223.5.5.5:53" + dnsEnv := os.Getenv("PICOCLAW_DNS_SERVER") + if dnsEnv == "" { + dnsEnv = "8.8.8.8:53;1.1.1.1:53" + } + + var dnsServers []string + for _, s := range strings.Split(dnsEnv, ";") { + s = strings.TrimSpace(s) + if s != "" { + // 如果没有带端口号,自动补上 :53 + if _, _, err := net.SplitHostPort(s); err != nil { + s = s + ":53" + } + dnsServers = append(dnsServers, s) + } + } + + // 轮询索引,在多个 DNS 服务器之间轮转 + var idx uint64 + + customResolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 5 * time.Second} + // Round-robin: 依次尝试不同的 DNS 服务器 + server := dnsServers[atomic.AddUint64(&idx, 1)%uint64(len(dnsServers))] + return d.DialContext(ctx, "udp", server) + }, + } + + // 覆盖全局 DefaultResolver + net.DefaultResolver = customResolver + + // 覆盖 http.DefaultTransport 使用自定义 DNS 解析的 DialContext + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + Resolver: customResolver, + } + + if tr, ok := http.DefaultTransport.(*http.Transport); ok { + tr.DialContext = dialer.DialContext + } +} diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index d63aa6014..c14e9e50d 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -9,7 +9,7 @@ import ( "path/filepath" "strings" - "github.com/chzyer/readline" + "github.com/ergochat/readline" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/agent" @@ -29,11 +29,6 @@ func agentCmd(message, sessionKey, model string, debug bool, sessionKey = "agent:main:cli:" + sessionKey } - if debug { - logger.SetLevel(logger.DEBUG) - fmt.Println("🔍 Debug mode enabled") - } - cfg, err := internal.LoadConfig() if err != nil { return fmt.Errorf("error loading config: %w", err) @@ -62,6 +57,13 @@ func agentCmd(message, sessionKey, model string, debug bool, } } + logger.ConfigureFromEnv() + + if debug { + logger.SetLevel(logger.DEBUG) + fmt.Println("🔍 Debug mode enabled") + } + // CLI flags win over workspace config if model != "" { cfg.Agents.Defaults.ModelName = model @@ -104,6 +106,7 @@ func agentCmd(message, sessionKey, model string, debug bool, msgBus := bus.NewMessageBus() defer msgBus.Close() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + defer agentLoop.Close() // Copy bootstrap files from config-dir to workspace if configDir != "" { diff --git a/cmd/picoclaw/internal/auth/command.go b/cmd/picoclaw/internal/auth/command.go index 12a0a3a8c..9de083d8d 100644 --- a/cmd/picoclaw/internal/auth/command.go +++ b/cmd/picoclaw/internal/auth/command.go @@ -16,6 +16,8 @@ func NewAuthCommand() *cobra.Command { newLogoutCommand(), newStatusCommand(), newModelsCommand(), + newWeixinCommand(), + newWeComCommand(), ) return cmd diff --git a/cmd/picoclaw/internal/auth/command_test.go b/cmd/picoclaw/internal/auth/command_test.go index 48dc704dd..3c7f2d3d6 100644 --- a/cmd/picoclaw/internal/auth/command_test.go +++ b/cmd/picoclaw/internal/auth/command_test.go @@ -32,6 +32,8 @@ func TestNewAuthCommand(t *testing.T) { "logout", "status", "models", + "weixin", + "wecom", } subcommands := cmd.Commands() diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go index a0a229167..10bb3a11c 100644 --- a/cmd/picoclaw/internal/auth/helpers.go +++ b/cmd/picoclaw/internal/auth/helpers.go @@ -17,24 +17,24 @@ import ( ) const ( - supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity" + supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity, antigravity" defaultAnthropicModel = "claude-sonnet-4.6" ) -func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error { +func authLoginCmd(provider string, useDeviceCode bool, useOauth bool, noBrowser bool) error { switch provider { case "openai": - return authLoginOpenAI(useDeviceCode) + return authLoginOpenAI(useDeviceCode, noBrowser) case "anthropic": return authLoginAnthropic(useOauth) case "google-antigravity", "antigravity": - return authLoginGoogleAntigravity() + return authLoginGoogleAntigravity(noBrowser) default: return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg) } } -func authLoginOpenAI(useDeviceCode bool) error { +func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error { cfg := auth.OpenAIOAuthConfig() var cred *auth.AuthCredential @@ -43,7 +43,7 @@ func authLoginOpenAI(useDeviceCode bool) error { if useDeviceCode { cred, err = auth.LoginDeviceCode(cfg) } else { - cred, err = auth.LoginBrowser(cfg) + cred, err = auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser}) } if err != nil { @@ -56,13 +56,10 @@ func authLoginOpenAI(useDeviceCode bool) error { appCfg, err := internal.LoadConfig() if err == nil { - // Update Providers (legacy format) - appCfg.Providers.OpenAI.AuthMethod = "oauth" - // Update or add openai in ModelList foundOpenAI := false for i := range appCfg.ModelList { - if isOpenAIModel(appCfg.ModelList[i].Model) { + if isOpenAIModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "oauth" foundOpenAI = true break @@ -71,15 +68,15 @@ func authLoginOpenAI(useDeviceCode bool) error { // If no openai in ModelList, add it if !foundOpenAI { - appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ - ModelName: "gpt-5.2", - Model: "openai/gpt-5.2", + appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{ + ModelName: "gpt-5.4", + Model: "openai/gpt-5.4", AuthMethod: "oauth", }) } // Update default model to use OpenAI - appCfg.Agents.Defaults.ModelName = "gpt-5.2" + appCfg.Agents.Defaults.ModelName = "gpt-5.4" if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { return fmt.Errorf("could not update config: %w", err) @@ -90,15 +87,15 @@ func authLoginOpenAI(useDeviceCode bool) error { if cred.AccountID != "" { fmt.Printf("Account: %s\n", cred.AccountID) } - fmt.Println("Default model set to: gpt-5.2") + fmt.Println("Default model set to: gpt-5.4") return nil } -func authLoginGoogleAntigravity() error { +func authLoginGoogleAntigravity(noBrowser bool) error { cfg := auth.GoogleAntigravityOAuthConfig() - cred, err := auth.LoginBrowser(cfg) + cred, err := auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser}) if err != nil { return fmt.Errorf("login failed: %w", err) } @@ -130,13 +127,10 @@ func authLoginGoogleAntigravity() error { appCfg, err := internal.LoadConfig() if err == nil { - // Update Providers (legacy format, for backward compatibility) - appCfg.Providers.Antigravity.AuthMethod = "oauth" - // Update or add antigravity in ModelList foundAntigravity := false for i := range appCfg.ModelList { - if isAntigravityModel(appCfg.ModelList[i].Model) { + if isAntigravityModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "oauth" foundAntigravity = true break @@ -145,7 +139,7 @@ func authLoginGoogleAntigravity() error { // If no antigravity in ModelList, add it if !foundAntigravity { - appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ + appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{ ModelName: "gemini-flash", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth", @@ -210,18 +204,16 @@ func authLoginAnthropicSetupToken() error { appCfg, err := internal.LoadConfig() if err == nil { - appCfg.Providers.Anthropic.AuthMethod = "oauth" - found := false for i := range appCfg.ModelList { - if isAnthropicModel(appCfg.ModelList[i].Model) { + if isAnthropicModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "oauth" found = true break } } if !found { - appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ + appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{ ModelName: defaultAnthropicModel, Model: "anthropic/" + defaultAnthropicModel, AuthMethod: "oauth", @@ -287,18 +279,17 @@ func authLoginPasteToken(provider string) error { if err == nil { switch provider { case "anthropic": - appCfg.Providers.Anthropic.AuthMethod = "token" // Update ModelList found := false for i := range appCfg.ModelList { - if isAnthropicModel(appCfg.ModelList[i].Model) { + if isAnthropicModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "token" found = true break } } if !found { - appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ + appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{ ModelName: defaultAnthropicModel, Model: "anthropic/" + defaultAnthropicModel, AuthMethod: "token", @@ -306,25 +297,24 @@ func authLoginPasteToken(provider string) error { appCfg.Agents.Defaults.ModelName = defaultAnthropicModel } case "openai": - appCfg.Providers.OpenAI.AuthMethod = "token" // Update ModelList found := false for i := range appCfg.ModelList { - if isOpenAIModel(appCfg.ModelList[i].Model) { + if isOpenAIModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "token" found = true break } } if !found { - appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ - ModelName: "gpt-5.2", - Model: "openai/gpt-5.2", + appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{ + ModelName: "gpt-5.4", + Model: "openai/gpt-5.4", AuthMethod: "token", }) } // Update default model - appCfg.Agents.Defaults.ModelName = "gpt-5.2" + appCfg.Agents.Defaults.ModelName = "gpt-5.4" } if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { return fmt.Errorf("could not update config: %w", err) @@ -352,28 +342,19 @@ func authLogoutCmd(provider string) error { for i := range appCfg.ModelList { switch provider { case "openai": - if isOpenAIModel(appCfg.ModelList[i].Model) { + if isOpenAIModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "" } case "anthropic": - if isAnthropicModel(appCfg.ModelList[i].Model) { + if isAnthropicModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "" } case "google-antigravity", "antigravity": - if isAntigravityModel(appCfg.ModelList[i].Model) { + if isAntigravityModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "" } } } - // Clear AuthMethod in Providers (legacy) - switch provider { - case "openai": - appCfg.Providers.OpenAI.AuthMethod = "" - case "anthropic": - appCfg.Providers.Anthropic.AuthMethod = "" - case "google-antigravity", "antigravity": - appCfg.Providers.Antigravity.AuthMethod = "" - } config.SaveConfig(internal.GetConfigPath(), appCfg) } @@ -392,10 +373,6 @@ func authLogoutCmd(provider string) error { for i := range appCfg.ModelList { appCfg.ModelList[i].AuthMethod = "" } - // Clear all AuthMethods in Providers (legacy) - appCfg.Providers.OpenAI.AuthMethod = "" - appCfg.Providers.Anthropic.AuthMethod = "" - appCfg.Providers.Antigravity.AuthMethod = "" config.SaveConfig(internal.GetConfigPath(), appCfg) } @@ -507,22 +484,20 @@ func authModelsCmd() error { return nil } -// isAntigravityModel checks if a model string belongs to antigravity provider -func isAntigravityModel(model string) bool { - return model == "antigravity" || - model == "google-antigravity" || - strings.HasPrefix(model, "antigravity/") || - strings.HasPrefix(model, "google-antigravity/") +// isAntigravityModel checks if a model config belongs to an Antigravity provider. +func isAntigravityModel(modelCfg *config.ModelConfig) bool { + protocol, _ := providers.ExtractProtocol(modelCfg) + return protocol == "antigravity" || protocol == "google-antigravity" } -// isOpenAIModel checks if a model string belongs to openai provider -func isOpenAIModel(model string) bool { - return model == "openai" || - strings.HasPrefix(model, "openai/") +// isOpenAIModel checks if a model config belongs to the OpenAI provider. +func isOpenAIModel(modelCfg *config.ModelConfig) bool { + protocol, _ := providers.ExtractProtocol(modelCfg) + return protocol == "openai" } -// isAnthropicModel checks if a model string belongs to anthropic provider -func isAnthropicModel(model string) bool { - return model == "anthropic" || - strings.HasPrefix(model, "anthropic/") +// isAnthropicModel checks if a model config belongs to the Anthropic provider. +func isAnthropicModel(modelCfg *config.ModelConfig) bool { + protocol, _ := providers.ExtractProtocol(modelCfg) + return protocol == "anthropic" } diff --git a/cmd/picoclaw/internal/auth/login.go b/cmd/picoclaw/internal/auth/login.go index afbe098aa..b9b44db34 100644 --- a/cmd/picoclaw/internal/auth/login.go +++ b/cmd/picoclaw/internal/auth/login.go @@ -7,6 +7,7 @@ func newLoginCommand() *cobra.Command { provider string useDeviceCode bool useOauth bool + noBrowser bool ) cmd := &cobra.Command{ @@ -14,12 +15,15 @@ func newLoginCommand() *cobra.Command { Short: "Login via OAuth or paste token", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return authLoginCmd(provider, useDeviceCode, useOauth) + return authLoginCmd(provider, useDeviceCode, useOauth, noBrowser) }, } - cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)") + cmd.Flags().StringVarP( + &provider, "provider", "p", "", "Provider to login with (openai, anthropic, google-antigravity, antigravity)", + ) cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)") + cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Do not auto-open a browser during OAuth login") cmd.Flags().BoolVar( &useOauth, "setup-token", false, "Use setup-token flow for Anthropic (from `claude setup-token`)", diff --git a/cmd/picoclaw/internal/auth/login_test.go b/cmd/picoclaw/internal/auth/login_test.go index d6a03c25b..5129d9aaf 100644 --- a/cmd/picoclaw/internal/auth/login_test.go +++ b/cmd/picoclaw/internal/auth/login_test.go @@ -18,6 +18,7 @@ func TestNewLoginSubCommand(t *testing.T) { assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("device-code")) + assert.NotNil(t, cmd.Flags().Lookup("no-browser")) providerFlag := cmd.Flags().Lookup("provider") require.NotNil(t, providerFlag) diff --git a/cmd/picoclaw/internal/auth/status_test.go b/cmd/picoclaw/internal/auth/status_test.go index 7748ba502..2f9a70721 100644 --- a/cmd/picoclaw/internal/auth/status_test.go +++ b/cmd/picoclaw/internal/auth/status_test.go @@ -1,12 +1,53 @@ package auth import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + pkgauth "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" ) +func captureAuthStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + t.Cleanup(func() { + os.Stdout = oldStdout + }) + + fn() + + require.NoError(t, w.Close()) + os.Stdout = oldStdout + + var buf bytes.Buffer + _, err = io.Copy(&buf, r) + require.NoError(t, err) + require.NoError(t, r.Close()) + return buf.String() +} + +func setAuthStatusTestHome(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw")) + return tmpDir +} + func TestNewStatusSubcommand(t *testing.T) { cmd := newStatusCommand() @@ -16,3 +57,47 @@ func TestNewStatusSubcommand(t *testing.T) { assert.False(t, cmd.HasFlags()) } + +func TestAuthStatusCmdShowsCanonicalGoogleAntigravityAfterLegacyRefresh(t *testing.T) { + tmpDir := setAuthStatusTestHome(t) + + legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": legacyExpiry.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "project_id": "legacy-project", + }, + }, + } + data, err := json.Marshal(legacyStore) + require.NoError(t, err) + + authPath := filepath.Join(tmpDir, ".picoclaw", "auth.json") + require.NoError(t, os.MkdirAll(filepath.Dir(authPath), 0o755)) + require.NoError(t, os.WriteFile(authPath, data, 0o600)) + + refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC) + err = pkgauth.SetCredential("google-antigravity", &pkgauth.AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: refreshedExpiry, + Provider: "google-antigravity", + AuthMethod: "oauth", + ProjectID: "fresh-project", + }) + require.NoError(t, err) + + output := captureAuthStdout(t, func() { + require.NoError(t, authStatusCmd()) + }) + + assert.Contains(t, output, "\nAuthenticated Providers:") + assert.Contains(t, output, "\n google-antigravity:\n") + assert.NotContains(t, output, "\n antigravity:\n") + assert.Contains(t, output, " Project: fresh-project") + assert.Contains(t, output, " Expires: 2026-04-16 12:30") + assert.Equal(t, 1, strings.Count(output, ":\n Method: oauth")) +} diff --git a/cmd/picoclaw/internal/auth/wecom.go b/cmd/picoclaw/internal/auth/wecom.go new file mode 100644 index 000000000..4b335f8cb --- /dev/null +++ b/cmd/picoclaw/internal/auth/wecom.go @@ -0,0 +1,428 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "runtime" + "strconv" + "strings" + "time" + + "github.com/mdp/qrterminal/v3" + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const ( + wecomQRSourceID = "picoclaw" + wecomQRGenerateEndpoint = "https://work.weixin.qq.com/ai/qc/generate" + wecomQRQueryEndpoint = "https://work.weixin.qq.com/ai/qc/query_result" + wecomQRPageEndpoint = "https://work.weixin.qq.com/ai/qc/gen" + wecomQRHTTPTimeout = 15 * time.Second + wecomQRPollInterval = 3 * time.Second + wecomQRPollTimeout = 5 * time.Minute + wecomDefaultWebSocketURL = "wss://openws.work.weixin.qq.com" +) + +type wecomQRScanner func(context.Context, wecomQRFlowOptions) (wecomQRBotInfo, error) + +type wecomQRFlowOptions struct { + HTTPClient *http.Client + GenerateURL string + QueryURL string + QRCodePageURL string + SourceID string + PollInterval time.Duration + PollTimeout time.Duration + Writer io.Writer +} + +type wecomQRBotInfo struct { + BotID string + Secret string +} + +type wecomQRSession struct { + SCode string + AuthURL string +} + +type wecomQRGenerateResponse struct { + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` + Data struct { + SCode string `json:"scode"` + AuthURL string `json:"auth_url"` + } `json:"data"` +} + +type wecomQRQueryResponse struct { + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` + Data struct { + Status string `json:"status"` + BotInfo struct { + BotID string `json:"botid"` + Secret string `json:"secret"` + } `json:"bot_info"` + } `json:"data"` +} + +func newWeComCommand() *cobra.Command { + var timeout time.Duration + + cmd := &cobra.Command{ + Use: "wecom", + Short: "Scan a WeCom QR code and configure channels.wecom", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return authWeComCmd(timeout) + }, + } + + cmd.Flags().DurationVar(&timeout, "timeout", wecomQRPollTimeout, "How long to wait for QR confirmation") + + return cmd +} + +func authWeComCmd(timeout time.Duration) error { + return authWeComCmdWithScanner(context.Background(), os.Stdout, timeout, scanWeComQRCodeInteractive) +} + +func authWeComCmdWithScanner( + ctx context.Context, + writer io.Writer, + timeout time.Duration, + scanner wecomQRScanner, +) error { + if scanner == nil { + return fmt.Errorf("wecom QR scanner is nil") + } + if writer == nil { + writer = os.Stdout + } + + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + opts := defaultWeComQRFlowOptions(timeout) + opts.Writer = writer + + botInfo, err := scanner(ctx, opts) + if err != nil { + return err + } + + applyWeComAuthResult(cfg, botInfo) + + if saveErr := config.SaveConfig(internal.GetConfigPath(), cfg); saveErr != nil { + return fmt.Errorf("failed to save config: %w", saveErr) + } + + fmt.Fprintln(writer) + fmt.Fprintln(writer, "WeCom connected.") + fmt.Fprintf(writer, "Bot ID: %s\n", botInfo.BotID) + fmt.Fprintf(writer, "Config: %s\n", internal.GetConfigPath()) + + return nil +} + +func defaultWeComQRFlowOptions(timeout time.Duration) wecomQRFlowOptions { + if timeout <= 0 { + timeout = wecomQRPollTimeout + } + + return wecomQRFlowOptions{ + HTTPClient: &http.Client{Timeout: wecomQRHTTPTimeout}, + GenerateURL: wecomQRGenerateEndpoint, + QueryURL: wecomQRQueryEndpoint, + QRCodePageURL: wecomQRPageEndpoint, + SourceID: wecomQRSourceID, + PollInterval: wecomQRPollInterval, + PollTimeout: timeout, + Writer: os.Stdout, + } +} + +func applyWeComAuthResult(cfg *config.Config, botInfo wecomQRBotInfo) { + bc := cfg.Channels.GetByType(config.ChannelWeCom) + if bc == nil { + bc = &config.Channel{Type: config.ChannelWeCom} + cfg.Channels["wecom"] = bc + } + bc.Enabled = true + + decoded, err := bc.GetDecoded() + if err != nil { + logger.ErrorCF("wecom", "failed to decode WeCom settings", map[string]any{ + "error": err.Error(), + }) + return + } + wecomCfg, ok := decoded.(*config.WeComSettings) + if !ok { + logger.ErrorCF("wecom", "unexpected WeCom settings type", map[string]any{ + "got": fmt.Sprintf("%T", decoded), + }) + return + } + wecomCfg.BotID = botInfo.BotID + wecomCfg.Secret = *config.NewSecureString(botInfo.Secret) + if strings.TrimSpace(wecomCfg.WebSocketURL) == "" { + wecomCfg.WebSocketURL = wecomDefaultWebSocketURL + } +} + +func scanWeComQRCodeInteractive(ctx context.Context, opts wecomQRFlowOptions) (wecomQRBotInfo, error) { + opts = normalizeWeComQRFlowOptions(opts) + + fmt.Fprintln(opts.Writer, "Requesting WeCom QR code...") + + session, err := fetchWeComQRCode(ctx, opts) + if err != nil { + return wecomQRBotInfo{}, err + } + + fmt.Fprintln(opts.Writer) + fmt.Fprintln(opts.Writer, "=======================================================") + fmt.Fprintln(opts.Writer, "Please scan the following QR code with WeCom:") + fmt.Fprintln(opts.Writer, "=======================================================") + fmt.Fprintln(opts.Writer) + + qrterminal.GenerateWithConfig(session.AuthURL, qrterminal.Config{ + Level: qrterminal.L, + Writer: opts.Writer, + HalfBlocks: true, + }) + + pageURL, err := buildWeComQRCodePageURL(opts.QRCodePageURL, opts.SourceID, session.SCode) + if err != nil { + return wecomQRBotInfo{}, err + } + + fmt.Fprintln(opts.Writer) + fmt.Fprintf(opts.Writer, "QR Code Link: %s\n", pageURL) + fmt.Fprintln(opts.Writer) + fmt.Fprintln(opts.Writer, "Waiting for scan...") + + return pollWeComQRCodeResult(ctx, opts, session.SCode) +} + +func normalizeWeComQRFlowOptions(opts wecomQRFlowOptions) wecomQRFlowOptions { + if opts.HTTPClient == nil { + opts.HTTPClient = &http.Client{Timeout: wecomQRHTTPTimeout} + } + if strings.TrimSpace(opts.GenerateURL) == "" { + opts.GenerateURL = wecomQRGenerateEndpoint + } + if strings.TrimSpace(opts.QueryURL) == "" { + opts.QueryURL = wecomQRQueryEndpoint + } + if strings.TrimSpace(opts.QRCodePageURL) == "" { + opts.QRCodePageURL = wecomQRPageEndpoint + } + if strings.TrimSpace(opts.SourceID) == "" { + opts.SourceID = wecomQRSourceID + } + if opts.PollInterval <= 0 { + opts.PollInterval = wecomQRPollInterval + } + if opts.PollTimeout <= 0 { + opts.PollTimeout = wecomQRPollTimeout + } + if opts.Writer == nil { + opts.Writer = os.Stdout + } + + return opts +} + +func fetchWeComQRCode(ctx context.Context, opts wecomQRFlowOptions) (wecomQRSession, error) { + generateURL, err := buildWeComQRGenerateURL(opts.GenerateURL, opts.SourceID, wecomPlatformCode()) + if err != nil { + return wecomQRSession{}, err + } + + var resp wecomQRGenerateResponse + if err := doWeComJSONGet(ctx, opts.HTTPClient, generateURL, &resp); err != nil { + return wecomQRSession{}, fmt.Errorf("failed to get WeCom QR code: %w", err) + } + if resp.ErrCode != 0 { + return wecomQRSession{}, fmt.Errorf( + "failed to get WeCom QR code: errcode=%d errmsg=%s", + resp.ErrCode, + resp.ErrMsg, + ) + } + if resp.Data.SCode == "" || resp.Data.AuthURL == "" { + return wecomQRSession{}, fmt.Errorf("failed to get WeCom QR code: response missing scode or auth_url") + } + + return wecomQRSession{ + SCode: resp.Data.SCode, + AuthURL: resp.Data.AuthURL, + }, nil +} + +func pollWeComQRCodeResult(ctx context.Context, opts wecomQRFlowOptions, scode string) (wecomQRBotInfo, error) { + if strings.TrimSpace(scode) == "" { + return wecomQRBotInfo{}, fmt.Errorf("missing WeCom QR scode") + } + + timeoutCtx, cancel := context.WithTimeout(ctx, opts.PollTimeout) + defer cancel() + + var scannedPrinted bool + + for { + status, err := queryWeComQRCodeStatus(timeoutCtx, opts, scode) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) { + return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan timed out after %s", opts.PollTimeout) + } + return wecomQRBotInfo{}, err + } + + switch strings.ToLower(status.Data.Status) { + case "success": + if status.Data.BotInfo.BotID == "" || status.Data.BotInfo.Secret == "" { + return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan succeeded but bot credentials are missing") + } + return wecomQRBotInfo{ + BotID: status.Data.BotInfo.BotID, + Secret: status.Data.BotInfo.Secret, + }, nil + case "expired": + return wecomQRBotInfo{}, fmt.Errorf("WeCom QR code expired, please retry") + case "scaned", "scanned": + if !scannedPrinted { + fmt.Fprintln(opts.Writer, "QR code scanned. Confirm the login in WeCom.") + scannedPrinted = true + } + } + + select { + case <-timeoutCtx.Done(): + if errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) { + return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan timed out after %s", opts.PollTimeout) + } + return wecomQRBotInfo{}, timeoutCtx.Err() + case <-time.After(opts.PollInterval): + } + } +} + +func queryWeComQRCodeStatus(ctx context.Context, opts wecomQRFlowOptions, scode string) (wecomQRQueryResponse, error) { + queryURL, err := buildWeComQRQueryURL(opts.QueryURL, scode) + if err != nil { + return wecomQRQueryResponse{}, err + } + + var resp wecomQRQueryResponse + if err := doWeComJSONGet(ctx, opts.HTTPClient, queryURL, &resp); err != nil { + return wecomQRQueryResponse{}, fmt.Errorf("failed to query WeCom QR result: %w", err) + } + if resp.ErrCode != 0 { + return wecomQRQueryResponse{}, fmt.Errorf( + "failed to query WeCom QR result: errcode=%d errmsg=%s", + resp.ErrCode, + resp.ErrMsg, + ) + } + + return resp, nil +} + +func buildWeComQRGenerateURL(baseURL, sourceID string, platformCode int) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("invalid WeCom QR generate URL: %w", err) + } + + query := u.Query() + query.Set("source", sourceID) + query.Set("sourceID", sourceID) + query.Set("plat", strconv.Itoa(platformCode)) + u.RawQuery = query.Encode() + + return u.String(), nil +} + +func buildWeComQRQueryURL(baseURL, scode string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("invalid WeCom QR query URL: %w", err) + } + + query := u.Query() + query.Set("scode", scode) + u.RawQuery = query.Encode() + + return u.String(), nil +} + +func buildWeComQRCodePageURL(baseURL, sourceID, scode string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("invalid WeCom QR page URL: %w", err) + } + + query := u.Query() + query.Set("source", sourceID) + query.Set("sourceID", sourceID) + query.Set("scode", scode) + u.RawQuery = query.Encode() + + return u.String(), nil +} + +func doWeComJSONGet(ctx context.Context, client *http.Client, targetURL string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if readErr != nil { + return fmt.Errorf("unexpected status %s", resp.Status) + } + return fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decode JSON response: %w", err) + } + + return nil +} + +func wecomPlatformCode() int { + switch runtime.GOOS { + case "darwin": + return 1 + case "windows": + return 2 + case "linux": + return 3 + default: + return 0 + } +} diff --git a/cmd/picoclaw/internal/auth/wecom_test.go b/cmd/picoclaw/internal/auth/wecom_test.go new file mode 100644 index 000000000..aafd39e69 --- /dev/null +++ b/cmd/picoclaw/internal/auth/wecom_test.go @@ -0,0 +1,179 @@ +package auth + +import ( + "bytes" + "context" + "net" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strconv" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newIPv4TestServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + + server := httptest.NewUnstartedServer(handler) + listener, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + + server.Listener = listener + server.Start() + t.Cleanup(server.Close) + return server +} + +func TestNewWeComCommand(t *testing.T) { + cmd := newWeComCommand() + + require.NotNil(t, cmd) + assert.Equal(t, "wecom", cmd.Use) + assert.Equal(t, "Scan a WeCom QR code and configure channels.wecom", cmd.Short) + assert.NotNil(t, cmd.Flags().Lookup("timeout")) +} + +func TestBuildWeComQRGenerateURL(t *testing.T) { + rawURL, err := buildWeComQRGenerateURL("https://example.com/ai/qc/generate", wecomQRSourceID, 3) + require.NoError(t, err) + + parsed, err := url.Parse(rawURL) + require.NoError(t, err) + + assert.Equal(t, wecomQRSourceID, parsed.Query().Get("source")) + assert.Equal(t, wecomQRSourceID, parsed.Query().Get("sourceID")) + assert.Equal(t, "3", parsed.Query().Get("plat")) +} + +func TestBuildWeComQRCodePageURL(t *testing.T) { + rawURL, err := buildWeComQRCodePageURL("https://example.com/ai/qc/gen", wecomQRSourceID, "scode-1") + require.NoError(t, err) + + parsed, err := url.Parse(rawURL) + require.NoError(t, err) + + assert.Equal(t, wecomQRSourceID, parsed.Query().Get("source")) + assert.Equal(t, wecomQRSourceID, parsed.Query().Get("sourceID")) + assert.Equal(t, "scode-1", parsed.Query().Get("scode")) +} + +func TestFetchWeComQRCode(t *testing.T) { + server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/generate", r.URL.Path) + assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source")) + assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID")) + assert.Equal(t, strconv.Itoa(wecomPlatformCode()), r.URL.Query().Get("plat")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`)) + })) + + opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{ + HTTPClient: server.Client(), + GenerateURL: server.URL + "/generate", + Writer: bytes.NewBuffer(nil), + }) + + session, err := fetchWeComQRCode(context.Background(), opts) + require.NoError(t, err) + assert.Equal(t, "scode-1", session.SCode) + assert.Equal(t, "https://example.com/qr", session.AuthURL) +} + +func TestPollWeComQRCodeResult(t *testing.T) { + var calls atomic.Int32 + + server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call := calls.Add(1) + assert.Equal(t, "/query", r.URL.Path) + assert.Equal(t, "scode-1", r.URL.Query().Get("scode")) + w.Header().Set("Content-Type", "application/json") + switch call { + case 1: + _, _ = w.Write([]byte(`{"data":{"status":"wait"}}`)) + case 2: + _, _ = w.Write([]byte(`{"data":{"status":"scaned"}}`)) + default: + _, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`)) + } + })) + + var output bytes.Buffer + opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{ + HTTPClient: server.Client(), + QueryURL: server.URL + "/query", + PollInterval: time.Millisecond, + PollTimeout: time.Second, + Writer: &output, + }) + + botInfo, err := pollWeComQRCodeResult(context.Background(), opts, "scode-1") + require.NoError(t, err) + assert.Equal(t, "bot-1", botInfo.BotID) + assert.Equal(t, "secret-1", botInfo.Secret) + assert.Contains(t, output.String(), "QR code scanned. Confirm the login in WeCom.") +} + +func TestApplyWeComAuthResult(t *testing.T) { + cfg := config.DefaultConfig() + require.NoError(t, config.InitChannelList(cfg.Channels)) + wecom := cfg.Channels["wecom"] + t.Logf("wecom: %+v", wecom) + decoded, err := wecom.GetDecoded() + require.NoError(t, err) + weCfg := decoded.(*config.WeComSettings) + weCfg.WebSocketURL = "" + + applyWeComAuthResult(cfg, wecomQRBotInfo{ + BotID: "bot-1", + Secret: "secret-1", + }) + + assert.True(t, wecom.Enabled) + assert.Equal(t, "bot-1", weCfg.BotID) + assert.Equal(t, "secret-1", weCfg.Secret.String()) + assert.Equal(t, wecomDefaultWebSocketURL, weCfg.WebSocketURL) +} + +func TestAuthWeComCmdWithScanner(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + t.Setenv(config.EnvHome, tmpDir) + t.Setenv(config.EnvConfig, configPath) + + var output bytes.Buffer + err := authWeComCmdWithScanner( + context.Background(), + &output, + time.Second, + func(_ context.Context, opts wecomQRFlowOptions) (wecomQRBotInfo, error) { + assert.Equal(t, wecomQRSourceID, opts.SourceID) + return wecomQRBotInfo{ + BotID: "bot-1", + Secret: "secret-1", + }, nil + }, + ) + require.NoError(t, err) + + cfg, err := config.LoadConfig(internal.GetConfigPath()) + require.NoError(t, err) + wecom := cfg.Channels["wecom"] + decoded, err := wecom.GetDecoded() + require.NoError(t, err) + weCfg := decoded.(*config.WeComSettings) + assert.True(t, wecom.Enabled) + assert.Equal(t, "bot-1", weCfg.BotID) + assert.Equal(t, "secret-1", weCfg.Secret.String()) + assert.Equal(t, wecomDefaultWebSocketURL, weCfg.WebSocketURL) + assert.Contains(t, output.String(), "WeCom connected.") +} diff --git a/cmd/picoclaw/internal/auth/weixin.go b/cmd/picoclaw/internal/auth/weixin.go new file mode 100644 index 000000000..0d060a5fe --- /dev/null +++ b/cmd/picoclaw/internal/auth/weixin.go @@ -0,0 +1,134 @@ +package auth + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/channels/weixin" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newWeixinCommand() *cobra.Command { + var baseURL string + var proxy string + var timeout int + + cmd := &cobra.Command{ + Use: "weixin", + Short: "Connect a WeChat personal account via QR code", + Long: `Start the interactive Weixin (WeChat personal) QR code login flow. + +A QR code is displayed in the terminal. Scan it with the WeChat mobile app +to authorize your account. On success, the bot token is saved to the picoclaw +config so you can start the gateway immediately. + +Example: + picoclaw auth weixin`, + RunE: func(cmd *cobra.Command, _ []string) error { + return runWeixinOnboard(baseURL, proxy, time.Duration(timeout)*time.Second) + }, + } + + cmd.Flags().StringVar(&baseURL, "base-url", "https://ilinkai.weixin.qq.com/", "iLink API base URL") + cmd.Flags().StringVar(&proxy, "proxy", "", "HTTP proxy URL (e.g. http://localhost:7890)") + cmd.Flags().IntVar(&timeout, "timeout", 300, "Login timeout in seconds") + + return cmd +} + +func runWeixinOnboard(baseURL, proxy string, timeout time.Duration) error { + fmt.Println("Starting Weixin (WeChat personal) login...") + fmt.Println() + + botToken, userID, accountID, returnedBaseURL, err := weixin.PerformLoginInteractive( + context.Background(), + weixin.AuthFlowOpts{ + BaseURL: baseURL, + Timeout: timeout, + Proxy: proxy, + }, + ) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + + fmt.Println() + fmt.Println("✅ Login successful!") + fmt.Printf(" Account ID : %s\n", accountID) + if userID != "" { + fmt.Printf(" User ID : %s\n", userID) + } + fmt.Println() + + // Prefer the server-returned base URL (may be region-specific) + effectiveBaseURL := returnedBaseURL + if effectiveBaseURL == "" { + effectiveBaseURL = baseURL + } + + if err := saveWeixinConfig(botToken, effectiveBaseURL, proxy); err != nil { + fmt.Printf("⚠️ Could not auto-save to config: %v\n", err) + printManualWeixinConfig(botToken, effectiveBaseURL) + return nil + } + + fmt.Println("✓ Config updated. Start the gateway with:") + fmt.Println() + fmt.Println(" picoclaw gateway") + fmt.Println() + fmt.Println("To restrict which WeChat users can send messages, add their user IDs") + fmt.Println("to channels.weixin.allow_from in your config.") + + return nil +} + +// saveWeixinConfig patches channels.weixin in the config and saves it. +func saveWeixinConfig(token, baseURL, proxy string) error { + cfgPath := internal.GetConfigPath() + + cfg, err := config.LoadConfig(cfgPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + bc := cfg.Channels.GetByType(config.ChannelWeixin) + if bc == nil { + bc = &config.Channel{Type: config.ChannelWeixin} + cfg.Channels[config.ChannelWeixin] = bc + } + bc.Enabled = true + + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + if weixinCfg, ok := decoded.(*config.WeixinSettings); ok { + weixinCfg.Token = *config.NewSecureString(token) + const defaultBase = "https://ilinkai.weixin.qq.com/" + if baseURL != "" && baseURL != defaultBase { + weixinCfg.BaseURL = baseURL + } + if proxy != "" { + weixinCfg.Proxy = proxy + } + } + } + + return config.SaveConfig(cfgPath, cfg) +} + +func printManualWeixinConfig(token, baseURL string) { + fmt.Println() + fmt.Println("Add the following to the channels section of your picoclaw config:") + fmt.Println() + fmt.Println(` "weixin": {`) + fmt.Println(` "enabled": true,`) + fmt.Printf(" \"token\": %q,\n", token) + const defaultBase = "https://ilinkai.weixin.qq.com/" + if baseURL != "" && baseURL != defaultBase { + fmt.Printf(" \"base_url\": %q,\n", baseURL) + } + fmt.Println(` "allow_from": []`) + fmt.Println(` }`) +} diff --git a/cmd/picoclaw/internal/cliui/cliui.go b/cmd/picoclaw/internal/cliui/cliui.go new file mode 100644 index 000000000..b1ba636c9 --- /dev/null +++ b/cmd/picoclaw/internal/cliui/cliui.go @@ -0,0 +1,147 @@ +// Package cliui renders human-oriented CLI output: bordered panels and columns +// on wide interactive terminals. Layout (boxes/columns) is independent of ANSI +// color: use --no-color or NO_COLOR to disable colors only; narrow or non-TTY +// stdout falls back to plain line-oriented output. +package cliui + +import ( + "os" + "sync" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "golang.org/x/term" +) + +// Minimum terminal width (columns) for bordered / structured layout. +// Below this, plain line-oriented output is used so boxes do not wrap badly. +const minWidthFancy = 88 + +// Minimum width to lay out some views in two columns (e.g. status providers). +const minWidthColumns = 104 + +var initMu sync.Mutex + +// Init configures lipgloss for this process. When disableAnsiColors is true +// (e.g. --no-color, NO_COLOR, or TERM=dumb), only color is turned off; Unicode +// borders still render when UseFancyLayout() is true. +func Init(disableAnsiColors bool) { + initMu.Lock() + defer initMu.Unlock() + if disableAnsiColors { + lipgloss.SetColorProfile(termenv.Ascii) + return + } + lipgloss.SetColorProfile(termenv.EnvColorProfile()) +} + +// StdoutWidth returns the terminal width or a sane default if unknown. +func StdoutWidth() int { + w, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || w < 20 { + return 80 + } + return w +} + +// UseFancyLayout is true when styled boxes/columns should be used. +func UseFancyLayout() bool { + if !term.IsTerminal(int(os.Stdout.Fd())) { + return false + } + return StdoutWidth() >= minWidthFancy +} + +// UseColumnLayout is true when a second content column is viable. +func UseColumnLayout() bool { + return UseFancyLayout() && StdoutWidth() >= minWidthColumns +} + +// InnerWidth is the target content width inside borders/margins. +func InnerWidth() int { + w := StdoutWidth() + // Rounded border + horizontal padding (lipgloss borders ~= 2 cols each side + padding). + const borderBudget = 8 + if w > borderBudget+48 { + return w - borderBudget + } + return 48 +} + +// StderrWidth returns stderr terminal width or a sane default. +func StderrWidth() int { + w, _, err := term.GetSize(int(os.Stderr.Fd())) + if err != nil || w < 20 { + return 80 + } + return w +} + +// UseFancyStderr is true when stderr can show boxed errors without ugly wraps. +func UseFancyStderr() bool { + if !term.IsTerminal(int(os.Stderr.Fd())) { + return false + } + return StderrWidth() >= minWidthFancy +} + +// InnerStderrWidth mirrors InnerWidth but for stderr. +func InnerStderrWidth() int { + w := StderrWidth() + const borderBudget = 8 + if w > borderBudget+48 { + return w - borderBudget + } + return 48 +} + +var ( + accentBlue = lipgloss.Color("#3E5DB9") + accentRed = lipgloss.Color("#D54646") + colorMuted = lipgloss.Color("#6B6B6B") + colorOK = lipgloss.Color("#2E7D32") +) + +func borderStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(accentBlue). + Padding(0, 1) +} + +func titleBarStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(accentRed). + Bold(true) +} + +func mutedStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(colorMuted) +} + +func bodyStyle() lipgloss.Style { + return lipgloss.NewStyle() +} + +func kvKeyStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentBlue).Bold(true) +} + +func kvValStyle() lipgloss.Style { + return lipgloss.NewStyle() +} + +// helpIntroStyle is the top tagline (PicoClaw blue, matches ASCII banner left side). +func helpIntroStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentBlue).Bold(true) +} + +// helpIdentStyle is the left column for commands and flags (blue identifiers). +func helpIdentStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentBlue).Bold(true) +} + +// helpPlaceholderStyle highlights in usage lines (red accent). +func helpPlaceholderStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentRed).Bold(true) +} diff --git a/cmd/picoclaw/internal/cliui/cliui_test.go b/cmd/picoclaw/internal/cliui/cliui_test.go new file mode 100644 index 000000000..c07e220ee --- /dev/null +++ b/cmd/picoclaw/internal/cliui/cliui_test.go @@ -0,0 +1,180 @@ +package cliui + +import ( + "testing" + + flag "github.com/spf13/pflag" +) + +func init() { + // Disable ANSI colors in tests so output is predictable plain text. + Init(true) +} + +// --------------------------------------------------------------------------- +// showErrHint +// --------------------------------------------------------------------------- + +func TestShowErrHint(t *testing.T) { + cases := []struct { + msg string + want bool + }{ + // Cobra flag errors — should show hint + {"unknown flag: --foo", true}, + {"unknown shorthand flag: 'f' in -f", true}, + {"flag needs an argument: --output", true}, + {"required flag(s) \"model\" not set", true}, + // Generic invalid-argument errors — should show hint + {"invalid argument \"abc\" for --count", true}, + // required flag errors — should show hint + {"required flag(s) \"model\" not set", true}, + // usage: in message — should show hint + {"bad input\nusage: picoclaw ...", true}, + // Should NOT false-positive on broad words + {"connection flagged by remote", false}, + {"feature flag not set", false}, + {"invalid API key provided", false}, + {"authentication required", false}, + // Unrelated messages — no hint + {"something went wrong", false}, + {"network timeout", false}, + } + + for _, tc := range cases { + got := showErrHint(tc.msg) + if got != tc.want { + t.Errorf("showErrHint(%q) = %v, want %v", tc.msg, got, tc.want) + } + } +} + +// --------------------------------------------------------------------------- +// styleUsageTokens +// --------------------------------------------------------------------------- + +func TestStyleUsageTokensContainsTokens(t *testing.T) { + cases := []struct { + input string + contains []string // substrings that must appear in plain output + }{ + { + "picoclaw agent ", + []string{"picoclaw agent", ""}, + }, + { + "picoclaw [command] [flags]", + []string{"picoclaw", "[command]", "[flags]"}, + }, + { + "picoclaw", + []string{"picoclaw"}, + }, + { + "cmd [--flag]", + []string{"cmd", "", "[--flag]"}, + }, + } + + for _, tc := range cases { + out := styleUsageTokens(tc.input) + for _, sub := range tc.contains { + if !containsStripped(out, sub) { + t.Errorf("styleUsageTokens(%q): output %q does not contain %q", tc.input, out, sub) + } + } + } +} + +// containsStripped checks whether plain contains sub after stripping ANSI escapes. +// Since Init(true) sets Ascii profile, lipgloss emits no escape codes in tests, +// so this is just a plain substring check. +func containsStripped(plain, sub string) bool { + return len(plain) >= len(sub) && findSubstring(plain, sub) +} + +func findSubstring(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// collectFlagRows +// --------------------------------------------------------------------------- + +func TestCollectFlagRows_Empty(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + rows := collectFlagRows(fs) + if len(rows) != 0 { + t.Fatalf("expected 0 rows for empty FlagSet, got %d", len(rows)) + } +} + +func TestCollectFlagRows_BasicFlags(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("output", "", "output file path") + fs.Bool("verbose", false, "enable verbose mode") + fs.Int("count", 1, "number of items") + + rows := collectFlagRows(fs) + + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + + // Rows must be sorted alphabetically by flag name. + names := make([]string, 0, len(rows)) + for _, r := range rows { + names = append(names, r[0]) + } + if names[0] > names[1] || names[1] > names[2] { + t.Errorf("rows not sorted: %v", names) + } +} + +func TestCollectFlagRows_Shorthand(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.StringP("model", "m", "", "model name") + + rows := collectFlagRows(fs) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + left := rows[0][0] + if !findSubstring(left, "-m") || !findSubstring(left, "--model") { + t.Errorf("expected shorthand and long form in %q", left) + } +} + +func TestCollectFlagRows_HiddenFlagsExcluded(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("visible", "", "this shows up") + hidden := fs.String("hidden", "", "this should not show up") + _ = hidden + _ = fs.MarkHidden("hidden") + + rows := collectFlagRows(fs) + if len(rows) != 1 { + t.Fatalf("expected 1 row (hidden excluded), got %d", len(rows)) + } + if !findSubstring(rows[0][0], "visible") { + t.Errorf("expected visible flag in rows, got %q", rows[0][0]) + } +} + +func TestCollectFlagRows_UsageInRightColumn(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("format", "json", "output format: json or text") + + rows := collectFlagRows(fs) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0][1] != "output format: json or text" { + t.Errorf("expected usage in right column, got %q", rows[0][1]) + } +} diff --git a/cmd/picoclaw/internal/cliui/help_cmd.go b/cmd/picoclaw/internal/cliui/help_cmd.go new file mode 100644 index 000000000..72956afaa --- /dev/null +++ b/cmd/picoclaw/internal/cliui/help_cmd.go @@ -0,0 +1,298 @@ +package cliui + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + flag "github.com/spf13/pflag" +) + +// RenderCommandHelp builds Ruff-style sectioned, two-column help when +// UseFancyLayout(); otherwise plain Cobra-style text. +func RenderCommandHelp(c *cobra.Command) string { + if !UseFancyLayout() { + return plainCommandHelp(c) + } + syncFlags(c) + + var b strings.Builder + head, sub := helpIntro(c) + if head != "" { + b.WriteString(helpIntroStyle().Render(head)) + b.WriteString("\n") + } + if sub != "" { + b.WriteString(mutedStyle().Render(sub)) + b.WriteString("\n") + } + if head != "" || sub != "" { + b.WriteString("\n") + } + + inner := InnerWidth() + contentW := inner - 6 + if contentW < 36 { + contentW = 36 + } + + // Usage + usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine())) + b.WriteString(sectionPanel("Usage", usageBody, inner)) + b.WriteString("\n") + + // Examples + if ex := strings.TrimSpace(c.Example); ex != "" { + exBody := bodyStyle().Width(contentW).Render(ex) + b.WriteString(sectionPanel("Examples", exBody, inner)) + b.WriteString("\n") + } + + // Subcommands + subs := visibleSubcommands(c) + if len(subs) > 0 { + rows := make([][2]string, 0, len(subs)) + for _, sub := range subs { + left := sub.Name() + if a := sub.Aliases; len(a) > 0 { + left += " (" + strings.Join(a, ", ") + ")" + } + rows = append(rows, [2]string{left, sub.Short}) + } + b.WriteString(sectionPanel("Commands", renderTwoColPairs(rows, contentW), inner)) + b.WriteString("\n") + } + + // Local options + local := c.LocalFlags() + opts := collectFlagRows(local) + if len(opts) > 0 { + title := "Options" + if !c.HasParent() { + title = "Flags" + } + b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), inner)) + b.WriteString("\n") + } + + // Global (inherited) options + if c.HasAvailableInheritedFlags() { + inh := collectFlagRows(c.InheritedFlags()) + if len(inh) > 0 { + b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), inner)) + b.WriteString("\n") + } + } + + return b.String() +} + +// RenderCommandQuickRef prints the same Usage / Flags / Global sections as help, +// for embedding after errors (stderr). outerW is typically InnerStderrWidth(). +func RenderCommandQuickRef(c *cobra.Command, outerW int) string { + if c == nil || outerW < 40 { + return "" + } + syncFlags(c) + contentW := outerW - 6 + if contentW < 36 { + contentW = 36 + } + var b strings.Builder + usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine())) + b.WriteString(sectionPanel("Usage", usageBody, outerW)) + b.WriteString("\n") + if len(c.Aliases) > 0 { + al := "Aliases: " + strings.Join(c.Aliases, ", ") + alBody := mutedStyle().MaxWidth(contentW).Render(al) + b.WriteString(sectionPanel("Aliases", alBody, outerW)) + b.WriteString("\n") + } + opts := collectFlagRows(c.LocalFlags()) + if len(opts) > 0 { + title := "Options" + if !c.HasParent() { + title = "Flags" + } + b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), outerW)) + b.WriteString("\n") + } + if c.HasAvailableInheritedFlags() { + inh := collectFlagRows(c.InheritedFlags()) + if len(inh) > 0 { + b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), outerW)) + b.WriteString("\n") + } + } + return b.String() +} + +func syncFlags(c *cobra.Command) { + _ = c.LocalFlags() + if c.HasAvailableInheritedFlags() { + _ = c.InheritedFlags() + } +} + +func plainCommandHelp(c *cobra.Command) string { + desc := c.Long + if desc == "" { + desc = c.Short + } + desc = strings.TrimRight(desc, " \t\n\r") + var b strings.Builder + if desc != "" { + fmt.Fprintln(&b, desc) + fmt.Fprintln(&b) + } + if c.Runnable() || c.HasSubCommands() { + b.WriteString(c.UsageString()) + } + return b.String() +} + +func helpIntro(c *cobra.Command) (head, sub string) { + head = strings.TrimSpace(c.Short) + long := strings.TrimSpace(c.Long) + if long == "" || long == head { + return head, "" + } + lines := strings.Split(long, "\n") + var rest []string + for i, ln := range lines { + ln = strings.TrimSpace(ln) + if ln == "" { + continue + } + if i == 0 && ln == head { + continue + } + rest = append(rest, ln) + } + sub = strings.Join(rest, "\n") + return head, sub +} + +func visibleSubcommands(c *cobra.Command) []*cobra.Command { + var out []*cobra.Command + for _, sub := range c.Commands() { + if sub.Hidden { + continue + } + out = append(out, sub) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() }) + return out +} + +func sectionPanel(title, body string, width int) string { + head := titleBarStyle().Render(title) + "\n\n" + return borderStyle().Width(width).Render(head + body) +} + +// styleUsageTokens highlights PicoClaw-blue command tokens and red /[groups]. +func styleUsageTokens(s string) string { + var b strings.Builder + for len(s) > 0 { + ia := strings.Index(s, "<") + ib := strings.Index(s, "[") + next, kind := -1, 0 // 1 = angle, 2 = bracket + switch { + case ia >= 0 && (ib < 0 || ia < ib): + next, kind = ia, 1 + case ib >= 0: + next, kind = ib, 2 + } + if next < 0 { + b.WriteString(helpIdentStyle().Render(s)) + break + } + if next > 0 { + b.WriteString(helpIdentStyle().Render(s[:next])) + } + s = s[next:] + if kind == 1 { + j := strings.Index(s, ">") + if j < 0 { + b.WriteString(helpIdentStyle().Render(s)) + break + } + b.WriteString(helpPlaceholderStyle().Render(s[:j+1])) + s = s[j+1:] + continue + } + j := strings.Index(s, "]") + if j < 0 { + b.WriteString(helpIdentStyle().Render(s)) + break + } + b.WriteString(helpPlaceholderStyle().Render(s[:j+1])) + s = s[j+1:] + } + return b.String() +} + +func collectFlagRows(fs *flag.FlagSet) [][2]string { + var names []string + seen := map[string][2]string{} + fs.VisitAll(func(f *flag.Flag) { + if f.Hidden { + return + } + left := formatFlagLeft(f) + right := f.Usage + if f.Deprecated != "" { + right += " (deprecated: " + f.Deprecated + ")" + } + names = append(names, f.Name) + seen[f.Name] = [2]string{left, right} + }) + sort.Strings(names) + rows := make([][2]string, 0, len(names)) + for _, n := range names { + rows = append(rows, seen[n]) + } + return rows +} + +func formatFlagLeft(f *flag.Flag) string { + if len(f.Shorthand) > 0 { + return "-" + f.Shorthand + ", --" + f.Name + } + return "--" + f.Name +} + +func renderTwoColPairs(rows [][2]string, contentW int) string { + if len(rows) == 0 { + return "" + } + leftW := 0 + for _, r := range rows { + if w := lipgloss.Width(r[0]); w > leftW { + leftW = w + } + } + const minLeft, maxLeft = 16, 34 + if leftW < minLeft { + leftW = minLeft + } + if leftW > maxLeft { + leftW = maxLeft + } + gap := " " + rightW := contentW - leftW - lipgloss.Width(gap) + if rightW < 24 { + rightW = 24 + } + + var b strings.Builder + for _, r := range rows { + left := helpIdentStyle().Width(leftW).Align(lipgloss.Left).Render(r[0]) + right := bodyStyle().Width(rightW).Render(strings.TrimSpace(r[1])) + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, left, gap, right)) + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/cmd/picoclaw/internal/cliui/help_error.go b/cmd/picoclaw/internal/cliui/help_error.go new file mode 100644 index 000000000..1e859b08f --- /dev/null +++ b/cmd/picoclaw/internal/cliui/help_error.go @@ -0,0 +1,75 @@ +package cliui + +import ( + "strings" + + "github.com/spf13/cobra" +) + +// FormatCLIError formats errors with the same boxed sections as help. When ctx +// is the command that was running when the error occurred, Usage / Flags panels +// are appended so styling matches picoclaw -h. +func FormatCLIError(msg string, ctx *cobra.Command) string { + msg = strings.TrimRight(msg, "\n") + if !UseFancyStderr() { + s := "Error: " + msg + "\n" + if ctx != nil && showErrHint(msg) { + s += "\n" + plainCommandHelp(ctx) + } + return s + } + w := InnerStderrWidth() + contentW := w - 6 + if contentW < 36 { + contentW = 36 + } + + title := titleBarStyle().Render("Error") + "\n\n" + + paras := strings.Split(msg, "\n") + var body strings.Builder + for i, p := range paras { + p = strings.TrimRight(p, " ") + if p == "" { + continue + } + st := bodyStyle().Width(contentW) + if i > 0 { + body.WriteString("\n") + } + if i == 0 { + body.WriteString(st.Render(p)) + } else { + body.WriteString(mutedStyle().Width(contentW).Render(p)) + } + } + + foot := "" + if showErrHint(msg) { + if ctx != nil { + foot = "\n\n" + mutedStyle().Width(contentW). + Render("Full command help: "+ctx.CommandPath()+" --help") + } else { + foot = "\n\n" + mutedStyle().Width(contentW). + Render("Tip: picoclaw --help · picoclaw --help") + } + } + + out := borderStyle().Width(w).Render(title+body.String()+foot) + "\n" + if ctx != nil && showErrHint(msg) { + if ref := RenderCommandQuickRef(ctx, w); ref != "" { + out += "\n" + ref + } + } + return out +} + +func showErrHint(msg string) bool { + m := strings.ToLower(msg) + return strings.Contains(m, "unknown flag") || + strings.Contains(m, "unknown shorthand flag") || + strings.Contains(m, "flag needs an argument") || + strings.Contains(m, "invalid argument") || + strings.Contains(m, "required flag") || + strings.Contains(m, "usage:") +} diff --git a/cmd/picoclaw/internal/cliui/mcp_show.go b/cmd/picoclaw/internal/cliui/mcp_show.go new file mode 100644 index 000000000..5d5af1e75 --- /dev/null +++ b/cmd/picoclaw/internal/cliui/mcp_show.go @@ -0,0 +1,384 @@ +package cliui + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// MCPShowServer holds the server metadata for PrintMCPShow. +type MCPShowServer struct { + Name string + Type string + Target string + Enabled bool + EffectiveDeferred bool // resolved value (per-server override or global default) + DeferredExplicit bool // true = per-server override set, false = inherited from global + EnvKeys []string // sorted env var names (values intentionally omitted) + EnvFile string + Headers []string // sorted header names +} + +// MCPShowTool holds one tool's info for PrintMCPShow. +type MCPShowTool struct { + Name string + Description string + Parameters []MCPShowParam +} + +// MCPShowParam is one parameter entry. +type MCPShowParam struct { + Name string + Type string + Description string + Required bool +} + +// PrintMCPShow renders the mcp show output (plain or fancy). +// w is where the output is written; pass cmd.OutOrStdout() from cobra commands. +func PrintMCPShow(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) { + if !UseFancyLayout() { + printMCPShowPlain(w, server, tools, disabled) + return + } + printMCPShowFancy(w, server, tools, disabled) +} + +// ── plain (narrow / non-TTY) ──────────────────────────────────────────────── + +func printMCPShowPlain(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) { + fmt.Fprintf(w, "Server: %s\n", server.Name) + fmt.Fprintf(w, "Type: %s\n", server.Type) + fmt.Fprintf(w, "Target: %s\n", server.Target) + fmt.Fprintf(w, "Enabled: %s\n", boolWord(server.Enabled)) + deferredLabel := boolWord(server.EffectiveDeferred) + if !server.DeferredExplicit { + deferredLabel += " (default)" + } + fmt.Fprintf(w, "Deferred: %s\n", deferredLabel) + if len(server.EnvKeys) > 0 { + fmt.Fprintf(w, "Env vars: %s\n", strings.Join(server.EnvKeys, ", ")) + } + if server.EnvFile != "" { + fmt.Fprintf(w, "Env file: %s\n", server.EnvFile) + } + if len(server.Headers) > 0 { + fmt.Fprintf(w, "Headers: %s\n", strings.Join(server.Headers, ", ")) + } + fmt.Fprintln(w) + + if disabled { + fmt.Fprintln(w, "Server is disabled; skipping tool discovery.") + return + } + if len(tools) == 0 { + fmt.Fprintln(w, "No tools exposed by this server.") + return + } + + fmt.Fprintf(w, "Tools (%d):\n", len(tools)) + for _, tool := range tools { + fmt.Fprintf(w, " %s\n", tool.Name) + if tool.Description != "" { + fmt.Fprintf(w, " %s\n", truncateDescription(tool.Description, 120)) + } + if len(tool.Parameters) == 0 { + fmt.Fprintln(w, " Parameters: none") + continue + } + for _, p := range tool.Parameters { + line := fmt.Sprintf(" - %s", p.Name) + if p.Type != "" { + line += fmt.Sprintf(" (%s", p.Type) + if p.Required { + line += ", required" + } + line += ")" + } else if p.Required { + line += " (required)" + } + if p.Description != "" { + line += ": " + truncateDescription(p.Description, 80) + } + fmt.Fprintln(w, line) + } + } +} + +// ── fancy (wide TTY) ──────────────────────────────────────────────────────── + +var ( + mcpToolNameStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentBlue).Bold(true) + } + mcpParamNameStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentRed).Bold(true) + } + mcpTagStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + } + mcpRequiredStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true) + } + mcpOptionalStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B")) + } + mcpDescStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + } +) + +func printMCPShowFancy(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) { + inner := InnerWidth() + box := borderStyle().Width(inner) + + var b strings.Builder + + // ── server header ── + b.WriteString(titleBarStyle().Render("⬡ " + server.Name)) + b.WriteString("\n\n") + + keyW := 10 + writeKV := func(key, val string) { + k := kvKeyStyle().Width(keyW).Render(key) + b.WriteString(k + " " + val + "\n") + } + + writeKV("Type", server.Type) + writeKV("Target", server.Target) + writeKV("Enabled", coloredBool(server.Enabled)) + deferredVal := coloredBool(server.EffectiveDeferred) + if !server.DeferredExplicit { + deferredVal += " " + mcpTagStyle().Render("(default)") + } + writeKV("Deferred", deferredVal) + if len(server.EnvKeys) > 0 { + writeKV("Env vars", mutedStyle().Render(strings.Join(server.EnvKeys, ", "))) + } + if server.EnvFile != "" { + writeKV("Env file", mutedStyle().Render(server.EnvFile)) + } + if len(server.Headers) > 0 { + writeKV("Headers", mutedStyle().Render(strings.Join(server.Headers, ", "))) + } + + if disabled { + b.WriteString("\n") + b.WriteString(mutedStyle().Render("Server is disabled; skipping tool discovery.")) + fmt.Fprintln(w, box.Render(b.String())) + return + } + + if len(tools) == 0 { + b.WriteString("\n") + b.WriteString(mutedStyle().Render("No tools exposed by this server.")) + fmt.Fprintln(w, box.Render(b.String())) + return + } + + // ── tools section ── + b.WriteString("\n") + b.WriteString(kvKeyStyle().Render(fmt.Sprintf("Tools (%d)", len(tools)))) + b.WriteString("\n") + + contentW := inner - 4 // account for box padding + for i, tool := range tools { + if i > 0 { + b.WriteString(strings.Repeat("─", contentW) + "\n") + } + b.WriteString("\n") + + // Tool name + index badge + badge := mcpTagStyle().Render(fmt.Sprintf("[%d/%d]", i+1, len(tools))) + b.WriteString(" " + mcpToolNameStyle().Render(tool.Name) + " " + badge + "\n") + + // Description (wrapped to content width) + if tool.Description != "" { + desc := truncateDescription(tool.Description, 160) + b.WriteString(" " + mcpDescStyle().Render(desc) + "\n") + } + + // Parameters + if len(tool.Parameters) == 0 { + b.WriteString(" " + mcpTagStyle().Render("no parameters") + "\n") + continue + } + + b.WriteString("\n") + for _, p := range tool.Parameters { + // name + pName := mcpParamNameStyle().Render(p.Name) + + // type tag + typeTag := "" + if p.Type != "" { + typeTag = " " + mcpTagStyle().Render("<"+p.Type+">") + } + + // required / optional badge + var reqBadge string + if p.Required { + reqBadge = " " + mcpRequiredStyle().Render("required") + } else { + reqBadge = " " + mcpOptionalStyle().Render("optional") + } + + b.WriteString(" " + pName + typeTag + reqBadge + "\n") + + if p.Description != "" { + desc := truncateDescription(p.Description, 120) + b.WriteString(" " + mutedStyle().Render(desc) + "\n") + } + } + } + + fmt.Fprintln(w, box.Render(b.String())) +} + +// ── mcp list ──────────────────────────────────────────────────────────────── + +// MCPListRow is one row in the mcp list output. +type MCPListRow struct { + Name string + Type string + Target string + Status string // "enabled", "disabled", "ok (N tools)", "error" + EffectiveDeferred bool // resolved value (per-server override or global default) + DeferredExplicit bool // true = per-server override set, false = inherited from global +} + +// PrintMCPList renders the mcp list output (plain or fancy). +func PrintMCPList(w io.Writer, rows []MCPListRow) { + if !UseFancyLayout() { + printMCPListPlain(w, rows) + return + } + printMCPListFancy(w, rows) +} + +func printMCPListPlain(w io.Writer, rows []MCPListRow) { + headers := []string{"Name", "Type", "Command", "Status", "Deferred"} + tableRows := make([][]string, len(rows)) + for i, r := range rows { + deferred := boolWord(r.EffectiveDeferred) + if !r.DeferredExplicit { + deferred += " (default)" + } + tableRows[i] = []string{r.Name, r.Type, r.Target, r.Status, deferred} + } + // reuse the ASCII table renderer already in helpers.go via the caller + // (list.go still uses renderTable for the plain path) + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = len(h) + } + for _, row := range tableRows { + for i, cell := range row { + if len(cell) > widths[i] { + widths[i] = len(cell) + } + } + } + border := func() { + fmt.Fprint(w, "+") + for _, width := range widths { + fmt.Fprint(w, strings.Repeat("-", width+2)+"+") + } + fmt.Fprintln(w) + } + writeRow := func(row []string) { + fmt.Fprint(w, "|") + for i, cell := range row { + fmt.Fprintf(w, " %s%s |", cell, strings.Repeat(" ", widths[i]-len(cell))) + } + fmt.Fprintln(w) + } + border() + writeRow(headers) + border() + for _, row := range tableRows { + writeRow(row) + } + border() +} + +func printMCPListFancy(w io.Writer, rows []MCPListRow) { + inner := InnerWidth() + box := borderStyle().Width(inner) + + var b strings.Builder + + title := fmt.Sprintf("MCP Servers (%d)", len(rows)) + b.WriteString(titleBarStyle().Render(title)) + b.WriteString("\n") + + contentW := inner - 4 + for i, row := range rows { + if i > 0 { + b.WriteString(strings.Repeat("─", contentW) + "\n") + } + b.WriteString("\n") + + statusBadge := mcpListStatusStyle(row.Status).Render(row.Status) + var deferredBadge string + if row.EffectiveDeferred { + if row.DeferredExplicit { + deferredBadge = " " + mcpTagStyle().Render("deferred") + } else { + deferredBadge = " " + mcpOptionalStyle().Render("deferred (default)") + } + } + b.WriteString(" " + mcpToolNameStyle().Render(row.Name) + " " + statusBadge + deferredBadge + "\n") + b.WriteString(" " + mcpTagStyle().Render(row.Type+" "+row.Target) + "\n") + } + + fmt.Fprintln(w, box.Render(b.String())) +} + +func mcpListStatusStyle(status string) lipgloss.Style { + switch { + case status == "enabled": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true) + case status == "disabled": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B")) + case strings.HasPrefix(status, "ok"): + return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true) + case status == "error": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true) + default: + return lipgloss.NewStyle() + } +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +func boolWord(v bool) string { + if v { + return "yes" + } + return "no" +} + +func coloredBool(v bool) string { + if v { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true).Render("yes") + } + return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Render("no") +} + +// truncateDescription strips newlines, collapses whitespace, and caps length. +func truncateDescription(s string, maxLen int) string { + // collapse newlines and repeated spaces into a single space + s = strings.Join(strings.Fields(s), " ") + if len(s) <= maxLen { + return s + } + // cut at last space before maxLen + cut := s[:maxLen] + if idx := strings.LastIndex(cut, " "); idx > maxLen/2 { + cut = cut[:idx] + } + return cut + "…" +} diff --git a/cmd/picoclaw/internal/cliui/onboard.go b/cmd/picoclaw/internal/cliui/onboard.go new file mode 100644 index 000000000..e74cf68c6 --- /dev/null +++ b/cmd/picoclaw/internal/cliui/onboard.go @@ -0,0 +1,110 @@ +package cliui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// PrintOnboardComplete prints the post-onboard “ready” message and next steps. +func PrintOnboardComplete(logo string, encrypt bool, configPath string) { + if !UseFancyLayout() { + printOnboardPlain(logo, encrypt, configPath) + return + } + printOnboardFancy(logo, encrypt, configPath) +} + +func printOnboardPlain(logo string, encrypt bool, configPath string) { + fmt.Printf("\n%s picoclaw is ready!\n", logo) + fmt.Println("\nNext steps:") + if encrypt { + fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:") + fmt.Println(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS") + fmt.Println(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd") + fmt.Println("") + fmt.Println(" 2. Add your API key to", configPath) + } else { + fmt.Println(" 1. Add your API key to", configPath) + } + fmt.Println("") + fmt.Println(" Recommended:") + fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)") + fmt.Println(" - Ollama: https://ollama.com (local, free)") + fmt.Println("") + fmt.Println(" See README.md for 17+ supported providers.") + fmt.Println("") + if encrypt { + fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"") + } else { + fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") + } +} + +func printOnboardFancy(logo string, encrypt bool, configPath string) { + inner := InnerWidth() + box := borderStyle().MaxWidth(inner + 8) + + ready := titleBarStyle().Render(logo+" picoclaw is ready!") + "\n" + fmt.Println() + fmt.Println(box.Width(inner).Render(strings.TrimSpace(ready))) + fmt.Println() + + steps := buildOnboardingSteps(encrypt, configPath) + rec := recommendedBlock() + chat := chatStep(encrypt) + + if UseColumnLayout() { + leftW := min(inner/2-2, 52) + rightW := inner - leftW - 4 + if rightW < 36 { + rightW = 36 + } + leftBlock := borderStyle().MaxWidth(leftW + 8).Width(leftW). + Render(titleBarStyle().Render("Next steps") + "\n\n" + bodyStyle().Width(leftW).Render(steps)) + rightBlock := borderStyle().MaxWidth(rightW + 8).Width(rightW). + Render(mutedStyle().Bold(true).Render("Recommended") + "\n\n" + bodyStyle().Width(rightW).Render(rec)) + gap := strings.Repeat(" ", 2) + fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, leftBlock, gap, rightBlock)) + fmt.Println() + full := borderStyle().Width(inner).Render(bodyStyle().Width(inner - 4).Render(chat)) + fmt.Println(full) + return + } + + // Same order as plain output: numbered steps → recommended → chat line. + next := titleBarStyle().Render("Next steps") + "\n\n" + + bodyStyle().Width(inner-4).Render(steps+"\n\n"+rec+"\n\n"+chat) + fmt.Println(borderStyle().Width(inner).Render(next)) +} + +func buildOnboardingSteps(encrypt bool, configPath string) string { + var b strings.Builder + if encrypt { + b.WriteString("1. Set your encryption passphrase before starting picoclaw:\n") + b.WriteString(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS\n") + b.WriteString(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd\n\n") + b.WriteString("2. Add your API key to\n ") + b.WriteString(configPath) + b.WriteString("\n") + } else { + b.WriteString("1. Add your API key to\n ") + b.WriteString(configPath) + b.WriteString("\n") + } + return b.String() +} + +func recommendedBlock() string { + return "• OpenRouter: https://openrouter.ai/keys\n (access 100+ models)\n\n" + + "• Ollama: https://ollama.com\n (local, free)\n\n" + + "See README.md for 17+ supported providers." +} + +func chatStep(encrypt bool) string { + if encrypt { + return "3. Chat:\n picoclaw agent -m \"Hello!\"" + } + return "2. Chat:\n picoclaw agent -m \"Hello!\"" +} diff --git a/cmd/picoclaw/internal/cliui/status.go b/cmd/picoclaw/internal/cliui/status.go new file mode 100644 index 000000000..f01fe296d --- /dev/null +++ b/cmd/picoclaw/internal/cliui/status.go @@ -0,0 +1,168 @@ +package cliui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// ProviderRow holds one provider's display name and status value. +type ProviderRow struct { + Name string + Val string +} + +// StatusReport is a structured status view for PrintStatus. +type StatusReport struct { + Logo string + Version string + Build string + ConfigPath string + ConfigOK bool + WorkspacePath string + WorkspaceOK bool + Model string + Providers []ProviderRow + OAuthLines []string // each full line "provider (method): state" +} + +// PrintStatus renders picoclaw status (plain or fancy). +func PrintStatus(r StatusReport) { + if !UseFancyLayout() { + printStatusPlain(r) + return + } + printStatusFancy(r) +} + +func printStatusPlain(r StatusReport) { + fmt.Printf("%s picoclaw Status\n", r.Logo) + fmt.Printf("Version: %s\n", r.Version) + if r.Build != "" { + fmt.Printf("Build: %s\n", r.Build) + } + fmt.Println() + + printPathLine("Config", r.ConfigPath, r.ConfigOK) + printPathLine("Workspace", r.WorkspacePath, r.WorkspaceOK) + + if r.ConfigOK { + fmt.Printf("Model: %s\n", r.Model) + for _, p := range r.Providers { + fmt.Printf("%s: %s\n", p.Name, p.Val) + } + if len(r.OAuthLines) > 0 { + fmt.Println("\nOAuth/Token Auth:") + for _, line := range r.OAuthLines { + fmt.Printf(" %s\n", line) + } + } + } +} + +func printPathLine(label, path string, ok bool) { + mark := "✗" + if ok { + mark = "✓" + } + fmt.Println(label+":", path, mark) +} + +func printStatusFancy(r StatusReport) { + inner := InnerWidth() + topBox := borderStyle().Width(inner) + + var head strings.Builder + head.WriteString(titleBarStyle().Render(r.Logo + " picoclaw Status")) + head.WriteString("\n\n") + head.WriteString(kvKeyStyle().Render("Version") + " " + kvValStyle().Render(r.Version)) + if r.Build != "" { + head.WriteString("\n") + head.WriteString(kvKeyStyle().Render("Build") + " " + kvValStyle().Render(r.Build)) + } + fmt.Println(topBox.Render(head.String())) + fmt.Println() + + if UseColumnLayout() && len(r.Providers) > 0 && r.ConfigOK { + leftW := (inner - 2) / 2 + rightW := inner - leftW - 2 + pathsNarrow := pathStatusPanel(r, leftW) + prov := providerTablePanel(r, rightW) + gap := strings.Repeat(" ", 2) + fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, pathsNarrow, gap, prov)) + } else { + fmt.Println(pathStatusPanel(r, inner)) + if len(r.Providers) > 0 && r.ConfigOK { + fmt.Println(providerTablePanel(r, inner)) + } + } + + if len(r.OAuthLines) > 0 && r.ConfigOK { + var ob strings.Builder + ob.WriteString(titleBarStyle().Render("OAuth / token auth") + "\n\n") + for _, line := range r.OAuthLines { + ob.WriteString(" • " + line + "\n") + } + fmt.Println() + fmt.Println(borderStyle().Width(inner).Render(ob.String())) + } +} + +func pathStatusPanel(r StatusReport, inner int) string { + cfgMark := statusMark(r.ConfigOK) + wsMark := statusMark(r.WorkspaceOK) + var b strings.Builder + b.WriteString(kvKeyStyle().Render("Config") + "\n") + b.WriteString(mutedStyle().Render(r.ConfigPath)) + b.WriteString(" " + cfgMark + "\n\n") + b.WriteString(kvKeyStyle().Render("Workspace") + "\n") + b.WriteString(mutedStyle().Render(r.WorkspacePath)) + b.WriteString(" " + wsMark + "\n") + if r.ConfigOK { + b.WriteString("\n") + b.WriteString(kvKeyStyle().Render("Model") + " " + kvValStyle().Render(r.Model)) + } + return borderStyle().Width(inner).Render(b.String()) +} + +func statusMark(ok bool) string { + if ok { + return lipgloss.NewStyle().Foreground(colorOK).Render("✓") + } + return lipgloss.NewStyle().Foreground(accentRed).Render("✗") +} + +func providerTablePanel(r StatusReport, colW int) string { + if len(r.Providers) == 0 { + return "" + } + keyW := min(22, colW/3) + if keyW < 14 { + keyW = 14 + } + valW := colW - keyW - 3 + if valW < 12 { + valW = 12 + } + + var b strings.Builder + b.WriteString(titleBarStyle().Render("Providers & local") + "\n\n") + for _, p := range r.Providers { + k := lipgloss.NewStyle().Foreground(accentBlue).Bold(true).Width(keyW).Render(p.Name) + v := styleProviderVal(p.Val).Width(valW).Render(p.Val) + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, k, " ", v)) + b.WriteString("\n") + } + return borderStyle().Width(colW).Render(strings.TrimRight(b.String(), "\n")) +} + +func styleProviderVal(s string) lipgloss.Style { + if s == "✓" || strings.HasPrefix(s, "✓ ") { + return lipgloss.NewStyle().Foreground(colorOK) + } + if s == "not set" { + return mutedStyle() + } + return lipgloss.NewStyle() +} diff --git a/cmd/picoclaw/internal/cliui/version.go b/cmd/picoclaw/internal/cliui/version.go new file mode 100644 index 000000000..7ecbdae7f --- /dev/null +++ b/cmd/picoclaw/internal/cliui/version.go @@ -0,0 +1,61 @@ +package cliui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// PrintVersion prints version, optional build info, and Go toolchain line. +func PrintVersion(logo, versionLine string, build, goVer string) { + if !UseFancyLayout() { + fmt.Printf("%s %s\n", logo, versionLine) + if build != "" { + fmt.Printf(" Build: %s\n", build) + } + if goVer != "" { + fmt.Printf(" Go: %s\n", goVer) + } + return + } + + inner := InnerWidth() + box := borderStyle().Width(inner) + + if UseColumnLayout() { + leftCol := kvKeyStyle().Width(12).Align(lipgloss.Right) + rightW := inner - 16 + rightStyle := kvValStyle().Width(rightW) + + rows := [][]string{ + {leftCol.Render("Version"), rightStyle.Render(versionLine)}, + } + if build != "" { + rows = append(rows, []string{leftCol.Render("Build"), rightStyle.Render(build)}) + } + if goVer != "" { + rows = append(rows, []string{leftCol.Render("Go"), rightStyle.Render(goVer)}) + } + var body strings.Builder + for _, r := range rows { + body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, r[0], " ", r[1])) + body.WriteString("\n") + } + header := titleBarStyle().Render(logo+" picoclaw") + "\n\n" + fmt.Println(box.Render(header + body.String())) + return + } + + var lines []string + lines = append(lines, titleBarStyle().Render(logo+" picoclaw")) + lines = append(lines, "") + lines = append(lines, kvKeyStyle().Render("Version")+" "+kvValStyle().Render(versionLine)) + if build != "" { + lines = append(lines, kvKeyStyle().Render("Build")+" "+kvValStyle().Render(build)) + } + if goVer != "" { + lines = append(lines, kvKeyStyle().Render("Go")+" "+kvValStyle().Render(goVer)) + } + fmt.Println(box.Render(strings.Join(lines, "\n"))) +} diff --git a/cmd/picoclaw/internal/cron/add.go b/cmd/picoclaw/internal/cron/add.go index 947557d5a..f9d73089d 100644 --- a/cmd/picoclaw/internal/cron/add.go +++ b/cmd/picoclaw/internal/cron/add.go @@ -14,7 +14,6 @@ func newAddCommand(storePath func() string) *cobra.Command { message string every int64 cronExp string - deliver bool channel string to string ) @@ -37,7 +36,7 @@ func newAddCommand(storePath func() string) *cobra.Command { } cs := cron.NewCronService(storePath(), nil) - job, err := cs.AddJob(name, schedule, message, deliver, channel, to) + job, err := cs.AddJob(name, schedule, message, channel, to) if err != nil { return fmt.Errorf("error adding job: %w", err) } @@ -52,7 +51,6 @@ func newAddCommand(storePath func() string) *cobra.Command { cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent") cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds") cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')") - cmd.Flags().BoolVarP(&deliver, "deliver", "d", false, "Deliver response to channel") cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery") cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery") diff --git a/cmd/picoclaw/internal/cron/add_test.go b/cmd/picoclaw/internal/cron/add_test.go index 09701fab5..53875dc51 100644 --- a/cmd/picoclaw/internal/cron/add_test.go +++ b/cmd/picoclaw/internal/cron/add_test.go @@ -21,7 +21,6 @@ func TestNewAddSubcommand(t *testing.T) { assert.NotNil(t, cmd.Flags().Lookup("every")) assert.NotNil(t, cmd.Flags().Lookup("cron")) - assert.NotNil(t, cmd.Flags().Lookup("deliver")) assert.NotNil(t, cmd.Flags().Lookup("to")) assert.NotNil(t, cmd.Flags().Lookup("channel")) diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go index 66a56f9ce..7dd03b495 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -1,23 +1,91 @@ package gateway import ( + "fmt" + "os" + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/gateway" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/netbind" + "github.com/sipeed/picoclaw/pkg/utils" ) +func resolveGatewayHostOverride(explicit bool, host string) (string, error) { + if !explicit { + return "", nil + } + normalized, err := netbind.NormalizeHostInput(host) + if err != nil { + return "", fmt.Errorf("invalid --host value: %w", err) + } + return normalized, nil +} + func NewGatewayCommand() *cobra.Command { var debug bool + var noTruncate bool + var allowEmpty bool + var host string cmd := &cobra.Command{ Use: "gateway", Aliases: []string{"g"}, Short: "Start picoclaw gateway", Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - return gatewayCmd(debug) + PreRunE: func(_ *cobra.Command, _ []string) error { + if noTruncate && !debug { + return fmt.Errorf("the --no-truncate option can only be used in conjunction with --debug (-d)") + } + + if noTruncate { + utils.SetDisableTruncation(true) + logger.Info("String truncation is globally disabled via 'no-truncate' flag") + } + + return nil + }, + RunE: func(cmd *cobra.Command, _ []string) error { + resolvedHost, err := resolveGatewayHostOverride(cmd.Flags().Changed("host"), host) + if err != nil { + return err + } + if resolvedHost != "" { + prevHost, hadPrev := os.LookupEnv(config.EnvGatewayHost) + if err := os.Setenv(config.EnvGatewayHost, resolvedHost); err != nil { + return fmt.Errorf("failed to set %s: %w", config.EnvGatewayHost, err) + } + defer func() { + if hadPrev { + _ = os.Setenv(config.EnvGatewayHost, prevHost) + return + } + _ = os.Unsetenv(config.EnvGatewayHost) + }() + } + + return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty) }, } cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") + cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs") + cmd.Flags().BoolVarP( + &allowEmpty, + "allow-empty", + "E", + false, + "Continue starting even when no default model is configured", + ) + cmd.Flags().StringVar( + &host, + "host", + "", + "Host address for gateway binding (overrides gateway.host for this run)", + ) return cmd } diff --git a/cmd/picoclaw/internal/gateway/command_test.go b/cmd/picoclaw/internal/gateway/command_test.go index 4d591ea67..825369abb 100644 --- a/cmd/picoclaw/internal/gateway/command_test.go +++ b/cmd/picoclaw/internal/gateway/command_test.go @@ -28,4 +28,39 @@ func TestNewGatewayCommand(t *testing.T) { assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("debug")) + assert.NotNil(t, cmd.Flags().Lookup("allow-empty")) + assert.NotNil(t, cmd.Flags().Lookup("host")) +} + +func TestResolveGatewayHostOverride(t *testing.T) { + tests := []struct { + name string + explicit bool + host string + wantHost string + wantErr bool + }{ + {name: "implicit empty host is allowed", explicit: false, host: "", wantHost: "", wantErr: false}, + {name: "explicit empty host rejected", explicit: true, host: " ", wantHost: "", wantErr: true}, + {name: "explicit localhost kept", explicit: true, host: " localhost ", wantHost: "localhost", wantErr: false}, + { + name: "explicit multi host normalized", + explicit: true, + host: " [::1] , 127.0.0.1 ", + wantHost: "::1,127.0.0.1", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveGatewayHostOverride(tt.explicit, tt.host) + if (err != nil) != tt.wantErr { + t.Fatalf("resolveGatewayHostOverride() err = %v, wantErr %t", err, tt.wantErr) + } + if got != tt.wantHost { + t.Fatalf("resolveGatewayHostOverride() host = %q, want %q", got, tt.wantHost) + } + }) + } } diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go deleted file mode 100644 index ee7369ba9..000000000 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ /dev/null @@ -1,257 +0,0 @@ -package gateway - -import ( - "context" - "fmt" - "log" - "os" - "os/signal" - "path/filepath" - "time" - - "github.com/sipeed/picoclaw/cmd/picoclaw/internal" - "github.com/sipeed/picoclaw/pkg/agent" - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/channels" - _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" - _ "github.com/sipeed/picoclaw/pkg/channels/discord" - _ "github.com/sipeed/picoclaw/pkg/channels/feishu" - _ "github.com/sipeed/picoclaw/pkg/channels/irc" - _ "github.com/sipeed/picoclaw/pkg/channels/line" - _ "github.com/sipeed/picoclaw/pkg/channels/magicform" - _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" - _ "github.com/sipeed/picoclaw/pkg/channels/matrix" - _ "github.com/sipeed/picoclaw/pkg/channels/onebot" - _ "github.com/sipeed/picoclaw/pkg/channels/pico" - _ "github.com/sipeed/picoclaw/pkg/channels/qq" - _ "github.com/sipeed/picoclaw/pkg/channels/slack" - _ "github.com/sipeed/picoclaw/pkg/channels/telegram" - _ "github.com/sipeed/picoclaw/pkg/channels/wecom" - _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" - _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/cron" - "github.com/sipeed/picoclaw/pkg/devices" - "github.com/sipeed/picoclaw/pkg/health" - "github.com/sipeed/picoclaw/pkg/heartbeat" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/providers" - "github.com/sipeed/picoclaw/pkg/state" - "github.com/sipeed/picoclaw/pkg/tools" - "github.com/sipeed/picoclaw/pkg/voice" -) - -func gatewayCmd(debug bool) error { - if debug { - logger.SetLevel(logger.DEBUG) - fmt.Println("🔍 Debug mode enabled") - } - - cfg, err := internal.LoadConfig() - if err != nil { - return fmt.Errorf("error loading config: %w", err) - } - - provider, modelID, err := providers.CreateProvider(cfg) - if err != nil { - return fmt.Errorf("error creating provider: %w", err) - } - - // Use the resolved model ID from provider creation - if modelID != "" { - cfg.Agents.Defaults.ModelName = modelID - } - - msgBus := bus.NewMessageBus() - agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) - - // Print agent startup info - fmt.Println("\n📦 Agent Status:") - startupInfo := agentLoop.GetStartupInfo() - toolsInfo := startupInfo["tools"].(map[string]any) - skillsInfo := startupInfo["skills"].(map[string]any) - fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) - fmt.Printf(" • Skills: %d/%d available\n", - skillsInfo["available"], - skillsInfo["total"]) - - // Log to file as well - logger.InfoCF("agent", "Agent initialized", - map[string]any{ - "tools_count": toolsInfo["count"], - "skills_total": skillsInfo["total"], - "skills_available": skillsInfo["available"], - }) - - // Setup cron tool and service - execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute - cronService := setupCronTool( - agentLoop, - msgBus, - cfg.WorkspacePath(), - cfg.Agents.Defaults.RestrictToWorkspace, - execTimeout, - cfg, - ) - - heartbeatService := heartbeat.NewHeartbeatService( - cfg.WorkspacePath(), - cfg.Heartbeat.Interval, - cfg.Heartbeat.Enabled, - ) - heartbeatService.SetBus(msgBus) - heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { - // Use cli:direct as fallback if no valid channel - if channel == "" || chatID == "" { - channel, chatID = "cli", "direct" - } - // Use ProcessHeartbeat - no session history, each heartbeat is independent - var response string - response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) - if err != nil { - return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) - } - if response == "HEARTBEAT_OK" { - return tools.SilentResult("Heartbeat OK") - } - // For heartbeat, always return silent - the subagent result will be - // sent to user via processSystemMessage when the async task completes - return tools.SilentResult(response) - }) - - // Create media store for file lifecycle management with TTL cleanup - mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{ - Enabled: cfg.Tools.MediaCleanup.Enabled, - MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute, - Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute, - }) - mediaStore.Start() - - channelManager, err := channels.NewManager(cfg, msgBus, mediaStore) - if err != nil { - mediaStore.Stop() - return fmt.Errorf("error creating channel manager: %w", err) - } - - // Inject channel manager and media store into agent loop - agentLoop.SetChannelManager(channelManager) - agentLoop.SetMediaStore(mediaStore) - - // Wire up voice transcription if a supported provider is configured. - if transcriber := voice.DetectTranscriber(cfg); transcriber != nil { - agentLoop.SetTranscriber(transcriber) - logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) - } - - enabledChannels := channelManager.GetEnabledChannels() - if len(enabledChannels) > 0 { - fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) - } else { - fmt.Println("⚠ Warning: No channels enabled") - } - - fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) - fmt.Println("Press Ctrl+C to stop") - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - if err := cronService.Start(); err != nil { - fmt.Printf("Error starting cron service: %v\n", err) - } - fmt.Println("✓ Cron service started") - - if err := heartbeatService.Start(); err != nil { - fmt.Printf("Error starting heartbeat service: %v\n", err) - } - fmt.Println("✓ Heartbeat service started") - - stateManager := state.NewManager(cfg.WorkspacePath()) - deviceService := devices.NewService(devices.Config{ - Enabled: cfg.Devices.Enabled, - MonitorUSB: cfg.Devices.MonitorUSB, - }, stateManager) - deviceService.SetBus(msgBus) - if err := deviceService.Start(ctx); err != nil { - fmt.Printf("Error starting device service: %v\n", err) - } else if cfg.Devices.Enabled { - fmt.Println("✓ Device event service started") - } - - // Setup shared HTTP server with health endpoints and webhook handlers - healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) - addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) - channelManager.SetupHTTPServer(addr, healthServer) - - if err := channelManager.StartAll(ctx); err != nil { - fmt.Printf("Error starting channels: %v\n", err) - return err - } - - fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) - - go agentLoop.Run(ctx) - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt) - <-sigChan - - fmt.Println("\nShutting down...") - if cp, ok := provider.(providers.StatefulProvider); ok { - cp.Close() - } - cancel() - msgBus.Close() - - // Use a fresh context with timeout for graceful shutdown, - // since the original ctx is already canceled. - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) - defer shutdownCancel() - - channelManager.StopAll(shutdownCtx) - deviceService.Stop() - heartbeatService.Stop() - cronService.Stop() - mediaStore.Stop() - agentLoop.Stop() - fmt.Println("✓ Gateway stopped") - - return nil -} - -func setupCronTool( - agentLoop *agent.AgentLoop, - msgBus *bus.MessageBus, - workspace string, - restrict bool, - execTimeout time.Duration, - cfg *config.Config, -) *cron.CronService { - cronStorePath := filepath.Join(workspace, "cron", "jobs.json") - - // Create cron service - cronService := cron.NewCronService(cronStorePath, nil) - - // Create and register CronTool if enabled - var cronTool *tools.CronTool - if cfg.Tools.IsToolEnabled("cron") { - var err error - cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) - if err != nil { - log.Fatalf("Critical error during CronTool initialization: %v", err) - } - - agentLoop.RegisterTool(cronTool) - } - - // Set onJob handler - if cronTool != nil { - cronService.SetOnJob(func(job *cron.CronJob) (string, error) { - result := cronTool.ExecuteJob(context.Background(), job) - return result, nil - }) - } - - return cronService -} diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go index f81d7013d..afe5074a7 100644 --- a/cmd/picoclaw/internal/helpers.go +++ b/cmd/picoclaw/internal/helpers.go @@ -1,64 +1,52 @@ package internal import ( - "fmt" "os" "path/filepath" - "runtime" + "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" ) -const Logo = "🦞" - -var ( - version = "dev" - gitCommit string - buildTime string - goVersion string -) +const Logo = pkg.Logo // GetPicoclawHome returns the picoclaw home directory. // Priority: $PICOCLAW_HOME > ~/.picoclaw func GetPicoclawHome() string { - if home := os.Getenv("PICOCLAW_HOME"); home != "" { - return home - } - home, _ := os.UserHomeDir() - return filepath.Join(home, ".picoclaw") + return config.GetHome() } func GetConfigPath() string { - if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" { + if configPath := os.Getenv(config.EnvConfig); configPath != "" { return configPath } return filepath.Join(GetPicoclawHome(), "config.json") } func LoadConfig() (*config.Config, error) { - return config.LoadConfig(GetConfigPath()) + cfg, err := config.LoadConfig(GetConfigPath()) + if err != nil { + return nil, err + } + logger.SetLevelFromString(cfg.Gateway.LogLevel) + return cfg, nil } // FormatVersion returns the version string with optional git commit +// Deprecated: Use pkg/config.FormatVersion instead func FormatVersion() string { - v := version - if gitCommit != "" { - v += fmt.Sprintf(" (git: %s)", gitCommit) - } - return v + return config.FormatVersion() } // FormatBuildInfo returns build time and go version info +// Deprecated: Use pkg/config.FormatBuildInfo instead func FormatBuildInfo() (string, string) { - build := buildTime - goVer := goVersion - if goVer == "" { - goVer = runtime.Version() - } - return build, goVer + return config.FormatBuildInfo() } // GetVersion returns the version string +// Deprecated: Use pkg/config.GetVersion instead func GetVersion() string { - return version + return config.GetVersion() } diff --git a/cmd/picoclaw/internal/helpers_test.go b/cmd/picoclaw/internal/helpers_test.go index 646be1ba1..953da8886 100644 --- a/cmd/picoclaw/internal/helpers_test.go +++ b/cmd/picoclaw/internal/helpers_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/config" ) func TestGetConfigPath(t *testing.T) { @@ -20,7 +22,7 @@ func TestGetConfigPath(t *testing.T) { } func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) { - t.Setenv("PICOCLAW_HOME", "/custom/picoclaw") + t.Setenv(config.EnvHome, "/custom/picoclaw") t.Setenv("HOME", "/tmp/home") got := GetConfigPath() @@ -31,7 +33,7 @@ func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) { func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) { t.Setenv("PICOCLAW_CONFIG", "/custom/config.json") - t.Setenv("PICOCLAW_HOME", "/custom/picoclaw") + t.Setenv(config.EnvHome, "/custom/picoclaw") t.Setenv("HOME", "/tmp/home") got := GetConfigPath() @@ -40,65 +42,6 @@ func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) { assert.Equal(t, want, got) } -func TestFormatVersion_NoGitCommit(t *testing.T) { - oldVersion, oldGit := version, gitCommit - t.Cleanup(func() { version, gitCommit = oldVersion, oldGit }) - - version = "1.2.3" - gitCommit = "" - - assert.Equal(t, "1.2.3", FormatVersion()) -} - -func TestFormatVersion_WithGitCommit(t *testing.T) { - oldVersion, oldGit := version, gitCommit - t.Cleanup(func() { version, gitCommit = oldVersion, oldGit }) - - version = "1.2.3" - gitCommit = "abc123" - - assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion()) -} - -func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) { - oldBuildTime, oldGoVersion := buildTime, goVersion - t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion }) - - buildTime = "2026-02-20T00:00:00Z" - goVersion = "go1.23.0" - - build, goVer := FormatBuildInfo() - - assert.Equal(t, buildTime, build) - assert.Equal(t, goVersion, goVer) -} - -func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) { - oldBuildTime, oldGoVersion := buildTime, goVersion - t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion }) - - buildTime = "" - goVersion = "go1.23.0" - - build, goVer := FormatBuildInfo() - - assert.Empty(t, build) - assert.Equal(t, goVersion, goVer) -} - -func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) { - oldBuildTime, oldGoVersion := buildTime, goVersion - t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion }) - - buildTime = "x" - goVersion = "" - - build, goVer := FormatBuildInfo() - - assert.Equal(t, "x", build) - assert.Equal(t, runtime.Version(), goVer) -} - func TestGetConfigPath_Windows(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("windows-specific HOME behavior varies; run on windows") @@ -112,17 +55,3 @@ func TestGetConfigPath_Windows(t *testing.T) { require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want) } - -func TestGetVersion(t *testing.T) { - assert.Equal(t, "dev", GetVersion()) -} - -func TestGetConfigPath_WithEnv(t *testing.T) { - t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json") - t.Setenv("HOME", "/tmp/home") // Also set home to ensure env is preferred - - got := GetConfigPath() - want := "/tmp/custom/config.json" - - assert.Equal(t, want, got) -} diff --git a/cmd/picoclaw/internal/mcp/add.go b/cmd/picoclaw/internal/mcp/add.go new file mode 100644 index 000000000..8ad68571f --- /dev/null +++ b/cmd/picoclaw/internal/mcp/add.go @@ -0,0 +1,249 @@ +package mcp + +import ( + "fmt" + "net/url" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type addOptions struct { + Env []string + EnvFile string + Headers []string + Transport string + Force bool + Deferred *bool // nil = not set, true = deferred, false = not deferred +} + +func newAddCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "add [flags] [args...]", + Short: "Add or update an MCP server", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, name, target, targetArgs, showHelp, err := parseAddArgs(args) + if showHelp { + return cmd.Help() + } + if err != nil { + return err + } + + cfg, err := loadConfig() + if err != nil { + return err + } + if cfg.Tools.MCP.Servers == nil { + cfg.Tools.MCP.Servers = make(map[string]config.MCPServerConfig) + } + + if _, exists := cfg.Tools.MCP.Servers[name]; exists && !opts.Force { + var overwrite bool + + overwrite, err = confirmOverwrite(cmd.InOrStdin(), cmd.OutOrStdout(), name) + if err != nil { + return fmt.Errorf("failed to confirm overwrite: %w", err) + } + if !overwrite { + return fmt.Errorf("aborted: MCP server %q already exists", name) + } + } + + server, err := buildServerConfig(target, targetArgs, opts) + if err != nil { + return err + } + + cfg.Tools.MCP.Enabled = true + cfg.Tools.MCP.Servers[name] = server + + if err := saveValidatedConfig(cfg); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q saved.\n", name) + return nil + }, + } + + flags := cmd.Flags() + flags.StringArrayP("env", "e", nil, "Environment variable in KEY=value format (repeatable, saved to config)") + flags.String("env-file", "", "Path to an env file for stdio servers (recommended for secrets)") + flags.StringArrayP("header", "H", nil, "HTTP header in 'Name: Value' or 'Name=Value' format (repeatable)") + flags.StringP("transport", "t", "stdio", "Transport type: stdio, http, or sse") + flags.BoolP("force", "f", false, "Overwrite an existing server without prompting") + flags.Bool("deferred", false, "Mark server as deferred (tools hidden until explicitly activated)") + flags.Bool("no-deferred", false, "Mark server as non-deferred (tools always active)") + + return cmd +} + +func parseAddArgs(args []string) (addOptions, string, string, []string, bool, error) { + opts := addOptions{Transport: "stdio"} + var positional []string + serverArgs := make([]string, 0) + explicitCommand := make([]string, 0) + + for i := 0; i < len(args); i++ { + arg := args[i] + + switch { + case arg == "--help" || arg == "-h": + return addOptions{}, "", "", nil, true, nil + case arg == "--": + if i+1 < len(args) { + explicitCommand = append(explicitCommand, args[i+1:]...) + } + i = len(args) + case arg == "--force" || arg == "-f": + opts.Force = true + case arg == "--deferred": + t := true + opts.Deferred = &t + case arg == "--no-deferred": + f := false + opts.Deferred = &f + case arg == "--transport" || arg == "-t": + if i+1 >= len(args) { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg) + } + i++ + opts.Transport = args[i] + case strings.HasPrefix(arg, "--transport="): + opts.Transport = strings.TrimPrefix(arg, "--transport=") + case arg == "--env" || arg == "-e": + if i+1 >= len(args) { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg) + } + i++ + opts.Env = append(opts.Env, args[i]) + case arg == "--env-file": + if i+1 >= len(args) { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg) + } + i++ + opts.EnvFile = args[i] + case strings.HasPrefix(arg, "--env="): + opts.Env = append(opts.Env, strings.TrimPrefix(arg, "--env=")) + case strings.HasPrefix(arg, "--env-file="): + opts.EnvFile = strings.TrimPrefix(arg, "--env-file=") + case arg == "--header" || arg == "-H": + if i+1 >= len(args) { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg) + } + i++ + opts.Headers = append(opts.Headers, args[i]) + case strings.HasPrefix(arg, "--header="): + opts.Headers = append(opts.Headers, strings.TrimPrefix(arg, "--header=")) + case strings.HasPrefix(arg, "-") && len(positional) >= 2: + serverArgs = append(serverArgs, args[i:]...) + i = len(args) + default: + positional = append(positional, arg) + } + } + + if len(explicitCommand) > 0 { + if len(positional) != 1 { + return addOptions{}, "", "", nil, false, fmt.Errorf( + "usage: picoclaw mcp add [flags] [args...] or picoclaw mcp add [flags] -- [args...]", + ) + } + if len(explicitCommand) == 0 { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing stdio command after --") + } + return opts, positional[0], explicitCommand[0], explicitCommand[1:], false, nil + } + + if len(positional) < 2 { + return addOptions{}, "", "", nil, false, fmt.Errorf( + "usage: picoclaw mcp add [flags] [args...] or picoclaw mcp add [flags] -- [args...]", + ) + } + + targetArgs := make([]string, 0, len(positional)-2+len(serverArgs)) + targetArgs = append(targetArgs, positional[2:]...) + targetArgs = append(targetArgs, serverArgs...) + + return opts, positional[0], positional[1], targetArgs, false, nil +} + +func buildServerConfig(target string, args []string, opts addOptions) (config.MCPServerConfig, error) { + transport := strings.ToLower(strings.TrimSpace(opts.Transport)) + if transport == "" { + transport = "stdio" + } + switch transport { + case "stdio", "http", "sse": + default: + return config.MCPServerConfig{}, fmt.Errorf("unsupported transport %q", opts.Transport) + } + + env, err := parseEnvAssignments(opts.Env) + if err != nil { + return config.MCPServerConfig{}, err + } + headers, err := parseHeaderAssignments(opts.Headers) + if err != nil { + return config.MCPServerConfig{}, err + } + + server := config.MCPServerConfig{ + Enabled: true, + Type: transport, + Deferred: opts.Deferred, + } + + switch transport { + case "http", "sse": + if len(env) > 0 { + return config.MCPServerConfig{}, fmt.Errorf("--env can only be used with stdio transport") + } + if strings.TrimSpace(opts.EnvFile) != "" { + return config.MCPServerConfig{}, fmt.Errorf("--env-file can only be used with stdio transport") + } + if len(args) > 0 { + return config.MCPServerConfig{}, fmt.Errorf("%s transport does not accept command arguments", transport) + } + parsedURL, err := url.ParseRequestURI(target) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + return config.MCPServerConfig{}, fmt.Errorf("invalid MCP URL %q", target) + } + server.URL = target + server.Headers = headers + return server, nil + } + + if len(headers) > 0 { + return config.MCPServerConfig{}, fmt.Errorf("--header can only be used with http or sse transport") + } + + if looksLikeRemoteURL(target) { + return config.MCPServerConfig{}, fmt.Errorf( + "target %q looks like a remote MCP URL, but transport is %q. Use --transport http or --transport sse", + target, + transport, + ) + } + + command := target + commandArgs := append([]string(nil), args...) + + if err := validateLocalCommandPath(target); err != nil { + return config.MCPServerConfig{}, err + } + if isLocalCommandPath(command) { + command = expandHomePath(command) + } + + server.Command = command + server.Args = commandArgs + server.Env = env + server.EnvFile = strings.TrimSpace(opts.EnvFile) + + return server, nil +} diff --git a/cmd/picoclaw/internal/mcp/command.go b/cmd/picoclaw/internal/mcp/command.go new file mode 100644 index 000000000..d6e21181a --- /dev/null +++ b/cmd/picoclaw/internal/mcp/command.go @@ -0,0 +1,25 @@ +package mcp + +import "github.com/spf13/cobra" + +func NewMCPCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Manage MCP server configuration", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand( + newAddCommand(), + newRemoveCommand(), + newListCommand(), + newEditCommand(), + newTestCommand(), + newShowCommand(), + ) + + return cmd +} diff --git a/cmd/picoclaw/internal/mcp/command_test.go b/cmd/picoclaw/internal/mcp/command_test.go new file mode 100644 index 000000000..be1c9763e --- /dev/null +++ b/cmd/picoclaw/internal/mcp/command_test.go @@ -0,0 +1,619 @@ +package mcp + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNewMCPCommand(t *testing.T) { + cmd := NewMCPCommand() + + require.NotNil(t, cmd) + + assert.Equal(t, "mcp", cmd.Use) + assert.Equal(t, "Manage MCP server configuration", cmd.Short) + assert.True(t, cmd.HasSubCommands()) + + allowedCommands := []string{ + "add", + "remove", + "list", + "edit", + "test", + "show", + } + + subcommands := cmd.Commands() + assert.Len(t, subcommands, len(allowedCommands)) + + for _, subcmd := range subcommands { + found := slices.Contains(allowedCommands, subcmd.Name()) + assert.True(t, found, "unexpected subcommand %q", subcmd.Name()) + assert.False(t, subcmd.Hidden) + } +} + +func TestMCPAddAddsGenericStdioServer(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{ + "add", + "sqlite", + "npx", + "-y", + "@modelcontextprotocol/server-sqlite", + "--db", + "./mydb.db", + }, "") + require.NoError(t, err) + assert.Contains(t, output, `MCP server "sqlite" saved`) + + cfg := readMCPConfig(t, configPath) + require.True(t, cfg.Tools.MCP.Enabled) + + server, ok := cfg.Tools.MCP.Servers["sqlite"] + require.True(t, ok) + assert.True(t, server.Enabled) + assert.Equal(t, "stdio", server.Type) + assert.Equal(t, "npx", server.Command) + assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"}, server.Args) +} + +func TestMCPAddSupportsHeadersAfterURL(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "apify", + "https://mcp.apify.com/", + "-t", + "http", + "--header", + "Authorization: Bearer OMITTED", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["apify"] + assert.Equal(t, "http", server.Type) + assert.Equal(t, "https://mcp.apify.com/", server.URL) + assert.Equal(t, map[string]string{"Authorization": "Bearer OMITTED"}, server.Headers) +} + +func TestMCPAddSupportsTransportBeforeName(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "--transport", + "sse", + "fiscal-ai", + "https://api.fiscal.ai/mcp/sse", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["fiscal-ai"] + assert.Equal(t, "sse", server.Type) + assert.Equal(t, "https://api.fiscal.ai/mcp/sse", server.URL) +} + +func TestMCPAddSupportsExplicitStdioCommandAfterSeparator(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "--transport", + "stdio", + "--env", + "AIRTABLE_API_KEY=YOUR_KEY", + "airtable", + "--", + "npx", + "-y", + "airtable-mcp-server", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["airtable"] + assert.Equal(t, "stdio", server.Type) + assert.Equal(t, "npx", server.Command) + assert.Equal(t, []string{"-y", "airtable-mcp-server"}, server.Args) + assert.Equal(t, map[string]string{"AIRTABLE_API_KEY": "YOUR_KEY"}, server.Env) +} + +func TestMCPAddSupportsEnvFileForStdio(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "--env-file", + ".env.mcp", + "filesystem", + "npx", + "-y", + "@modelcontextprotocol/server-filesystem", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["filesystem"] + assert.Equal(t, "stdio", server.Type) + assert.Equal(t, "npx", server.Command) + assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-filesystem"}, server.Args) + assert.Equal(t, ".env.mcp", server.EnvFile) +} + +func TestMCPAddRejectsEnvFileForHTTP(t *testing.T) { + setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "--transport", + "http", + "--env-file", + ".env.mcp", + "context7", + "https://mcp.context7.com/mcp", + }, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "--env-file can only be used with stdio transport") +} + +func TestMCPAddRejectsNonExecutableLocalCommand(t *testing.T) { + setupMCPConfigEnv(t) + + tmpDir := t.TempDir() + localCmd := filepath.Join(tmpDir, "server.sh") + require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o644)) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "local", localCmd}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "not executable") +} + +func TestMCPAddExpandsHomeInSavedLocalCommand(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + localCmd := filepath.Join(homeDir, "bin", "my-mcp") + require.NoError(t, os.MkdirAll(filepath.Dir(localCmd), 0o755)) + require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o755)) + + tildeCmd := "~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp") + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "local-home", tildeCmd}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["local-home"] + assert.Equal(t, localCmd, server.Command) +} + +func TestMCPAddShowsClearErrorForRemoteURLWithoutTransport(t *testing.T) { + setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "apify", "https://mcp.apify.com/"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), `looks like a remote MCP URL`) + assert.Contains(t, err.Error(), `Use --transport http or --transport sse`) +} + +func TestMCPAddOverwritePromptDecline(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "old", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "n\n") + require.Error(t, err) + assert.Contains(t, output, `Overwrite? [y/N]:`) + assert.Contains(t, err.Error(), "aborted") + + cfg := readMCPConfig(t, configPath) + assert.Equal(t, "old", cfg.Tools.MCP.Servers["filesystem"].Command) +} + +func TestMCPAddOverwriteWithConfirmation(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "old", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "y\n") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + assert.Equal(t, "new-command", cfg.Tools.MCP.Servers["filesystem"].Command) +} + +func TestMCPAddHTTPServer(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "context7", + "--transport", + "http", + "https://mcp.context7.com/mcp", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["context7"] + assert.Equal(t, "http", server.Type) + assert.Equal(t, "https://mcp.context7.com/mcp", server.URL) + assert.Empty(t, server.Command) +} + +func TestMCPRemoveRemovesLastServerAndDisablesMCP(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"remove", "filesystem"}, "") + require.NoError(t, err) + assert.Contains(t, output, `MCP server "filesystem" removed`) + + cfg := readMCPConfig(t, configPath) + assert.False(t, cfg.Tools.MCP.Enabled) + assert.Empty(t, cfg.Tools.MCP.Servers) +} + +func TestMCPListPrintsTable(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "context7": { + Enabled: true, + Type: "http", + URL: "https://mcp.context7.com/mcp", + }, + "filesystem": { + Enabled: false, + Type: "stdio", + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"}, + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"list"}, "") + require.NoError(t, err) + assert.Contains(t, output, "| Name") + assert.Contains(t, output, "context7") + assert.Contains(t, output, "filesystem") + assert.Contains(t, output, "https://mcp.context7.com/mcp") + assert.Contains(t, output, "disabled") +} + +func TestMCPListWithStatusUsesProbe(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + originalProbe := serverProbe + defer func() { serverProbe = originalProbe }() + serverProbe = func(_ context.Context, name string, server config.MCPServerConfig, workspacePath string) (probeResult, error) { + assert.Equal(t, "filesystem", name) + assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath) + assert.Equal(t, "npx", server.Command) + return probeResult{ToolCount: 3}, nil + } + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"list", "--status"}, "") + require.NoError(t, err) + assert.Contains(t, output, "ok (3 tools)") +} + +func TestMCPEditUsesEditor(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + originalEditor := editorCommand + defer func() { editorCommand = originalEditor }() + + var gotName string + var gotArgs []string + editorCommand = func(name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = append([]string(nil), args...) + return exec.Command("sh", "-c", "exit 0") + } + + t.Setenv("EDITOR", `dummy-editor --wait`) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"edit"}, "") + require.NoError(t, err) + + assert.Equal(t, "dummy-editor", gotName) + assert.Equal(t, []string{"--wait", configPath}, gotArgs) + _, statErr := os.Stat(configPath) + assert.NoError(t, statErr) +} + +func TestMCPEditRequiresEditor(t *testing.T) { + setupMCPConfigEnv(t) + t.Setenv("EDITOR", "") + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"edit"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "$EDITOR is not set") +} + +func TestMCPTestUsesProbe(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: false, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + originalProbe := serverProbe + defer func() { serverProbe = originalProbe }() + serverProbe = func(_ context.Context, name string, _ config.MCPServerConfig, workspacePath string) (probeResult, error) { + assert.Equal(t, "filesystem", name) + assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath) + return probeResult{ToolCount: 2}, nil + } + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"test", "filesystem"}, "") + require.NoError(t, err) + assert.Contains(t, output, `MCP server "filesystem" reachable (2 tools)`) +} + +func TestMCPAddDeferredFlag(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "--deferred", "myserver", "npx", "my-mcp"}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["myserver"] + require.NotNil(t, server.Deferred) + assert.True(t, *server.Deferred) +} + +func TestMCPAddNoDeferredFlag(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "--no-deferred", "myserver", "npx", "my-mcp"}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["myserver"] + require.NotNil(t, server.Deferred) + assert.False(t, *server.Deferred) +} + +func TestMCPAddNoDeferredByDefault(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "myserver", "npx", "my-mcp"}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["myserver"] + assert.Nil(t, server.Deferred) +} + +func TestMCPShowNotFound(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, nil) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"show", "missing"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), `"missing" not found`) +} + +func TestMCPShowDisabledServer(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "myserver": { + Enabled: false, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"show", "myserver"}, "") + require.NoError(t, err) + assert.Contains(t, output, "myserver") + assert.Contains(t, output, "disabled") +} + +func TestMCPShowUsesProbe(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "myserver": { + Enabled: true, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + original := serverShowProbe + defer func() { serverShowProbe = original }() + serverShowProbe = func(_ context.Context, name string, _ config.MCPServerConfig, _ string) ([]toolDetail, error) { + assert.Equal(t, "myserver", name) + return []toolDetail{ + { + Name: "read_file", + Description: "Read a file from the filesystem", + Parameters: []paramDetail{ + {Name: "path", Type: "string", Description: "File path", Required: true}, + {Name: "encoding", Type: "string", Description: "Character encoding", Required: false}, + }, + }, + { + Name: "list_dir", + Description: "List directory contents", + Parameters: nil, + }, + }, nil + } + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"show", "myserver"}, "") + require.NoError(t, err) + assert.Contains(t, output, "myserver") + assert.Contains(t, output, "read_file") + assert.Contains(t, output, "Read a file from the filesystem") + assert.Contains(t, output, "path") + assert.Contains(t, output, "string") + assert.Contains(t, output, "required") + assert.Contains(t, output, "list_dir") + assert.Contains(t, output, "none") +} + +func setupMCPConfigEnv(t *testing.T) string { + t.Helper() + + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv(config.EnvConfig, configPath) + t.Setenv(config.EnvHome, filepath.Dir(configPath)) + return configPath +} + +func writeMCPConfig(t *testing.T, path string, cfg *config.Config) { + t.Helper() + + if cfg == nil { + cfg = config.DefaultConfig() + } + + require.NoError(t, config.SaveConfig(path, cfg)) +} + +func readMCPConfig(t *testing.T, path string) *config.Config { + t.Helper() + + cfg, err := config.LoadConfig(path) + require.NoError(t, err) + return cfg +} + +func executeCommand(cmd *cobra.Command, args []string, stdin string) (string, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + cmd.SetArgs(args) + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetIn(strings.NewReader(stdin)) + + err := cmd.Execute() + return stdout.String() + stderr.String(), err +} diff --git a/cmd/picoclaw/internal/mcp/edit.go b/cmd/picoclaw/internal/mcp/edit.go new file mode 100644 index 000000000..06dcb6aef --- /dev/null +++ b/cmd/picoclaw/internal/mcp/edit.go @@ -0,0 +1,54 @@ +package mcp + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "go.mau.fi/util/shlex" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" +) + +func newEditCommand() *cobra.Command { + return &cobra.Command{ + Use: "edit", + Short: "Open the PicoClaw config in $EDITOR", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + editor := strings.TrimSpace(os.Getenv("EDITOR")) + if editor == "" { + return fmt.Errorf("$EDITOR is not set") + } + + cfg, err := loadConfig() + if err != nil { + return err + } + if err = saveValidatedConfig(cfg); err != nil { + return err + } + + editorArgs, err := shlex.Split(editor) + if err != nil { + return fmt.Errorf("failed to parse $EDITOR: %w", err) + } + if len(editorArgs) == 0 { + return fmt.Errorf("$EDITOR is empty") + } + + editorArgs = append(editorArgs, internal.GetConfigPath()) + process := editorCommand(editorArgs[0], editorArgs[1:]...) + process.Stdin = cmd.InOrStdin() + process.Stdout = cmd.OutOrStdout() + process.Stderr = cmd.ErrOrStderr() + + if err := process.Run(); err != nil { + return fmt.Errorf("failed to start editor: %w", err) + } + + return nil + }, + } +} diff --git a/cmd/picoclaw/internal/mcp/helpers.go b/cmd/picoclaw/internal/mcp/helpers.go new file mode 100644 index 000000000..0fb0b245c --- /dev/null +++ b/cmd/picoclaw/internal/mcp/helpers.go @@ -0,0 +1,359 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + + "github.com/google/jsonschema-go/jsonschema" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" + picomcp "github.com/sipeed/picoclaw/pkg/mcp" +) + +type probeResult struct { + ToolCount int +} + +var ( + editorCommand = exec.Command + serverProbe = defaultServerProbe + + mcpConfigSchemaOnce sync.Once + mcpConfigSchema *jsonschema.Resolved + errMcpConfigSchema error +) + +const mcpConfigSchemaJSON = `{ + "type": "object", + "properties": { + "tools": { + "type": "object", + "properties": { + "mcp": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "discovery": { "type": "object", "additionalProperties": true }, + "max_inline_text_chars": { "type": "integer" }, + "servers": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "deferred": { "type": "boolean" }, + "command": { "type": "string" }, + "args": { + "type": "array", + "items": { "type": "string" } + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "env_file": { "type": "string" }, + "type": { + "type": "string", + "enum": ["stdio", "http", "sse"] + }, + "url": { "type": "string" }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "required": ["enabled"], + "anyOf": [ + { "required": ["command"] }, + { "required": ["url"] } + ], + "additionalProperties": false + } + } + }, + "required": ["enabled"], + "additionalProperties": true + } + }, + "required": ["mcp"], + "additionalProperties": true + } + }, + "required": ["tools"], + "additionalProperties": true +}` + +func loadConfig() (*config.Config, error) { + cfg, err := config.LoadConfig(internal.GetConfigPath()) + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + return cfg, nil +} + +func saveValidatedConfig(cfg *config.Config) error { + if cfg == nil { + return fmt.Errorf("config is nil") + } + + data, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to serialize config: %w", err) + } + + if err := validateConfigDocument(data); err != nil { + return err + } + + if err := config.SaveConfig(internal.GetConfigPath(), cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + return nil +} + +func validateConfigDocument(data []byte) error { + var instance map[string]any + if err := json.Unmarshal(data, &instance); err != nil { + return fmt.Errorf("failed to decode serialized config: %w", err) + } + + schema, err := loadMCPConfigSchema() + if err != nil { + return fmt.Errorf("failed to load MCP config schema: %w", err) + } + + if err := schema.Validate(instance); err != nil { + return fmt.Errorf("config validation failed: %w", err) + } + + return nil +} + +func loadMCPConfigSchema() (*jsonschema.Resolved, error) { + mcpConfigSchemaOnce.Do(func() { + var schema jsonschema.Schema + if err := json.Unmarshal([]byte(mcpConfigSchemaJSON), &schema); err != nil { + errMcpConfigSchema = err + return + } + mcpConfigSchema, errMcpConfigSchema = schema.Resolve(nil) + }) + + return mcpConfigSchema, errMcpConfigSchema +} + +func inferTransportType(server config.MCPServerConfig) string { + switch server.Type { + case "stdio", "http", "sse": + return server.Type + } + if server.URL != "" { + return "sse" + } + if server.Command != "" { + return "stdio" + } + return "unknown" +} + +func renderServerTarget(server config.MCPServerConfig) string { + transport := inferTransportType(server) + if transport == "http" || transport == "sse" { + if server.URL == "" { + return "" + } + return server.URL + } + + parts := append([]string{server.Command}, server.Args...) + rendered := strings.TrimSpace(strings.Join(parts, " ")) + if rendered == "" { + return "" + } + return rendered +} + +func sortedServerNames(servers map[string]config.MCPServerConfig) []string { + names := make([]string, 0, len(servers)) + for name := range servers { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func parseEnvAssignments(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + + env := make(map[string]string, len(values)) + for _, entry := range values { + key, value, found := strings.Cut(entry, "=") + if !found { + return nil, fmt.Errorf("invalid env assignment %q: expected KEY=value", entry) + } + key = strings.TrimSpace(key) + if key == "" { + return nil, fmt.Errorf("invalid env assignment %q: key cannot be empty", entry) + } + env[key] = value + } + + return env, nil +} + +func parseHeaderAssignments(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + + headers := make(map[string]string, len(values)) + for _, entry := range values { + key, value, found := strings.Cut(entry, ":") + if !found { + key, value, found = strings.Cut(entry, "=") + } + if !found { + return nil, fmt.Errorf("invalid header %q: expected 'Name: Value' or 'Name=Value'", entry) + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" { + return nil, fmt.Errorf("invalid header %q: name cannot be empty", entry) + } + headers[key] = value + } + + return headers, nil +} + +func looksLikeRemoteURL(target string) bool { + parsedURL, err := url.ParseRequestURI(target) + if err != nil { + return false + } + if parsedURL.Host == "" { + return false + } + switch strings.ToLower(parsedURL.Scheme) { + case "http", "https": + return true + default: + return false + } +} + +func isLocalCommandPath(command string) bool { + if command == "" { + return false + } + if looksLikeRemoteURL(command) { + return false + } + return filepath.IsAbs(command) || + filepath.VolumeName(command) != "" || + strings.HasPrefix(command, "."+string(os.PathSeparator)) || + strings.HasPrefix(command, ".."+string(os.PathSeparator)) || + command == "." || + command == ".." || + strings.ContainsRune(command, os.PathSeparator) +} + +func expandHomePath(path string) string { + if path == "" || path[0] != '~' { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + if path == "~" { + return home + } + if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "~\\") { + return filepath.Join(home, path[2:]) + } + return path +} + +func validateLocalCommandPath(command string) error { + if !isLocalCommandPath(command) { + return nil + } + + path := expandHomePath(command) + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("local command %q does not exist", command) + } + return fmt.Errorf("failed to stat local command %q: %w", command, err) + } + if info.IsDir() { + return fmt.Errorf("local command %q is a directory", command) + } + if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 { + return fmt.Errorf("local command %q is not executable", command) + } + return nil +} + +func defaultServerProbe( + ctx context.Context, + name string, + server config.MCPServerConfig, + workspacePath string, +) (probeResult, error) { + mgr := picomcp.NewManager() + defer func() { _ = mgr.Close() }() + + server.Enabled = true + mcpCfg := config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + name: server, + }, + } + + if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil { + return probeResult{}, err + } + + conn, ok := mgr.GetServer(name) + if !ok { + return probeResult{}, fmt.Errorf("server %q did not register a connection", name) + } + + return probeResult{ToolCount: len(conn.Tools)}, nil +} + +func confirmOverwrite(r io.Reader, w io.Writer, name string) (bool, error) { + if _, err := fmt.Fprintf(w, "MCP server %q already exists. Overwrite? [y/N]: ", name); err != nil { + return false, err + } + + var answer string + if _, err := fmt.Fscanln(r, &answer); err != nil { + if errors.Is(err, io.EOF) { + return false, nil + } + return false, err + } + + answer = strings.TrimSpace(strings.ToLower(answer)) + return answer == "y" || answer == "yes", nil +} diff --git a/cmd/picoclaw/internal/mcp/list.go b/cmd/picoclaw/internal/mcp/list.go new file mode 100644 index 000000000..f95fcf65d --- /dev/null +++ b/cmd/picoclaw/internal/mcp/list.go @@ -0,0 +1,78 @@ +package mcp + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" +) + +func newListCommand() *cobra.Command { + var ( + includeStatus bool + timeout time.Duration + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List configured MCP servers", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + if len(cfg.Tools.MCP.Servers) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No MCP servers configured.") + return nil + } + + rows := make([]cliui.MCPListRow, 0, len(cfg.Tools.MCP.Servers)) + for _, name := range sortedServerNames(cfg.Tools.MCP.Servers) { + server := cfg.Tools.MCP.Servers[name] + status := "disabled" + if server.Enabled { + status = "enabled" + } + + if includeStatus && server.Enabled { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + result, probeErr := serverProbe(ctx, name, server, cfg.WorkspacePath()) + cancel() + if probeErr != nil { + status = "error" + } else { + status = fmt.Sprintf("ok (%d tools)", result.ToolCount) + } + } + + effectiveDeferred := cfg.Tools.MCP.Discovery.Enabled + deferredExplicit := server.Deferred != nil + if deferredExplicit { + effectiveDeferred = *server.Deferred + } + + rows = append(rows, cliui.MCPListRow{ + Name: name, + Type: inferTransportType(server), + Target: renderServerTarget(server), + Status: status, + EffectiveDeferred: effectiveDeferred, + DeferredExplicit: deferredExplicit, + }) + } + + cliui.PrintMCPList(cmd.OutOrStdout(), rows) + return nil + }, + } + + cmd.Flags().BoolVar(&includeStatus, "status", false, "Ping enabled servers and show live status") + cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Timeout for each live status check") + + return cmd +} diff --git a/cmd/picoclaw/internal/mcp/remove.go b/cmd/picoclaw/internal/mcp/remove.go new file mode 100644 index 000000000..d82af941d --- /dev/null +++ b/cmd/picoclaw/internal/mcp/remove.go @@ -0,0 +1,39 @@ +package mcp + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newRemoveCommand() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove an MCP server from config", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name := args[0] + if _, exists := cfg.Tools.MCP.Servers[name]; !exists { + return fmt.Errorf("MCP server %q not found", name) + } + + delete(cfg.Tools.MCP.Servers, name) + if len(cfg.Tools.MCP.Servers) == 0 { + cfg.Tools.MCP.Servers = nil + cfg.Tools.MCP.Enabled = false + } + + if err := saveValidatedConfig(cfg); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q removed.\n", name) + return nil + }, + } +} diff --git a/cmd/picoclaw/internal/mcp/show.go b/cmd/picoclaw/internal/mcp/show.go new file mode 100644 index 000000000..65953c2da --- /dev/null +++ b/cmd/picoclaw/internal/mcp/show.go @@ -0,0 +1,237 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" + "github.com/sipeed/picoclaw/pkg/config" + picomcp "github.com/sipeed/picoclaw/pkg/mcp" +) + +type toolDetail struct { + Name string + Description string + Parameters []paramDetail +} + +type paramDetail struct { + Name string + Type string + Description string + Required bool +} + +var serverShowProbe = defaultServerShowProbe + +func defaultServerShowProbe( + ctx context.Context, + name string, + server config.MCPServerConfig, + workspacePath string, +) ([]toolDetail, error) { + mgr := picomcp.NewManager() + defer func() { _ = mgr.Close() }() + + server.Enabled = true + mcpCfg := config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + name: server, + }, + } + + if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil { + return nil, err + } + + conn, ok := mgr.GetServer(name) + if !ok { + return nil, fmt.Errorf("server %q did not register a connection", name) + } + + details := make([]toolDetail, 0, len(conn.Tools)) + for _, tool := range conn.Tools { + details = append(details, toolDetail{ + Name: tool.Name, + Description: tool.Description, + Parameters: extractParameters(tool.InputSchema), + }) + } + return details, nil +} + +func extractParameters(schema any) []paramDetail { + schemaMap := normalizeSchema(schema) + properties, ok := schemaMap["properties"].(map[string]any) + if !ok || len(properties) == 0 { + return nil + } + + required := make(map[string]struct{}) + switch raw := schemaMap["required"].(type) { + case []string: + for _, name := range raw { + required[name] = struct{}{} + } + case []any: + for _, value := range raw { + if name, ok := value.(string); ok { + required[name] = struct{}{} + } + } + } + + names := make([]string, 0, len(properties)) + for name := range properties { + names = append(names, name) + } + sort.Strings(names) + + params := make([]paramDetail, 0, len(names)) + for _, name := range names { + param := paramDetail{Name: name} + if propMap, ok := properties[name].(map[string]any); ok { + if typeName, ok := propMap["type"].(string); ok { + param.Type = strings.TrimSpace(typeName) + } + if desc, ok := propMap["description"].(string); ok { + param.Description = strings.TrimSpace(desc) + } + } + _, param.Required = required[name] + params = append(params, param) + } + return params +} + +func normalizeSchema(schema any) map[string]any { + if schema == nil { + return map[string]any{} + } + if schemaMap, ok := schema.(map[string]any); ok { + return schemaMap + } + + var jsonData []byte + switch raw := schema.(type) { + case json.RawMessage: + jsonData = raw + case []byte: + jsonData = raw + default: + var err error + jsonData, err = json.Marshal(schema) + if err != nil { + return map[string]any{} + } + } + + var result map[string]any + if err := json.Unmarshal(jsonData, &result); err != nil { + return map[string]any{} + } + return result +} + +func newShowCommand() *cobra.Command { + var timeout time.Duration + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show details and tools for a configured MCP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name := args[0] + server, exists := cfg.Tools.MCP.Servers[name] + if !exists { + return fmt.Errorf("MCP server %q not found", name) + } + + serverInfo := buildServerInfo(name, server, cfg.Tools.MCP.Discovery.Enabled) + + if !server.Enabled { + cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, nil, true) + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + details, err := serverShowProbe(ctx, name, server, cfg.WorkspacePath()) + if err != nil { + return fmt.Errorf("failed to connect to MCP server %q: %w", name, err) + } + + tools := make([]cliui.MCPShowTool, 0, len(details)) + for _, d := range details { + params := make([]cliui.MCPShowParam, 0, len(d.Parameters)) + for _, p := range d.Parameters { + params = append(params, cliui.MCPShowParam{ + Name: p.Name, + Type: p.Type, + Description: p.Description, + Required: p.Required, + }) + } + tools = append(tools, cliui.MCPShowTool{ + Name: d.Name, + Description: d.Description, + Parameters: params, + }) + } + + cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, tools, false) + return nil + }, + } + + cmd.Flags().DurationVar(&timeout, "timeout", 10*time.Second, "Connection timeout") + + return cmd +} + +func buildServerInfo(name string, server config.MCPServerConfig, discoveryEnabled bool) cliui.MCPShowServer { + effectiveDeferred := discoveryEnabled + deferredExplicit := server.Deferred != nil + if deferredExplicit { + effectiveDeferred = *server.Deferred + } + info := cliui.MCPShowServer{ + Name: name, + Type: inferTransportType(server), + Target: renderServerTarget(server), + Enabled: server.Enabled, + EffectiveDeferred: effectiveDeferred, + DeferredExplicit: deferredExplicit, + EnvFile: server.EnvFile, + } + if len(server.Env) > 0 { + keys := make([]string, 0, len(server.Env)) + for k := range server.Env { + keys = append(keys, k) + } + sort.Strings(keys) + info.EnvKeys = keys + } + if len(server.Headers) > 0 { + keys := make([]string, 0, len(server.Headers)) + for k := range server.Headers { + keys = append(keys, k) + } + sort.Strings(keys) + info.Headers = keys + } + return info +} diff --git a/cmd/picoclaw/internal/mcp/test.go b/cmd/picoclaw/internal/mcp/test.go new file mode 100644 index 000000000..101cfee65 --- /dev/null +++ b/cmd/picoclaw/internal/mcp/test.go @@ -0,0 +1,46 @@ +package mcp + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" +) + +func newTestCommand() *cobra.Command { + var timeout time.Duration + + cmd := &cobra.Command{ + Use: "test ", + Short: "Test connectivity for a configured MCP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name := args[0] + server, exists := cfg.Tools.MCP.Servers[name] + if !exists { + return fmt.Errorf("MCP server %q not found", name) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + result, err := serverProbe(ctx, name, server, cfg.WorkspacePath()) + if err != nil { + return fmt.Errorf("failed to reach MCP server %q: %w", name, err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q reachable (%d tools).\n", name, result.ToolCount) + return nil + }, + } + + cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Connection timeout") + + return cmd +} diff --git a/cmd/picoclaw/internal/model/add.go b/cmd/picoclaw/internal/model/add.go new file mode 100644 index 000000000..b3ebba340 --- /dev/null +++ b/cmd/picoclaw/internal/model/add.go @@ -0,0 +1,200 @@ +package model + +import ( + "bufio" + "fmt" + "io" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +const defaultAliasName = "custom-prefer" + +func newAddCommand() *cobra.Command { + var ( + apiBase string + apiKey string + modelID string + alias string + modelType string + ) + + cmd := &cobra.Command{ + Use: "add", + Short: "Add a model from an OpenAI-compatible endpoint", + Long: `Add a model entry by querying an OpenAI-compatible endpoint exposing +GET /models, then setting it as the default model. + +If --model is omitted, the available models are listed and you can pick one +interactively. If --model is provided, the entry is written without contacting +the server. + +Sample interactive session (key shown masked): + + $ picoclaw model add \ + -b https://ark.cn-beijing.volces.com/api/v3 \ + -k 7dff****-****-****-****-********e829 + + 115 model(s) available: + 1) doubao-lite-128k-240428 (doubao-lite-128k) + 2) doubao-pro-128k-240515 (doubao-pro-128k) + ... + 48) deepseek-r1-250120 (deepseek-r1) + 78) kimi-k2-250711 (kimi-k2) + ... + 115) doubao-seed3d-2-0-260328 (doubao-seed3d-2-0) + Pick a model (number or id): 48 + ✓ Saved model 'custom-prefer' (deepseek-r1-250120) and set as default.`, + Example: ` picoclaw model add --api-base https://api.openai.com/v1 --api-key sk-... + picoclaw model add -b http://localhost:8000/v1 -k dummy -m my-model -n local`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runAdd(addOptions{ + apiBase: strings.TrimSpace(apiBase), + apiKey: strings.TrimSpace(apiKey), + modelID: strings.TrimSpace(modelID), + alias: strings.TrimSpace(alias), + modelType: strings.TrimSpace(modelType), + stdin: cmd.InOrStdin(), + stdout: cmd.OutOrStdout(), + }) + }, + } + + cmd.Flags().StringVarP(&apiBase, "api-base", "b", "", + "API base URL (required), e.g. https://api.openai.com/v1") + cmd.Flags().StringVarP(&apiKey, "api-key", "k", "", "API key (required)") + cmd.Flags().StringVarP(&modelID, "model", "m", "", + "Model id; when set, skips the interactive picker and the network call") + cmd.Flags().StringVarP(&alias, "name", "n", defaultAliasName, + "Local alias written to model_list and used as the default model name") + cmd.Flags().StringVar(&modelType, "type", "openai-compatible", + "Endpoint type (only 'openai-compatible' is supported today)") + _ = cmd.MarkFlagRequired("api-base") + _ = cmd.MarkFlagRequired("api-key") + + return cmd +} + +type addOptions struct { + apiBase string + apiKey string + modelID string + alias string + modelType string + stdin io.Reader + stdout io.Writer +} + +func runAdd(opt addOptions) error { + if opt.modelType != "" && opt.modelType != "openai-compatible" { + return fmt.Errorf("unsupported --type %q (only 'openai-compatible' is supported)", opt.modelType) + } + if opt.alias == "" { + opt.alias = defaultAliasName + } + + selected := opt.modelID + if selected == "" { + entries, err := fetchOpenAIModels(opt.apiBase, opt.apiKey) + if err != nil { + return fmt.Errorf("fetch models: %w", err) + } + if len(entries) == 0 { + return fmt.Errorf("no models returned by %s", opt.apiBase) + } + selected, err = pickModel(opt.stdin, opt.stdout, entries) + if err != nil { + return err + } + } + + return upsertModelDefault(opt.apiBase, opt.apiKey, opt.alias, selected, opt.stdout) +} + +func pickModel(stdin io.Reader, stdout io.Writer, entries []modelEntry) (string, error) { + fmt.Fprintf(stdout, "\n%d model(s) available:\n", len(entries)) + for i, m := range entries { + line := m.ID + if m.Name != "" && m.Name != m.ID { + line = fmt.Sprintf("%s (%s)", m.ID, m.Name) + } + fmt.Fprintf(stdout, " %3d) %s\n", i+1, line) + } + + scanner := bufio.NewScanner(stdin) + for { + fmt.Fprint(stdout, "Pick a model (number or id): ") + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("read input: %w", err) + } + return "", fmt.Errorf("no selection provided") + } + text := strings.TrimSpace(scanner.Text()) + if text == "" { + continue + } + if idx, err := strconv.Atoi(text); err == nil { + if idx < 1 || idx > len(entries) { + fmt.Fprintf(stdout, "Out of range. Enter 1-%d.\n", len(entries)) + continue + } + return entries[idx-1].ID, nil + } + for _, m := range entries { + if m.ID == text { + return m.ID, nil + } + } + fmt.Fprintln(stdout, "Not a valid number or model id; try again.") + } +} + +func upsertModelDefault(apiBase, apiKey, alias, modelID string, stdout io.Writer) error { + configPath := internal.GetConfigPath() + cfg, err := config.LoadConfig(configPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + secureKeys := config.SimpleSecureStrings(apiKey) + + found := false + for _, m := range cfg.ModelList { + if m == nil { + continue + } + if m.ModelName == alias { + m.Model = modelID + m.APIBase = apiBase + m.APIKeys = secureKeys + m.Enabled = true + found = true + break + } + } + if !found { + cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{ + ModelName: alias, + Model: modelID, + APIBase: apiBase, + APIKeys: secureKeys, + Enabled: true, + }) + } + + cfg.Agents.Defaults.ModelName = alias + + if err := config.SaveConfig(configPath, cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Fprintf(stdout, "✓ Saved model '%s' (%s) and set as default.\n", alias, modelID) + return nil +} diff --git a/cmd/picoclaw/internal/model/add_test.go b/cmd/picoclaw/internal/model/add_test.go new file mode 100644 index 000000000..5da4d5e7f --- /dev/null +++ b/cmd/picoclaw/internal/model/add_test.go @@ -0,0 +1,257 @@ +package model + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestFetchOpenAIModels_DataEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/models", r.URL.Path) + assert.Equal(t, "Bearer secret", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"gpt-foo","name":"Foo"},{"id":"gpt-bar"}]}`)) + })) + defer srv.Close() + + entries, err := fetchOpenAIModels(srv.URL, "secret") + require.NoError(t, err) + require.Len(t, entries, 2) + assert.Equal(t, "gpt-foo", entries[0].ID) + assert.Equal(t, "Foo", entries[0].Name) + assert.Equal(t, "gpt-bar", entries[1].ID) +} + +func TestFetchOpenAIModels_BareArray(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"a"},{"id":"b"}]`)) + })) + defer srv.Close() + + entries, err := fetchOpenAIModels(srv.URL, "secret") + require.NoError(t, err) + require.Len(t, entries, 2) + assert.Equal(t, "a", entries[0].ID) + assert.Equal(t, "b", entries[1].ID) +} + +func TestFetchOpenAIModels_TrimsTrailingSlash(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(`{"data":[{"id":"x"}]}`)) + })) + defer srv.Close() + + _, err := fetchOpenAIModels(srv.URL+"/", "k") + require.NoError(t, err) + assert.Equal(t, "/models", gotPath) +} + +func TestFetchOpenAIModels_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "nope", http.StatusUnauthorized) + })) + defer srv.Close() + + _, err := fetchOpenAIModels(srv.URL, "bad") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 401") +} + +func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + entries, err := fetchOpenAIModels(srv.URL, "k") + require.NoError(t, err) + assert.Empty(t, entries) +} + +func TestFetchOpenAIModels_EmptyBareArray(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + entries, err := fetchOpenAIModels(srv.URL, "k") + require.NoError(t, err) + assert.Empty(t, entries) +} + +func TestFetchOpenAIModels_UnrecognizedShape(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"models":"not-supported"}`)) + })) + defer srv.Close() + + _, err := fetchOpenAIModels(srv.URL, "k") + require.Error(t, err) + assert.Contains(t, err.Error(), "unrecognized shape") +} + +func TestFetchOpenAIModels_RequiresInputs(t *testing.T) { + _, err := fetchOpenAIModels("", "k") + require.Error(t, err) + assert.Contains(t, err.Error(), "api base") + + _, err = fetchOpenAIModels("https://example.com", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "api key") +} + +func TestPickModel_ByIndex(t *testing.T) { + entries := []modelEntry{{ID: "a"}, {ID: "b"}, {ID: "c"}} + out := &bytes.Buffer{} + got, err := pickModel(strings.NewReader("2\n"), out, entries) + require.NoError(t, err) + assert.Equal(t, "b", got) + assert.Contains(t, out.String(), "3 model(s) available") +} + +func TestPickModel_ByID(t *testing.T) { + entries := []modelEntry{{ID: "alpha"}, {ID: "beta"}} + out := &bytes.Buffer{} + got, err := pickModel(strings.NewReader("beta\n"), out, entries) + require.NoError(t, err) + assert.Equal(t, "beta", got) +} + +func TestPickModel_RetriesOnInvalid(t *testing.T) { + entries := []modelEntry{{ID: "x"}} + out := &bytes.Buffer{} + got, err := pickModel(strings.NewReader("\n9\nnot-a-model\nx\n"), out, entries) + require.NoError(t, err) + assert.Equal(t, "x", got) + rendered := out.String() + assert.Contains(t, rendered, "Out of range") + assert.Contains(t, rendered, "Not a valid number") +} + +func TestRunAdd_WithExplicitModel_NoNetwork(t *testing.T) { + initTest(t) + + out := &bytes.Buffer{} + err := runAdd(addOptions{ + apiBase: "https://invalid.invalid/v1", + apiKey: "k", + modelID: "explicit-model", + alias: "myalias", + modelType: "openai-compatible", + stdout: out, + }) + require.NoError(t, err) + assert.Contains(t, out.String(), "Saved model 'myalias' (explicit-model)") + + cfg, err := config.LoadConfig(configPath) + require.NoError(t, err) + assert.Equal(t, "myalias", cfg.Agents.Defaults.GetModelName()) + added := findModelByName(cfg, "myalias") + require.NotNil(t, added, "expected model 'myalias' in model_list") + assert.Equal(t, "explicit-model", added.Model) + assert.Equal(t, "https://invalid.invalid/v1", added.APIBase) + assert.True(t, added.Enabled) + require.Len(t, added.APIKeys, 1) + assert.Equal(t, "k", added.APIKeys[0].String()) +} + +func findModelByName(cfg *config.Config, name string) *config.ModelConfig { + for _, m := range cfg.ModelList { + if m != nil && m.ModelName == name { + return m + } + } + return nil +} + +func TestRunAdd_FetchAndPick(t *testing.T) { + initTest(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer my-key", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`)) + })) + defer srv.Close() + + out := &bytes.Buffer{} + err := runAdd(addOptions{ + apiBase: srv.URL, + apiKey: "my-key", + alias: defaultAliasName, + modelType: "openai-compatible", + stdin: strings.NewReader("2\n"), + stdout: out, + }) + require.NoError(t, err) + + cfg, err := config.LoadConfig(configPath) + require.NoError(t, err) + assert.Equal(t, defaultAliasName, cfg.Agents.Defaults.GetModelName()) + added := findModelByName(cfg, defaultAliasName) + require.NotNil(t, added) + assert.Equal(t, "m2", added.Model) +} + +func TestRunAdd_UpsertsExistingAlias(t *testing.T) { + initTest(t) + + first := &bytes.Buffer{} + require.NoError(t, runAdd(addOptions{ + apiBase: "https://a.example/v1", + apiKey: "k1", + modelID: "m1", + alias: "shared", + stdout: first, + })) + + second := &bytes.Buffer{} + require.NoError(t, runAdd(addOptions{ + apiBase: "https://b.example/v1", + apiKey: "k2", + modelID: "m2", + alias: "shared", + stdout: second, + })) + + cfg, err := config.LoadConfig(configPath) + require.NoError(t, err) + matches := 0 + for _, m := range cfg.ModelList { + if m != nil && m.ModelName == "shared" { + matches++ + } + } + assert.Equal(t, 1, matches, "alias should be updated, not duplicated") + + updated := findModelByName(cfg, "shared") + require.NotNil(t, updated) + assert.Equal(t, "m2", updated.Model) + assert.Equal(t, "https://b.example/v1", updated.APIBase) + assert.Equal(t, "k2", updated.APIKeys[0].String()) +} + +func TestRunAdd_RejectsUnsupportedType(t *testing.T) { + initTest(t) + + err := runAdd(addOptions{ + apiBase: "https://x/v1", + apiKey: "k", + modelID: "m", + alias: "a", + modelType: "anthropic", + stdout: &bytes.Buffer{}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --type") +} diff --git a/cmd/picoclaw/internal/model/command.go b/cmd/picoclaw/internal/model/command.go new file mode 100644 index 000000000..c412993a0 --- /dev/null +++ b/cmd/picoclaw/internal/model/command.go @@ -0,0 +1,139 @@ +package model + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +// LocalModel is a special model name that indicates that the model is local and with or without api_key. +const LocalModel = "local-model" + +func NewModelCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "model [model_name]", + Short: "Show or change the default model", + Long: `Show or change the default model configuration. + +If no argument is provided, shows the current default model. +If a model name is provided, sets it as the default model. + +To onboard a model from a custom OpenAI-compatible endpoint (fetch the +available list online and pick one), use the 'add' subcommand: + + picoclaw model add --help + +Examples: + picoclaw model # Show current default model + picoclaw model gpt-5.2 # Set gpt-5.2 as default + picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default + picoclaw model local-model # Set local VLLM server as default + picoclaw model add -b URL -k KEY # Add a model from a custom endpoint + +Note: 'local-model' is a special value for using a local VLLM server +(running at localhost:8000 by default) which does not require an API key.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + configPath := internal.GetConfigPath() + + // Load current config + cfg, err := config.LoadConfig(configPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if len(args) == 0 { + // Show current default model + showCurrentModel(cfg) + return nil + } + + // Set new default model + modelName := args[0] + return setDefaultModel(configPath, cfg, modelName) + }, + } + + cmd.AddCommand(newAddCommand()) + + return cmd +} + +func showCurrentModel(cfg *config.Config) { + defaultModel := cfg.Agents.Defaults.ModelName + + if defaultModel == "" { + fmt.Println("No default model is currently set.") + fmt.Println("\nAvailable models in your config:") + listAvailableModels(cfg) + } else { + fmt.Printf("Current default model: %s\n", defaultModel) + fmt.Println("\nAvailable models in your config:") + listAvailableModels(cfg) + } + + fmt.Println("\nTip: 'picoclaw model add -b URL -k KEY' adds a model from a custom") + fmt.Println(" OpenAI-compatible endpoint (see 'picoclaw model add --help').") +} + +func listAvailableModels(cfg *config.Config) { + if len(cfg.ModelList) == 0 { + fmt.Println(" No models configured in model_list") + return + } + + defaultModel := cfg.Agents.Defaults.ModelName + + for _, model := range cfg.ModelList { + marker := " " + if model.ModelName == defaultModel { + marker = "> " + } + if !model.Enabled { + continue + } + fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model) + } +} + +func setDefaultModel(configPath string, cfg *config.Config, modelName string) error { + // Validate that the model exists in model_list + modelFound := false + for _, model := range cfg.ModelList { + if model.Enabled && model.ModelName == modelName { + modelFound = true + break + } + } + + if !modelFound && modelName != LocalModel { + return fmt.Errorf("cannot found model '%s' in config", modelName) + } + + // Update the default model + // Clear old model field and set new model_name + oldModel := cfg.Agents.Defaults.ModelName + + cfg.Agents.Defaults.ModelName = modelName + + // Save config back to file + if err := config.SaveConfig(configPath, cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("✓ Default model changed from '%s' to '%s'\n", + formatModelName(oldModel), modelName) + fmt.Println("\nThe new default model will be used for all agent interactions.") + + return nil +} + +func formatModelName(name string) string { + if name == "" { + return "(none)" + } + return name +} diff --git a/cmd/picoclaw/internal/model/command_test.go b/cmd/picoclaw/internal/model/command_test.go new file mode 100644 index 000000000..9e2a7bbae --- /dev/null +++ b/cmd/picoclaw/internal/model/command_test.go @@ -0,0 +1,408 @@ +package model + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/config" +) + +var configPath = "" + +func initTest(t *testing.T) { + tmpDir := t.TempDir() + configPath = filepath.Join(tmpDir, "config.json") + _ = os.Setenv("PICOCLAW_CONFIG", configPath) +} + +// captureStdout captures stdout during the execution of fn and returns the captured output +func captureStdout(fn func()) string { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String() +} + +func TestNewModelCommand(t *testing.T) { + cmd := NewModelCommand() + + require.NotNil(t, cmd) + + assert.Equal(t, "model [model_name]", cmd.Use) + assert.Equal(t, "Show or change the default model", cmd.Short) + + assert.Len(t, cmd.Aliases, 0) + + assert.False(t, cmd.HasFlags()) + + assert.Nil(t, cmd.Run) + assert.NotNil(t, cmd.RunE) + + assert.Nil(t, cmd.PersistentPreRunE) + assert.Nil(t, cmd.PersistentPreRun) + assert.Nil(t, cmd.PersistentPostRun) +} + +func TestShowCurrentModel_WithDefaultModel(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "gpt-4", + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + { + ModelName: "claude-3", + Model: "anthropic/claude-3", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + }, + } + + output := captureStdout(func() { + showCurrentModel(cfg) + }) + + assert.Contains(t, output, "Current default model: gpt-4") + assert.Contains(t, output, "Available models in your config:") + assert.Contains(t, output, "gpt-4") + assert.Contains(t, output, "claude-3") +} + +func TestShowCurrentModel_NoDefaultModel(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "", + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + }, + } + + output := captureStdout(func() { + showCurrentModel(cfg) + }) + + assert.Contains(t, output, "No default model is currently set.") + assert.Contains(t, output, "Available models in your config:") +} + +func TestListAvailableModels_Empty(t *testing.T) { + cfg := &config.Config{ + ModelList: []*config.ModelConfig{}, + } + + output := captureStdout(func() { + listAvailableModels(cfg) + }) + + assert.Contains(t, output, "No models configured in model_list") +} + +func TestListAvailableModels_WithModels(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "gpt-4", + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + { + ModelName: "claude-3", + Model: "anthropic/claude-3", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + {ModelName: "no-key-model", Model: "openai/test"}, + }, + } + + output := captureStdout(func() { + listAvailableModels(cfg) + }) + + assert.NotEmpty(t, output) + assert.Contains(t, output, "> - gpt-4 (openai/gpt-4)") + assert.Contains(t, output, "claude-3 (anthropic/claude-3)") + assert.NotContains(t, output, "no-key-model") +} + +func TestSetDefaultModel_ValidModel(t *testing.T) { + initTest(t) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "old-model", + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "new-model", + Model: "openai/new-model", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + { + ModelName: "old-model", + Model: "openai/old-model", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + }, + } + + output := captureStdout(func() { + err := setDefaultModel(configPath, cfg, "new-model") + assert.NoError(t, err) + }) + + assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'") + + // Verify config was updated + updatedCfg, err := config.LoadConfig(configPath) + require.NoError(t, err) + assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName) +} + +func TestSetDefaultModel_InvalidModel(t *testing.T) { + initTest(t) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "existing-model", + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "existing-model", + Model: "openai/existing", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + }, + } + + assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model")) +} + +func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) { + initTest(t) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "existing-model", + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "existing-model", + Model: "openai/existing", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + {ModelName: "no-key-model", Model: "openai/nokey"}, + }, + } + + assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model")) +} + +func TestSetDefaultModel_SaveConfigError(t *testing.T) { + // Use an invalid path to trigger save error + invalidPath := "/nonexistent/directory/config.json" + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "old-model", + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "new-model", + Model: "openai/new-model", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + }, + } + + err := setDefaultModel(invalidPath, cfg, "new-model") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to save config") +} + +func TestFormatModelName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"empty string", "", "(none)"}, + {"simple model", "gpt-4", "gpt-4"}, + {"model with version", "claude-sonnet-4.6", "claude-sonnet-4.6"}, + {"model with spaces", "my model", "my model"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatModelName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestModelCommandExecution_Show(t *testing.T) { + initTest(t) + + // Create a test config + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "test-model", + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "test-model", + Model: "openai/test", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + }, + } + + err := config.SaveConfig(configPath, cfg) + require.NoError(t, err) + + cmd := NewModelCommand() + + output := captureStdout(func() { + err = cmd.RunE(cmd, []string{}) + assert.NoError(t, err) + }) + + assert.Contains(t, output, "Current default model: test-model") +} + +func TestModelCommandExecution_Set(t *testing.T) { + initTest(t) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "old-model", + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "old-model", + Model: "openai/old", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + { + ModelName: "new-model", + Model: "openai/new", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + }, + } + + err := config.SaveConfig(configPath, cfg) + require.NoError(t, err) + + cmd := NewModelCommand() + + output := captureStdout(func() { + err = cmd.RunE(cmd, []string{"new-model"}) + assert.NoError(t, err) + }) + + assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'") +} + +func TestModelCommandExecution_TooManyArgs(t *testing.T) { + cmd := NewModelCommand() + + err := cmd.RunE(cmd, []string{"model1", "model2"}) + + assert.Error(t, err) +} + +func TestListAvailableModels_MarkerLogic(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "middle-model", + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "first-model", + Model: "openai/first", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + { + ModelName: "middle-model", + Model: "openai/middle", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + { + ModelName: "last-model", + Model: "openai/last", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, + }, + } + + output := captureStdout(func() { + listAvailableModels(cfg) + }) + + assert.Contains(t, output, " - first-model (openai/first)") + assert.Contains(t, output, "> - middle-model (openai/middle)") + assert.Contains(t, output, " - last-model (openai/last)") +} diff --git a/cmd/picoclaw/internal/model/online.go b/cmd/picoclaw/internal/model/online.go new file mode 100644 index 000000000..9b8f7811d --- /dev/null +++ b/cmd/picoclaw/internal/model/online.go @@ -0,0 +1,77 @@ +package model + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type modelEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +type modelsAPIResponse struct { + Data []modelEntry `json:"data"` +} + +// fetchOpenAIModels GETs /models with Bearer auth and accepts both the +// {data:[…]} envelope and a bare array shape used by various OpenAI-compatible servers. +func fetchOpenAIModels(baseURL, apiKey string) ([]modelEntry, error) { + if strings.TrimSpace(baseURL) == "" { + return nil, fmt.Errorf("api base is required") + } + if strings.TrimSpace(apiKey) == "" { + return nil, fmt.Errorf("api key is required") + } + + url := strings.TrimRight(baseURL, "/") + "/models" + + client := &http.Client{Timeout: 15 * time.Second} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + // {"data": [...]} envelope. Distinguish "envelope shape with empty list" + // from "object without a data key" via Data being non-nil after unmarshal: + // json.Unmarshal sets Data to []modelEntry{} for `{"data":[]}` but leaves + // it as nil when "data" is absent or null. + var envelope modelsAPIResponse + if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil { + return envelope.Data, nil + } + + // Bare-array shape, including `[]`. + var arr []modelEntry + if err := json.Unmarshal(body, &arr); err == nil { + return arr, nil + } + + preview := body + if len(preview) > 256 { + preview = preview[:256] + } + return nil, fmt.Errorf("decode response: unrecognized shape: %s", strings.TrimSpace(string(preview))) +} diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go index ec1012959..bf8f4104f 100644 --- a/cmd/picoclaw/internal/onboard/command.go +++ b/cmd/picoclaw/internal/onboard/command.go @@ -6,19 +6,29 @@ import ( "github.com/spf13/cobra" ) -//go:generate cp -r ../../../../workspace . +//go:generate go run ../../../../scripts/copydir.go ../../../../workspace ./workspace //go:embed workspace var embeddedFiles embed.FS func NewOnboardCommand() *cobra.Command { + var encrypt bool + cmd := &cobra.Command{ Use: "onboard", Aliases: []string{"o"}, Short: "Initialize picoclaw configuration and workspace", + // Run without subcommands → original onboard flow Run: func(cmd *cobra.Command, args []string) { - onboard() + if len(args) == 0 { + onboard(encrypt) + } else { + _ = cmd.Help() + } }, } + cmd.Flags().BoolVar(&encrypt, "enc", false, + "Enable credential encryption (generates SSH key and prompts for passphrase)") + return cmd } diff --git a/cmd/picoclaw/internal/onboard/command_test.go b/cmd/picoclaw/internal/onboard/command_test.go index bc799a079..56936190b 100644 --- a/cmd/picoclaw/internal/onboard/command_test.go +++ b/cmd/picoclaw/internal/onboard/command_test.go @@ -24,6 +24,9 @@ func TestNewOnboardCommand(t *testing.T) { assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) - assert.False(t, cmd.HasFlags()) + assert.True(t, cmd.HasFlags()) + encFlag := cmd.Flags().Lookup("enc") + require.NotNil(t, encFlag, "expected --enc flag to be registered") + assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false") assert.False(t, cmd.HasSubCommands()) } diff --git a/cmd/picoclaw/internal/onboard/helpers.go b/cmd/picoclaw/internal/onboard/helpers.go index 4db8bdc8b..ecc699d4b 100644 --- a/cmd/picoclaw/internal/onboard/helpers.go +++ b/cmd/picoclaw/internal/onboard/helpers.go @@ -6,25 +6,72 @@ import ( "os" "path/filepath" + "golang.org/x/term" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/credential" ) -func onboard() { +func onboard(encrypt bool) { configPath := internal.GetConfigPath() + configExists := false if _, err := os.Stat(configPath); err == nil { - fmt.Printf("Config already exists at %s\n", configPath) - fmt.Print("Overwrite? (y/n): ") - var response string - fmt.Scanln(&response) - if response != "y" { - fmt.Println("Aborted.") - return + configExists = true + if encrypt { + // Only ask for confirmation when *both* config and SSH key already exist, + // indicating a full re-onboard that would reset the config to defaults. + sshKeyPath, _ := credential.DefaultSSHKeyPath() + if _, err := os.Stat(sshKeyPath); err == nil { + // Both exist — confirm a full reset. + fmt.Printf("Config already exists at %s\n", configPath) + fmt.Print("Overwrite config with defaults? (y/n): ") + var response string + fmt.Scanln(&response) + if response != "y" { + fmt.Println("Aborted.") + return + } + configExists = false // user agreed to reset; treat as fresh + } + // Config exists but SSH key is missing — keep existing config, only add SSH key. } } - cfg := config.DefaultConfig() + var err error + if encrypt { + fmt.Println("\nSet up credential encryption") + fmt.Println("-----------------------------") + passphrase, pErr := promptPassphrase() + if pErr != nil { + fmt.Printf("Error: %v\n", pErr) + os.Exit(1) + } + // Expose the passphrase to credential.PassphraseProvider (which calls + // os.Getenv by default) so that SaveConfig can encrypt api_keys. + // This process is a one-shot CLI tool; the env var is never exposed outside + // the current process and disappears when it exits. + os.Setenv(credential.PassphraseEnvVar, passphrase) + + if err = setupSSHKey(); err != nil { + fmt.Printf("Error generating SSH key: %v\n", err) + os.Exit(1) + } + } + + var cfg *config.Config + if configExists { + // Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase. + cfg, err = config.LoadConfig(configPath) + if err != nil { + fmt.Printf("Error loading existing config: %v\n", err) + os.Exit(1) + } + } else { + cfg = config.DefaultConfig() + } if err := config.SaveConfig(configPath, cfg); err != nil { fmt.Printf("Error saving config: %v\n", err) os.Exit(1) @@ -33,17 +80,62 @@ func onboard() { workspace := cfg.WorkspacePath() createWorkspaceTemplates(workspace) - fmt.Printf("%s picoclaw is ready!\n", internal.Logo) - fmt.Println("\nNext steps:") - fmt.Println(" 1. Add your API key to", configPath) - fmt.Println("") - fmt.Println(" Recommended:") - fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)") - fmt.Println(" - Ollama: https://ollama.com (local, free)") - fmt.Println("") - fmt.Println(" See README.md for 17+ supported providers.") - fmt.Println("") - fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") + cliui.PrintOnboardComplete(internal.Logo, encrypt, configPath) +} + +// promptPassphrase reads the encryption passphrase twice from the terminal +// (with echo disabled) and returns it. Returns an error if the passphrase is +// empty or if the two inputs do not match. +func promptPassphrase() (string, error) { + fmt.Print("Enter passphrase for credential encryption: ") + p1, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("reading passphrase: %w", err) + } + if len(p1) == 0 { + return "", fmt.Errorf("passphrase must not be empty") + } + + fmt.Print("Confirm passphrase: ") + p2, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("reading passphrase confirmation: %w", err) + } + + if string(p1) != string(p2) { + return "", fmt.Errorf("passphrases do not match") + } + return string(p1), nil +} + +// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key. +// If the key already exists the user is warned and asked to confirm overwrite. +// Answering anything other than "y" keeps the existing key (not an error). +func setupSSHKey() error { + keyPath, err := credential.DefaultSSHKeyPath() + if err != nil { + return fmt.Errorf("cannot determine SSH key path: %w", err) + } + + if _, err := os.Stat(keyPath); err == nil { + fmt.Printf("\n⚠️ WARNING: %s already exists.\n", keyPath) + fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.") + fmt.Print(" Overwrite? (y/n): ") + var response string + fmt.Scanln(&response) + if response != "y" { + fmt.Println("Keeping existing SSH key.") + return nil + } + } + + if err := credential.GenerateSSHKey(keyPath); err != nil { + return err + } + fmt.Printf("SSH key generated: %s\n", keyPath) + return nil } func createWorkspaceTemplates(workspace string) { @@ -80,6 +172,9 @@ func copyEmbeddedToTarget(targetDir string) error { if err != nil { return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err) } + if new_path == "AGENTS.md" || new_path == "IDENTITY.md" { + return nil + } // Build target file path targetPath := filepath.Join(targetDir, new_path) diff --git a/cmd/picoclaw/internal/onboard/helpers_test.go b/cmd/picoclaw/internal/onboard/helpers_test.go index f3e0c92e0..23fc97c5a 100644 --- a/cmd/picoclaw/internal/onboard/helpers_test.go +++ b/cmd/picoclaw/internal/onboard/helpers_test.go @@ -6,20 +6,32 @@ import ( "testing" ) -func TestCopyEmbeddedToTargetUsesAgentsMarkdown(t *testing.T) { +func TestCopyEmbeddedToTargetUsesStructuredAgentFiles(t *testing.T) { targetDir := t.TempDir() if err := copyEmbeddedToTarget(targetDir); err != nil { t.Fatalf("copyEmbeddedToTarget() error = %v", err) } - agentsPath := filepath.Join(targetDir, "AGENTS.md") - if _, err := os.Stat(agentsPath); err != nil { - t.Fatalf("expected %s to exist: %v", agentsPath, err) + agentPath := filepath.Join(targetDir, "AGENT.md") + if _, err := os.Stat(agentPath); err != nil { + t.Fatalf("expected %s to exist: %v", agentPath, err) } - legacyPath := filepath.Join(targetDir, "AGENT.md") - if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { - t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err) + soulPath := filepath.Join(targetDir, "SOUL.md") + if _, err := os.Stat(soulPath); err != nil { + t.Fatalf("expected %s to exist: %v", soulPath, err) + } + + userPath := filepath.Join(targetDir, "USER.md") + if _, err := os.Stat(userPath); err != nil { + t.Fatalf("expected %s to exist: %v", userPath, err) + } + + for _, legacyName := range []string{"AGENTS.md", "IDENTITY.md"} { + legacyPath := filepath.Join(targetDir, legacyName) + if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { + t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err) + } } } diff --git a/cmd/picoclaw/internal/skills/command.go b/cmd/picoclaw/internal/skills/command.go index 65eb127b9..151605264 100644 --- a/cmd/picoclaw/internal/skills/command.go +++ b/cmd/picoclaw/internal/skills/command.go @@ -12,7 +12,6 @@ import ( type deps struct { workspace string - installer *skills.SkillInstaller skillsLoader *skills.SkillsLoader } @@ -29,7 +28,6 @@ func NewSkillsCommand() *cobra.Command { } d.workspace = cfg.WorkspacePath() - d.installer = skills.NewSkillInstaller(d.workspace) // get global config directory and builtin skills directory globalDir := filepath.Dir(internal.GetConfigPath()) @@ -44,13 +42,6 @@ func NewSkillsCommand() *cobra.Command { }, } - installerFn := func() (*skills.SkillInstaller, error) { - if d.installer == nil { - return nil, fmt.Errorf("skills installer is not initialized") - } - return d.installer, nil - } - loaderFn := func() (*skills.SkillsLoader, error) { if d.skillsLoader == nil { return nil, fmt.Errorf("skills loader is not initialized") @@ -67,10 +58,10 @@ func NewSkillsCommand() *cobra.Command { cmd.AddCommand( newListCommand(loaderFn), - newInstallCommand(installerFn), + newInstallCommand(), newInstallBuiltinCommand(workspaceFn), newListBuiltinCommand(), - newRemoveCommand(installerFn), + newRemoveCommand(), newSearchCommand(), newShowCommand(loaderFn), ) diff --git a/cmd/picoclaw/internal/skills/helpers.go b/cmd/picoclaw/internal/skills/helpers.go index a59a2013a..e27a32711 100644 --- a/cmd/picoclaw/internal/skills/helpers.go +++ b/cmd/picoclaw/internal/skills/helpers.go @@ -2,6 +2,7 @@ package skills import ( "context" + "encoding/json" "fmt" "io" "os" @@ -11,12 +12,23 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/utils" ) const skillsSearchMaxResults = 20 +type installedSkillOriginMeta struct { + Version int `json:"version"` + OriginKind string `json:"origin_kind,omitempty"` + Registry string `json:"registry,omitempty"` + Slug string `json:"slug,omitempty"` + RegistryURL string `json:"registry_url,omitempty"` + InstalledVersion string `json:"installed_version,omitempty"` + InstalledAt int64 `json:"installed_at"` +} + func skillsListCmd(loader *skills.SkillsLoader) { allSkills := loader.ListSkills() @@ -35,50 +47,32 @@ func skillsListCmd(loader *skills.SkillsLoader) { } } -func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error { - fmt.Printf("Installing skill from %s...\n", repo) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - if err := installer.InstallFromGitHub(ctx, repo); err != nil { - return fmt.Errorf("failed to install skill: %w", err) - } - - fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo)) - - return nil -} - // skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub). -func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error { +func skillsInstallFromRegistry(cfg *config.Config, registryName, target string) error { err := utils.ValidateSkillIdentifier(registryName) if err != nil { return fmt.Errorf("✗ invalid registry name: %w", err) } - err = utils.ValidateSkillIdentifier(slug) - if err != nil { - return fmt.Errorf("✗ invalid slug: %w", err) - } - - fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName) - - registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ - MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, - ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), - }) + registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills) registry := registryMgr.GetRegistry(registryName) if registry == nil { return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName) } + dirName, err := registry.ResolveInstallDirName(target) + if err != nil { + return fmt.Errorf("✗ invalid install target %q: %w", target, err) + } + + fmt.Printf("Installing skill '%s' from %s registry...\n", target, registryName) + workspace := cfg.WorkspacePath() - targetDir := filepath.Join(workspace, "skills", slug) + targetDir := filepath.Join(workspace, "skills", dirName) if _, err = os.Stat(targetDir); err == nil { - return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir) + return fmt.Errorf("\u2717 skill '%s' already installed at %s", dirName, targetDir) } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) @@ -88,7 +82,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er return fmt.Errorf("\u2717 failed to create skills directory: %v", err) } - result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir) + result, err := registry.DownloadAndInstall(ctx, target, "", targetDir) if err != nil { rmErr := os.RemoveAll(targetDir) if rmErr != nil { @@ -103,14 +97,34 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr) } - return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug) + return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", target) } if result.IsSuspicious { - fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug) + fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", target) } - fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version) + if !workspaceHasValidSkillDirectory(workspace, dirName) { + _ = os.RemoveAll(targetDir) + return fmt.Errorf("✗ failed to install skill: registry archive for %q is not a valid skill", target) + } + + normalizedSlug, registryURL := skills.BuildInstallMetadataForRegistryInstance(registry, target, result.Version) + installedAt := time.Now().UnixMilli() + if err := writeInstalledSkillOriginMeta(targetDir, installedSkillOriginMeta{ + Version: 1, + OriginKind: "third_party", + Registry: registry.Name(), + Slug: normalizedSlug, + RegistryURL: registryURL, + InstalledVersion: result.Version, + InstalledAt: installedAt, + }); err != nil { + _ = os.RemoveAll(targetDir) + return fmt.Errorf("✗ failed to persist skill metadata: %w", err) + } + + fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", dirName, result.Version) if result.Summary != "" { fmt.Printf(" %s\n", result.Summary) } @@ -118,15 +132,51 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er return nil } -func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) { - fmt.Printf("Removing skill '%s'...\n", skillName) - - if err := installer.Uninstall(skillName); err != nil { - fmt.Printf("✗ Failed to remove skill: %v\n", err) - os.Exit(1) +func writeInstalledSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error { + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err } + return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600) +} - fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName) +func workspaceHasValidSkillDirectory(workspace, directory string) bool { + loader := skills.NewSkillsLoader(workspace, "", "") + for _, skill := range loader.ListSkills() { + if skill.Source != "workspace" { + continue + } + if filepath.Base(filepath.Dir(skill.Path)) == directory { + return true + } + } + return false +} + +func skillsRemoveFromWorkspace(workspace string, toolsConfig config.SkillsToolsConfig, skillName string) error { + name := strings.TrimSpace(skillName) + name = strings.Trim(name, "/") + if name == "" { + return fmt.Errorf("skill name is required") + } + if strings.Contains(name, "/") { + dirName, err := skills.GitHubInstallDirNameFromToolsConfig(toolsConfig, name) + if err != nil || dirName == "" { + return fmt.Errorf("invalid skill name %q", skillName) + } + name = dirName + } + if name == "." || name == ".." { + return fmt.Errorf("invalid skill name %q", skillName) + } + skillDir := filepath.Join(workspace, "skills", name) + if _, err := os.Stat(skillDir); os.IsNotExist(err) { + return fmt.Errorf("skill '%s' not found", name) + } + if err := os.RemoveAll(skillDir); err != nil { + return fmt.Errorf("failed to remove skill '%s': %w", name, err) + } + return nil } func skillsInstallBuiltinCmd(workspace string) { @@ -226,10 +276,7 @@ func skillsSearchCmd(query string) { return } - registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ - MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, - ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), - }) + registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() diff --git a/cmd/picoclaw/internal/skills/helpers_test.go b/cmd/picoclaw/internal/skills/helpers_test.go new file mode 100644 index 000000000..366b7f8a8 --- /dev/null +++ b/cmd/picoclaw/internal/skills/helpers_test.go @@ -0,0 +1,191 @@ +package skills + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestSkillsInstallFromRegistryWritesOriginMetadata(t *testing.T) { + workspace := t.TempDir() + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = workspace + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/repos/foo/bar": + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"})) + case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review": + assert.Equal(t, "ref=master", r.URL.RawQuery) + require.NoError(t, json.NewEncoder(w).Encode([]map[string]any{{ + "type": "file", + "name": "SKILL.md", + "download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md", + }})) + case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md": + _, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github") + require.True(t, ok) + githubRegistry.BaseURL = server.URL + cfg.Tools.Skills.Registries.Set("github", githubRegistry) + + target := server.URL + "/foo/bar/tree/master/.agents/skills/pr-review" + require.NoError(t, skillsInstallFromRegistry(cfg, "github", target)) + + metaPath := filepath.Join(workspace, "skills", "pr-review", ".skill-origin.json") + data, err := os.ReadFile(metaPath) + require.NoError(t, err) + + var meta installedSkillOriginMeta + require.NoError(t, json.Unmarshal(data, &meta)) + assert.Equal(t, "third_party", meta.OriginKind) + assert.Equal(t, "github", meta.Registry) + assert.Equal(t, "foo/bar/.agents/skills/pr-review", meta.Slug) + assert.Equal(t, server.URL+"/foo/bar/tree/master/.agents/skills/pr-review", meta.RegistryURL) + assert.Equal(t, "master", meta.InstalledVersion) + assert.NotZero(t, meta.InstalledAt) +} + +func TestSkillsInstallFromRegistryRejectsInvalidSkillArchive(t *testing.T) { + workspace := t.TempDir() + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = workspace + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/repos/foo/bar": + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"})) + case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review": + require.NoError(t, json.NewEncoder(w).Encode([]map[string]any{{ + "type": "file", + "name": "SKILL.md", + "download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md", + }})) + case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md": + _, _ = w.Write([]byte("---\nname: bad_skill\ndescription: Invalid skill name\n---\n# Invalid\n")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github") + require.True(t, ok) + githubRegistry.BaseURL = server.URL + cfg.Tools.Skills.Registries.Set("github", githubRegistry) + + target := server.URL + "/foo/bar/tree/master/.agents/skills/pr-review" + err := skillsInstallFromRegistry(cfg, "github", target) + require.Error(t, err) + assert.Contains(t, err.Error(), "is not a valid skill") + _, statErr := os.Stat(filepath.Join(workspace, "skills", "pr-review")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestSkillsRemoveFromWorkspaceRejectsDotTarget(t *testing.T) { + workspace := t.TempDir() + skillsDir := filepath.Join(workspace, "skills") + require.NoError(t, os.MkdirAll(skillsDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillsDir, "keep.txt"), []byte("keep"), 0o644)) + + err := skillsRemoveFromWorkspace(workspace, config.DefaultConfig().Tools.Skills, ".") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid skill name") + + _, statErr := os.Stat(skillsDir) + assert.NoError(t, statErr) + _, fileErr := os.Stat(filepath.Join(skillsDir, "keep.txt")) + assert.NoError(t, fileErr) +} + +func TestSkillsRemoveFromWorkspaceUsesLastPathSegment(t *testing.T) { + workspace := t.TempDir() + targetDir := filepath.Join(workspace, "skills", "pr-review") + require.NoError(t, os.MkdirAll(targetDir, 0o755)) + + err := skillsRemoveFromWorkspace( + workspace, + config.DefaultConfig().Tools.Skills, + "https://github.com/foo/bar/tree/main/.agents/skills/pr-review", + ) + require.NoError(t, err) + + _, statErr := os.Stat(targetDir) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestSkillsRemoveFromWorkspaceSupportsRepoRootGitHubBlobURL(t *testing.T) { + workspace := t.TempDir() + targetDir := filepath.Join(workspace, "skills", "bar") + require.NoError(t, os.MkdirAll(targetDir, 0o755)) + + err := skillsRemoveFromWorkspace( + workspace, + config.DefaultConfig().Tools.Skills, + "https://github.com/foo/bar/blob/feature/skills-registry/SKILL.md", + ) + require.NoError(t, err) + + _, statErr := os.Stat(targetDir) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestSkillsRemoveFromWorkspaceSupportsGitHubEnterpriseURL(t *testing.T) { + workspace := t.TempDir() + targetDir := filepath.Join(workspace, "skills", "pr-review") + require.NoError(t, os.MkdirAll(targetDir, 0o755)) + + cfg := config.DefaultConfig() + githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github") + require.True(t, ok) + githubRegistry.BaseURL = "https://ghe.example.com/git" + cfg.Tools.Skills.Registries.Set("github", githubRegistry) + + err := skillsRemoveFromWorkspace( + workspace, + cfg.Tools.Skills, + "https://ghe.example.com/git/foo/bar/tree/main/.agents/skills/pr-review", + ) + require.NoError(t, err) + + _, statErr := os.Stat(targetDir) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestSkillsRemoveFromWorkspaceDoesNotRequireEnabledGitHubRegistry(t *testing.T) { + workspace := t.TempDir() + targetDir := filepath.Join(workspace, "skills", "pr-review") + require.NoError(t, os.MkdirAll(targetDir, 0o755)) + + cfg := config.DefaultConfig() + githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github") + require.True(t, ok) + githubRegistry.Enabled = false + cfg.Tools.Skills.Registries.Set("github", githubRegistry) + + err := skillsRemoveFromWorkspace( + workspace, + cfg.Tools.Skills, + "https://github.com/foo/bar/tree/main/.agents/skills/pr-review", + ) + require.NoError(t, err) + + _, statErr := os.Stat(targetDir) + assert.True(t, os.IsNotExist(statErr)) +} diff --git a/cmd/picoclaw/internal/skills/install.go b/cmd/picoclaw/internal/skills/install.go index 78bc421db..6c9b2d7c1 100644 --- a/cmd/picoclaw/internal/skills/install.go +++ b/cmd/picoclaw/internal/skills/install.go @@ -6,15 +6,14 @@ import ( "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" - "github.com/sipeed/picoclaw/pkg/skills" ) -func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command { +func newInstallCommand() *cobra.Command { var registry string cmd := &cobra.Command{ Use: "install", - Short: "Install skill from GitHub", + Short: "Install skill from GitHub or a registry", Example: ` picoclaw skills install sipeed/picoclaw-skills/weather picoclaw skills install --registry clawhub github @@ -34,21 +33,15 @@ picoclaw skills install --registry clawhub github return nil }, RunE: func(_ *cobra.Command, args []string) error { - installer, err := installerFn() + cfg, err := internal.LoadConfig() if err != nil { return err } - if registry != "" { - cfg, err := internal.LoadConfig() - if err != nil { - return err - } - return skillsInstallFromRegistry(cfg, registry, args[0]) } - return skillsInstallCmd(installer, args[0]) + return skillsInstallFromRegistry(cfg, "github", args[0]) }, } diff --git a/cmd/picoclaw/internal/skills/install_test.go b/cmd/picoclaw/internal/skills/install_test.go index 6b362822d..a8c6ec7ec 100644 --- a/cmd/picoclaw/internal/skills/install_test.go +++ b/cmd/picoclaw/internal/skills/install_test.go @@ -8,12 +8,12 @@ import ( ) func TestNewInstallSubcommand(t *testing.T) { - cmd := newInstallCommand(nil) + cmd := newInstallCommand() require.NotNil(t, cmd) assert.Equal(t, "install", cmd.Use) - assert.Equal(t, "Install skill from GitHub", cmd.Short) + assert.Equal(t, "Install skill from GitHub or a registry", cmd.Short) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) @@ -79,7 +79,7 @@ func TestInstallCommandArgs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cmd := newInstallCommand(nil) + cmd := newInstallCommand() if tt.registry != "" { require.NoError(t, cmd.Flags().Set("registry", tt.registry)) diff --git a/cmd/picoclaw/internal/skills/remove.go b/cmd/picoclaw/internal/skills/remove.go index cd7d3a8b4..4c9a44d8d 100644 --- a/cmd/picoclaw/internal/skills/remove.go +++ b/cmd/picoclaw/internal/skills/remove.go @@ -3,10 +3,10 @@ package skills import ( "github.com/spf13/cobra" - "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" ) -func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command { +func newRemoveCommand() *cobra.Command { cmd := &cobra.Command{ Use: "remove", Aliases: []string{"rm", "uninstall"}, @@ -14,12 +14,11 @@ func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra Args: cobra.ExactArgs(1), Example: `picoclaw skills remove weather`, RunE: func(_ *cobra.Command, args []string) error { - installer, err := installerFn() + cfg, err := internal.LoadConfig() if err != nil { return err } - skillsRemoveCmd(installer, args[0]) - return nil + return skillsRemoveFromWorkspace(cfg.WorkspacePath(), cfg.Tools.Skills, args[0]) }, } diff --git a/cmd/picoclaw/internal/skills/remove_test.go b/cmd/picoclaw/internal/skills/remove_test.go index b4c79760c..cc4d94a09 100644 --- a/cmd/picoclaw/internal/skills/remove_test.go +++ b/cmd/picoclaw/internal/skills/remove_test.go @@ -8,7 +8,7 @@ import ( ) func TestNewRemoveSubcommand(t *testing.T) { - cmd := newRemoveCommand(nil) + cmd := newRemoveCommand() require.NotNil(t, cmd) diff --git a/cmd/picoclaw/internal/status/helpers.go b/cmd/picoclaw/internal/status/helpers.go index ab28f4885..f80b1f9c7 100644 --- a/cmd/picoclaw/internal/status/helpers.go +++ b/cmd/picoclaw/internal/status/helpers.go @@ -5,7 +5,10 @@ import ( "os" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" ) func statusCmd() { @@ -16,85 +19,127 @@ func statusCmd() { } configPath := internal.GetConfigPath() + build, _ := config.FormatBuildInfo() - fmt.Printf("%s picoclaw Status\n", internal.Logo) - fmt.Printf("Version: %s\n", internal.FormatVersion()) - build, _ := internal.FormatBuildInfo() - if build != "" { - fmt.Printf("Build: %s\n", build) - } - fmt.Println() - - if _, err := os.Stat(configPath); err == nil { - fmt.Println("Config:", configPath, "✓") - } else { - fmt.Println("Config:", configPath, "✗") - } + _, configStatErr := os.Stat(configPath) + configOK := configStatErr == nil workspace := cfg.WorkspacePath() - if _, err := os.Stat(workspace); err == nil { - fmt.Println("Workspace:", workspace, "✓") - } else { - fmt.Println("Workspace:", workspace, "✗") + _, wsErr := os.Stat(workspace) + wsOK := wsErr == nil + + report := cliui.StatusReport{ + Logo: internal.Logo, + Version: config.FormatVersion(), + Build: build, + ConfigPath: configPath, + ConfigOK: configOK, + WorkspacePath: workspace, + WorkspaceOK: wsOK, + Model: cfg.Agents.Defaults.GetModelName(), } - if _, err := os.Stat(configPath); err == nil { - fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName()) + if configOK { + // PicoClaw moved to a model-centric configuration (model_list). Status should + // not depend on a legacy cfg.Providers field (which may not exist under some + // build tags). We infer provider availability from model_list entries. + hasProtocolKey := func(protocol string) bool { + want := providers.NormalizeProvider(protocol) + for _, m := range cfg.ModelList { + if m == nil { + continue + } + got, _ := providers.ExtractProtocol(m) + if got == want && m.APIKey() != "" { + return true + } + } + return false + } + findLocalModelBase := func(modelName string) (string, bool) { + for _, m := range cfg.ModelList { + if m == nil { + continue + } + if m.ModelName == modelName && m.APIBase != "" { + return m.APIBase, true + } + } + return "", false + } + findProtocolBase := func(protocol string) (string, bool) { + want := providers.NormalizeProvider(protocol) + for _, m := range cfg.ModelList { + if m == nil { + continue + } + got, _ := providers.ExtractProtocol(m) + if got == want && m.APIBase != "" { + return m.APIBase, true + } + } + return "", false + } - hasOpenRouter := cfg.Providers.OpenRouter.APIKey != "" - hasAnthropic := cfg.Providers.Anthropic.APIKey != "" - hasOpenAI := cfg.Providers.OpenAI.APIKey != "" - hasGemini := cfg.Providers.Gemini.APIKey != "" - hasZhipu := cfg.Providers.Zhipu.APIKey != "" - hasQwen := cfg.Providers.Qwen.APIKey != "" - hasGroq := cfg.Providers.Groq.APIKey != "" - hasVLLM := cfg.Providers.VLLM.APIBase != "" - hasMoonshot := cfg.Providers.Moonshot.APIKey != "" - hasDeepSeek := cfg.Providers.DeepSeek.APIKey != "" - hasVolcEngine := cfg.Providers.VolcEngine.APIKey != "" - hasNvidia := cfg.Providers.Nvidia.APIKey != "" - hasOllama := cfg.Providers.Ollama.APIBase != "" + hasOpenRouter := hasProtocolKey("openrouter") + hasAnthropic := hasProtocolKey("anthropic") + hasOpenAI := hasProtocolKey("openai") + hasGemini := hasProtocolKey("gemini") + hasZhipu := hasProtocolKey("zhipu") + hasQwen := hasProtocolKey("qwen") + hasGroq := hasProtocolKey("groq") + hasMoonshot := hasProtocolKey("moonshot") + hasDeepSeek := hasProtocolKey("deepseek") + hasVolcEngine := hasProtocolKey("volcengine") + hasNvidia := hasProtocolKey("nvidia") - status := func(enabled bool) string { + // Local endpoints: allow both the special reserved name and protocol-based entries. + vllmBase, hasVLLM := findLocalModelBase("local-model") + if !hasVLLM { + vllmBase, hasVLLM = findProtocolBase("vllm") + } + ollamaBase, hasOllama := findProtocolBase("ollama") + + val := func(enabled bool, extra ...string) string { if enabled { + if len(extra) > 0 && extra[0] != "" { + return "✓ " + extra[0] + } return "✓" } return "not set" } - fmt.Println("OpenRouter API:", status(hasOpenRouter)) - fmt.Println("Anthropic API:", status(hasAnthropic)) - fmt.Println("OpenAI API:", status(hasOpenAI)) - fmt.Println("Gemini API:", status(hasGemini)) - fmt.Println("Zhipu API:", status(hasZhipu)) - fmt.Println("Qwen API:", status(hasQwen)) - fmt.Println("Groq API:", status(hasGroq)) - fmt.Println("Moonshot API:", status(hasMoonshot)) - fmt.Println("DeepSeek API:", status(hasDeepSeek)) - fmt.Println("VolcEngine API:", status(hasVolcEngine)) - fmt.Println("Nvidia API:", status(hasNvidia)) - if hasVLLM { - fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase) - } else { - fmt.Println("vLLM/Local: not set") - } - if hasOllama { - fmt.Printf("Ollama: ✓ %s\n", cfg.Providers.Ollama.APIBase) - } else { - fmt.Println("Ollama: not set") + + report.Providers = []cliui.ProviderRow{ + {Name: "OpenRouter API", Val: val(hasOpenRouter)}, + {Name: "Anthropic API", Val: val(hasAnthropic)}, + {Name: "OpenAI API", Val: val(hasOpenAI)}, + {Name: "Gemini API", Val: val(hasGemini)}, + {Name: "Zhipu API", Val: val(hasZhipu)}, + {Name: "Qwen API", Val: val(hasQwen)}, + {Name: "Groq API", Val: val(hasGroq)}, + {Name: "Moonshot API", Val: val(hasMoonshot)}, + {Name: "DeepSeek API", Val: val(hasDeepSeek)}, + {Name: "VolcEngine API", Val: val(hasVolcEngine)}, + {Name: "Nvidia API", Val: val(hasNvidia)}, + {Name: "vLLM / local", Val: val(hasVLLM, vllmBase)}, + {Name: "Ollama", Val: val(hasOllama, ollamaBase)}, } store, _ := auth.LoadStore() if store != nil && len(store.Credentials) > 0 { - fmt.Println("\nOAuth/Token Auth:") for provider, cred := range store.Credentials { - status := "authenticated" + st := "authenticated" if cred.IsExpired() { - status = "expired" + st = "expired" } else if cred.NeedsRefresh() { - status = "needs refresh" + st = "needs refresh" } - fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status) + report.OAuthLines = append(report.OAuthLines, + fmt.Sprintf("%s (%s): %s", provider, cred.AuthMethod, st)) } } } + + cliui.PrintStatus(report) } diff --git a/cmd/picoclaw/internal/status/helpers_test.go b/cmd/picoclaw/internal/status/helpers_test.go new file mode 100644 index 000000000..f037b6bfa --- /dev/null +++ b/cmd/picoclaw/internal/status/helpers_test.go @@ -0,0 +1,89 @@ +package status + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error = %v", err) + } + os.Stdout = w + + fn() + + _ = w.Close() + os.Stdout = oldStdout + defer r.Close() + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("io.Copy() error = %v", err) + } + return buf.String() +} + +func TestStatusCmd_RecognizesProviderFieldWithoutModelPrefix(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + workspace := filepath.Join(tmpDir, "workspace") + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatalf("os.MkdirAll() error = %v", err) + } + + t.Setenv(config.EnvConfig, configPath) + t.Setenv(config.EnvHome, tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "gpt-5.4", + Workspace: workspace, + Provider: "openai", + MaxTokens: 65536, + Temperature: nil, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "gpt-5.4", + Provider: "openai", + Model: "gpt-5.4", + APIBase: "https://api.openai.com/v1", + APIKeys: config.SimpleSecureStrings("test-key"), + Enabled: true, + }, + { + ModelName: "qwen-plus", + Provider: "qwen", + Model: "qwen-plus", + APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1", + APIKeys: config.SimpleSecureStrings("test-key"), + Enabled: true, + }, + }, + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("config.SaveConfig() error = %v", err) + } + + output := captureStdout(t, statusCmd) + + if !strings.Contains(output, "OpenAI API: \u2713") { + t.Fatalf("status output missing OpenAI provider: %s", output) + } + if !strings.Contains(output, "Qwen API: \u2713") { + t.Fatalf("status output missing Qwen provider: %s", output) + } +} diff --git a/cmd/picoclaw/internal/version/command.go b/cmd/picoclaw/internal/version/command.go index 1cf686671..81da4b878 100644 --- a/cmd/picoclaw/internal/version/command.go +++ b/cmd/picoclaw/internal/version/command.go @@ -1,11 +1,11 @@ package version import ( - "fmt" - "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" + "github.com/sipeed/picoclaw/pkg/config" ) func NewVersionCommand() *cobra.Command { @@ -22,12 +22,6 @@ func NewVersionCommand() *cobra.Command { } func printVersion() { - fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion()) - build, goVer := internal.FormatBuildInfo() - if build != "" { - fmt.Printf(" Build: %s\n", build) - } - if goVer != "" { - fmt.Printf(" Go: %s\n", goVer) - } + build, goVer := config.FormatBuildInfo() + cliui.PrintVersion(internal.Logo, "picoclaw "+config.FormatVersion(), build, goVer) } diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index d9263462e..abcf03a34 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -9,30 +9,78 @@ package main import ( "fmt" "os" + "time" "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/mcp" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/model" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/status" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/version" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/updater" ) +var rootNoColor bool + +func syncCliUIColor(root *cobra.Command) { + no, _ := root.PersistentFlags().GetBool("no-color") + cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb") +} + +// earlyColorDisabled matches lipgloss/banner behavior from env and argv before Cobra parses flags. +func earlyColorDisabled() bool { + if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" { + return true + } + for i := 1; i < len(os.Args); i++ { + arg := os.Args[i] + if arg == "--no-color" || arg == "--no-color=true" || arg == "--no-color=1" { + return true + } + } + return false +} + func NewPicoclawCommand() *cobra.Command { - short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion()) + short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo) + long := fmt.Sprintf(`%s PicoClaw is a lightweight personal AI assistant. + +Version: %s`, internal.Logo, config.FormatVersion()) cmd := &cobra.Command{ - Use: "picoclaw", - Short: short, - Example: "picoclaw list", + Use: "picoclaw", + Short: short, + Long: long, + Example: `picoclaw version +picoclaw onboard +picoclaw --no-color status`, + SilenceErrors: true, + // Avoid plain UsageString() on stderr/stdout when a command fails; cliui + // renders matching panels on stderr instead. + SilenceUsage: true, + PersistentPreRun: func(c *cobra.Command, _ []string) { + syncCliUIColor(c.Root()) + }, } + cmd.PersistentFlags().BoolVar(&rootNoColor, "no-color", false, + "Disable colors (boxed layout unchanged)") + + cmd.SetHelpFunc(func(c *cobra.Command, _ []string) { + syncCliUIColor(c.Root()) + fmt.Fprint(c.OutOrStdout(), cliui.RenderCommandHelp(c)) + }) + cmd.AddCommand( onboard.NewOnboardCommand(), agent.NewAgentCommand(), @@ -40,8 +88,11 @@ func NewPicoclawCommand() *cobra.Command { gateway.NewGatewayCommand(), status.NewStatusCommand(), cron.NewCronCommand(), + mcp.NewMCPCommand(), migrate.NewMigrateCommand(), skills.NewSkillsCommand(), + model.NewModelCommand(), + updater.NewUpdateCommand("picoclaw"), version.NewVersionCommand(), ) @@ -59,12 +110,44 @@ const ( colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " + "\033[0m\r\n" + plainBanner = "\r\n" + + "██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" + + "██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" + + "██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" + + "██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" + + "██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " + + "\r\n" ) func main() { - fmt.Printf("%s", banner) + cliui.Init(earlyColorDisabled()) + + if earlyColorDisabled() { + fmt.Print(plainBanner) + } else { + fmt.Printf("%s", banner) + } + + tzEnv := os.Getenv("TZ") + if tzEnv != "" { + fmt.Println("TZ environment:", tzEnv) + zoneinfoEnv := os.Getenv("ZONEINFO") + fmt.Println("ZONEINFO environment:", zoneinfoEnv) + loc, err := time.LoadLocation(tzEnv) + if err != nil { + fmt.Println("Error loading time zone:", err) + } else { + fmt.Println("Time zone loaded successfully:", loc) + time.Local = loc //nolint:gosmopolitan // We intentionally set local timezone from TZ env + } + } + cmd := NewPicoclawCommand() - if err := cmd.Execute(); err != nil { + last, err := cmd.ExecuteC() + if err != nil { + syncCliUIColor(cmd) + fmt.Fprint(os.Stderr, cliui.FormatCLIError(err.Error(), last)) os.Exit(1) } } diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index 3740ba358..037c7c2e6 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -3,12 +3,14 @@ package main import ( "fmt" "slices" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" ) func TestNewPicoclawCommand(t *testing.T) { @@ -16,20 +18,22 @@ func TestNewPicoclawCommand(t *testing.T) { require.NotNil(t, cmd) - short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion()) + short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo) + longHas := strings.Contains(cmd.Long, config.FormatVersion()) assert.Equal(t, "picoclaw", cmd.Use) assert.Equal(t, short, cmd.Short) + assert.True(t, longHas) assert.True(t, cmd.HasSubCommands()) assert.True(t, cmd.HasAvailableSubCommands()) - assert.False(t, cmd.HasFlags()) + assert.True(t, cmd.PersistentFlags().Lookup("no-color") != nil) assert.Nil(t, cmd.Run) assert.Nil(t, cmd.RunE) - assert.Nil(t, cmd.PersistentPreRun) + assert.NotNil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) allowedCommands := []string{ @@ -37,10 +41,13 @@ func TestNewPicoclawCommand(t *testing.T) { "auth", "cron", "gateway", + "mcp", "migrate", + "model", "onboard", "skills", "status", + "update", "version", } diff --git a/config/config.example.json b/config/config.example.json index 7fb75b506..bd12bcd98 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -4,18 +4,27 @@ "workspace_root": "/data/workspaces", "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true, - "model_name": "gpt4", + "model_name": "gpt-5.4", "max_tokens": 8192, + "context_window": 131072, "temperature": 0.7, "max_tool_iterations": 20, "summarize_message_threshold": 20, - "summarize_token_percent": 75 + "summarize_token_percent": 75, + "split_on_marker": false, + "max_llm_retries": 2, + "llm_retry_backoff_secs": 2, + "tool_feedback": { + "enabled": false, + "max_args_length": 300, + "separate_messages": false + } } }, "model_list": [ { - "model_name": "gpt4", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key", "api_base": "https://api.openai.com/v1" }, @@ -26,8 +35,16 @@ "api_base": "https://api.anthropic.com/v1", "thinking_level": "high" }, + { + "_comment": "Anthropic Messages API - use native format for direct Anthropic API access", + "model_name": "claude-opus-4-6", + "model": "anthropic-messages/claude-opus-4-6", + "api_key": "sk-ant-your-key", + "api_base": "https://api.anthropic.com" + }, { "model_name": "gemini", + "_comment": "Optional: set \"tool_schema_transform\": \"simple\" for providers that reject complex tool JSON Schema.", "model": "antigravity/gemini-2.0-flash", "auth_method": "oauth" }, @@ -37,14 +54,40 @@ "api_key": "sk-your-deepseek-key" }, { - "model_name": "loadbalanced-gpt4", - "model": "openai/gpt-5.2", + "model_name": "venice-uncensored", + "model": "venice/venice-uncensored", + "api_key": "your-venice-api-key" + }, + { + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" + }, + { + "model_name": "longcat", + "model": "longcat/LongCat-Flash-Thinking", + "api_key": "your-longcat-api-key" + }, + { + "model_name": "modelscope-qwen", + "model": "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507", + "api_key": "your-modelscope-access-token", + "api_base": "https://api-inference.modelscope.cn/v1" + }, + { + "model_name": "azure-gpt5", + "model": "azure/my-gpt5-deployment", + "api_key": "your-azure-api-key", + "api_base": "https://your-resource.openai.azure.com" + }, + { + "model_name": "loadbalanced-gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-key1", "api_base": "https://api1.example.com/v1" }, { - "model_name": "loadbalanced-gpt4", - "model": "openai/gpt-5.2", + "model_name": "loadbalanced-gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-key2", "api_base": "https://api2.example.com/v1" } @@ -55,10 +98,12 @@ "token": "YOUR_TELEGRAM_BOT_TOKEN", "base_url": "", "proxy": "", - "allow_from": [ - "YOUR_USER_ID" - ], - "reasoning_channel_id": "" + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": false, + "reasoning_channel_id": "", + "streaming": { + "enabled": true + } }, "discord": { "enabled": false, @@ -99,8 +144,13 @@ "encrypt_key": "", "verification_token": "", "allow_from": [], + "placeholder": { + "enabled": true, + "text": ["Thinking...", "Processing...", "Typing..."] + }, "reasoning_channel_id": "", - "random_reaction_emoji": [] + "random_reaction_emoji": [], + "is_lark": false }, "dingtalk": { "enabled": false, @@ -129,9 +179,11 @@ }, "placeholder": { "enabled": true, - "text": "Thinking... 💭" + "text": ["Thinking...", "Processing...", "Typing..."] }, - "reasoning_channel_id": "" + "reasoning_channel_id": "", + "crypto_database_path": "", + "crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY" }, "line": { "enabled": false, @@ -151,38 +203,33 @@ "reasoning_channel_id": "" }, "wecom": { - "_comment": "WeCom Bot - Easier setup, supports group chats", + "_comment": "WeCom AI Bot over WebSocket.", "enabled": false, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_path": "/webhook/wecom", + "bot_id": "YOUR_BOT_ID", + "secret": "YOUR_SECRET", + "websocket_url": "wss://openws.work.weixin.qq.com", + "send_thinking_message": true, "allow_from": [], - "reply_timeout": 5, "reasoning_channel_id": "" }, - "wecom_app": { - "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only.", + "pico": { "enabled": false, - "corp_id": "YOUR_CORP_ID", - "corp_secret": "YOUR_CORP_SECRET", - "agent_id": 1000002, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-app", - "allow_from": [], - "reply_timeout": 5, - "reasoning_channel_id": "" + "token": "YOUR_PICO_TOKEN", + "allow_token_query": false, + "allow_origins": [], + "ping_interval": 30, + "read_timeout": 60, + "max_connections": 100, + "allow_from": [] }, - "wecom_aibot": { - "_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.", + "pico_client": { "enabled": false, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-aibot", - "max_steps": 10, - "welcome_message": "Hello! I'm your AI assistant. How can I help you today?", - "reasoning_channel_id": "" + "url": "wss://remote-pico-server/pico/ws", + "token": "YOUR_PICO_TOKEN", + "session_id": "", + "ping_interval": 30, + "read_timeout": 60, + "allow_from": [] }, "magicform": { "_comment": "MagicForm - Webhook-based channel for MagicForm agentic task delegation", @@ -216,79 +263,18 @@ "reasoning_channel_id": "" } }, - "providers": { - "_comment": "DEPRECATED: Use model_list instead. This will be removed in a future version", - "anthropic": { - "api_key": "", - "api_base": "" - }, - "openai": { - "api_key": "", - "api_base": "", - "web_search": true - }, - "openrouter": { - "api_key": "sk-or-v1-xxx", - "api_base": "" - }, - "groq": { - "api_key": "gsk_xxx", - "api_base": "" - }, - "zhipu": { - "api_key": "YOUR_ZHIPU_API_KEY", - "api_base": "" - }, - "gemini": { - "api_key": "", - "api_base": "" - }, - "vllm": { - "api_key": "", - "api_base": "" - }, - "nvidia": { - "api_key": "nvapi-xxx", - "api_base": "", - "proxy": "http://127.0.0.1:7890" - }, - "moonshot": { - "api_key": "sk-xxx", - "api_base": "" - }, - "qwen": { - "api_key": "sk-xxx", - "api_base": "" - }, - "ollama": { - "api_key": "", - "api_base": "http://localhost:11434/v1" - }, - "cerebras": { - "api_key": "", - "api_base": "" - }, - "volcengine": { - "api_key": "", - "api_base": "" - }, - "mistral": { - "api_key": "", - "api_base": "https://api.mistral.ai/v1" - }, - "avian": { - "api_key": "", - "api_base": "https://api.avian.io/v1" - } - }, "tools": { "allow_read_paths": null, "allow_write_paths": null, "web": { "enabled": true, + "prefer_native": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", "brave": { "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", + "api_keys": ["YOUR_BRAVE_API_KEY"], "max_results": 5 }, "tavily": { @@ -297,13 +283,19 @@ "base_url": "", "max_results": 0 }, - "duckduckgo": { + "provider": "auto", + "sogou": { "enabled": true, "max_results": 5 }, + "duckduckgo": { + "enabled": false, + "max_results": 5 + }, "perplexity": { "enabled": false, - "api_key": "", + "api_key": "pplx-xxx", + "api_keys": ["pplx-xxx"], "max_results": 5 }, "searxng": { @@ -318,7 +310,14 @@ "search_engine": "search_std", "max_results": 5 }, - "fetch_limit_bytes": 10485760 + "baidu_search": { + "enabled": false, + "api_key": "", + "base_url": "https://qianfan.baidubce.com/v2/ai_search/web_search", + "max_results": 10 + }, + "fetch_limit_bytes": 10485760, + "private_host_whitelist": [] }, "cron": { "enabled": true, @@ -326,6 +325,13 @@ }, "mcp": { "enabled": false, + "discovery": { + "enabled": false, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, "servers": { "context7": { "enabled": false, @@ -338,19 +344,12 @@ "filesystem": { "enabled": false, "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-filesystem", - "/tmp" - ] + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] }, "github": { "enabled": false, "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-github" - ], + "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" } @@ -358,10 +357,7 @@ "brave-search": { "enabled": false, "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-brave-search" - ], + "args": ["-y", "@modelcontextprotocol/server-brave-search"], "env": { "BRAVE_API_KEY": "YOUR_BRAVE_API_KEY" } @@ -378,10 +374,7 @@ "slack": { "enabled": false, "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-slack" - ], + "args": ["-y", "@modelcontextprotocol/server-slack"], "env": { "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" @@ -408,8 +401,19 @@ "timeout": 0, "max_zip_size": 0, "max_response_size": 0 + }, + "github": { + "enabled": true, + "base_url": "https://github.com", + "auth_token": "", + "proxy": "http://127.0.0.1:7891" } }, + "github": { + "base_url": "https://github.com", + "proxy": "http://127.0.0.1:7891", + "token": "" + }, "max_concurrent_searches": 2, "search_cache": { "max_size": 50, @@ -443,7 +447,14 @@ "enabled": true }, "read_file": { - "enabled": true + "enabled": true, + "mode": "bytes" + }, + "serial": { + "enabled": false + }, + "send_tts": { + "enabled": false }, "spawn": { "enabled": true @@ -469,8 +480,32 @@ "enabled": false, "monitor_usb": true }, + "voice": { + "model_name": "", + "echo_transcription": false + }, + "hooks": { + "enabled": true, + "defaults": { + "observer_timeout_ms": 500, + "interceptor_timeout_ms": 5000, + "approval_timeout_ms": 60000 + } + }, + "events": { + "logging": { + "enabled": true, + "include": ["agent.*"], + "exclude": [], + "min_severity": "info", + "include_payload": false + } + }, "gateway": { - "host": "127.0.0.1", - "port": 18790 + "_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.", + "host": "localhost", + "port": 18790, + "hot_reload": false, + "log_level": "fatal" } } diff --git a/docker/Dockerfile b/docker/Dockerfile index 480244127..f36a98ff6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -26,18 +26,9 @@ RUN apk add --no-cache ca-certificates tzdata curl HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget -q --spider http://localhost:18790/health || exit 1 -# Copy binary +# Copy binary and first-run entrypoint (same as release image). COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh -# Create non-root user and group -RUN addgroup -g 1000 picoclaw && \ - adduser -D -u 1000 -G picoclaw picoclaw - -# Switch to non-root user -USER picoclaw - -# Run onboard to create initial directories and config -RUN /usr/local/bin/picoclaw onboard - -ENTRYPOINT ["picoclaw"] -CMD ["gateway"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/Dockerfile.full b/docker/Dockerfile.full index 30e1680d5..1dc0679c9 100644 --- a/docker/Dockerfile.full +++ b/docker/Dockerfile.full @@ -1,7 +1,7 @@ # ============================================================ # Stage 1: Build the picoclaw binary # ============================================================ -FROM golang:1.26.0-alpine AS builder +FROM golang:1.25-alpine AS builder RUN apk add --no-cache git make diff --git a/docker/Dockerfile.goreleaser.launcher b/docker/Dockerfile.goreleaser.launcher new file mode 100644 index 000000000..97944afc1 --- /dev/null +++ b/docker/Dockerfile.goreleaser.launcher @@ -0,0 +1,11 @@ +FROM alpine:3.21 + +ARG TARGETPLATFORM + +RUN apk add --no-cache ca-certificates tzdata + +COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw +COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher + +ENTRYPOINT ["picoclaw-launcher"] +CMD ["-console", "-public", "-no-browser"] diff --git a/docker/Dockerfile.heavy b/docker/Dockerfile.heavy new file mode 100644 index 000000000..81f6976a2 --- /dev/null +++ b/docker/Dockerfile.heavy @@ -0,0 +1,60 @@ +# ============================================================ +# Stage 1: Build the picoclaw binary +# ============================================================ +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git make + +WORKDIR /src + +# Cache dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source and build +COPY . . +RUN make build + +# ============================================================ +# Stage 2: Node.js runtime with Python + MCP support +# ============================================================ +FROM node:24-alpine3.23 + +RUN apk add --no-cache \ + ca-certificates \ + curl \ + git \ + python3 \ + py3-pip \ + chromium \ + jq + +# Install Playwright browsers for agent-browser +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers +RUN npm install -g agent-browser && \ + npx playwright install chromium && \ + chmod -R o+rx $PLAYWRIGHT_BROWSERS_PATH + +# Install uv +RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ + ln -s /root/.local/bin/uv /usr/local/bin/uv && \ + ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \ + uv --version + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider http://localhost:18790/health || exit 1 + +# Copy binary +COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw + +# Run onboard to create initial directories and config +RUN /usr/local/bin/picoclaw onboard + +# Copy default workspace +COPY workspace/ /root/.picoclaw/workspace/ + +VOLUME /root/.picoclaw/workspace + +ENTRYPOINT ["picoclaw"] +CMD ["gateway"] diff --git a/docker/Dockerfile.launcher b/docker/Dockerfile.launcher new file mode 100644 index 000000000..33fdc6d6e --- /dev/null +++ b/docker/Dockerfile.launcher @@ -0,0 +1,65 @@ +# ============================================================ +# Stage 1: Build frontend assets (Node.js + pnpm) +# ============================================================ +FROM node:24-alpine3.23 AS frontend + +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /src/web/frontend + +# Cache frontend dependencies +COPY web/frontend/package.json web/frontend/pnpm-lock.yaml ./ +RUN CI=true pnpm install --frozen-lockfile + +# Build frontend +COPY web/frontend/ ./ +RUN pnpm build:backend + +# ============================================================ +# Stage 2: Build Go binaries (picoclaw + picoclaw-launcher) +# ============================================================ +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git make + +WORKDIR /src + +# Cache Go dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source +COPY . . + +# Copy pre-built frontend assets into the backend embed directory +COPY --from=frontend /src/web/backend/dist web/backend/dist + +# Build picoclaw binary (includes go generate) +RUN make build + +# Build picoclaw-launcher binary (frontend already built in stage 1) +# Mirror ldflags from web/Makefile to inject version metadata +RUN CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config && \ + VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev) && \ + GIT_COMMIT=$(git rev-parse --short=8 HEAD 2>/dev/null || echo dev) && \ + BUILD_TIME=$(date +%FT%T%z) && \ + GO_VERSION=$(go env GOVERSION) && \ + CGO_ENABLED=0 go build -v -tags goolm,stdjson \ + -ldflags "-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" \ + -o build/picoclaw-launcher ./web/backend/ + +# ============================================================ +# Stage 3: Minimal runtime image +# ============================================================ +FROM alpine:3.23 + +RUN apk add --no-cache ca-certificates tzdata curl + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider http://localhost:18790/health || exit 1 + +COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw +COPY --from=builder /src/build/picoclaw-launcher /usr/local/bin/picoclaw-launcher + +ENTRYPOINT ["picoclaw-launcher"] +CMD ["-console", "-public", "-no-browser"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9ec71abab..b12959fc7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -4,6 +4,9 @@ services: # docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello" # ───────────────────────────────────────────── picoclaw-agent: + build: + context: .. + dockerfile: docker/Dockerfile image: docker.io/sipeed/picoclaw:latest container_name: picoclaw-agent profiles: @@ -19,12 +22,15 @@ services: # ───────────────────────────────────────────── # PicoClaw Gateway (Long-running Bot) - # docker compose -f docker/docker-compose.yml up picoclaw-gateway + # docker compose -f docker/docker-compose.yml --profile gateway up # ───────────────────────────────────────────── picoclaw-gateway: + build: + context: .. + dockerfile: docker/Dockerfile image: docker.io/sipeed/picoclaw:latest container_name: picoclaw-gateway - restart: on-failure + restart: unless-stopped profiles: - gateway # Uncomment to access host network; leave commented unless needed. @@ -32,3 +38,27 @@ services: # - "host.docker.internal:host-gateway" volumes: - ./data:/root/.picoclaw + + # ───────────────────────────────────────────── + # PicoClaw Launcher (Web Console + Gateway) + # docker compose -f docker/docker-compose.yml --profile launcher up + # ───────────────────────────────────────────── + picoclaw-launcher: + build: + context: .. + dockerfile: docker/Dockerfile.launcher + image: docker.io/sipeed/picoclaw:launcher + container_name: picoclaw-launcher + restart: unless-stopped + profiles: + - launcher + environment: + - PICOCLAW_GATEWAY_HOST=0.0.0.0 + # Set a fixed dashboard token instead of a random one each restart. + # If not set, a random token is generated and printed to the console on startup. + #- PICOCLAW_LAUNCHER_TOKEN=your-secret-token-here + ports: + - "18800:18800" + - "18790:18790" + volumes: + - ./data:/root/.picoclaw diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b6fc724b5..6fafb5150 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -12,4 +12,10 @@ if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.js exit 0 fi +# Remove stale PID file from a previous container run. +# After docker kill / OOM / crash the PID file may linger on the bind-mounted +# volume and block the next gateway start (the recorded PID could collide with +# an unrelated process inside the new container). +rm -f "${HOME}/.picoclaw/.picoclaw.pid" + exec picoclaw gateway "$@" diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..529eb49ec --- /dev/null +++ b/docs/README.md @@ -0,0 +1,132 @@ +# PicoClaw Documentation + +PicoClaw documentation is organized by document type first and language second. + +This file describes the recommended documentation layout, how translated files should be named, and what `make lint-docs` currently checks locally. + +These conventions are intended as contributor guidance for new or moved docs. Existing docs may still have historical exceptions, and `make lint-docs` only checks a common subset of the patterns described here. + +## Reader Navigation + +If you are browsing docs rather than reorganizing them, start with these directory indexes: + +- [Guides](guides/README.md): setup, configuration, provider, and workflow guides. +- [Reference](reference/README.md): precise configuration and behavior reference. +- [Operations](operations/README.md): debugging and troubleshooting material. +- [Security](security/README.md): security-focused guides and controls. +- [Architecture](architecture/README.md): implementation notes and internal design docs. +- [Migration](migration/README.md): upgrade and migration notes. + +For channel-specific setup, start with [Chat Apps Configuration](guides/chat-apps.md) and then drill into `docs/channels//README.md` as needed. + +## Principles + +- Choose the document type directory first. Do not create language buckets such as `docs/zh/` or `docs/fr/`. +- Keep each translated document next to its English source document. +- Use English as the base filename with no locale suffix. +- Use lowercase locale suffixes for translations, for example `configuration.zh.md` or `README.pt-br.md`. +- Keep module-specific docs next to the code they describe instead of moving them into `docs/`. + +## Recommended Directories + +- `README.md`: English project entry document at the repository root. +- `docs/project/`: translated project entry documents such as `README.zh.md` and `CONTRIBUTING.zh.md`. +- `docs/guides/`: setup and usage guides. +- `docs/reference/`: reference material and detailed configuration docs. +- `docs/operations/`: debugging and troubleshooting docs. +- `docs/security/`: security-related documentation. +- `docs/architecture/`: architecture and internal design notes. +- `docs/channels/`: channel-specific integration guides. +- `docs/design/`: design proposals and investigations. +- `docs/migration/`: migration notes. + +## Recommended Naming + +- English documents use the base filename: + - `README.md` + - `configuration.md` +- Translations use `..md`: + - `README.zh.md` + - `configuration.fr.md` + - `README.pt-br.md` +- Code-adjacent translated READMEs follow the same rule: + - `pkg/audio/asr/README.zh.md` + - `pkg/isolation/README.zh.md` + +## Common Patterns To Avoid + +- Root-level translated entry docs such as `README.zh.md` or `CONTRIBUTING.fr.md` + - Use `docs/project/README.zh.md` or `docs/project/CONTRIBUTING.fr.md` instead. +- Language directories under `docs/` such as `docs/zh/`, `docs/ZH/`, `docs/ja/`, or `docs/fr/` + - Use `docs//..md` instead. +- Nested locale buckets such as `docs/guides/zh/configuration.md` or `docs/channels/telegram/zh/README.md` + - Keep translations beside the English source file instead. +- Legacy translation filenames such as `README_zh.md` or `README_CN.md` + - Use `README.zh.md`. +- Non-canonical locale suffixes such as `configuration_zh.md` or `configuration.ZH.md` + - Use lowercase `..md`, for example `configuration.zh.md`. + +## Translation Placement + +- For docs under `docs/guides`, `docs/reference`, `docs/operations`, `docs/security`, `docs/architecture`, `docs/channels`, and `docs/migration`, keep translations beside the English source file. +- For project entry translations, keep translated files in `docs/project/` and keep the English source in the repository root. +- In most cases, each translated file should have an English source document: + - `docs/guides/configuration.zh.md` usually sits beside `docs/guides/configuration.md` + - `docs/project/README.zh.md` usually corresponds to `README.md` +- Exception: `docs/design/` may contain locale-specific working notes without an English source document. The naming rules still apply there. + +## Code-Adjacent Docs + +Keep documentation next to the implementation when it primarily describes a package, command, example, or subproject. + +Examples: + +- `pkg/**/README.md` +- `cmd/**/README.md` +- `web/README.md` +- `examples/**/README.md` + +These files still follow the same translation naming rules. + +## Adding a New Document + +1. Pick the correct document type directory. +2. Create the English source file first. +3. Add translated siblings after the English source exists when that source is part of the same docs set. +4. Update links from existing docs when the new doc becomes a navigation target. +5. Run `make lint-docs` locally when adding or moving docs. + +## Examples + +- New setup guide: + - `docs/guides/launcher-setup.md` + - `docs/guides/launcher-setup.zh.md` +- New security guide: + - `docs/security/token-rotation.md` +- New translated package README: + - `pkg/channels/README.zh.md` + +## Validation + +Run: + +```bash +make lint-docs +``` + +The local docs linter currently checks these common cases: + +- no root-level translated `README` or `CONTRIBUTING` files +- no `docs//` language buckets, regardless of case +- no nested locale buckets under typed docs directories +- no legacy `README_*.md` filenames +- no non-canonical translation-like filenames such as `_zh.md` or `.ZH.md` +- no extra Markdown files directly under `docs/` except `docs/README.md` +- every translated Markdown file has a matching English source file + - except for locale-specific working notes under `docs/design/` + +`make lint-docs` is a local consistency check for common naming and placement mistakes. It helps contributors stay close to the recommended layout, but it is not intended to describe every acceptable documentation pattern in the repository. + +When a check fails, `make lint-docs` prints the failing path, the reason, and a suggested fix. + +If you change these recommendations or want the local linter to reflect them more closely, update this file and `scripts/lint-docs.sh` together. diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 000000000..e5fc3b540 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,13 @@ +# Architecture + +Internal architecture notes for major runtime mechanisms and subsystem design. + +- [Steering](steering.md): injecting messages into a running agent loop between tool calls. +- [SubTurn Mechanism](subturn.md): sub-agent coordination, concurrency control, and lifecycle handling. +- [Session System](session-system.md): session scope allocation, JSONL persistence, alias compatibility, and migration. ([ZH](session-system.zh.md)) +- [Routing System](routing-system.md): agent dispatch, session policy selection, and light/heavy model routing. ([ZH](routing-system.zh.md)) +- [Runtime Events](runtime-events.md): runtime event envelope, centralized event logging, filters, and examples. ([ZH](runtime-events.zh.md)) +- [Hook System Guide](hooks/README.md): current hook architecture and protocol details. +- [Agent Refactor](agent-refactor/README.md): notes and checkpoints for the agent refactor work. + +For proposal-style or exploratory docs, also see [`../design/`](../design/). diff --git a/docs/agent-refactor/README.md b/docs/architecture/agent-refactor/README.md similarity index 100% rename from docs/agent-refactor/README.md rename to docs/architecture/agent-refactor/README.md diff --git a/docs/architecture/agent-refactor/agent-rename-plan.md b/docs/architecture/agent-refactor/agent-rename-plan.md new file mode 100644 index 000000000..f4ab408fe --- /dev/null +++ b/docs/architecture/agent-refactor/agent-rename-plan.md @@ -0,0 +1,100 @@ +# Agent File Rename Plan + +## Goal + +Unify `pkg/agent/` package file naming to resolve the `loop_*` prefix naming confusion and unclear responsibility boundaries. + +## Change Overview + +### File Renames (12 files) + +| Original | New | Description | +|----------|-----|-------------| +| `loop.go` | `agent.go` | AgentLoop main body + lifecycle methods | +| `loop_message.go` | `agent_message.go` | Message handling and routing | +| `loop_outbound.go` | `agent_outbound.go` | Response publishing | +| `loop_event.go` | `agent_event.go` | Event system | +| `loop_command.go` | `agent_command.go` | Command processing | +| `loop_steering.go` | `agent_steering.go` | Steering message handling | +| `loop_transcribe.go` | `agent_transcribe.go` | Audio transcription | +| `loop_media.go` | `agent_media.go` | Media processing | +| `loop_mcp.go` | `agent_mcp.go` | MCP initialization | +| `loop_utils.go` | `agent_utils.go` | Utility functions | +| `loop_inject.go` | `agent_inject.go` | Dependency injection | +| `loop_turn.go` | `turn_coord.go` | Turn coordinator | + +### File Merges (2 → 1) + +| Original | New | Description | +|----------|-----|-------------| +| `turn.go` + `turn_exec.go` | `turn_state.go` | Turn-related type definitions | + +## Final File Structure + +``` +pkg/agent/ +├── agent.go # AgentLoop + Run/Stop/Close lifecycle +├── agent_message.go # Message processing +├── agent_outbound.go # Response publishing +├── agent_event.go # Event system +├── agent_command.go # Command processing +├── agent_steering.go # Steering +├── agent_transcribe.go # Transcription +├── agent_media.go # Media processing +├── agent_mcp.go # MCP +├── agent_utils.go # Utility functions +├── agent_inject.go # Dependency injection +├── turn_coord.go # runTurn + coordinator +├── turn_state.go # turnState + turnExecution + Control + ToolControl + LLMPhase +├── pipeline.go # Pipeline struct + NewPipeline +├── pipeline_setup.go +├── pipeline_llm.go +├── pipeline_execute.go +└── pipeline_finalize.go +``` + +## Naming Convention + +| Prefix | Content | Example | +|--------|---------|---------| +| `agent_*` | AgentLoop method files | `agent_message.go`, `agent_event.go` | +| `turn_*` | Turn lifecycle related | `turn_coord.go`, `turn_state.go` | +| `pipeline_*` | Pipeline methods | `pipeline_setup.go`, `pipeline_llm.go` | +| `context_*` | Context management | `context_manager.go`, `context_legacy.go` | +| `hook_*` | Hook system | `hook_process.go`, `hook_mount.go` | + +## Architecture Layers + +``` +┌─────────────────────────────────────────────────────────┐ +│ AgentLoop (agent.go) │ +│ - Message loop Run/Stop/Close │ +│ - Dependency injection (agent_inject.go) │ +│ - Message routing (agent_message.go) │ +│ - Response publishing (agent_outbound.go) │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Turn Coordinator (turn_coord.go) │ +│ - runTurn(): main coordinator │ +│ - abortTurn(): abort │ +│ - askSideQuestion(): side question │ +│ - selectCandidates(): model selection │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Pipeline (pipeline_*.go) │ +│ - SetupTurn(): initialization │ +│ - CallLLM(): LLM call │ +│ - ExecuteTools(): tool execution │ +│ - Finalize(): finalization │ +└─────────────────────────────────────────────────────────┘ +``` + +## Verification Results + +- ✅ `go build ./pkg/agent/...` - Pass +- ✅ `go vet ./pkg/agent/...` - No warnings +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - Pass diff --git a/docs/architecture/agent-refactor/agent-rename-plan.zh.md b/docs/architecture/agent-refactor/agent-rename-plan.zh.md new file mode 100644 index 000000000..938817e10 --- /dev/null +++ b/docs/architecture/agent-refactor/agent-rename-plan.zh.md @@ -0,0 +1,100 @@ +# Agent 文件重命名计划 + +## 目标 + +统一 `pkg/agent/` 包的文件命名,解决 `loop_*` 前缀命名混乱、职责边界不清晰的问题。 + +## 变更概览 + +### 文件重命名(12 个) + +| 原文件 | 新文件 | 说明 | +|--------|--------|------| +| `loop.go` | `agent.go` | AgentLoop 主体 + 生命周期方法 | +| `loop_message.go` | `agent_message.go` | 消息处理和路由 | +| `loop_outbound.go` | `agent_outbound.go` | 响应发布 | +| `loop_event.go` | `agent_event.go` | 事件系统 | +| `loop_command.go` | `agent_command.go` | 命令处理 | +| `loop_steering.go` | `agent_steering.go` | Steering 消息处理 | +| `loop_transcribe.go` | `agent_transcribe.go` | 音频转录 | +| `loop_media.go` | `agent_media.go` | 媒体处理 | +| `loop_mcp.go` | `agent_mcp.go` | MCP 初始化 | +| `loop_utils.go` | `agent_utils.go` | 工具函数 | +| `loop_inject.go` | `agent_inject.go` | 依赖注入 | +| `loop_turn.go` | `turn_coord.go` | Turn 协调器 | + +### 文件合并(2 → 1) + +| 原文件 | 新文件 | 说明 | +|--------|--------|------| +| `turn.go` + `turn_exec.go` | `turn_state.go` | Turn 相关类型定义 | + +## 最终文件结构 + +``` +pkg/agent/ +├── agent.go # AgentLoop + Run/Stop/Close 生命周期 +├── agent_message.go # 消息处理 +├── agent_outbound.go # 响应发布 +├── agent_event.go # 事件系统 +├── agent_command.go # 命令处理 +├── agent_steering.go # Steering +├── agent_transcribe.go # 转录 +├── agent_media.go # 媒体处理 +├── agent_mcp.go # MCP +├── agent_utils.go # 工具函数 +├── agent_inject.go # 依赖注入 +├── turn_coord.go # runTurn + 协调器 +├── turn_state.go # turnState + turnExecution + Control + ToolControl + LLMPhase +├── pipeline.go # Pipeline struct + NewPipeline +├── pipeline_setup.go +├── pipeline_llm.go +├── pipeline_execute.go +└── pipeline_finalize.go +``` + +## 命名约定 + +| 前缀 | 内容 | 示例 | +|------|------|------| +| `agent_*` | AgentLoop 的方法文件 | `agent_message.go`, `agent_event.go` | +| `turn_*` | Turn 生命周期相关 | `turn_coord.go`, `turn_state.go` | +| `pipeline_*` | Pipeline 方法 | `pipeline_setup.go`, `pipeline_llm.go` | +| `context_*` | 上下文管理 | `context_manager.go`, `context_legacy.go` | +| `hook_*` | Hook 系统 | `hook_process.go`, `hook_mount.go` | + +## 架构层次 + +``` +┌─────────────────────────────────────────────────────────┐ +│ AgentLoop (agent.go) │ +│ - 消息循环 Run/Stop/Close │ +│ - 依赖注入 (agent_inject.go) │ +│ - 消息路由 (agent_message.go) │ +│ - 响应发布 (agent_outbound.go) │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Turn Coordinator (turn_coord.go) │ +│ - runTurn(): 主协调器 │ +│ - abortTurn(): 中止 │ +│ - askSideQuestion(): 侧问 │ +│ - selectCandidates(): 模型选择 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Pipeline (pipeline_*.go) │ +│ - SetupTurn(): 初始化 │ +│ - CallLLM(): LLM 调用 │ +│ - ExecuteTools(): 工具执行 │ +│ - Finalize(): 终结 │ +└─────────────────────────────────────────────────────────┘ +``` + +## 验证结果 + +- ✅ `go build ./pkg/agent/...` - 通过 +- ✅ `go vet ./pkg/agent/...` - 无警告 +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - 通过 diff --git a/docs/architecture/agent-refactor/context.md b/docs/architecture/agent-refactor/context.md new file mode 100644 index 000000000..2269d9258 --- /dev/null +++ b/docs/architecture/agent-refactor/context.md @@ -0,0 +1,164 @@ +# Context + +## What this document covers + +This document makes explicit the boundaries of context management in the agent loop: + +- what fills the context window and how space is divided +- what is stored in session history vs. built at request time +- when and how context compression happens +- how token budgets are estimated + +These are existing concepts. This document clarifies their boundaries rather than introducing new ones. + +--- + +## Context window regions + +The context window is the model's total input capacity. Four regions fill it: + +| Region | Assembled by | Stored in session? | +|---|---|---| +| System prompt | `BuildMessages()` — static + dynamic parts | No | +| Summary | `SetSummary()` stores it; `BuildMessages()` injects it | Separate from history | +| Session history | User / assistant / tool messages | Yes | +| Tool definitions | Provider adapter injects at call time | No | + +`MaxTokens` (the output generation limit) must also be reserved from the total budget. + +The available space for history is therefore: + +``` +history_budget = ContextWindow - system_prompt - summary - tool_definitions - MaxTokens +``` + +--- + +## ContextWindow vs MaxTokens + +These serve different purposes: + +- **MaxTokens** — maximum tokens the LLM may generate in one response. Sent as the `max_tokens` request parameter. +- **ContextWindow** — the model's total input context capacity. + +These were previously set to the same value, which caused the summarization threshold to fire either far too early (at the default 32K) or not at all (when a user raised `max_tokens`). + +Current default when not explicitly configured: `ContextWindow = MaxTokens * 4`. + +--- + +## Session history + +Session history stores only conversation messages: + +- `user` — user input +- `assistant` — LLM response (may include `ToolCalls`) +- `tool` — tool execution results + +Session history does **not** contain: + +- System prompts — assembled at request time by `BuildMessages` +- Summary content — stored separately via `SetSummary`, injected by `BuildMessages` + +This distinction matters: any code that operates on session history — compression, boundary detection, token estimation — must not assume a system message is present. + +--- + +## Turn + +A **Turn** is one complete cycle: + +> user message -> LLM iterations (possibly including tool calls) -> final assistant response + +This definition comes from the agent loop design (#1316). In session history, Turn boundaries are identified by `user`-role messages. + +Turn is the atomic unit for compression. Cutting inside a Turn can orphan tool-call sequences — an assistant message with `ToolCalls` separated from its corresponding `tool` results. Compressing at Turn boundaries avoids this by construction. + +`parseTurnBoundaries(history)` returns the starting index of each Turn. +`findSafeBoundary(history, targetIndex)` snaps a target cut point to the nearest Turn boundary. + +--- + +## Compression paths + +Three compression paths exist, in order of preference: + +### 1. Async summarization + +`maybeSummarize` runs after each Turn completes. + +Triggers when message count exceeds a threshold, or when estimated history tokens exceed a percentage of `ContextWindow`. If triggered, a background goroutine calls the LLM to produce a summary of the oldest messages. The summary is stored via `SetSummary`; `BuildMessages` injects it into the system prompt on the next call. + +Cut point uses `findSafeBoundary` so no Turn is split. + +### 2. Proactive budget check + +`isOverContextBudget` runs before each LLM call. + +Uses the full budget formula: `message_tokens + tool_def_tokens + MaxTokens > ContextWindow`. If over budget, triggers `forceCompression` and rebuilds messages before calling the LLM. + +This prevents wasted (and billed) LLM calls that would otherwise fail with a context-window error. + +### 3. Emergency compression (reactive) + +`forceCompression` runs when the LLM returns a context-window error despite the proactive check. + +Drops the oldest ~50% of Turns. If the history is a single Turn with no safe split point (e.g. one user message followed by a massive tool response), falls back to keeping only the most recent user message — breaking Turn atomicity as a last resort to avoid a context-exceeded loop. + +Stores a compression note in the session summary (not in history messages) so `BuildMessages` can include it in the next system prompt. + +This is the fallback for when the token estimate undershoots reality. + +--- + +## Token estimation + +Estimation uses a heuristic of ~2.5 characters per token (`chars * 2 / 5`). + +`estimateMessageTokens` counts: + +- `Content` (rune count, for multibyte correctness) +- `ReasoningContent` (extended thinking / chain-of-thought) +- `ToolCalls` — ID, type, function name, arguments +- `ToolCallID` (tool result metadata) +- Per-message overhead (role label, JSON structure) +- `Media` items — flat per-item token estimate, added directly to the final count (not through the character heuristic, since actual cost depends on resolution and provider-specific image tokenization) + +`estimateToolDefsTokens` counts tool definition overhead: name, description, JSON schema of parameters. + +These are deliberately heuristic. The proactive check handles the common case; the reactive path catches estimation errors. + +--- + +## Interface boundaries + +Context budget functions (`parseTurnBoundaries`, `findSafeBoundary`, `estimateMessageTokens`, `isOverContextBudget`) are **pure functions**. They take `[]providers.Message` and integer parameters. They have no dependency on `AgentLoop` or any other runtime struct. + +`BuildMessages` is the sole assembler of the final message array sent to the LLM. Budget functions inform compression decisions but do not construct messages. + +`forceCompression` and `summarizeSession` mutate session state (history and summary). `BuildMessages` reads that state to construct context. The flow is: + +``` +budget check --> compression decision --> mutate session --> BuildMessages reads session --> LLM call +``` + +--- + +## Known gaps + +These are recognized limitations in the current implementation, documented here for visibility: + +- **Summarization trigger does not use the full budget formula.** `maybeSummarize` compares estimated history tokens against a percentage of `ContextWindow`. It does not account for system prompt size, tool definition overhead, or `MaxTokens` reserve. The proactive check covers the critical path (preventing 400 errors), but the summarization trigger could be aligned with the same budget model for more accurate early compression. + +- **Token estimation is heuristic.** It does not account for provider-specific tokenization, exact system prompt size (assembled separately), or variable image token costs. The two-path design (proactive + reactive) is intended to tolerate this imprecision. + +- **Reactive retry does not preserve media.** When the reactive path rebuilds context after compression, it currently passes empty values for media references. This is a pre-existing issue in the main loop, not introduced by the budget system. + +--- + +## What this document does not cover + +- How `AGENT.md` frontmatter configures context parameters — that is part of the Agent definition work +- How the context builder assembles context in the new architecture — that is upcoming work +- How compression events surface through the event system — that is part of the event model (#1316) +- Subagent context isolation — that is a separate track diff --git a/docs/architecture/agent-refactor/loop-split.md b/docs/architecture/agent-refactor/loop-split.md new file mode 100644 index 000000000..5395baeeb --- /dev/null +++ b/docs/architecture/agent-refactor/loop-split.md @@ -0,0 +1,77 @@ +# AgentLoop File Split + +> **Note:** This document describes the file split that was completed in a previous phase. The `loop_*` naming has since been renamed to `agent_*` and `turn_*`. See [agent-rename-plan.md](./agent-rename-plan.md) for the current file structure. + +## Overview + +The `pkg/agent/loop.go` file (originally 4384 lines) has been split into 12 focused source files. This is a pure refactoring with no behavioral changes. + +## Goals + +- Reduce cognitive load when navigating agent loop code +- Enable parallel work by decoupling concerns +- Maintain all existing functionality and tests +- Keep imports minimal per file + +## Original File Map (Renamed in Phase 2) + +| Old File | New File | Responsibility | +|----------|----------|----------------| +| `loop.go` | `agent.go` | Core `AgentLoop` struct, `Run`, `Stop`, `Close` | +| `loop_turn.go` | `turn_coord.go` + `pipeline_*.go` | Turn execution: coordinator + Pipeline methods | +| `loop_utils.go` | `agent_utils.go` | Standalone utility functions | +| `loop_init.go` | `agent_init.go` | `NewAgentLoop` constructor and tool registration | +| `loop_message.go` | `agent_message.go` | Message handling and routing | +| `loop_command.go` | `agent_command.go` | Command processing | +| `loop_mcp.go` | `agent_mcp.go` | MCP runtime | +| `loop_event.go` | `agent_event.go` | Event system helpers | +| `loop_media.go` | `agent_media.go` | Media resolution | +| `loop_outbound.go` | `agent_outbound.go` | Response publishing | +| `loop_transcribe.go` | `agent_transcribe.go` | Audio transcription | +| `loop_steering.go` | `agent_steering.go` | Steering queue | +| `loop_inject.go` | `agent_inject.go` | Setter injection | + +## Current File Structure + +See [agent-rename-plan.md](./agent-rename-plan.md) for the complete current file structure. + +## Phase 2: Rename and Pipeline Restructuring + +Phase 2 completed the following: + +1. **File renaming**: All `loop_*` files renamed to `agent_*` or `turn_*` +2. **Turn state merging**: `turn.go` + `turn_exec.go` → `turn_state.go` +3. **Pipeline extraction**: Split large `runTurn` into Pipeline methods + +### Pipeline Architecture + +The Pipeline methods provide structured turn execution: + +| Method | File | Responsibility | +|--------|------|----------------| +| `SetupTurn()` | `pipeline_setup.go` | History assembly, message building, candidate selection | +| `CallLLM()` | `pipeline_llm.go` | PreLLM hooks, fallback, retry, AfterLLM hooks | +| `ExecuteTools()` | `pipeline_execute.go` | Tool execution with hooks | +| `Finalize()` | `pipeline_finalize.go` | Session persistence, compression | + +## Core Principles Applied + +### 1. Same Package, Independent Files +All files belong to the `agent` package and compile together. This preserves the original visibility rules. + +### 2. No Logic Changes +All functions were moved verbatim. The extraction preserved behavioral equivalence. + +### 3. Shared Types in turn_state.go +The `turnState`, `turnExecution`, `Control`, `ToolControl`, and `LLMPhase` types are centralized in `turn_state.go`. + +## Testing + +All existing tests pass. The 5 failing tests (`TestGlobalSkillFileContentChange` and 4 Seahorse tests) are pre-existing failures unrelated to this refactor. + +Build status: `go build ./pkg/agent/...` passes with no errors. + +## See Also + +- [agent-rename-plan.md](./agent-rename-plan.md) — Current file naming convention +- [context.md](context.md) — context management and session handling diff --git a/docs/architecture/agent-refactor/pipeline-restructuring-plan.md b/docs/architecture/agent-refactor/pipeline-restructuring-plan.md new file mode 100644 index 000000000..b77987af1 --- /dev/null +++ b/docs/architecture/agent-refactor/pipeline-restructuring-plan.md @@ -0,0 +1,68 @@ +# Pipeline Restructuring Plan + +## Goal + +Split `agent/pipeline.go` (~1400 lines) into multiple logical files, organizing code by responsibility. + +## Final File Structure + +``` +pkg/agent/ +├── pipeline.go # Pipeline struct + NewPipeline (~39 lines) +├── pipeline_setup.go # SetupTurn method (~115 lines) +├── pipeline_llm.go # CallLLM method (~519 lines) +├── pipeline_execute.go # ExecuteTools method (~693 lines) +└── pipeline_finalize.go # Finalize method (~78 lines) +``` + +## Actual Line Counts + +| File | Lines | +|------|-------| +| `pipeline.go` | 39 | +| `pipeline_setup.go` | 115 | +| `pipeline_llm.go` | 519 | +| `pipeline_execute.go` | 693 | +| `pipeline_finalize.go` | 78 | +| **Total** | **1444** | + +## Responsibility Matrix + +| File | Method | Responsibility | +|------|--------|----------------| +| `pipeline.go` | `Pipeline` struct, `NewPipeline()` | Pipeline dependency container | +| `pipeline_setup.go` | `SetupTurn()` | Turn initialization: history assembly, message building, candidate selection | +| `pipeline_llm.go` | `CallLLM()` | LLM call: PreLLM hooks, fallback, retry, AfterLLM hooks | +| `pipeline_execute.go` | `ExecuteTools()` | Tool execution: BeforeTool/ApproveTool/AfterTool hooks, media sending, steering handling | +| `pipeline_finalize.go` | `Finalize()` | Turn finalization: session save, compression, status setting | + +## Relationship Between Pipeline and Turn Coordinator + +``` +AgentLoop (agent.go) + │ + ├── runAgentLoop() ──────────────────┐ + │ │ + │ ┌───────────────────────────────▼───────────────────────────────┐ + │ │ Turn Coordinator (turn_coord.go) │ + │ │ │ + │ │ runTurn() { │ + │ │ exec = pipeline.SetupTurn() │ + │ │ loop { │ + │ │ ctrl = pipeline.CallLLM() ──► Pipeline (pipeline_*.go) │ + │ │ if ctrl == ToolLoop { │ + │ │ toolCtrl = pipeline.ExecuteTools() │ + │ │ } │ + │ │ } │ + │ │ return pipeline.Finalize() │ + │ │ } │ + │ └─────────────────────────────────────────────────────────────┘ + │ + └── Publish response (agent_outbound.go) +``` + +## Verification Results + +- ✅ `go build ./pkg/agent/...` - Pass +- ✅ `go vet ./pkg/agent/...` - No warnings +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - Pass diff --git a/docs/architecture/agent-refactor/pipeline-restructuring-plan.zh.md b/docs/architecture/agent-refactor/pipeline-restructuring-plan.zh.md new file mode 100644 index 000000000..2de1396ad --- /dev/null +++ b/docs/architecture/agent-refactor/pipeline-restructuring-plan.zh.md @@ -0,0 +1,68 @@ +# Pipeline 重构文档 + +## 目标 + +将 `agent/pipeline.go` (1400行) 拆分为多个逻辑文件,代码按职责组织。 + +## 最终文件结构 + +``` +pkg/agent/ +├── pipeline.go # Pipeline struct + NewPipeline (~39行) +├── pipeline_setup.go # SetupTurn 方法 (~115行) +├── pipeline_llm.go # CallLLM 方法 (~519行) +├── pipeline_execute.go # ExecuteTools 方法 (~693行) +└── pipeline_finalize.go # Finalize 方法 (~78行) +``` + +## 实际行数 + +| 文件 | 行数 | +|------|------| +| `pipeline.go` | 39 | +| `pipeline_setup.go` | 115 | +| `pipeline_llm.go` | 519 | +| `pipeline_execute.go` | 693 | +| `pipeline_finalize.go` | 78 | +| **总计** | **1444** | + +## 职责说明 + +| 文件 | 方法 | 职责 | +|------|------|------| +| `pipeline.go` | `Pipeline` struct, `NewPipeline()` | Pipeline 依赖容器 | +| `pipeline_setup.go` | `SetupTurn()` | Turn 初始化:历史组装、消息构建、候选人选择 | +| `pipeline_llm.go` | `CallLLM()` | LLM 调用:PreLLM hook、fallback、重试、AfterLLM hook | +| `pipeline_execute.go` | `ExecuteTools()` | 工具执行:BeforeTool/ApproveTool/AfterTool hook、媒体发送、steering 处理 | +| `pipeline_finalize.go` | `Finalize()` | Turn 终结:会话保存、压缩、状态设置 | + +## Pipeline 与 Turn Coordinator 的关系 + +``` +AgentLoop (agent.go) + │ + ├── runAgentLoop() ──────────────────┐ + │ │ + │ ┌───────────────────────────────▼───────────────────────────────┐ + │ │ Turn Coordinator (turn_coord.go) │ + │ │ │ + │ │ runTurn() { │ + │ │ exec = pipeline.SetupTurn() │ + │ │ loop { │ + │ │ ctrl = pipeline.CallLLM() ──► Pipeline (pipeline_*.go) │ + │ │ if ctrl == ToolLoop { │ + │ │ toolCtrl = pipeline.ExecuteTools() │ + │ │ } │ + │ │ } │ + │ │ return pipeline.Finalize() │ + │ │ } │ + │ └─────────────────────────────────────────────────────────────┘ + │ + └── 发布响应 (agent_outbound.go) +``` + +## 验证结果 + +- ✅ `go build ./pkg/agent/...` - 通过 +- ✅ `go vet ./pkg/agent/...` - 无警告 +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - 通过 diff --git a/docs/architecture/hooks/README.md b/docs/architecture/hooks/README.md new file mode 100644 index 000000000..06f1a2c07 --- /dev/null +++ b/docs/architecture/hooks/README.md @@ -0,0 +1,743 @@ +# Hook System Guide + +This document describes the hook system that is implemented in the current repository, not the older design draft. + +The current implementation supports two mounting modes: + +1. In-process hooks +2. Out-of-process process hooks (`JSON-RPC over stdio`) + +The repository no longer ships standalone example source files. The Go and Python examples below are embedded directly in this document. If you want to use them, copy them into your own local files first. + +## Supported Hook Types + +| Type | Interface | Stage | Can modify data | +| --- | --- | --- | --- | +| Observer | `RuntimeEventObserver` | Runtime event bus broadcast | No | +| LLM interceptor | `LLMInterceptor` | `before_llm` / `after_llm` | Yes | +| Tool interceptor | `ToolInterceptor` | `before_tool` / `after_tool` | Yes | +| Tool approver | `ToolApprover` | `approve_tool` | No, returns allow/deny | + +The currently exposed synchronous hook points are: + +- `before_llm` +- `after_llm` +- `before_tool` +- `after_tool` +- `approve_tool` + +Everything else is exposed as read-only events. + +## Hook Actions + +Hooks can return different actions to control the flow: + +| Action | Applicable Stages | Effect | +| --- | --- | --- | +| `continue` | All interceptors | Pass through without modification | +| `modify` | `before_llm`, `after_llm`, `before_tool`, `after_tool` | Modify request/response and continue | +| `respond` | `before_tool` | Return a tool result directly, skip actual tool execution | +| `deny_tool` | `before_tool` | Deny tool execution, return error message | +| `abort_turn` | All interceptors | Abort the current turn | +| `hard_abort` | All interceptors | Force stop the entire agent loop | + +### The `respond` Action + +The `respond` action is special: it allows a `before_tool` hook to provide the tool result directly, skipping the actual tool execution. This is useful for: + +1. **Plugin tool injection**: External hooks can implement tools without registering them in the tool registry +2. **Tool result caching**: Return cached results for repeated tool calls +3. **Tool mocking**: Return mock results for testing purposes + +When a hook returns `respond` with a `HookResult`, the agent loop: +1. Skips the actual tool execution +2. Uses the provided result as if the tool had executed +3. Continues the turn normally with the result + +Example (Go in-process hook): + +```go +func (h *MyHook) BeforeTool( + ctx context.Context, + call *agent.ToolCallHookRequest, +) (*agent.ToolCallHookRequest, agent.HookDecision, error) { + if call.Tool == "my_plugin_tool" { + next := call.Clone() + next.HookResult = &tools.ToolResult{ + ForLLM: "Plugin tool executed successfully", + Silent: false, + IsError: false, + } + return next, agent.HookDecision{Action: agent.HookActionRespond}, nil + } + return call, agent.HookDecision{Action: agent.HookActionContinue}, nil +} +``` + +Example (Python process hook): + +```python +def handle_before_tool(params: dict) -> dict: + tool = params.get("tool", "") + if tool == "my_plugin_tool": + return { + "action": "respond", + "result": { + "for_llm": "Plugin tool executed successfully", + "silent": False, + "is_error": False + } + } + return {"action": "continue"} +``` + +## Execution Order + +`HookManager` sorts hooks like this: + +1. In-process hooks first +2. Process hooks second +3. Lower `priority` first within the same source +4. Name order as the final tie-breaker + +## Timeouts + +Global defaults live under `hooks.defaults`: + +- `observer_timeout_ms` +- `interceptor_timeout_ms` +- `approval_timeout_ms` + +Note: the current implementation does not support per-process-hook `timeout_ms`. Timeouts are global defaults. + +## Quick Start + +If your first goal is simply to prove that the hook flow works and observe real requests, the easiest path is the Python process-hook example below: + +1. Enable `hooks.enabled` +2. Save the Python example from this document to a local file, for example `/tmp/review_gate.py` +3. Set `PICOCLAW_HOOK_LOG_FILE` +4. Restart the gateway +5. Watch the log file with `tail -f` + +Example: + +```json +{ + "hooks": { + "enabled": true, + "processes": { + "py_review_gate": { + "enabled": true, + "priority": 100, + "transport": "stdio", + "command": [ + "python3", + "/tmp/review_gate.py" + ], + "observe": [ + "agent.tool.exec_start", + "agent.tool.exec_end", + "agent.tool.exec_skipped" + ], + "intercept": [ + "before_tool", + "approve_tool" + ], + "env": { + "PICOCLAW_HOOK_LOG_FILE": "/tmp/picoclaw-hook-review-gate.log" + } + } + } + } +} +``` + +Watch it with: + +```bash +tail -f /tmp/picoclaw-hook-review-gate.log +``` + +If you are developing PicoClaw itself rather than only validating the protocol, continue with the Go in-process example as well. + +## What The Two Examples Are For + +- Go in-process example + Best for validating the host-side hook chain and understanding `MountHook()` plus the synchronous stages +- Python process example + Best for understanding the `JSON-RPC over stdio` protocol and verifying the message flow between PicoClaw and an external process + +Both examples are intentionally safe: they only log, never rewrite, and never deny. + +## Go In-Process Example + +The following is a minimal logging hook for in-process use. It implements: + +1. `RuntimeEventObserver` +2. `LLMInterceptor` +3. `ToolInterceptor` +4. `ToolApprover` + +It only records activity. It does not rewrite requests or reject tools. + +You can save it as your own Go file, for example `pkg/myhooks/example_logger.go`: + +```go +package myhooks + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/agent" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type ExampleLoggerHookOptions struct { + LogFile string `json:"log_file,omitempty"` + LogEvents bool `json:"log_events,omitempty"` +} + +type ExampleLoggerHook struct { + logFile string + logEvents bool + mu sync.Mutex +} + +func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook { + return &ExampleLoggerHook{ + logFile: strings.TrimSpace(opts.LogFile), + logEvents: opts.LogEvents, + } +} + +func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error { + _ = ctx + if h == nil || !h.logEvents { + return nil + } + h.record("event", evt.Scope, map[string]any{ + "event": evt.Kind.String(), + "payload": evt.Payload, + }, nil) + return nil +} + +func (h *ExampleLoggerHook) BeforeLLM( + ctx context.Context, + req *agent.LLMHookRequest, +) (*agent.LLMHookRequest, agent.HookDecision, error) { + _ = ctx + h.record("before_llm", req.Meta, req, agent.HookDecision{Action: agent.HookActionContinue}) + return req, agent.HookDecision{Action: agent.HookActionContinue}, nil +} + +func (h *ExampleLoggerHook) AfterLLM( + ctx context.Context, + resp *agent.LLMHookResponse, +) (*agent.LLMHookResponse, agent.HookDecision, error) { + _ = ctx + h.record("after_llm", resp.Meta, resp, agent.HookDecision{Action: agent.HookActionContinue}) + return resp, agent.HookDecision{Action: agent.HookActionContinue}, nil +} + +func (h *ExampleLoggerHook) BeforeTool( + ctx context.Context, + call *agent.ToolCallHookRequest, +) (*agent.ToolCallHookRequest, agent.HookDecision, error) { + _ = ctx + h.record("before_tool", call.Meta, call, agent.HookDecision{Action: agent.HookActionContinue}) + return call, agent.HookDecision{Action: agent.HookActionContinue}, nil +} + +func (h *ExampleLoggerHook) AfterTool( + ctx context.Context, + result *agent.ToolResultHookResponse, +) (*agent.ToolResultHookResponse, agent.HookDecision, error) { + _ = ctx + h.record("after_tool", result.Meta, result, agent.HookDecision{Action: agent.HookActionContinue}) + return result, agent.HookDecision{Action: agent.HookActionContinue}, nil +} + +func (h *ExampleLoggerHook) ApproveTool( + ctx context.Context, + req *agent.ToolApprovalRequest, +) (agent.ApprovalDecision, error) { + _ = ctx + decision := agent.ApprovalDecision{Approved: true} + h.record("approve_tool", req.Meta, req, decision) + return decision, nil +} + +func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) { + logger.InfoCF("hooks", "Example hook observed", map[string]any{ + "stage": stage, + }) + if h == nil || h.logFile == "" { + return + } + + entry := map[string]any{ + "ts": time.Now().UTC(), + "stage": stage, + "refs": refs, + "payload": payload, + "decision": decision, + } + + body, err := json.Marshal(entry) + if err != nil { + logger.WarnCF("hooks", "Example hook log encode failed", map[string]any{ + "stage": stage, + "error": err.Error(), + }) + return + } + + h.mu.Lock() + defer h.mu.Unlock() + + if dir := filepath.Dir(h.logFile); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + logger.WarnCF("hooks", "Example hook log mkdir failed", map[string]any{ + "stage": stage, + "path": h.logFile, + "error": err.Error(), + }) + return + } + } + + file, err := os.OpenFile(h.logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + logger.WarnCF("hooks", "Example hook log open failed", map[string]any{ + "stage": stage, + "path": h.logFile, + "error": err.Error(), + }) + return + } + defer func() { _ = file.Close() }() + + if _, err := file.Write(append(body, '\n')); err != nil { + logger.WarnCF("hooks", "Example hook log write failed", map[string]any{ + "stage": stage, + "path": h.logFile, + "error": err.Error(), + }) + } +} +``` + +### Mounting It In Code + +If code mounting is enough, call this after `AgentLoop` is initialized: + +```go +hook := myhooks.NewExampleLoggerHook(myhooks.ExampleLoggerHookOptions{ + LogFile: "/tmp/picoclaw-hook-example-logger.log", + LogEvents: true, +}) + +if err := al.MountHook(agent.NamedHook("example-logger", hook)); err != nil { + panic(err) +} +``` + +### If You Also Want Config Mounting + +The hook system supports builtin hooks, but that requires you to compile the factory into your binary. In practice, that means you need registration code like this alongside the hook definition above: + +```go +package myhooks + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + if err := agent.RegisterBuiltinHook("example_logger", func( + ctx context.Context, + spec config.BuiltinHookConfig, + ) (any, error) { + _ = ctx + + var opts ExampleLoggerHookOptions + if len(spec.Config) > 0 { + if err := json.Unmarshal(spec.Config, &opts); err != nil { + return nil, fmt.Errorf("decode example_logger config: %w", err) + } + } + return NewExampleLoggerHook(opts), nil + }); err != nil { + panic(err) + } +} +``` + +Only after you register that builtin will the following config work: + +```json +{ + "hooks": { + "enabled": true, + "builtins": { + "example_logger": { + "enabled": true, + "priority": 10, + "config": { + "log_file": "/tmp/picoclaw-hook-example-logger.log", + "log_events": true + } + } + } + } +} +``` + +### How To Observe It + +- If `log_file` is set, each hook call is appended as JSON Lines +- If `log_file` is not set, the hook still writes summaries to the gateway log +- Requests that only hit the LLM path usually show `before_llm` and `after_llm` +- Requests that trigger tools usually also show `before_tool`, `approve_tool`, and `after_tool` +- If `log_events=true`, you will also see `event` + +Typical log lines: + +```json +{"ts":"2026-03-21T14:10:00Z","stage":"before_tool","meta":{"session_key":"session-1"},"payload":{"tool":"echo_text","arguments":{"text":"hello"}},"decision":{"action":"continue"}} +{"ts":"2026-03-21T14:10:00Z","stage":"approve_tool","meta":{"session_key":"session-1"},"payload":{"tool":"echo_text","arguments":{"text":"hello"}},"decision":{"approved":true}} +``` + +If you only see `before_llm` and `after_llm`, that usually means the request did not trigger any tool call, not that the hook failed to mount. + +## Python Process-Hook Example + +The following script is a minimal process-hook example. It uses only the Python standard library and supports: + +1. `hook.hello` +2. `hook.runtime_event` +3. `hook.before_tool` +4. `hook.approve_tool` + +It only records activity. It does not rewrite or deny anything. + +Save it to any local path, for example `/tmp/review_gate.py`: + +```python +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import signal +import sys +from datetime import datetime, timezone +from typing import Any + +LOG_EVENTS = os.getenv("PICOCLAW_HOOK_LOG_EVENTS", "1").lower() not in {"0", "false", "no"} +LOG_FILE = os.getenv("PICOCLAW_HOOK_LOG_FILE", "").strip() + + +def append_log(entry: dict[str, Any]) -> None: + if not LOG_FILE: + return + + payload = { + "ts": datetime.now(timezone.utc).isoformat(), + **entry, + } + try: + log_dir = os.path.dirname(LOG_FILE) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + with open(LOG_FILE, "a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, ensure_ascii=True) + "\n") + except OSError as exc: + log_stderr(f"failed to write hook log file {LOG_FILE}: {exc}") + + +def send_response(message_id: int, result: Any | None = None, error: str | None = None) -> None: + payload: dict[str, Any] = { + "jsonrpc": "2.0", + "id": message_id, + } + if error is not None: + payload["error"] = {"code": -32000, "message": error} + else: + payload["result"] = result if result is not None else {} + + append_log({ + "direction": "out", + "id": message_id, + "response": payload.get("result"), + "error": payload.get("error"), + }) + + try: + sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n") + sys.stdout.flush() + except BrokenPipeError: + raise SystemExit(0) from None + + +def log_stderr(message: str) -> None: + try: + sys.stderr.write(message + "\n") + sys.stderr.flush() + except BrokenPipeError: + raise SystemExit(0) from None + + +def handle_shutdown_signal(signum: int, _frame: Any) -> None: + raise KeyboardInterrupt(f"received signal {signum}") + + +def handle_before_tool(params: dict[str, Any]) -> dict[str, Any]: + _ = params + return {"action": "continue"} + + +def handle_approve_tool(params: dict[str, Any]) -> dict[str, Any]: + _ = params + return {"approved": True} + + +def handle_request(method: str, params: dict[str, Any]) -> dict[str, Any]: + if method == "hook.hello": + return {"ok": True, "name": "python-review-gate"} + if method == "hook.before_tool": + return handle_before_tool(params) + if method == "hook.approve_tool": + return handle_approve_tool(params) + if method == "hook.before_llm": + return {"action": "continue"} + if method == "hook.after_llm": + return {"action": "continue"} + if method == "hook.after_tool": + return {"action": "continue"} + raise KeyError(f"method not found: {method}") + + +def main() -> int: + try: + for raw_line in sys.stdin: + line = raw_line.strip() + if not line: + continue + + try: + message = json.loads(line) + except json.JSONDecodeError as exc: + log_stderr(f"failed to decode request: {exc}") + append_log({ + "direction": "in", + "decode_error": str(exc), + "raw": line, + }) + continue + + method = message.get("method") + message_id = message.get("id", 0) + params = message.get("params") or {} + if not isinstance(params, dict): + params = {} + + append_log({ + "direction": "in", + "id": message_id, + "method": method, + "params": params, + "notification": not bool(message_id), + }) + + if not message_id: + if method == "hook.runtime_event" and LOG_EVENTS: + log_stderr(f"observed event: {params.get('kind')}") + continue + + try: + result = handle_request(str(method or ""), params) + except KeyError as exc: + send_response(int(message_id), error=str(exc)) + continue + except Exception as exc: + send_response(int(message_id), error=f"unexpected error: {exc}") + continue + + send_response(int(message_id), result=result) + except KeyboardInterrupt: + return 0 + + return 0 + + +if __name__ == "__main__": + signal.signal(signal.SIGINT, handle_shutdown_signal) + signal.signal(signal.SIGTERM, handle_shutdown_signal) + raise SystemExit(main()) +``` + +### Configuration + +```json +{ + "hooks": { + "enabled": true, + "processes": { + "py_review_gate": { + "enabled": true, + "priority": 100, + "transport": "stdio", + "command": [ + "python3", + "/abs/path/to/review_gate.py" + ], + "observe": [ + "agent.tool.exec_start", + "agent.tool.exec_end", + "agent.tool.exec_skipped" + ], + "intercept": [ + "before_tool", + "approve_tool" + ], + "env": { + "PICOCLAW_HOOK_LOG_FILE": "/tmp/picoclaw-hook-review-gate.log" + } + } + } + } +} +``` + +### Environment Variables + +- `PICOCLAW_HOOK_LOG_EVENTS` + Whether to write `hook.runtime_event` summaries to `stderr`, enabled by default +- `PICOCLAW_HOOK_LOG_FILE` + Path to an external log file. When set, the script appends inbound hook requests, notifications, and outbound responses as JSON Lines + +Note: `PICOCLAW_HOOK_LOG_FILE` has no default. If you do not set it, the script does not write any file logs. + +### How To Confirm It Received Hooks + +Watch two places: + +- Gateway logs + Useful for confirming that the host successfully started the process and for seeing event summaries written to `stderr` +- `PICOCLAW_HOOK_LOG_FILE` + Useful for seeing the exact requests the script received and the exact responses it returned + +Typical interpretation: + +- Only `hook.hello` + The process started and completed the handshake, but no business hook request has arrived yet +- `hook.runtime_event` + The `observe` configuration is working +- `hook.before_tool` + The `intercept: ["before_tool", ...]` configuration is working +- `hook.approve_tool` + The approval hook path is working + +Because this example never rewrites or denies, the expected responses look like: + +```json +{"direction":"out","id":7,"response":{"action":"continue"},"error":null} +{"direction":"out","id":8,"response":{"approved":true},"error":null} +``` + +A complete sample: + +```json +{"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false} +{"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null} +{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.runtime_event","params":{"kind":"agent.tool.exec_start"},"notification":true} +{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false} +{"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null} +``` + +Additional notes: + +- Timestamps are UTC +- `notification=true` means it was a notification such as `hook.runtime_event`, which does not expect a response +- `id` increases within a single hook process; if the process restarts, the counter starts over + +## Process-Hook Protocol + +Current process hooks use `JSON-RPC over stdio`: + +- PicoClaw starts the external process +- Requests and responses are exchanged as one JSON message per line +- `hook.runtime_event` is a notification and does not need a response +- `hook.before_llm`, `hook.after_llm`, `hook.before_tool`, `hook.after_tool`, and `hook.approve_tool` are request/response calls + +The host does not currently accept new RPCs initiated by the process hook. In practice, that means an external hook can only respond to PicoClaw calls; it cannot call back into the host to send channel messages. + +## Configuration Fields + +### `hooks.builtins.` + +- `enabled` +- `priority` +- `config` + +### `hooks.processes.` + +- `enabled` +- `priority` +- `transport` + Currently only `stdio` is supported +- `command` +- `dir` +- `env` +- `observe` +- `intercept` + +## Troubleshooting + +If a hook looks like it is not firing, check these in order: + +1. `hooks.enabled` +2. Whether the target builtin or process hook is `enabled` +3. Whether the process-hook `command` path is correct +4. Whether you are watching the correct log file +5. Whether the current request actually reached the stage you care about +6. Whether `observe` or `intercept` contains the hook point you want + +A practical minimal troubleshooting pair is: + +- Use the Python process-hook example from this document to validate the external protocol +- Use the Go in-process example from this document to validate the host-side chain + +If the Python side shows `hook.hello` but no business hook requests, the protocol is usually fine; the current request simply did not trigger the stage you expected. + +## Scope And Limits + +The current hook system is best suited for: + +- LLM request rewriting +- Tool argument normalization +- Pre-execution tool approval +- Auditing and observability + +It is not yet well suited for: + +- External hooks actively sending channel messages +- Suspending a turn and waiting for human approval replies +- Full inbound/outbound message interception across the whole platform + +If you want a real human approval workflow, use hooks as the approval entry point and keep the state machine plus channel interaction in a separate `ApprovalManager`. diff --git a/docs/architecture/hooks/README.zh.md b/docs/architecture/hooks/README.zh.md new file mode 100644 index 000000000..1fff40832 --- /dev/null +++ b/docs/architecture/hooks/README.zh.md @@ -0,0 +1,743 @@ +# Hook 系统使用说明 + +这份文档对应当前仓库里已经实现的 hook 系统,而不是设计草案。 + +当前实现支持两类挂载方式: + +1. 进程内 hook +2. 进程外 process hook(`JSON-RPC over stdio`) + +当前仓库不再内置示例代码文件。下面的 Go / Python 示例都直接写在本文档里;如果你要使用它们,需要先复制到你自己的文件路径。 + +## 支持的 hook 类型 + +| 类型 | 接口 | 作用阶段 | 能否改写 | +| --- | --- | --- | --- | +| 观察型 | `RuntimeEventObserver` | runtime event bus 广播事件时 | 否 | +| LLM 拦截型 | `LLMInterceptor` | `before_llm` / `after_llm` | 是 | +| Tool 拦截型 | `ToolInterceptor` | `before_tool` / `after_tool` | 是 | +| Tool 审批型 | `ToolApprover` | `approve_tool` | 否,返回批准/拒绝 | + +当前公开的同步点位只有: + +- `before_llm` +- `after_llm` +- `before_tool` +- `after_tool` +- `approve_tool` + +其余 lifecycle 通过事件形式只读暴露。 + +## Hook Actions + +Hook 可以返回不同的 action 来控制流程: + +| Action | 适用阶段 | 效果 | +| --- | --- | --- | +| `continue` | 所有拦截型 | 放行,不做修改 | +| `modify` | `before_llm`, `after_llm`, `before_tool`, `after_tool` | 改写请求/响应后放行 | +| `respond` | `before_tool` | 直接返回工具结果,跳过实际工具执行 | +| `deny_tool` | `before_tool` | 拒绝工具执行,返回错误信息 | +| `abort_turn` | 所有拦截型 | 中止当前 turn | +| `hard_abort` | 所有拦截型 | 强制终止整个 agent loop | + +### `respond` Action + +`respond` action 是特殊的:它允许 `before_tool` hook 直接提供工具结果,跳过实际工具执行。适用于: + +1. **插件工具注入**:外部 hook 可以实现工具,无需在 ToolRegistry 注册 +2. **工具结果缓存**:对重复调用返回缓存结果 +3. **工具模拟**:测试时返回模拟结果 + +当 hook 返回 `respond` 并携带 `HookResult` 时,agent loop 会: +1. 跳过实际工具执行 +2. 使用提供的结果作为工具执行结果 +3. 正常继续 turn 流程 + +示例(Go 进程内 hook): + +```go +func (h *MyHook) BeforeTool( + ctx context.Context, + call *agent.ToolCallHookRequest, +) (*agent.ToolCallHookRequest, agent.HookDecision, error) { + if call.Tool == "my_plugin_tool" { + next := call.Clone() + next.HookResult = &tools.ToolResult{ + ForLLM: "Plugin tool executed successfully", + Silent: false, + IsError: false, + } + return next, agent.HookDecision{Action: agent.HookActionRespond}, nil + } + return call, agent.HookDecision{Action: agent.HookActionContinue}, nil +} +``` + +示例(Python process hook): + +```python +def handle_before_tool(params: dict) -> dict: + tool = params.get("tool", "") + if tool == "my_plugin_tool": + return { + "action": "respond", + "result": { + "for_llm": "Plugin tool executed successfully", + "silent": False, + "is_error": False + } + } + return {"action": "continue"} +``` + +## 执行顺序 + +HookManager 的排序规则是: + +1. 先执行进程内 hook +2. 再执行 process hook +3. 同一来源内按 `priority` 从小到大 +4. 若 `priority` 相同,再按名字排序 + +## 超时 + +当前配置在 `hooks.defaults` 中统一设置: + +- `observer_timeout_ms` +- `interceptor_timeout_ms` +- `approval_timeout_ms` + +注意:当前实现还没有单个 process hook 自己的 `timeout_ms` 字段,超时配置是全局默认值。 + +## 快速开始 + +如果你的目标只是先把当前 hook 流程跑通并观察到实际请求,最省事的是先用下面的 Python process hook 示例: + +1. 打开 `hooks.enabled` +2. 把下面文档里的 Python 示例保存到本地文件,例如 `/tmp/review_gate.py` +3. 给它配置 `PICOCLAW_HOOK_LOG_FILE` +4. 重启 gateway +5. 用 `tail -f` 观察日志文件 + +例如: + +```json +{ + "hooks": { + "enabled": true, + "processes": { + "py_review_gate": { + "enabled": true, + "priority": 100, + "transport": "stdio", + "command": [ + "python3", + "/tmp/review_gate.py" + ], + "observe": [ + "agent.tool.exec_start", + "agent.tool.exec_end", + "agent.tool.exec_skipped" + ], + "intercept": [ + "before_tool", + "approve_tool" + ], + "env": { + "PICOCLAW_HOOK_LOG_FILE": "/tmp/picoclaw-hook-review-gate.log" + } + } + } + } +} +``` + +观察方式: + +```bash +tail -f /tmp/picoclaw-hook-review-gate.log +``` + +如果你是在开发 PicoClaw 本体,而不是只想验证协议,那么再看后面的 Go in-process 示例。 + +## 两个示例的定位 + +- Go in-process 示例 + 适合验证宿主内的 hook 链路、理解 `MountHook()` 和各个同步点位 +- Python process 示例 + 适合理解 `JSON-RPC over stdio` 协议、确认宿主和外部进程之间的消息来回是否正常 + +这两个示例都刻意保持为“只记录、不改写、不拒绝”的安全模式。它们的目的不是提供策略能力,而是帮你观察当前 hook 系统。 + +## Go 进程内示例 + +下面这段代码是一个最小的“记录型” in-process hook。它实现了: + +1. `RuntimeEventObserver` +2. `LLMInterceptor` +3. `ToolInterceptor` +4. `ToolApprover` + +它只记录,不改写请求,也不拒绝工具。 + +你可以把它保存成你自己的 Go 文件,例如 `pkg/myhooks/example_logger.go`: + +```go +package myhooks + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/agent" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type ExampleLoggerHookOptions struct { + LogFile string `json:"log_file,omitempty"` + LogEvents bool `json:"log_events,omitempty"` +} + +type ExampleLoggerHook struct { + logFile string + logEvents bool + mu sync.Mutex +} + +func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook { + return &ExampleLoggerHook{ + logFile: strings.TrimSpace(opts.LogFile), + logEvents: opts.LogEvents, + } +} + +func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error { + _ = ctx + if h == nil || !h.logEvents { + return nil + } + h.record("event", evt.Scope, map[string]any{ + "event": evt.Kind.String(), + "payload": evt.Payload, + }, nil) + return nil +} + +func (h *ExampleLoggerHook) BeforeLLM( + ctx context.Context, + req *agent.LLMHookRequest, +) (*agent.LLMHookRequest, agent.HookDecision, error) { + _ = ctx + h.record("before_llm", req.Meta, req, agent.HookDecision{Action: agent.HookActionContinue}) + return req, agent.HookDecision{Action: agent.HookActionContinue}, nil +} + +func (h *ExampleLoggerHook) AfterLLM( + ctx context.Context, + resp *agent.LLMHookResponse, +) (*agent.LLMHookResponse, agent.HookDecision, error) { + _ = ctx + h.record("after_llm", resp.Meta, resp, agent.HookDecision{Action: agent.HookActionContinue}) + return resp, agent.HookDecision{Action: agent.HookActionContinue}, nil +} + +func (h *ExampleLoggerHook) BeforeTool( + ctx context.Context, + call *agent.ToolCallHookRequest, +) (*agent.ToolCallHookRequest, agent.HookDecision, error) { + _ = ctx + h.record("before_tool", call.Meta, call, agent.HookDecision{Action: agent.HookActionContinue}) + return call, agent.HookDecision{Action: agent.HookActionContinue}, nil +} + +func (h *ExampleLoggerHook) AfterTool( + ctx context.Context, + result *agent.ToolResultHookResponse, +) (*agent.ToolResultHookResponse, agent.HookDecision, error) { + _ = ctx + h.record("after_tool", result.Meta, result, agent.HookDecision{Action: agent.HookActionContinue}) + return result, agent.HookDecision{Action: agent.HookActionContinue}, nil +} + +func (h *ExampleLoggerHook) ApproveTool( + ctx context.Context, + req *agent.ToolApprovalRequest, +) (agent.ApprovalDecision, error) { + _ = ctx + decision := agent.ApprovalDecision{Approved: true} + h.record("approve_tool", req.Meta, req, decision) + return decision, nil +} + +func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) { + logger.InfoCF("hooks", "Example hook observed", map[string]any{ + "stage": stage, + }) + if h == nil || h.logFile == "" { + return + } + + entry := map[string]any{ + "ts": time.Now().UTC(), + "stage": stage, + "refs": refs, + "payload": payload, + "decision": decision, + } + + body, err := json.Marshal(entry) + if err != nil { + logger.WarnCF("hooks", "Example hook log encode failed", map[string]any{ + "stage": stage, + "error": err.Error(), + }) + return + } + + h.mu.Lock() + defer h.mu.Unlock() + + if dir := filepath.Dir(h.logFile); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + logger.WarnCF("hooks", "Example hook log mkdir failed", map[string]any{ + "stage": stage, + "path": h.logFile, + "error": err.Error(), + }) + return + } + } + + file, err := os.OpenFile(h.logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + logger.WarnCF("hooks", "Example hook log open failed", map[string]any{ + "stage": stage, + "path": h.logFile, + "error": err.Error(), + }) + return + } + defer func() { _ = file.Close() }() + + if _, err := file.Write(append(body, '\n')); err != nil { + logger.WarnCF("hooks", "Example hook log write failed", map[string]any{ + "stage": stage, + "path": h.logFile, + "error": err.Error(), + }) + } +} +``` + +### 如何挂载 + +如果你只需要代码挂载,直接在 `AgentLoop` 初始化后调用: + +```go +hook := myhooks.NewExampleLoggerHook(myhooks.ExampleLoggerHookOptions{ + LogFile: "/tmp/picoclaw-hook-example-logger.log", + LogEvents: true, +}) + +if err := al.MountHook(agent.NamedHook("example-logger", hook)); err != nil { + panic(err) +} +``` + +### 如果你还想用配置挂载 + +当前 hook 系统支持 builtin hook,但这要求你自己把 factory 编进二进制。也就是说,下面这段注册代码需要和上面的 hook 定义一起放进你的工程里: + +```go +package myhooks + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + if err := agent.RegisterBuiltinHook("example_logger", func( + ctx context.Context, + spec config.BuiltinHookConfig, + ) (any, error) { + _ = ctx + + var opts ExampleLoggerHookOptions + if len(spec.Config) > 0 { + if err := json.Unmarshal(spec.Config, &opts); err != nil { + return nil, fmt.Errorf("decode example_logger config: %w", err) + } + } + return NewExampleLoggerHook(opts), nil + }); err != nil { + panic(err) + } +} +``` + +只有在你自己注册了 builtin 之后,下面的配置才会生效: + +```json +{ + "hooks": { + "enabled": true, + "builtins": { + "example_logger": { + "enabled": true, + "priority": 10, + "config": { + "log_file": "/tmp/picoclaw-hook-example-logger.log", + "log_events": true + } + } + } + } +} +``` + +### 如何观察它是否生效 + +- 如果设置了 `log_file`,它会把每次 hook 调用按 JSON Lines 写入文件 +- 如果没有设置 `log_file`,它仍然会把摘要写到 gateway 日志 +- 普通只走 LLM 的请求,通常会看到 `before_llm` 和 `after_llm` +- 触发工具调用的请求,通常还会看到 `before_tool`、`approve_tool`、`after_tool` +- 如果 `log_events=true`,还会额外看到 `event` + +典型日志: + +```json +{"ts":"2026-03-21T14:10:00Z","stage":"before_tool","meta":{"session_key":"session-1"},"payload":{"tool":"echo_text","arguments":{"text":"hello"}},"decision":{"action":"continue"}} +{"ts":"2026-03-21T14:10:00Z","stage":"approve_tool","meta":{"session_key":"session-1"},"payload":{"tool":"echo_text","arguments":{"text":"hello"}},"decision":{"approved":true}} +``` + +如果你只看到了 `before_llm` / `after_llm`,没有看到 tool 相关阶段,通常不是 hook 没挂上,而是这次请求本身没有触发工具调用。 + +## Python process hook 示例 + +下面这段脚本是一个最小的 `process hook` 示例。它只使用 Python 标准库,支持: + +1. `hook.hello` +2. `hook.runtime_event` +3. `hook.before_tool` +4. `hook.approve_tool` + +它默认只记录,不改写,也不拒绝。 + +你可以把它保存到任意本地路径,例如 `/tmp/review_gate.py`: + +```python +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import signal +import sys +from datetime import datetime, timezone +from typing import Any + +LOG_EVENTS = os.getenv("PICOCLAW_HOOK_LOG_EVENTS", "1").lower() not in {"0", "false", "no"} +LOG_FILE = os.getenv("PICOCLAW_HOOK_LOG_FILE", "").strip() + + +def append_log(entry: dict[str, Any]) -> None: + if not LOG_FILE: + return + + payload = { + "ts": datetime.now(timezone.utc).isoformat(), + **entry, + } + try: + log_dir = os.path.dirname(LOG_FILE) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + with open(LOG_FILE, "a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, ensure_ascii=True) + "\n") + except OSError as exc: + log_stderr(f"failed to write hook log file {LOG_FILE}: {exc}") + + +def send_response(message_id: int, result: Any | None = None, error: str | None = None) -> None: + payload: dict[str, Any] = { + "jsonrpc": "2.0", + "id": message_id, + } + if error is not None: + payload["error"] = {"code": -32000, "message": error} + else: + payload["result"] = result if result is not None else {} + + append_log({ + "direction": "out", + "id": message_id, + "response": payload.get("result"), + "error": payload.get("error"), + }) + + try: + sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n") + sys.stdout.flush() + except BrokenPipeError: + raise SystemExit(0) from None + + +def log_stderr(message: str) -> None: + try: + sys.stderr.write(message + "\n") + sys.stderr.flush() + except BrokenPipeError: + raise SystemExit(0) from None + + +def handle_shutdown_signal(signum: int, _frame: Any) -> None: + raise KeyboardInterrupt(f"received signal {signum}") + + +def handle_before_tool(params: dict[str, Any]) -> dict[str, Any]: + _ = params + return {"action": "continue"} + + +def handle_approve_tool(params: dict[str, Any]) -> dict[str, Any]: + _ = params + return {"approved": True} + + +def handle_request(method: str, params: dict[str, Any]) -> dict[str, Any]: + if method == "hook.hello": + return {"ok": True, "name": "python-review-gate"} + if method == "hook.before_tool": + return handle_before_tool(params) + if method == "hook.approve_tool": + return handle_approve_tool(params) + if method == "hook.before_llm": + return {"action": "continue"} + if method == "hook.after_llm": + return {"action": "continue"} + if method == "hook.after_tool": + return {"action": "continue"} + raise KeyError(f"method not found: {method}") + + +def main() -> int: + try: + for raw_line in sys.stdin: + line = raw_line.strip() + if not line: + continue + + try: + message = json.loads(line) + except json.JSONDecodeError as exc: + log_stderr(f"failed to decode request: {exc}") + append_log({ + "direction": "in", + "decode_error": str(exc), + "raw": line, + }) + continue + + method = message.get("method") + message_id = message.get("id", 0) + params = message.get("params") or {} + if not isinstance(params, dict): + params = {} + + append_log({ + "direction": "in", + "id": message_id, + "method": method, + "params": params, + "notification": not bool(message_id), + }) + + if not message_id: + if method == "hook.runtime_event" and LOG_EVENTS: + log_stderr(f"observed event: {params.get('kind')}") + continue + + try: + result = handle_request(str(method or ""), params) + except KeyError as exc: + send_response(int(message_id), error=str(exc)) + continue + except Exception as exc: + send_response(int(message_id), error=f"unexpected error: {exc}") + continue + + send_response(int(message_id), result=result) + except KeyboardInterrupt: + return 0 + + return 0 + + +if __name__ == "__main__": + signal.signal(signal.SIGINT, handle_shutdown_signal) + signal.signal(signal.SIGTERM, handle_shutdown_signal) + raise SystemExit(main()) +``` + +### 如何配置 + +```json +{ + "hooks": { + "enabled": true, + "processes": { + "py_review_gate": { + "enabled": true, + "priority": 100, + "transport": "stdio", + "command": [ + "python3", + "/abs/path/to/review_gate.py" + ], + "observe": [ + "agent.tool.exec_start", + "agent.tool.exec_end", + "agent.tool.exec_skipped" + ], + "intercept": [ + "before_tool", + "approve_tool" + ], + "env": { + "PICOCLAW_HOOK_LOG_FILE": "/tmp/picoclaw-hook-review-gate.log" + } + } + } + } +} +``` + +### 环境变量 + +- `PICOCLAW_HOOK_LOG_EVENTS` + 是否把 `hook.runtime_event` 写到 `stderr`,默认开启 +- `PICOCLAW_HOOK_LOG_FILE` + 外部日志文件路径。设置后,脚本会把收到的 hook 请求、notification 和返回结果按 JSON Lines 追加到该文件 + +注意:`PICOCLAW_HOOK_LOG_FILE` 没有默认值。不设置时,脚本不会自动落盘日志。 + +### 如何确认它收到了 hook + +推荐同时看两个地方: + +- gateway 日志 + 用来观察宿主是否成功启动了外部进程,以及脚本写到 `stderr` 的事件摘要 +- `PICOCLAW_HOOK_LOG_FILE` + 用来观察脚本实际收到了什么请求、返回了什么响应 + +典型判断方式: + +- 只看到 `hook.hello` + 说明进程启动并完成握手了,但还没有新的业务 hook 请求真正打进来 +- 看到 `hook.runtime_event` + 说明 `observe` 配置生效了 +- 看到 `hook.before_tool` + 说明 `intercept: ["before_tool", ...]` 生效了 +- 看到 `hook.approve_tool` + 说明审批 hook 生效了 + +这份示例脚本不会改写任何参数,也不会拒绝工具,所以你应该看到的典型返回是: + +```json +{"direction":"out","id":7,"response":{"action":"continue"},"error":null} +{"direction":"out","id":8,"response":{"approved":true},"error":null} +``` + +一组完整样例: + +```json +{"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false} +{"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null} +{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.runtime_event","params":{"kind":"agent.tool.exec_start"},"notification":true} +{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false} +{"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null} +``` + +补充说明: + +- 时间戳是 UTC,不是本地时区 +- `notification=true` 表示这是 `hook.runtime_event` 这类不需要响应的通知 +- `id` 会随着当前进程内的请求递增;如果 hook 进程重启,计数会重新开始 + +## Process Hook 协议约定 + +当前 process hook 使用 `JSON-RPC over stdio`: + +- PicoClaw 启动外部进程 +- 请求和响应都按“一行一个 JSON 消息”传输 +- `hook.runtime_event` 是 notification,不需要响应 +- `hook.before_llm` / `hook.after_llm` / `hook.before_tool` / `hook.after_tool` / `hook.approve_tool` 是 request/response + +当前宿主不会接受 process hook 主动发起的新 RPC。也就是说,外部 hook 现在只能“响应 PicoClaw 的调用”,不能反向调用宿主去发送 channel 消息。 + +## 配置字段 + +### `hooks.builtins.` + +- `enabled` +- `priority` +- `config` + +### `hooks.processes.` + +- `enabled` +- `priority` +- `transport` + 当前只支持 `stdio` +- `command` +- `dir` +- `env` +- `observe` +- `intercept` + +## 排查建议 + +当你觉得“hook 没触发”时,优先按这个顺序排查: + +1. `hooks.enabled` 是否为 `true` +2. 对应的 builtin/process hook 是否 `enabled` +3. process hook 的 `command` 路径是否正确 +4. 你看的是否是正确的日志文件 +5. 当前请求是否真的走到了对应阶段 +6. `observe` / `intercept` 是否包含了你想看的点位 + +一个很实用的最小排查组合是: + +- 先用文档里的 Python process 示例确认外部协议没问题 +- 再用文档里的 Go in-process 示例确认宿主内的 hook 链路没问题 + +如果前者有 `hook.hello` 但没有业务请求,通常不是协议挂了,而是当前这次请求没有真正触发对应的 hook 点位。 + +## 适用边界 + +当前 hook 系统最适合做这些事: + +- LLM 请求改写 +- 工具参数规范化 +- 工具执行前审批 +- 审计和观测 + +当前还不适合直接承载这些需求: + +- 外部 hook 主动发 channel 消息 +- 挂起 turn 并等待人工审批回复 +- inbound/outbound 全链路消息拦截 + +如果你要做人审流转,推荐把 hook 作为审批入口,把审批状态机和 channel 交互放到独立的 `ApprovalManager`。 diff --git a/docs/architecture/hooks/hook-json-protocol.md b/docs/architecture/hooks/hook-json-protocol.md new file mode 100644 index 000000000..725869a02 --- /dev/null +++ b/docs/architecture/hooks/hook-json-protocol.md @@ -0,0 +1,577 @@ +# Hook JSON-RPC Protocol Details + +All hooks use `JSON-RPC 2.0` format, with one JSON message per line, transmitted via stdio. + +--- + +## Basic Protocol Structure + +### Request (PicoClaw → Hook) + +```json +{"jsonrpc":"2.0","id":1,"method":"hook.xxx","params":{...}} +``` + +### Response (Hook → PicoClaw) + +Success: +```json +{"jsonrpc":"2.0","id":1,"result":{...}} +``` + +Error: +```json +{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"error message"}} +``` + +--- + +## 1. `hook.hello` (Handshake) + +Handshake must be completed at startup, otherwise the hook process will be terminated. + +### Request + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "hook.hello", + "params": { + "name": "py_review_gate", + "version": 1, + "modes": ["observe", "tool", "approve"] + } +} +``` + +| Field | Description | +|-------|-------------| +| `name` | hook name (from configuration) | +| `version` | protocol version, currently `1` | +| `modes` | capability modes supported by the hook | + +### Response + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "ok": true, + "name": "python-review-gate" + } +} +``` + +--- + +## 2. `hook.before_llm` + +Triggered before sending request to LLM. Can be used to inject tools. + +### Request + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "hook.before_llm", + "params": { + "meta": { + "AgentID": "agent-1", + "TurnID": "turn-1", + "ParentTurnID": "", + "SessionKey": "session-1", + "Iteration": 0, + "TracePath": "runTurn", + "Source": "turn.llm.request" + }, + "model": "claude-sonnet", + "messages": [ + {"role": "user", "content": "hello"} + ], + "tools": [ + { + "type": "function", + "function": { + "name": "echo", + "description": "echo text", + "parameters": {"type": "object"} + } + } + ], + "options": { + "temperature": 0.7 + }, + "channel": "cli", + "chat_id": "chat-1", + "graceful_terminal": false + } +} +``` + +| Field | Description | +|-------|-------------| +| `meta` | event metadata for tracing | +| `model` | requested model name | +| `messages` | conversation history | +| `tools` | list of available tool definitions | +| `options` | LLM parameters (temperature, max_tokens, etc.) | +| `channel` | request source channel | +| `chat_id` | session ID | + +### Response (Tool Injection Example) + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "action": "modify", + "request": { + "model": "claude-sonnet", + "messages": [{"role": "user", "content": "hello"}], + "tools": [ + { + "type": "function", + "function": { + "name": "echo", + "description": "echo", + "parameters": {} + } + }, + { + "type": "function", + "function": { + "name": "my_plugin_tool", + "description": "Plugin injected tool", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string"} + } + } + } + } + ] + } + } +} +``` + +| Field | Description | +|-------|-------------| +| `action` | decision action (see table below) | +| `request` | modified request object | + +--- + +## 3. `hook.after_llm` + +Triggered after receiving LLM response. Can modify response content. + +### Request + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "method": "hook.after_llm", + "params": { + "meta": { + "AgentID": "agent-1", + "TurnID": "turn-1", + "SessionKey": "session-1" + }, + "model": "claude-sonnet", + "response": { + "role": "assistant", + "content": "Hi!", + "tool_calls": [ + { + "id": "tc-1", + "type": "function", + "function": { + "name": "echo", + "arguments": "{\"text\":\"hi\"}" + } + } + ] + }, + "channel": "cli", + "chat_id": "chat-1" + } +} +``` + +### Response + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "action": "continue" + } +} +``` + +--- + +## 4. `hook.before_tool` + +Triggered before tool execution. Can modify tool name and arguments, deny execution, or return result directly. + +### Request + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "method": "hook.before_tool", + "params": { + "meta": { + "AgentID": "agent-1", + "TurnID": "turn-1", + "SessionKey": "session-1" + }, + "tool": "echo_text", + "arguments": { + "text": "hello" + }, + "channel": "cli", + "chat_id": "chat-1" + } +} +``` + +| Field | Description | +|-------|-------------| +| `tool` | tool name | +| `arguments` | tool arguments | + +### Response (Modify Arguments) + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "result": { + "action": "modify", + "call": { + "tool": "echo_text", + "arguments": { + "text": "modified hello" + } + } + } +} +``` + +### Response (Deny Execution) + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "result": { + "action": "deny_tool", + "reason": "Invalid arguments" + } +} +``` + +### Response (Return Result Directly - respond) + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "result": { + "action": "respond", + "call": { + "tool": "my_plugin_tool", + "arguments": { + "query": "hello" + } + }, + "result": { + "for_llm": "Plugin tool executed successfully", + "for_user": "", + "silent": false, + "is_error": false + } + } +} +``` + +The `respond` action allows hooks to return tool results directly, skipping actual tool execution. Use cases: +1. **Plugin tool injection**: External hooks can implement tools without registering in ToolRegistry +2. **Tool result caching**: Return cached results for repeated calls +3. **Tool mocking**: Return mock results during testing + +| Field | Description | +|-------|-------------| +| `action` | must be `respond` | +| `call` | modified call information (optional) | +| `result` | tool result to return directly | + +--- + +## 5. `hook.after_tool` + +Triggered after tool execution completes. Can modify the result returned to LLM. + +### Request + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "method": "hook.after_tool", + "params": { + "meta": { + "AgentID": "agent-1", + "TurnID": "turn-1", + "SessionKey": "session-1" + }, + "tool": "echo_text", + "arguments": { + "text": "hello" + }, + "result": { + "for_llm": "echoed: hello", + "for_user": "", + "silent": false, + "is_error": false, + "async": false, + "media": [], + "artifact_tags": [], + "response_handled": false + }, + "duration": 15000000, + "channel": "cli", + "chat_id": "chat-1" + } +} +``` + +| Field | Description | +|-------|-------------| +| `result.for_llm` | content returned to LLM | +| `result.for_user` | content sent to user | +| `result.silent` | whether silent (not sent to user) | +| `result.is_error` | whether it's an error | +| `result.async` | whether executed asynchronously | +| `result.media` | list of media references | +| `result.artifact_tags` | local artifact path tags | +| `result.response_handled` | whether response has been handled | +| `duration` | execution time (nanoseconds) | + +### Response + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "result": { + "action": "continue" + } +} +``` + +--- + +## 6. `hook.approve_tool` + +Approval hook for deciding whether to allow execution of sensitive tools. + +### Request + +```json +{ + "jsonrpc": "2.0", + "id": 6, + "method": "hook.approve_tool", + "params": { + "meta": { + "AgentID": "agent-1", + "TurnID": "turn-1", + "SessionKey": "session-1" + }, + "tool": "bash", + "arguments": { + "command": "rm -rf /" + }, + "channel": "cli", + "chat_id": "chat-1" + } +} +``` + +### Response (Approved) + +```json +{ + "jsonrpc": "2.0", + "id": 6, + "result": { + "approved": true + } +} +``` + +### Response (Denied) + +```json +{ + "jsonrpc": "2.0", + "id": 6, + "result": { + "approved": false, + "reason": "Dangerous command, execution denied" + } +} +``` + +--- + +## 7. `hook.runtime_event` (notification) + +Runtime observer event, broadcast only, no response required. `id` is `0` or absent. + +```json +{ + "jsonrpc": "2.0", + "method": "hook.runtime_event", + "params": { + "kind": "agent.tool.exec_start", + "source": { + "component": "agent", + "name": "agent-1" + }, + "scope": { + "agent_id": "agent-1", + "session_key": "session-1", + "turn_id": "turn-1", + "channel": "cli", + "chat_id": "chat-1" + }, + "payload": { + "Tool": "echo_text", + "Arguments": {"text": "hello"} + } + } +} +``` + +Common `Kind` values: +- `agent.turn.start` / `agent.turn.end` +- `agent.llm.request` / `agent.llm.response` +- `agent.tool.exec_start` / `agent.tool.exec_end` / `agent.tool.exec_skipped` +- `agent.steering.injected` +- `agent.interrupt.received` +- `agent.error` + +Legacy observe configuration names such as `turn_end` and `tool_exec_start` are still accepted and normalized to runtime event names. New process hook notifications use `hook.runtime_event`. + +--- + +## Action Options + +| action | Applicable hooks | Effect | +|--------|-----------------|--------| +| `continue` | All interceptor types | Pass through without modification | +| `modify` | `before_llm`, `before_tool`, `after_llm`, `after_tool` | Modify request/response and pass through | +| `respond` | `before_tool` | Return tool result directly, skip actual execution. **Note: AfterTool is NOT called (design decision - respond provides final answer).** | +| `deny_tool` | `before_tool` | Deny tool execution | +| `abort_turn` | All interceptor types | Abort current turn, return error | +| `hard_abort` | All interceptor types | Force stop entire agent loop | + +--- + +## Complete Flow Example + +```json +{"jsonrpc":"2.0","id":1,"method":"hook.hello","params":{"name":"my_hook","version":1,"modes":["tool","approve"]}} +{"jsonrpc":"2.0","id":1,"result":{"ok":true,"name":"my_hook"}} +{"jsonrpc":"2.0","id":2,"method":"hook.before_llm","params":{"model":"claude-sonnet","messages":[{"role":"user","content":"hello"}],"tools":[]}} +{"jsonrpc":"2.0","id":2,"result":{"action":"continue"}} +{"jsonrpc":"2.0","id":3,"method":"hook.before_tool","params":{"tool":"bash","arguments":{"command":"ls"}}} +{"jsonrpc":"2.0","id":3,"result":{"action":"continue"}} +{"jsonrpc":"2.0","id":4,"method":"hook.approve_tool","params":{"tool":"bash","arguments":{"command":"ls"}}} +{"jsonrpc":"2.0","id":4,"result":{"approved":true}} +{"jsonrpc":"2.0","id":5,"method":"hook.after_tool","params":{"tool":"bash","arguments":{"command":"ls"},"result":{"for_llm":"file1.txt\nfile2.txt"},"duration":5000000}} +{"jsonrpc":"2.0","id":5,"result":{"action":"continue"}} +{"jsonrpc":"2.0","id":6,"method":"hook.after_llm","params":{"model":"claude-sonnet","response":{"role":"assistant","content":"Files listed"}}} +{"jsonrpc":"2.0","id":6,"result":{"action":"continue"}} +``` + +--- + +## Plugin Tool Injection via `before_llm` and `before_tool` + +Standard flow for plugin tool injection: + +1. In `before_llm`, inject tool definition to let LLM know the tool is available +2. In `before_tool`, use `respond` action to return tool execution result directly + +### `before_llm` Inject Tool Definition + +```python +def handle_before_llm(params: dict) -> dict: + tools = params.get("tools", []) + + # Add plugin tool definition + tools.append({ + "type": "function", + "function": { + "name": "my_plugin_tool", + "description": "Plugin provided tool", + "parameters": { + "type": "object", + "properties": { + "input": {"type": "string", "description": "Input content"} + }, + "required": ["input"] + } + } + }) + + return { + "action": "modify", + "request": { + "model": params["model"], + "messages": params["messages"], + "tools": tools, + "options": params.get("options", {}) + } + } +``` + +### `before_tool` Return Execution Result + +```python +def handle_before_tool(params: dict) -> dict: + tool = params.get("tool", "") + + if tool == "my_plugin_tool": + # Implement tool logic here + args = params.get("arguments", {}) + input_text = args.get("input", "") + + # Return result directly, no need to register in ToolRegistry + return { + "action": "respond", + "result": { + "for_llm": f"Plugin tool executed successfully, input: {input_text}", + "silent": False, + "is_error": False + } + } + + return {"action": "continue"} +``` + +This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw. diff --git a/docs/architecture/hooks/hook-json-protocol.zh.md b/docs/architecture/hooks/hook-json-protocol.zh.md new file mode 100644 index 000000000..9c11c6270 --- /dev/null +++ b/docs/architecture/hooks/hook-json-protocol.zh.md @@ -0,0 +1,577 @@ +# Hook JSON-RPC 协议详解 + +所有 hook 使用 `JSON-RPC 2.0` 格式,每行一个 JSON 消息,通过 stdio 传输。 + +--- + +## 基础协议结构 + +### 请求(PicoClaw → Hook) + +```json +{"jsonrpc":"2.0","id":1,"method":"hook.xxx","params":{...}} +``` + +### 响应(Hook → PicoClaw) + +成功: +```json +{"jsonrpc":"2.0","id":1,"result":{...}} +``` + +错误: +```json +{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"错误信息"}} +``` + +--- + +## 1. `hook.hello`(握手) + +启动时必须完成握手,否则 hook 进程会被终止。 + +### 请求 + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "hook.hello", + "params": { + "name": "py_review_gate", + "version": 1, + "modes": ["observe", "tool", "approve"] + } +} +``` + +| 字段 | 说明 | +|------|------| +| `name` | hook 名称(来自配置) | +| `version` | 协议版本,当前为 `1` | +| `modes` | hook 支持的能力模式 | + +### 响应 + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "ok": true, + "name": "python-review-gate" + } +} +``` + +--- + +## 2. `hook.before_llm` + +在发送请求给 LLM 之前触发。可用于注入工具。 + +### 请求 + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "hook.before_llm", + "params": { + "meta": { + "AgentID": "agent-1", + "TurnID": "turn-1", + "ParentTurnID": "", + "SessionKey": "session-1", + "Iteration": 0, + "TracePath": "runTurn", + "Source": "turn.llm.request" + }, + "model": "claude-sonnet", + "messages": [ + {"role": "user", "content": "hello"} + ], + "tools": [ + { + "type": "function", + "function": { + "name": "echo", + "description": "echo text", + "parameters": {"type": "object"} + } + } + ], + "options": { + "temperature": 0.7 + }, + "channel": "cli", + "chat_id": "chat-1", + "graceful_terminal": false + } +} +``` + +| 字段 | 说明 | +|------|------| +| `meta` | 事件元数据,用于追踪 | +| `model` | 请求的模型名称 | +| `messages` | 对话历史 | +| `tools` | 可用工具定义列表 | +| `options` | LLM 参数(temperature、max_tokens 等) | +| `channel` | 请求来源通道 | +| `chat_id` | 会话 ID | + +### 响应(注入工具示例) + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "action": "modify", + "request": { + "model": "claude-sonnet", + "messages": [{"role": "user", "content": "hello"}], + "tools": [ + { + "type": "function", + "function": { + "name": "echo", + "description": "echo", + "parameters": {} + } + }, + { + "type": "function", + "function": { + "name": "my_plugin_tool", + "description": "插件注入的工具", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string"} + } + } + } + } + ] + } + } +} +``` + +| 字段 | 说明 | +|------|------| +| `action` | 决策动作(见下表) | +| `request` | 修改后的请求对象 | + +--- + +## 3. `hook.after_llm` + +在收到 LLM 响应后触发。可修改响应内容。 + +### 请求 + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "method": "hook.after_llm", + "params": { + "meta": { + "AgentID": "agent-1", + "TurnID": "turn-1", + "SessionKey": "session-1" + }, + "model": "claude-sonnet", + "response": { + "role": "assistant", + "content": "Hi!", + "tool_calls": [ + { + "id": "tc-1", + "type": "function", + "function": { + "name": "echo", + "arguments": "{\"text\":\"hi\"}" + } + } + ] + }, + "channel": "cli", + "chat_id": "chat-1" + } +} +``` + +### 响应 + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "action": "continue" + } +} +``` + +--- + +## 4. `hook.before_tool` + +在执行工具前触发。可修改工具名称和参数,或拒绝执行,或直接返回结果。 + +### 请求 + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "method": "hook.before_tool", + "params": { + "meta": { + "AgentID": "agent-1", + "TurnID": "turn-1", + "SessionKey": "session-1" + }, + "tool": "echo_text", + "arguments": { + "text": "hello" + }, + "channel": "cli", + "chat_id": "chat-1" + } +} +``` + +| 字段 | 说明 | +|------|------| +| `tool` | 工具名称 | +| `arguments` | 工具参数 | + +### 响应(改写参数) + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "result": { + "action": "modify", + "call": { + "tool": "echo_text", + "arguments": { + "text": "modified hello" + } + } + } +} +``` + +### 响应(拒绝执行) + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "result": { + "action": "deny_tool", + "reason": "参数不合法" + } +} +``` + +### 响应(直接返回结果 - respond) + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "result": { + "action": "respond", + "call": { + "tool": "my_plugin_tool", + "arguments": { + "query": "hello" + } + }, + "result": { + "for_llm": "Plugin tool executed successfully", + "for_user": "", + "silent": false, + "is_error": false + } + } +} +``` + +`respond` action 允许 hook 直接返回工具结果,跳过实际工具执行。适用于: +1. **插件工具注入**:外部 hook 可实现工具,无需在 ToolRegistry 注册 +2. **工具结果缓存**:对重复调用返回缓存结果 +3. **工具模拟**:测试时返回模拟结果 + +| 字段 | 说明 | +|------|------| +| `action` | 必须为 `respond` | +| `call` | 修改后的调用信息(可选) | +| `result` | 直接返回的工具结果 | + +--- + +## 5. `hook.after_tool` + +在工具执行完成后触发。可修改返回给 LLM 的结果。 + +### 请求 + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "method": "hook.after_tool", + "params": { + "meta": { + "AgentID": "agent-1", + "TurnID": "turn-1", + "SessionKey": "session-1" + }, + "tool": "echo_text", + "arguments": { + "text": "hello" + }, + "result": { + "for_llm": "echoed: hello", + "for_user": "", + "silent": false, + "is_error": false, + "async": false, + "media": [], + "artifact_tags": [], + "response_handled": false + }, + "duration": 15000000, + "channel": "cli", + "chat_id": "chat-1" + } +} +``` + +| 字段 | 说明 | +|------|------| +| `result.for_llm` | 返回给 LLM 的内容 | +| `result.for_user` | 发送给用户的内容 | +| `result.silent` | 是否静默(不发送给用户) | +| `result.is_error` | 是否为错误 | +| `result.async` | 是否异步执行 | +| `result.media` | 媒体引用列表 | +| `result.artifact_tags` | 本地产物路径标签 | +| `result.response_handled` | 是否已处理响应 | +| `duration` | 执行耗时(纳秒) | + +### 响应 + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "result": { + "action": "continue" + } +} +``` + +--- + +## 6. `hook.approve_tool` + +审批型 hook,用于决定是否允许执行敏感工具。 + +### 请求 + +```json +{ + "jsonrpc": "2.0", + "id": 6, + "method": "hook.approve_tool", + "params": { + "meta": { + "AgentID": "agent-1", + "TurnID": "turn-1", + "SessionKey": "session-1" + }, + "tool": "bash", + "arguments": { + "command": "rm -rf /" + }, + "channel": "cli", + "chat_id": "chat-1" + } +} +``` + +### 响应(批准) + +```json +{ + "jsonrpc": "2.0", + "id": 6, + "result": { + "approved": true + } +} +``` + +### 响应(拒绝) + +```json +{ + "jsonrpc": "2.0", + "id": 6, + "result": { + "approved": false, + "reason": "危险命令,禁止执行" + } +} +``` + +--- + +## 7. `hook.runtime_event`(notification) + +runtime 观察型事件,仅广播,无需响应。`id` 为 `0` 或不存在。 + +```json +{ + "jsonrpc": "2.0", + "method": "hook.runtime_event", + "params": { + "kind": "agent.tool.exec_start", + "source": { + "component": "agent", + "name": "agent-1" + }, + "scope": { + "agent_id": "agent-1", + "session_key": "session-1", + "turn_id": "turn-1", + "channel": "cli", + "chat_id": "chat-1" + }, + "payload": { + "Tool": "echo_text", + "Arguments": {"text": "hello"} + } + } +} +``` + +常见 `Kind` 值: +- `agent.turn.start` / `agent.turn.end` +- `agent.llm.request` / `agent.llm.response` +- `agent.tool.exec_start` / `agent.tool.exec_end` / `agent.tool.exec_skipped` +- `agent.steering.injected` +- `agent.interrupt.received` +- `agent.error` + +旧 observe 配置名如 `turn_end`、`tool_exec_start` 仍然可用,并会归一化为 runtime event 名称。新的 process hook 通知使用 `hook.runtime_event`。 + +--- + +## action 可选值 + +| action | 适用 hook | 效果 | +|--------|----------|------| +| `continue` | 所有拦截型 | 放行,不做修改 | +| `modify` | `before_llm`, `before_tool`, `after_llm`, `after_tool` | 改写请求/响应后放行 | +| `respond` | `before_tool` | 直接返回工具结果,跳过实际执行 | +| `deny_tool` | `before_tool` | 拒绝执行该工具 | +| `abort_turn` | 所有拦截型 | 中止当前 turn,返回错误 | +| `hard_abort` | 所有拦截型 | 强制终止整个 agent loop | + +--- + +## 完整流程示例 + +```json +{"jsonrpc":"2.0","id":1,"method":"hook.hello","params":{"name":"my_hook","version":1,"modes":["tool","approve"]}} +{"jsonrpc":"2.0","id":1,"result":{"ok":true,"name":"my_hook"}} +{"jsonrpc":"2.0","id":2,"method":"hook.before_llm","params":{"model":"claude-sonnet","messages":[{"role":"user","content":"hello"}],"tools":[]}} +{"jsonrpc":"2.0","id":2,"result":{"action":"continue"}} +{"jsonrpc":"2.0","id":3,"method":"hook.before_tool","params":{"tool":"bash","arguments":{"command":"ls"}}} +{"jsonrpc":"2.0","id":3,"result":{"action":"continue"}} +{"jsonrpc":"2.0","id":4,"method":"hook.approve_tool","params":{"tool":"bash","arguments":{"command":"ls"}}} +{"jsonrpc":"2.0","id":4,"result":{"approved":true}} +{"jsonrpc":"2.0","id":5,"method":"hook.after_tool","params":{"tool":"bash","arguments":{"command":"ls"},"result":{"for_llm":"file1.txt\nfile2.txt"},"duration":5000000}} +{"jsonrpc":"2.0","id":5,"result":{"action":"continue"}} +{"jsonrpc":"2.0","id":6,"method":"hook.after_llm","params":{"model":"claude-sonnet","response":{"role":"assistant","content":"已列出文件"}}} +{"jsonrpc":"2.0","id":6,"result":{"action":"continue"}} +``` + +--- + +## 通过 `before_llm` 和 `before_tool` 实现插件工具注入 + +插件工具注入的标准流程: + +1. 在 `before_llm` 中注入工具定义,让 LLM 知道有这个工具可用 +2. 在 `before_tool` 中使用 `respond` action 直接返回工具执行结果 + +### `before_llm` 注入工具定义 + +```python +def handle_before_llm(params: dict) -> dict: + tools = params.get("tools", []) + + # 添加插件工具定义 + tools.append({ + "type": "function", + "function": { + "name": "my_plugin_tool", + "description": "插件提供的工具", + "parameters": { + "type": "object", + "properties": { + "input": {"type": "string", "description": "输入内容"} + }, + "required": ["input"] + } + } + }) + + return { + "action": "modify", + "request": { + "model": params["model"], + "messages": params["messages"], + "tools": tools, + "options": params.get("options", {}) + } + } +``` + +### `before_tool` 返回执行结果 + +```python +def handle_before_tool(params: dict) -> dict: + tool = params.get("tool", "") + + if tool == "my_plugin_tool": + # 在这里实现工具逻辑 + args = params.get("arguments", {}) + input_text = args.get("input", "") + + # 直接返回结果,无需在 ToolRegistry 注册 + return { + "action": "respond", + "result": { + "for_llm": f"插件工具执行成功,输入: {input_text}", + "silent": False, + "is_error": False + } + } + + return {"action": "continue"} +``` + +通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。 diff --git a/docs/architecture/hooks/plugin-tool-injection.md b/docs/architecture/hooks/plugin-tool-injection.md new file mode 100644 index 000000000..9e699867b --- /dev/null +++ b/docs/architecture/hooks/plugin-tool-injection.md @@ -0,0 +1,587 @@ +# Plugin Tool Injection Example + +This document demonstrates how to use PicoClaw's hook system to implement external plugin tool injection, allowing LLM to call tools implemented by external hook processes. + +--- + +## Core Principle + +Through the hook system's `respond` action, external hooks can: + +1. Inject tool **definitions** in `before_llm`, letting LLM know the tool is available +2. Return tool **execution results** directly in `before_tool` using `respond` action, skipping ToolRegistry + +This way, external hooks can fully implement plugin tools without registering any tools inside PicoClaw. + +--- + +## Complete Example: Weather Query Plugin + +Below is a complete Python hook example implementing a weather query plugin tool. + +### 1. Hook Script Implementation + +Save as `/tmp/weather_plugin.py`: + +```python +#!/usr/bin/env python3 +"""Weather query plugin hook example""" +from __future__ import annotations + +import json +import sys +import signal +from typing import Any + +# Simulated weather data +WEATHER_DATA = { + "Beijing": {"temp": 15, "weather": "Sunny", "humidity": 45}, + "Shanghai": {"temp": 18, "weather": "Cloudy", "humidity": 60}, + "Guangzhou": {"temp": 25, "weather": "Sunny", "humidity": 70}, + "Shenzhen": {"temp": 26, "weather": "Cloudy", "humidity": 75}, +} + + +def get_weather(city: str) -> dict: + """Get weather data (simulated)""" + data = WEATHER_DATA.get(city) + if data: + return { + "for_llm": f"{city} weather: {data['weather']}, temperature {data['temp']}°C, humidity {data['humidity']}%", + "for_user": "", + "silent": False, + "is_error": False, + } + return { + "for_llm": f"Weather data not found for city {city}", + "for_user": "", + "silent": False, + "is_error": True, + } + + +def handle_hello(params: dict) -> dict: + return {"ok": True, "name": "weather-plugin"} + + +def handle_before_llm(params: dict) -> dict: + """Inject weather query tool definition""" + tools = params.get("tools", []) + + # Add weather query tool + tools.append({ + "type": "function", + "function": { + "name": "get_weather", + "description": "Query weather information for a specified city", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "City name, e.g.: Beijing, Shanghai, Guangzhou" + } + }, + "required": ["city"] + } + } + }) + + return { + "action": "modify", + "request": { + "model": params.get("model"), + "messages": params.get("messages", []), + "tools": tools, + "options": params.get("options", {}), + } + } + + +def handle_before_tool(params: dict) -> dict: + """Handle tool call, return result directly""" + tool = params.get("tool", "") + args = params.get("arguments", {}) + + if tool == "get_weather": + city = args.get("city", "") + result = get_weather(city) + + # Use respond action to return result directly, skip ToolRegistry + return { + "action": "respond", + "result": result, + } + + # Other tools continue normal flow + return {"action": "continue"} + + +def handle_request(method: str, params: dict) -> dict: + if method == "hook.hello": + return handle_hello(params) + if method == "hook.before_llm": + return handle_before_llm(params) + if method == "hook.before_tool": + return handle_before_tool(params) + if method == "hook.after_llm": + return {"action": "continue"} + if method == "hook.after_tool": + return {"action": "continue"} + if method == "hook.approve_tool": + return {"approved": True} + raise KeyError(f"method not found: {method}") + + +def send_response(message_id: int, result: Any | None = None, error: str | None = None) -> None: + payload: dict[str, Any] = { + "jsonrpc": "2.0", + "id": message_id, + } + if error is not None: + payload["error"] = {"code": -32000, "message": error} + else: + payload["result"] = result if result is not None else {} + + sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n") + sys.stdout.flush() + + +def main() -> int: + for raw_line in sys.stdin: + line = raw_line.strip() + if not line: + continue + + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + + method = message.get("method") + message_id = message.get("id", 0) + params = message.get("params") or {} + + if not message_id: + continue + + try: + result = handle_request(str(method or ""), params) + send_response(int(message_id), result=result) + except KeyError as exc: + send_response(int(message_id), error=str(exc)) + except Exception as exc: + send_response(int(message_id), error=f"unexpected error: {exc}") + + return 0 + + +if __name__ == "__main__": + signal.signal(signal.SIGINT, lambda *_: raise SystemExit(0)) + signal.signal(signal.SIGTERM, lambda *_: raise SystemExit(0)) + raise SystemExit(main()) +``` + +### 2. Configure PicoClaw + +Add hook configuration in the config file: + +```json +{ + "hooks": { + "enabled": true, + "processes": { + "weather_plugin": { + "enabled": true, + "priority": 100, + "transport": "stdio", + "command": ["python3", "/tmp/weather_plugin.py"], + "intercept": ["before_llm", "before_tool"] + } + } + } +} +``` + +### 3. Test Results + +When user asks "What's the weather in Beijing today?": + +1. PicoClaw sends `hook.before_llm`, hook injects `get_weather` tool definition +2. LLM sees tool definition, decides to call `get_weather(city="Beijing")` +3. PicoClaw sends `hook.before_tool`, hook uses `respond` action to return weather data +4. LLM receives result, replies to user "Beijing is sunny today, temperature 15°C" + +--- + +## Flow Diagram + +``` +User: "What's the weather in Beijing today?" + ↓ + PicoClaw + ↓ + hook.before_llm + ↓ (inject get_weather tool definition) + LLM request + ↓ + LLM decides to call get_weather(city="Beijing") + ↓ + hook.before_tool + ↓ (respond action returns weather data) + Return result directly to LLM + ↓ (skip ToolRegistry) + LLM replies: "Beijing is sunny today, temperature 15°C" +``` + +--- + +## Key Points + +### `before_llm` Inject Tool Definition + +Tool definition follows OpenAI function calling format: + +```json +{ + "type": "function", + "function": { + "name": "tool_name", + "description": "tool description", + "parameters": { + "type": "object", + "properties": { + "param_name": { + "type": "string", + "description": "parameter description" + } + }, + "required": ["list of required parameters"] + } + } +} +``` + +### `before_tool` Use respond Action + +`respond` action response format: + +```json +{ + "action": "respond", + "result": { + "for_llm": "Content returned to LLM", + "for_user": "Optional, content sent to user", + "silent": false, + "is_error": false, + "media": ["Optional, media reference list"], + "response_handled": false + } +} +``` + +| Field | Description | +|-------|-------------| +| `for_llm` | Required, LLM will see this content | +| `for_user` | Optional, sent directly to user | +| `silent` | When true, not sent to user | +| `is_error` | When true, indicates execution failure | +| `media` | Optional, media file references (images, files, etc.) | +| `response_handled` | When true, indicates user request is handled, turn will end | + +--- + +## Media File Handling + +The `respond` action supports returning media files (images, files, etc.). There are two processing modes: + +### 1. Automatic Delivery (`response_handled=true`) + +When `response_handled=true`, media files are automatically sent to the user and the turn ends: + +```json +{ + "action": "respond", + "result": { + "for_llm": "Image sent to user", + "for_user": "", + "media": ["media://abc123"], + "response_handled": true + } +} +``` + +Use cases: +- Image generation plugin directly returning results +- File download plugin sending files to user + +### 2. LLM Visible (`response_handled=false`) + +When `response_handled=false`, media references are passed to the LLM, which can see the content in the next request: + +```json +{ + "action": "respond", + "result": { + "for_llm": "Image loaded, path: /tmp/image.png [file:/tmp/image.png]", + "media": ["media://abc123"] + } +} +``` + +After seeing the content, the LLM can decide: +- Use `send_file` tool to send to user +- Analyze image content and reply to user +- Other processing approaches + +### Media Reference Format + +Media references use the `media://` protocol: + +``` +media:// +``` + +These references are managed by PicoClaw's MediaStore and can be: +- Sent to user via channel +- Converted to base64 in LLM vision requests + +### Alternative: Use Existing Tools + +If the plugin generates files, you can return the file path and let the LLM call `send_file` or similar tools: + +```json +{ + "action": "respond", + "result": { + "for_llm": "Image generated, saved at /tmp/generated_image.png. Use send_file tool to send to user.", + "for_user": "", + "silent": false + } +} +``` + +This approach: +- More decoupled, LLM decides when to send +- Leverages existing tool mechanisms +- Supports batch sending, delayed sending, etc. + +--- + +## Multi-Tool Injection Example + +Multiple tools can be injected simultaneously: + +```python +def handle_before_llm(params: dict) -> dict: + tools = params.get("tools", []) + + # Tool 1: Weather query + tools.append({ + "type": "function", + "function": { + "name": "get_weather", + "description": "Query city weather", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string", "description": "City name"} + }, + "required": ["city"] + } + } + }) + + # Tool 2: Calculator + tools.append({ + "type": "function", + "function": { + "name": "calculate", + "description": "Perform mathematical calculations", + "parameters": { + "type": "object", + "properties": { + "expression": {"type": "string", "description": "Mathematical expression"} + }, + "required": ["expression"] + } + } + }) + + return { + "action": "modify", + "request": { + "model": params.get("model"), + "messages": params.get("messages", []), + "tools": tools, + "options": params.get("options", {}), + } + } + + +def handle_before_tool(params: dict) -> dict: + tool = params.get("tool", "") + args = params.get("arguments", {}) + + if tool == "get_weather": + return { + "action": "respond", + "result": get_weather(args.get("city", "")), + } + + if tool == "calculate": + # Simple calculation example + try: + expr = args.get("expression", "") + result = eval(expr) # Note: needs security handling in actual use + return { + "action": "respond", + "result": { + "for_llm": f"Calculation result: {result}", + "silent": False, + "is_error": False, + }, + } + except Exception as e: + return { + "action": "respond", + "result": { + "for_llm": f"Calculation error: {e}", + "silent": False, + "is_error": True, + }, + } + + return {"action": "continue"} +``` + +--- + +## Coexistence with Built-in Tools + +Injected plugin tools coexist with PicoClaw built-in tools: + +- Built-in tools (like `bash`, `read_file`) execute normally through ToolRegistry +- Plugin tools return results through hook's `respond` action +- `handle_before_tool` only handles plugin tools, other tools return `continue` + +--- + +## Go In-Process Hook Example + +If you need to implement plugin tool injection in Go code: + +```go +package myhooks + +import ( + "context" + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/tools" +) + +type WeatherPluginHook struct{} + +func (h *WeatherPluginHook) BeforeLLM( + ctx context.Context, + req *agent.LLMHookRequest, +) (*agent.LLMHookRequest, agent.HookDecision, error) { + // Inject tool definition + req.Tools = append(req.Tools, agent.ToolDefinition{ + Type: "function", + Function: agent.FunctionDefinition{ + Name: "get_weather", + Description: "Query city weather", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "city": map[string]any{ + "type": "string", + "description": "City name", + }, + }, + "required": []string{"city"}, + }, + }, + }) + + return req, agent.HookDecision{Action: agent.HookActionContinue}, nil +} + +func (h *WeatherPluginHook) BeforeTool( + ctx context.Context, + call *agent.ToolCallHookRequest, +) (*agent.ToolCallHookRequest, agent.HookDecision, error) { + if call.Tool == "get_weather" { + city := call.Arguments["city"].(string) + + // Set HookResult, use respond action + next := call.Clone() + next.HookResult = &tools.ToolResult{ + ForLLM: getWeatherData(city), + Silent: false, + IsError: false, + } + + return next, agent.HookDecision{Action: agent.HookActionRespond}, nil + } + + return call, agent.HookDecision{Action: agent.HookActionContinue}, nil +} + +func getWeatherData(city string) string { + // Implement weather query logic + return fmt.Sprintf("%s weather: Sunny, temperature 20°C", city) +} +``` + +--- + +## Summary + +Through the hook system's `respond` action, external processes can: + +1. **Inject tool definitions**: Let LLM know new tools are available +2. **Provide tool implementation**: Return execution results directly, no need to register in ToolRegistry +3. **Coexist with built-in tools**: Does not affect normal operation of PicoClaw's original tools + +This provides a flexible and elegant solution for plugin development. + +--- + +## Security Boundaries + +### Bypassing Approval Checks + +**Important**: The `respond` action bypasses `ApproveTool` approval checks. + +This means: +- A `before_tool` hook can return `respond` for **any tool name**, including sensitive tools (like `bash`) +- The tool won't go through the approval process, directly returning the hook-provided result +- This is designed for plugin tools but introduces security risks + +### Security Recommendations + +1. **Review hook configuration**: Ensure only trusted hook processes are enabled +2. **Limit hook scope**: Add your own security checks in hook implementation +3. **Use `deny_tool` for rejection**: Use `deny_tool` action instead of `respond` with error for denying execution + +### Example: Hook-Internal Security Check + +```python +def handle_before_tool(params: dict) -> dict: + tool = params.get("tool", "") + args = params.get("arguments", {}) + + # Security check: only handle plugin tools + if tool in ["get_weather", "calculate"]: + return { + "action": "respond", + "result": execute_plugin_tool(tool, args), + } + + # Other tools continue normal flow (will go through approval) + return {"action": "continue"} +``` + +This ensures the hook only affects plugin tools, not system tool approval flow. \ No newline at end of file diff --git a/docs/architecture/hooks/plugin-tool-injection.zh.md b/docs/architecture/hooks/plugin-tool-injection.zh.md new file mode 100644 index 000000000..ccc7ff7f6 --- /dev/null +++ b/docs/architecture/hooks/plugin-tool-injection.zh.md @@ -0,0 +1,587 @@ +# 插件工具注入示例 + +本文档展示如何利用 PicoClaw 的 hook 系统实现外部插件工具注入,让 LLM 能调用由外部 hook 进程实现的工具。 + +--- + +## 核心原理 + +通过 hook 系统的 `respond` action,外部 hook 可以: + +1. 在 `before_llm` 中注入工具**定义**,让 LLM 知道有这个工具可用 +2. 在 `before_tool` 中使用 `respond` action 直接返回工具**执行结果**,跳过 ToolRegistry + +这样,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具。 + +--- + +## 完整示例:天气查询插件 + +下面是一个完整的 Python hook 示例,实现一个天气查询插件工具。 + +### 1. Hook 脚本实现 + +保存为 `/tmp/weather_plugin.py`: + +```python +#!/usr/bin/env python3 +"""天气查询插件 hook 示例""" +from __future__ import annotations + +import json +import sys +import signal +from typing import Any + +# 模拟天气数据 +WEATHER_DATA = { + "北京": {"temp": 15, "weather": "晴", "humidity": 45}, + "上海": {"temp": 18, "weather": "多云", "humidity": 60}, + "广州": {"temp": 25, "weather": "晴", "humidity": 70}, + "深圳": {"temp": 26, "weather": "多云", "humidity": 75}, +} + + +def get_weather(city: str) -> dict: + """获取天气数据(模拟)""" + data = WEATHER_DATA.get(city) + if data: + return { + "for_llm": f"{city}天气:{data['weather']},温度{data['temp']}°C,湿度{data['humidity']}%", + "for_user": "", + "silent": False, + "is_error": False, + } + return { + "for_llm": f"未找到城市 {city} 的天气数据", + "for_user": "", + "silent": False, + "is_error": True, + } + + +def handle_hello(params: dict) -> dict: + return {"ok": True, "name": "weather-plugin"} + + +def handle_before_llm(params: dict) -> dict: + """注入天气查询工具定义""" + tools = params.get("tools", []) + + # 添加天气查询工具 + tools.append({ + "type": "function", + "function": { + "name": "get_weather", + "description": "查询指定城市的天气信息", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "城市名称,如:北京、上海、广州" + } + }, + "required": ["city"] + } + } + }) + + return { + "action": "modify", + "request": { + "model": params.get("model"), + "messages": params.get("messages", []), + "tools": tools, + "options": params.get("options", {}), + } + } + + +def handle_before_tool(params: dict) -> dict: + """处理工具调用,直接返回结果""" + tool = params.get("tool", "") + args = params.get("arguments", {}) + + if tool == "get_weather": + city = args.get("city", "") + result = get_weather(city) + + # 使用 respond action 直接返回结果,跳过 ToolRegistry + return { + "action": "respond", + "result": result, + } + + # 其他工具继续正常流程 + return {"action": "continue"} + + +def handle_request(method: str, params: dict) -> dict: + if method == "hook.hello": + return handle_hello(params) + if method == "hook.before_llm": + return handle_before_llm(params) + if method == "hook.before_tool": + return handle_before_tool(params) + if method == "hook.after_llm": + return {"action": "continue"} + if method == "hook.after_tool": + return {"action": "continue"} + if method == "hook.approve_tool": + return {"approved": True} + raise KeyError(f"method not found: {method}") + + +def send_response(message_id: int, result: Any | None = None, error: str | None = None) -> None: + payload: dict[str, Any] = { + "jsonrpc": "2.0", + "id": message_id, + } + if error is not None: + payload["error"] = {"code": -32000, "message": error} + else: + payload["result"] = result if result is not None else {} + + sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n") + sys.stdout.flush() + + +def main() -> int: + for raw_line in sys.stdin: + line = raw_line.strip() + if not line: + continue + + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + + method = message.get("method") + message_id = message.get("id", 0) + params = message.get("params") or {} + + if not message_id: + continue + + try: + result = handle_request(str(method or ""), params) + send_response(int(message_id), result=result) + except KeyError as exc: + send_response(int(message_id), error=str(exc)) + except Exception as exc: + send_response(int(message_id), error=f"unexpected error: {exc}") + + return 0 + + +if __name__ == "__main__": + signal.signal(signal.SIGINT, lambda *_: raise SystemExit(0)) + signal.signal(signal.SIGTERM, lambda *_: raise SystemExit(0)) + raise SystemExit(main()) +``` + +### 2. 配置 PicoClaw + +在配置文件中添加 hook 配置: + +```json +{ + "hooks": { + "enabled": true, + "processes": { + "weather_plugin": { + "enabled": true, + "priority": 100, + "transport": "stdio", + "command": ["python3", "/tmp/weather_plugin.py"], + "intercept": ["before_llm", "before_tool"] + } + } + } +} +``` + +### 3. 测试效果 + +当用户问"北京今天天气怎么样?"时: + +1. PicoClaw 发送 `hook.before_llm`,hook 注入 `get_weather` 工具定义 +2. LLM 看到工具定义,决定调用 `get_weather(city="北京")` +3. PicoClaw 发送 `hook.before_tool`,hook 使用 `respond` action 返回天气数据 +4. LLM 收到结果,回复用户"北京今天晴天,温度15°C" + +--- + +## 流程图解 + +``` +用户: "北京今天天气怎么样?" + ↓ + PicoClaw + ↓ + hook.before_llm + ↓ (注入 get_weather 工具定义) + LLM 请求 + ↓ + LLM 决定调用 get_weather(city="北京") + ↓ + hook.before_tool + ↓ (respond action 返回天气数据) + 直接返回结果给 LLM + ↓ (跳过 ToolRegistry) + LLM 回复: "北京今天晴天,温度15°C" +``` + +--- + +## 关键点说明 + +### `before_llm` 注入工具定义 + +工具定义遵循 OpenAI function calling 格式: + +```json +{ + "type": "function", + "function": { + "name": "工具名称", + "description": "工具描述", + "parameters": { + "type": "object", + "properties": { + "参数名": { + "type": "string", + "description": "参数描述" + } + }, + "required": ["必需参数列表"] + } + } +} +``` + +### `before_tool` 使用 respond action + +`respond` action 的响应格式: + +```json +{ + "action": "respond", + "result": { + "for_llm": "返回给 LLM 的内容", + "for_user": "可选,发送给用户的内容", + "silent": false, + "is_error": false, + "media": ["可选,媒体引用列表"], + "response_handled": false + } +} +``` + +| 字段 | 说明 | +|------|------| +| `for_llm` | 必须,LLM 会看到这个内容 | +| `for_user` | 可选,直接发送给用户 | +| `silent` | 为 true 时不发送给用户 | +| `is_error` | 为 true 时表示执行失败 | +| `media` | 可选,媒体文件引用列表(如图片、文件) | +| `response_handled` | 为 true 时表示已处理用户请求,轮次将结束 | + +--- + +## 媒体文件处理 + +`respond` action 支持返回媒体文件(图片、文件等)。有两种处理方式: + +### 1. 自动发送(`response_handled=true`) + +当 `response_handled=true` 时,媒体文件会自动发送给用户,轮次结束: + +```json +{ + "action": "respond", + "result": { + "for_llm": "图片已发送给用户", + "for_user": "", + "media": ["media://abc123"], + "response_handled": true + } +} +``` + +适用场景: +- 图像生成插件直接返回结果 +- 文件下载插件发送文件给用户 + +### 2. LLM 可见(`response_handled=false`) + +当 `response_handled=false` 时,媒体引用会传递给 LLM,LLM 可以在下一轮请求中看到内容: + +```json +{ + "action": "respond", + "result": { + "for_llm": "图片已加载,路径:/tmp/image.png [file:/tmp/image.png]", + "media": ["media://abc123"] + } +} +``` + +LLM 看到内容后,可以自主决定: +- 使用 `send_file` 工具发送给用户 +- 分析图片内容并回复用户 +- 其他处理方式 + +### 媒体引用格式 + +媒体引用使用 `media://` 协议: + +``` +media:// +``` + +这些引用由 PicoClaw 的 MediaStore 管理,可以: +- 通过 channel 发送给用户 +- 在 LLM vision 请求中转换为 base64 + +### 替代方案:使用现有工具 + +如果插件生成文件,可以返回文件路径让 LLM 调用 `send_file` 等工具: + +```json +{ + "action": "respond", + "result": { + "for_llm": "图片已生成,保存在 /tmp/generated_image.png。使用 send_file 工具发送给用户。", + "for_user": "", + "silent": false + } +} +``` + +这种方式: +- 更解耦,LLM 自主决策发送时机 +- 利用现有工具机制 +- 支持批量发送、延迟发送等场景 + +--- + +## 多工具注入示例 + +可以同时注入多个工具: + +```python +def handle_before_llm(params: dict) -> dict: + tools = params.get("tools", []) + + # 工具1:天气查询 + tools.append({ + "type": "function", + "function": { + "name": "get_weather", + "description": "查询城市天气", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string", "description": "城市名称"} + }, + "required": ["city"] + } + } + }) + + # 工具2:计算器 + tools.append({ + "type": "function", + "function": { + "name": "calculate", + "description": "执行数学计算", + "parameters": { + "type": "object", + "properties": { + "expression": {"type": "string", "description": "数学表达式"} + }, + "required": ["expression"] + } + } + }) + + return { + "action": "modify", + "request": { + "model": params.get("model"), + "messages": params.get("messages", []), + "tools": tools, + "options": params.get("options", {}), + } + } + + +def handle_before_tool(params: dict) -> dict: + tool = params.get("tool", "") + args = params.get("arguments", {}) + + if tool == "get_weather": + return { + "action": "respond", + "result": get_weather(args.get("city", "")), + } + + if tool == "calculate": + # 简单计算示例 + try: + expr = args.get("expression", "") + result = eval(expr) # 注意:实际使用时需要安全处理 + return { + "action": "respond", + "result": { + "for_llm": f"计算结果: {result}", + "silent": False, + "is_error": False, + }, + } + except Exception as e: + return { + "action": "respond", + "result": { + "for_llm": f"计算错误: {e}", + "silent": False, + "is_error": True, + }, + } + + return {"action": "continue"} +``` + +--- + +## 与内置工具共存 + +注入的插件工具与 PicoClaw 内置工具共存: + +- 内置工具(如 `bash`、`read_file`)正常通过 ToolRegistry 执行 +- 插件工具通过 hook 的 `respond` action 返回结果 +- `handle_before_tool` 中只处理插件工具,其他工具返回 `continue` + +--- + +## Go 进程内 Hook 示例 + +如果需要在 Go 代码中实现插件工具注入: + +```go +package myhooks + +import ( + "context" + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/tools" +) + +type WeatherPluginHook struct{} + +func (h *WeatherPluginHook) BeforeLLM( + ctx context.Context, + req *agent.LLMHookRequest, +) (*agent.LLMHookRequest, agent.HookDecision, error) { + // 注入工具定义 + req.Tools = append(req.Tools, agent.ToolDefinition{ + Type: "function", + Function: agent.FunctionDefinition{ + Name: "get_weather", + Description: "查询城市天气", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "city": map[string]any{ + "type": "string", + "description": "城市名称", + }, + }, + "required": []string{"city"}, + }, + }, + }) + + return req, agent.HookDecision{Action: agent.HookActionContinue}, nil +} + +func (h *WeatherPluginHook) BeforeTool( + ctx context.Context, + call *agent.ToolCallHookRequest, +) (*agent.ToolCallHookRequest, agent.HookDecision, error) { + if call.Tool == "get_weather" { + city := call.Arguments["city"].(string) + + // 设置 HookResult,使用 respond action + next := call.Clone() + next.HookResult = &tools.ToolResult{ + ForLLM: getWeatherData(city), + Silent: false, + IsError: false, + } + + return next, agent.HookDecision{Action: agent.HookActionRespond}, nil + } + + return call, agent.HookDecision{Action: agent.HookActionContinue}, nil +} + +func getWeatherData(city string) string { + // 实现天气查询逻辑 + return fmt.Sprintf("%s天气:晴,温度20°C", city) +} +``` + +--- + +## 总结 + +通过 hook 系统的 `respond` action,外部进程可以: + +1. **注入工具定义**:让 LLM 知道有新工具可用 +2. **提供工具实现**:直接返回执行结果,无需注册到 ToolRegistry +3. **与内置工具共存**:不影响 PicoClaw 原有工具的正常运行 + +这为插件开发提供了灵活、优雅的解决方案。 + +--- + +## 安全边界说明 + +### 绕过审批检查 + +**重要**:`respond` action 会绕过 `ApproveTool` 审批检查。 + +这意味着: +- `before_tool` hook 可以为**任何工具名称**返回 `respond`,包括敏感工具(如 `bash`) +- 工具不会经过审批流程,直接返回 hook 提供的结果 +- 这是为了支持插件工具而设计,但也带来了安全风险 + +### 安全建议 + +1. **审查 hook 配置**:确保只有可信的 hook 进程被启用 +2. **限制 hook 权限**:在 hook 实现中添加自己的安全检查 +3. **优先使用 `deny_tool`**:对于拒绝执行,使用 `deny_tool` action 而非 `respond` 返回错误 + +### 示例:hook 内置安全检查 + +```python +def handle_before_tool(params: dict) -> dict: + tool = params.get("tool", "") + args = params.get("arguments", {}) + + # 安全检查:只处理插件工具 + if tool in ["get_weather", "calculate"]: + return { + "action": "respond", + "result": execute_plugin_tool(tool, args), + } + + # 其他工具继续正常流程(会经过审批) + return {"action": "continue"} +``` + +这样可以确保 hook 只影响插件工具,不影响系统工具的审批流程。 \ No newline at end of file diff --git a/docs/architecture/routing-system.md b/docs/architecture/routing-system.md new file mode 100644 index 000000000..ad6c3abfc --- /dev/null +++ b/docs/architecture/routing-system.md @@ -0,0 +1,282 @@ +# Routing System + +> Back to [README](../README.md) + +In PicoClaw, the runtime "routing system" is not just one decision. +It is the combined pipeline that decides: + +1. which agent handles an inbound message +2. which session dimensions should isolate that conversation +3. whether the turn should use the agent's primary model or a configured light model + +This document covers the runtime path in `pkg/routing` and its integration in `pkg/agent`. +It does not describe the launcher's HTTP `ServeMux` routes or the frontend's TanStack Router files under `web/`. + +## Routing Layers + +| Layer | Files | Responsibility | +| --- | --- | --- | +| Agent dispatch | `pkg/routing/route.go`, `pkg/routing/agent_id.go` | Choose the target agent for the inbound message. | +| Session policy selection | `pkg/routing/route.go` | Decide which dimensions should define session isolation for that routed turn. | +| Model routing | `pkg/routing/router.go`, `pkg/routing/features.go`, `pkg/routing/classifier.go` | Choose between the primary model and a configured light model based on message complexity. | +| Runtime integration | `pkg/agent/registry.go`, `pkg/agent/agent_message.go`, `pkg/agent/turn_coord.go` | Apply the route result, allocate session scope, and select model candidates before provider execution. | + +## End-To-End Flow + +The normal path for a user message is: + +```text +InboundMessage + -> NormalizeInboundContext + -> RouteResolver.ResolveRoute(...) + -> session.AllocateRouteSession(...) + -> ensureSessionMetadata(...) + -> Router.SelectModel(...) + -> provider execution +``` + +The first half answers "who should handle this message and what session does it belong to". +The second half answers "which model tier should that agent use for this turn". + +## Agent Dispatch + +`routing.RouteResolver` turns a normalized `bus.InboundContext` into a `ResolvedRoute`: + +```go +type ResolvedRoute struct { + AgentID string + Channel string + AccountID string + SessionPolicy SessionPolicy + MatchedBy string +} +``` + +`MatchedBy` is a debugging aid. +Typical values are: + +- `default` +- `dispatch.rule` +- `dispatch.rule:` + +## Dispatch Input View + +Before matching rules, the resolver builds a normalized `dispatchView`. +Each field is normalized to the exact shape expected by rule matching. + +| Selector field | Runtime shape | +| --- | --- | +| `channel` | lowercased channel name | +| `account` | normalized account ID | +| `space` | `:` | +| `chat` | `:` | +| `topic` | `topic:` | +| `sender` | lowercased canonical sender ID | +| `mentioned` | boolean copied from inbound context | + +This means dispatch rules must match the normalized shape, for example: + +```json +{ + "agents": { + "dispatch": { + "rules": [ + { + "name": "support-group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-100123" + } + }, + { + "name": "slack-mentions", + "agent": "support", + "when": { + "channel": "slack", + "space": "workspace:t001", + "mentioned": true + } + } + ] + } + } +} +``` + +## Dispatch Algorithm + +`ResolveRoute(...)` follows this sequence: + +1. Normalize `channel` and `account`. +2. Clone `session.identity_links` from config. +3. Build the normalized dispatch view. +4. Scan `agents.dispatch.rules` in order. +5. Skip rules with no constraints at all. +6. Return the first rule whose selector fields all match exactly. +7. If no rule matches, fall back to the default agent. + +Important consequences: + +- first match wins +- there is no score or priority field beyond list order +- invalid target agent IDs fall back to the default agent +- sender matching can see canonical identities produced by `identity_links` + +## Default Agent Resolution + +If no dispatch rule wins, or if a rule points at an unknown agent, the resolver picks a default agent using this order: + +1. the agent marked `default: true` +2. otherwise the first entry in `agents.list` +3. otherwise implicit `main` + +Both agent IDs and account IDs are normalized through the helpers in `pkg/routing/agent_id.go`. + +## Session Policy Handoff + +Agent dispatch does not directly build a session key. +Instead it emits a `SessionPolicy`: + +```go +type SessionPolicy struct { + Dimensions []string + IdentityLinks map[string][]string +} +``` + +The dimensions come from: + +- global `session.dimensions` +- or `dispatch_rule.session_dimensions` when the matching rule overrides them + +Only these dimension names survive normalization: + +- `space` +- `chat` +- `topic` +- `sender` + +Invalid or duplicated entries are silently dropped. + +`pkg/session/AllocateRouteSession(...)` then turns that policy into: + +- a structured `SessionScope` +- a canonical routed session key +- legacy compatibility aliases + +So the routing package owns "what should isolate this conversation", while the session package owns "how that isolation becomes keys and durable storage". + +## Identity Links + +`session.identity_links` is shared between dispatch and session allocation. +That is intentional: a sender canonicalized for routing should also map to the same session identity. + +Without that symmetry, the system could route two messages to the same agent but still fragment their history into different sessions. + +## Model Routing + +The second routing stage decides whether a turn can use a cheaper or faster light model. + +Config shape: + +```json +{ + "routing": { + "enabled": true, + "light_model": "gemini-2.0-flash", + "threshold": 0.35 + } +} +``` + +`pkg/routing.Router` compares the current turn against structural features and returns: + +- chosen model name +- whether the light model was used +- computed complexity score + +If the score is below the threshold, the light model wins. +Otherwise the agent's primary model is used. +At runtime this only matters when the agent actually has light-model candidates configured; otherwise execution stays on the primary candidate set. + +## Complexity Features + +`ExtractFeatures(...)` computes a language-agnostic feature vector: + +| Feature | Meaning | +| --- | --- | +| `TokenEstimate` | Approximate token count; CJK runes count more accurately than a flat rune split. | +| `CodeBlockCount` | Number of fenced code blocks in the current message. | +| `RecentToolCalls` | Tool-call count across the last six history entries. | +| `ConversationDepth` | Total history length. | +| `HasAttachments` | Detects embedded media or common media URL/file extensions. | + +This is intentionally structural rather than keyword-based, so the router behaves the same across languages. + +## RuleClassifier Scoring + +The current classifier is `RuleClassifier`. +It uses a weighted sum capped to `[0, 1]`. + +| Signal | Score | +| --- | --- | +| attachments present | `1.00` | +| token estimate `> 200` | `0.35` | +| token estimate `> 50` | `0.15` | +| code block present | `0.40` | +| recent tool calls `> 3` | `0.25` | +| recent tool calls `1..3` | `0.10` | +| conversation depth `> 10` | `0.10` | + +The default threshold is `0.35`. +That makes the following behavior intentional: + +- trivial chat stays on the light model +- code tasks usually jump to the heavy model immediately +- attachments always force the heavy model +- long, plain-text prompts cross the heavy-model boundary at the default threshold + +## Runtime Integration + +Agent dispatch and model routing happen in different places: + +- `pkg/agent/registry.go` owns `RouteResolver` +- `pkg/agent/agent_message.go` resolves the route and allocates session scope +- `pkg/agent/turn_coord.go:selectCandidates` calls `agent.Router.SelectModel(...)` + +When the light model is selected, the agent loop swaps to `agent.LightCandidates`. +When it is not selected, execution stays on the agent's primary provider candidate set. + +## Explicit Session Keys + +One nuance sits just outside `pkg/routing` but matters for the full routing story. + +After a route is allocated, `pkg/agent/agent_utils.go:resolveScopeKey` preserves an explicit incoming session key when the caller already supplied: + +- an opaque canonical key +- a legacy `agent:...` key + +That makes manual system flows, tests, and compatibility paths deterministic even when the normal routed scope would have produced a different key. + +## What This Document Does Not Cover + +The repository also contains two unrelated route systems: + +- backend HTTP routes registered in `web/backend/api/router.go` +- frontend file routes under `web/frontend/src/routes/` + +Those are launcher implementation details. +They are separate from the runtime routing system described here. + +## Related Files + +- `pkg/routing/route.go` +- `pkg/routing/router.go` +- `pkg/routing/classifier.go` +- `pkg/routing/features.go` +- `pkg/routing/agent_id.go` +- `pkg/session/allocator.go` +- `pkg/agent/registry.go` +- `pkg/agent/agent_message.go` +- `pkg/agent/turn_coord.go` diff --git a/docs/architecture/routing-system.zh.md b/docs/architecture/routing-system.zh.md new file mode 100644 index 000000000..018b9e7b2 --- /dev/null +++ b/docs/architecture/routing-system.zh.md @@ -0,0 +1,281 @@ +# 路由系统 + +> 返回 [README](../README.md) + +在 PicoClaw 里,“路由系统”不是单一判断。 +它实际上是组合起来的一条运行时决策链,负责决定: + +1. 哪个 agent 来处理一条入站消息 +2. 这条消息应该落在哪种 session 隔离维度下 +3. 这一轮该使用 agent 的主模型,还是配置中的轻量模型 + +本文覆盖 `pkg/routing` 及其在 `pkg/agent` 中的集成方式。 +它不讨论 `web/` 目录下 launcher 的 HTTP `ServeMux` 路由,也不讨论前端 TanStack Router 文件路由。 + +## 路由分层 + +| 层次 | 文件 | 作用 | +| --- | --- | --- | +| Agent 分发 | `pkg/routing/route.go`、`pkg/routing/agent_id.go` | 为入站消息选择目标 agent。 | +| Session 策略选择 | `pkg/routing/route.go` | 决定该 turn 的会话隔离维度。 | +| 模型路由 | `pkg/routing/router.go`、`pkg/routing/features.go`、`pkg/routing/classifier.go` | 根据消息复杂度在主模型和轻量模型之间做选择。 | +| 运行时集成 | `pkg/agent/registry.go`、`pkg/agent/loop_message.go`、`pkg/agent/loop_turn.go` | 应用 route 结果、分配 session scope,并在真正调用 provider 前选出模型候选集。 | + +## 端到端流程 + +普通用户消息的路径如下: + +```text +InboundMessage + -> NormalizeInboundContext + -> RouteResolver.ResolveRoute(...) + -> session.AllocateRouteSession(...) + -> ensureSessionMetadata(...) + -> Router.SelectModel(...) + -> provider execution +``` + +前半段回答的是“谁来处理,以及属于哪段会话”。 +后半段回答的是“这个 agent 这一轮该走哪一档模型”。 + +## Agent 分发 + +`routing.RouteResolver` 会把归一化后的 `bus.InboundContext` 转成 `ResolvedRoute`: + +```go +type ResolvedRoute struct { + AgentID string + Channel string + AccountID string + SessionPolicy SessionPolicy + MatchedBy string +} +``` + +`MatchedBy` 主要用于日志和调试,常见值包括: + +- `default` +- `dispatch.rule` +- `dispatch.rule:` + +## Dispatch 输入视图 + +真正做规则匹配前,resolver 会先构造一个归一化后的 `dispatchView`。 +每个字段都会变成规则匹配所期待的固定形状。 + +| Selector 字段 | 运行时形状 | +| --- | --- | +| `channel` | 小写 channel 名称 | +| `account` | 归一化后的 account ID | +| `space` | `:` | +| `chat` | `:` | +| `topic` | `topic:` | +| `sender` | 小写 canonical sender ID | +| `mentioned` | 直接来自 inbound context 的布尔值 | + +这意味着 dispatch rule 必须写成归一化后的形状,例如: + +```json +{ + "agents": { + "dispatch": { + "rules": [ + { + "name": "support-group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-100123" + } + }, + { + "name": "slack-mentions", + "agent": "support", + "when": { + "channel": "slack", + "space": "workspace:t001", + "mentioned": true + } + } + ] + } + } +} +``` + +## Dispatch 算法 + +`ResolveRoute(...)` 的流程是: + +1. 归一化 `channel` 和 `account`。 +2. 从配置复制 `session.identity_links`。 +3. 构建归一化后的 dispatch view。 +4. 按顺序扫描 `agents.dispatch.rules`。 +5. 没有任何约束条件的 rule 会被跳过。 +6. 第一个所有 selector 字段都精确匹配的 rule 胜出。 +7. 如果没有 rule 匹配,则回退到默认 agent。 + +这带来几个重要结论: + +- 第一条命中的规则优先,没有额外 priority 字段 +- rule 顺序本身就是优先级 +- 指向无效 agent 的 rule 最终会回退到默认 agent +- sender 匹配看到的是经过 `identity_links` 归一化后的身份 + +## 默认 Agent 解析 + +如果没有 dispatch rule 命中,或者 rule 指向了不存在的 agent,resolver 会按以下顺序选择默认 agent: + +1. `default: true` 的 agent +2. 否则取 `agents.list` 的第一项 +3. 如果配置里没有 agent,则使用隐式 `main` + +Agent ID 和 Account ID 都会经过 `pkg/routing/agent_id.go` 中的归一化逻辑。 + +## Session 策略交接 + +Agent 分发本身不会直接生成 session key。 +它只会产出一个 `SessionPolicy`: + +```go +type SessionPolicy struct { + Dimensions []string + IdentityLinks map[string][]string +} +``` + +维度来源有两种: + +- 全局 `session.dimensions` +- 如果命中的 dispatch rule 指定了 `session_dimensions`,则用 rule 覆盖 + +最终只有这些维度名会被保留下来: + +- `space` +- `chat` +- `topic` +- `sender` + +非法项或重复项会被静默丢弃。 + +随后 `pkg/session/AllocateRouteSession(...)` 再把这份策略转成: + +- 结构化 `SessionScope` +- canonical routed session key +- legacy 兼容 alias + +所以可以把职责边界理解为: + +- `pkg/routing` 决定“这段对话应该按什么维度隔离” +- `pkg/session` 决定“这些维度如何变成 key 和持久化状态” + +## Identity Links + +`session.identity_links` 会同时被 dispatch 和 session allocation 使用。 +这是刻意保持一致的设计:如果某个 sender 在路由阶段已经被规范化,那么 session 阶段也应该落到同一个身份上。 + +否则就会出现“消息路由到了同一个 agent,但上下文仍被拆成多个 session”的问题。 + +## 模型路由 + +第二阶段路由决定这一轮能否使用更便宜或更快的轻量模型。 + +配置形状如下: + +```json +{ + "routing": { + "enabled": true, + "light_model": "gemini-2.0-flash", + "threshold": 0.35 + } +} +``` + +`pkg/routing.Router` 会根据当前 turn 的结构特征,返回: + +- 选中的模型名 +- 是否使用了 light model +- 复杂度分数 + +当分数低于阈值时,走轻量模型;否则仍使用 agent 的主模型。 +但在运行时,只有当 agent 实际配置了 light-model candidates 时,这个判断才会产生效果;否则仍会停留在主模型候选集上。 + +## 复杂度特征 + +`ExtractFeatures(...)` 会计算一个与自然语言内容无关、偏结构化的特征向量: + +| 特征 | 含义 | +| --- | --- | +| `TokenEstimate` | 估算 token 数;对 CJK 文本比简单 rune 平分更准确。 | +| `CodeBlockCount` | 当前消息中 fenced code block 的数量。 | +| `RecentToolCalls` | 最近 6 条历史消息中的 tool call 总数。 | +| `ConversationDepth` | 整体历史长度。 | +| `HasAttachments` | 是否检测到嵌入媒体或常见媒体 URL / 文件扩展名。 | + +这样做的目的,是让模型路由不依赖关键词,从而在不同语言下都保持一致行为。 + +## RuleClassifier 评分 + +当前分类器是 `RuleClassifier`,使用加权求和并把结果截断到 `[0, 1]`。 + +| 信号 | 分值 | +| --- | --- | +| 存在附件 | `1.00` | +| token 估计 `> 200` | `0.35` | +| token 估计 `> 50` | `0.15` | +| 存在代码块 | `0.40` | +| 最近 tool calls `> 3` | `0.25` | +| 最近 tool calls `1..3` | `0.10` | +| 会话深度 `> 10` | `0.10` | + +默认阈值是 `0.35`。 +这意味着以下行为是刻意设计出来的: + +- 很轻的闲聊仍走轻量模型 +- 编码类请求通常会立刻切到重模型 +- 带附件的请求一定走重模型 +- 很长的纯文本请求在默认阈值下也会跨过重模型边界 + +## 运行时集成 + +Agent 分发和模型路由发生在不同位置: + +- `pkg/agent/registry.go` 持有 `RouteResolver` +- `pkg/agent/loop_message.go` 负责 resolve route 并分配 session scope +- `pkg/agent/loop_turn.go:selectCandidates` 调用 `agent.Router.SelectModel(...)` + +当 light model 被选中时,agent loop 会切换到 `agent.LightCandidates`。 +如果没有被选中,则继续使用 agent 的主 provider 候选集。 + +## 显式 Session Key + +还有一个不在 `pkg/routing` 内部、但对整体“路由语义”很重要的细节。 + +在 route 分配完成后,`pkg/agent/loop_utils.go:resolveScopeKey` 会优先保留调用方显式传入的 session key,只要它属于以下格式之一: + +- 不透明 canonical key +- legacy `agent:...` key + +这样一来,手工系统流、测试和兼容路径即使在正常路由 scope 会生成不同 key 的情况下,仍然能保持确定性。 + +## 本文不覆盖的内容 + +仓库里还存在两套和这里无关的“route”系统: + +- `web/backend/api/router.go` 注册的后端 HTTP 路由 +- `web/frontend/src/routes/` 下的前端文件路由 + +它们属于 launcher 的实现细节,和本文描述的运行时路由系统是两回事。 + +## 相关文件 + +- `pkg/routing/route.go` +- `pkg/routing/router.go` +- `pkg/routing/classifier.go` +- `pkg/routing/features.go` +- `pkg/routing/agent_id.go` +- `pkg/session/allocator.go` +- `pkg/agent/registry.go` +- `pkg/agent/loop_message.go` +- `pkg/agent/loop_turn.go` diff --git a/docs/architecture/runtime-events.md b/docs/architecture/runtime-events.md new file mode 100644 index 000000000..5d625a34b --- /dev/null +++ b/docs/architecture/runtime-events.md @@ -0,0 +1,216 @@ +# Runtime Events And Event Logging + +PicoClaw runtime events are the read-only observation surface for agent, channel, gateway, message bus, and MCP activity. Publishing events and printing logs are separate responsibilities: + +- Event publishing: components publish `pkg/events.Event` values to the runtime event bus for hooks, tests, diagnostics, and future UI consumers. +- Event logging: the built-in runtime event logger subscribes to the same bus and prints only the events selected by configuration. + +This keeps runtime code focused on publishing events while log policy stays centralized. + +## Default Behavior + +By default, only `agent.*` events are printed: + +```json +{ + "events": { + "logging": { + "enabled": true, + "include": ["agent.*"], + "min_severity": "info", + "include_payload": false + } + } +} +``` + +This preserves the previous behavior: agent turn, LLM, tool, steering, subturn, and error events appear in logs. Channel, gateway, bus, and MCP events are still published to the runtime event bus, but they are not printed unless configured. + +## Configuration + +The configuration lives under `events.logging` in `config.json`: + +| Field | Type | Default | Description | +| ----- | ---- | ------- | ----------- | +| `enabled` | bool | `true` | Enables the built-in event logger subscription | +| `include` | string[] | `["agent.*"]` | Event kinds to print; supports exact matches, `*`, and patterns such as `agent.*` | +| `exclude` | string[] | `[]` | Event kinds to suppress after include matching | +| `min_severity` | string | `info` | Minimum severity: `debug`, `info`, `warn`, or `error` | +| `include_payload` | bool | `false` | Adds raw event payloads to log fields | + +`include_payload` is disabled by default. Agent events print safe summary fields such as `user_len`, `args_count`, and `content_len` instead of full user messages or tool arguments. Enable raw payload logging only for short-lived diagnostics in a trusted log environment. + +## Matching Rules + +`include` and `exclude` match the `Event.Kind` string: + +```json +{ + "events": { + "logging": { + "include": ["gateway.*", "channel.lifecycle.*", "agent.error"], + "exclude": ["gateway.ready"], + "min_severity": "info" + } + } +} +``` + +Common patterns: + +- `["agent.*"]`: print agent events only. +- `["*"]`: print all runtime events. +- `["gateway.*", "channel.*"]`: print gateway and channel events only. +- `exclude: ["agent.llm.delta"]`: suppress high-volume streaming delta events. +- `min_severity: "warn"`: print warn and error events only. + +## Environment Variables + +The same settings can be overridden with environment variables: + +```bash +PICOCLAW_EVENTS_LOGGING_ENABLED=true +PICOCLAW_EVENTS_LOGGING_INCLUDE="gateway.*,channel.lifecycle.*" +PICOCLAW_EVENTS_LOGGING_EXCLUDE="gateway.ready" +PICOCLAW_EVENTS_LOGGING_MIN_SEVERITY=info +PICOCLAW_EVENTS_LOGGING_INCLUDE_PAYLOAD=false +``` + +`include` and `exclude` use comma-separated values. + +## Event Names And Triggers + +The table below lists the current runtime event kinds, when they are emitted, and the most useful event details. `Source`, `Scope`, and `Correlation` are shared envelope fields that may appear on every event. The "Details" column refers to useful payload fields or log summary fields. + +### Agent + +| Event | Trigger | Details | +| ----- | ------- | ------- | +| `agent.turn.start` | An agent starts processing one user or system input after the turn scope has been created. | `user_len`, `media_count`; scope usually includes `agent_id`, `session_key`, `turn_id`, `channel`, `chat_id`, `message_id` | +| `agent.turn.end` | A turn exits, whether it completed, errored, or was hard-aborted. | `status` (`completed`/`error`/`aborted`), `iterations_total`, `duration_ms`, `final_len` | +| `agent.llm.request` | Before each LLM provider request. | `model`, `messages`, `tools`, `max_tokens` | +| `agent.llm.delta` | Reserved for streaming LLM deltas; the kind is defined, but the current implementation has no natural emit site. | `content_delta_len`, `reasoning_delta_len` | +| `agent.llm.response` | After the LLM provider returns a complete response. | `content_len`, `tool_calls`, `has_reasoning` | +| `agent.llm.retry` | Before retrying an LLM request after context, rate-limit, transient provider, or fallback handling. | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` | +| `agent.context.compress` | Agent context history is compressed, for example during proactive budget checks or LLM retry handling. | `reason`, `dropped_messages`, `remaining_messages` | +| `agent.session.summarize` | Async session history summarization completes. | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` | +| `agent.tool.exec_start` | Before the agent executes a tool call. | `tool`, `args_count`; full arguments are not logged by default | +| `agent.tool.exec_end` | After a tool call completes, including successful results, tool errors, and async results. | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` | +| `agent.tool.exec_skipped` | A tool call is skipped because the tool is unavailable, arguments are invalid, or turn control logic requires skipping it. | `tool`, `reason` | +| `agent.steering.injected` | Queued steering messages are injected into the next LLM context. | `count`, `total_content_len` | +| `agent.follow_up.queued` | An async tool result is queued back into the inbound/follow-up flow. | `source_tool`, `content_len` | +| `agent.interrupt.received` | A turn accepts steering, graceful interrupt, or hard-abort input. | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` | +| `agent.subturn.spawn` | A parent turn creates a child turn/subagent. | `child_agent_id`, `label`, `parent_turn_id` | +| `agent.subturn.end` | A child turn ends. | `child_agent_id`, `status` | +| `agent.subturn.result_delivered` | A child turn result is delivered to the target channel/chat. | `target_channel`, `target_chat_id`, `content_len` | +| `agent.subturn.orphan` | A child turn result cannot be delivered or cannot be associated back to its parent turn. | `parent_turn_id`, `child_turn_id`, `reason` | +| `agent.error` | Agent execution reports an error. | `stage`, `error` | + +### Channel + +| Event | Trigger | Details | +| ----- | ------- | ------- | +| `channel.lifecycle.initialized` | The channel manager creates and registers a channel instance from config. | `type`; scope includes `channel` | +| `channel.lifecycle.started` | Channel `Start()` succeeds and worker goroutines have been started; added channels during hot reload also emit it. | `type` | +| `channel.lifecycle.start_failed` | Channel `Start()` fails. | `type`, `error`; severity is `error` | +| `channel.lifecycle.stopped` | Channel `Stop()` succeeds. | `type` | +| `channel.webhook.registered` | A channel webhook handler is registered on the shared HTTP mux. | `type`; scope includes `channel` | +| `channel.webhook.unregistered` | A channel webhook handler is removed from the shared HTTP mux. | `type`; scope includes `channel` | +| `channel.message.outbound_queued` | An outbound text or media message is queued into its channel worker. | `media`, `content_len`, `reply_to_message_id`; scope comes from the original inbound context | +| `channel.message.outbound_sent` | An outbound text or media message is sent successfully, or a placeholder edit handled the response. | `media`, `content_len`, `message_ids`, `reply_to_message_id` | +| `channel.message.outbound_failed` | An outbound text or media message exhausts retries or hits a permanent failure. | `media`, `content_len`, `retries`, `error`, `reply_to_message_id`; severity is `error` | +| `channel.rate_limited` | A channel worker is waiting for a rate-limit token and the context is canceled, interrupting this delivery. | `media`, `content_len`, `error`, `reply_to_message_id`; severity is `warn` | + +### Message Bus + +| Event | Trigger | Details | +| ----- | ------- | ------- | +| `bus.publish.failed` | Publishing inbound, outbound, media, audio, or voice-control data fails, or required context is missing. | `stream`, `error`; scope is derived from message context when possible | +| `bus.close.started` | Message bus shutdown begins. | `drained` is usually `0` | +| `bus.close.drained` | Shutdown waits for buffered messages to drain and at least one buffered message was drained. | `drained` | +| `bus.close.completed` | Message bus shutdown completes. | `drained` | + +### Gateway + +| Event | Trigger | Details | +| ----- | ------- | ------- | +| `gateway.start` | Gateway startup reaches the agent/runtime event bus/bootstrap binding point. | `duration_ms` | +| `gateway.ready` | Gateway services, channel manager, HTTP server, and other core services are ready. | `duration_ms` | +| `gateway.shutdown` | Gateway shutdown begins. | No fixed payload; envelope fields may be the only fields | +| `gateway.reload.started` | Hot reload execution starts. | `duration_ms` | +| `gateway.reload.completed` | Hot reload completes successfully. | `duration_ms` | +| `gateway.reload.failed` | Hot reload fails. | `duration_ms`, `error`; severity is `error` | + +### MCP + +| Event | Trigger | Details | +| ----- | ------- | ------- | +| `mcp.server.connecting` | The MCP manager is about to connect to a server. | `server`, `type`, `url`, `command` | +| `mcp.server.connected` | An MCP server connects and its tool list has been initialized. | `server`, `type`, `url`, `command`, `tool_count` | +| `mcp.server.failed` | An MCP server connection fails, or the manager is closed before connecting. | `server`, `type`, `url`, `command`, `error`; severity is `error` | +| `mcp.tool.discovered` | A tool from an MCP server is discovered and registered. | `server`, `type`, `url`, `command`, `tool` | +| `mcp.tool.call.start` | The MCP tool wrapper starts a remote tool call. | `server`, `tool`; when emitted inside an agent turn, scope includes turn/chat information | +| `mcp.tool.call.end` | The MCP tool wrapper finishes a remote tool call, including failures. | `server`, `tool`, `duration_ms`, `is_error`, `error` | + +## Log Fields + +Runtime event logs include stable envelope fields when available: + +- `event_id` +- `event_kind` +- `severity` +- `event_time` +- `source_component` +- `source_name` +- `agent_id` +- `session_key` +- `turn_id` +- `channel` +- `account` +- `chat_id` +- `topic_id` +- `space_id` +- `space_type` +- `chat_type` +- `sender_id` +- `message_id` +- `trace_id` +- `parent_turn_id` +- `request_id` +- `reply_to_id` + +Agent events add safe payload summaries: + +| Event | Summary fields | +| ----- | -------------- | +| `agent.turn.start` | `user_len`, `media_count` | +| `agent.turn.end` | `status`, `iterations_total`, `duration_ms`, `final_len` | +| `agent.llm.request` | `model`, `messages`, `tools`, `max_tokens` | +| `agent.llm.delta` | `content_delta_len`, `reasoning_delta_len` | +| `agent.llm.response` | `content_len`, `tool_calls`, `has_reasoning` | +| `agent.llm.retry` | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` | +| `agent.context.compress` | `reason`, `dropped_messages`, `remaining_messages` | +| `agent.session.summarize` | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` | +| `agent.tool.exec_start` | `tool`, `args_count` | +| `agent.tool.exec_end` | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` | +| `agent.tool.exec_skipped` | `tool`, `reason` | +| `agent.steering.injected` | `count`, `total_content_len` | +| `agent.follow_up.queued` | `source_tool`, `content_len` | +| `agent.interrupt.received` | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` | +| `agent.subturn.spawn` | `child_agent_id`, `label` | +| `agent.subturn.end` | `child_agent_id`, `status` | +| `agent.subturn.result_delivered` | `target_channel`, `target_chat_id`, `content_len` | +| `agent.subturn.orphan` | `parent_turn_id`, `child_turn_id`, `reason` | +| `agent.error` | `stage`, `error` | + +## Event Domains + +Runtime event kinds are defined in `pkg/events/kind.go`. Event logging can select these domains: + +- `agent.*`: agent turn, LLM, tool, context, steering, interrupt, subturn, and error events. +- `channel.*`: channel lifecycle, webhook registration, outbound queued/sent/failed, and rate limiting. +- `bus.*`: publish failures and close lifecycle. +- `gateway.*`: start, ready, shutdown, and reload lifecycle. +- `mcp.*`: MCP server connection, tool discovery, and tool call events. + +See [`../../config/config.example.json`](../../config/config.example.json) for the default event logging example. diff --git a/docs/architecture/runtime-events.zh.md b/docs/architecture/runtime-events.zh.md new file mode 100644 index 000000000..3ed384537 --- /dev/null +++ b/docs/architecture/runtime-events.zh.md @@ -0,0 +1,216 @@ +# Runtime Events 与事件日志 + +PicoClaw 的 runtime event 是运行时观察面,用来描述 agent、channel、gateway、message bus、MCP 等组件发生了什么。事件发布和日志打印是两件事: + +- 事件发布:组件把 `pkg/events.Event` 发布到 runtime event bus,供 hook、测试、调试工具或后续 UI 消费。 +- 事件日志:内置 runtime event logger 订阅同一个 bus,并按配置把匹配的事件打印到日志。 + +这样可以让业务流程继续只负责发布事件,日志策略统一收口到一个地方。 + +## 默认行为 + +默认配置只打印 `agent.*` 事件: + +```json +{ + "events": { + "logging": { + "enabled": true, + "include": ["agent.*"], + "min_severity": "info", + "include_payload": false + } + } +} +``` + +这个默认值保持了旧行为:agent turn、LLM、tool、steering、subturn、error 等事件会出现在日志中;channel、gateway、bus、MCP 事件仍会发布到 runtime event bus,但默认不打印,避免网关启动和消息投递日志过于嘈杂。 + +## 配置项 + +配置位于 `config.json` 的 `events.logging`: + +| 字段 | 类型 | 默认值 | 说明 | +| ---- | ---- | ------ | ---- | +| `enabled` | bool | `true` | 是否启用内置事件日志订阅器 | +| `include` | string[] | `["agent.*"]` | 允许打印的事件 kind,支持精确匹配、`*`、`agent.*` 这类 glob/prefix | +| `exclude` | string[] | `[]` | 在 include 命中后排除的事件 kind,匹配规则同 include | +| `min_severity` | string | `info` | 最低打印级别:`debug`、`info`、`warn`、`error` | +| `include_payload` | bool | `false` | 是否把原始 payload 放进日志字段 | + +`include_payload` 默认关闭。agent 事件日志会输出安全摘要字段,例如 `user_len`、`args_count`、`content_len`,不会默认输出完整用户消息或工具参数。只有在排查问题、并且确认日志存储环境可信时,才建议临时打开 `include_payload`。 + +## 匹配规则 + +`include` 和 `exclude` 都匹配 `Event.Kind` 字符串: + +```json +{ + "events": { + "logging": { + "include": ["gateway.*", "channel.lifecycle.*", "agent.error"], + "exclude": ["gateway.ready"], + "min_severity": "info" + } + } +} +``` + +常用写法: + +- `["agent.*"]`:只打印 agent 事件。 +- `["*"]`:打印所有 runtime events。 +- `["gateway.*", "channel.*"]`:只打印 gateway 和 channel 事件。 +- `exclude: ["agent.llm.delta"]`:排除高频流式 delta 事件。 +- `min_severity: "warn"`:只打印 warn/error 事件。 + +## 环境变量 + +同一组配置也可以通过环境变量覆盖,适合临时调试: + +```bash +PICOCLAW_EVENTS_LOGGING_ENABLED=true +PICOCLAW_EVENTS_LOGGING_INCLUDE="gateway.*,channel.lifecycle.*" +PICOCLAW_EVENTS_LOGGING_EXCLUDE="gateway.ready" +PICOCLAW_EVENTS_LOGGING_MIN_SEVERITY=info +PICOCLAW_EVENTS_LOGGING_INCLUDE_PAYLOAD=false +``` + +`include` 和 `exclude` 的环境变量使用逗号分隔。 + +## 事件名称与触发时机 + +下面列出当前 runtime event kind、触发时机和主要事件详情。`Source`、`Scope`、`Correlation` 是所有事件都可能携带的 envelope 字段;表里的“主要详情”指 payload 或日志摘要中最有用的字段。 + +### Agent + +| 事件名 | 触发时机 | 主要详情 | +| ------ | -------- | -------- | +| `agent.turn.start` | agent 开始处理一次用户输入或系统输入,turn scope 已创建时 | `user_len`, `media_count`; scope 通常包含 `agent_id`, `session_key`, `turn_id`, `channel`, `chat_id`, `message_id` | +| `agent.turn.end` | 一次 turn 退出时,无论完成、报错还是 hard abort | `status` (`completed`/`error`/`aborted`), `iterations_total`, `duration_ms`, `final_len` | +| `agent.llm.request` | 每次调用 LLM provider 前 | `model`, `messages`, `tools`, `max_tokens` | +| `agent.llm.delta` | 预留给流式 LLM delta;当前实现已定义但没有自然发送点 | `content_delta_len`, `reasoning_delta_len` | +| `agent.llm.response` | LLM provider 返回完整响应后 | `content_len`, `tool_calls`, `has_reasoning` | +| `agent.llm.retry` | LLM 请求因上下文、限流、临时错误等原因准备重试前 | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` | +| `agent.context.compress` | 上下文历史被压缩时,例如主动预算检查或 LLM retry 处理 | `reason`, `dropped_messages`, `remaining_messages` | +| `agent.session.summarize` | 会话历史异步摘要完成时 | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` | +| `agent.tool.exec_start` | agent 准备执行一个工具调用前 | `tool`, `args_count`; 默认不打印完整参数 | +| `agent.tool.exec_end` | 工具调用完成后,包括成功、工具错误和 async 结果 | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` | +| `agent.tool.exec_skipped` | 工具调用被跳过时,例如工具不可用、参数无效或 turn 控制逻辑要求跳过 | `tool`, `reason` | +| `agent.steering.injected` | queued steering message 被注入下一轮 LLM 上下文时 | `count`, `total_content_len` | +| `agent.follow_up.queued` | async 工具结果被重新排入 inbound/follow-up 流程时 | `source_tool`, `content_len` | +| `agent.interrupt.received` | turn 接受 steering、graceful interrupt 或 hard abort 指令时 | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` | +| `agent.subturn.spawn` | 父 turn 创建子 turn/subagent 时 | `child_agent_id`, `label`, `parent_turn_id` | +| `agent.subturn.end` | 子 turn 结束时 | `child_agent_id`, `status` | +| `agent.subturn.result_delivered` | 子 turn 结果成功投递到目标 channel/chat 时 | `target_channel`, `target_chat_id`, `content_len` | +| `agent.subturn.orphan` | 子 turn 结果无法投递或无法关联回父 turn 时 | `parent_turn_id`, `child_turn_id`, `reason` | +| `agent.error` | agent 执行流程报告错误时 | `stage`, `error` | + +### Channel + +| 事件名 | 触发时机 | 主要详情 | +| ------ | -------- | -------- | +| `channel.lifecycle.initialized` | channel manager 根据配置创建并注册 channel 实例后 | `type`; scope 包含 `channel` | +| `channel.lifecycle.started` | channel `Start()` 成功,worker 已启动时;热重载新增 channel 也会触发 | `type` | +| `channel.lifecycle.start_failed` | channel `Start()` 失败时 | `type`, `error`; severity 为 `error` | +| `channel.lifecycle.stopped` | channel `Stop()` 成功后 | `type` | +| `channel.webhook.registered` | channel 的 webhook handler 被注册到共享 HTTP mux 时 | `type`; scope 包含 `channel` | +| `channel.webhook.unregistered` | channel 的 webhook handler 从共享 HTTP mux 移除时 | `type`; scope 包含 `channel` | +| `channel.message.outbound_queued` | outbound 文本或媒体消息被放入对应 channel worker 队列时 | `media`, `content_len`, `reply_to_message_id`; scope 来自原 inbound context | +| `channel.message.outbound_sent` | outbound 文本或媒体消息成功发送,或 placeholder edit 已处理响应时 | `media`, `content_len`, `message_ids`, `reply_to_message_id` | +| `channel.message.outbound_failed` | outbound 文本或媒体消息重试耗尽或遇到永久失败时 | `media`, `content_len`, `retries`, `error`, `reply_to_message_id`; severity 为 `error` | +| `channel.rate_limited` | channel worker 等待 rate limiter token 时被 context 取消,导致本次发送被限流/中断 | `media`, `content_len`, `error`, `reply_to_message_id`; severity 为 `warn` | + +### Message Bus + +| 事件名 | 触发时机 | 主要详情 | +| ------ | -------- | -------- | +| `bus.publish.failed` | inbound、outbound、media、audio 或 voice control 发布失败,或缺少必要 context 时 | `stream`, `error`; scope 尽量来自消息 context | +| `bus.close.started` | message bus 开始关闭时 | `drained` 通常为 `0` | +| `bus.close.drained` | close 期间等待队列 drain,并且 drain 到至少一条 buffered message 时 | `drained` | +| `bus.close.completed` | message bus 完成关闭时 | `drained` | + +### Gateway + +| 事件名 | 触发时机 | 主要详情 | +| ------ | -------- | -------- | +| `gateway.start` | gateway 完成 agent/runtime event bus/bootstrap 绑定后 | `duration_ms` | +| `gateway.ready` | gateway 服务、channel manager、HTTP 等关键服务启动完成后 | `duration_ms` | +| `gateway.shutdown` | gateway 开始关闭流程时 | 无固定 payload,可能只有 envelope 字段 | +| `gateway.reload.started` | 热重载开始执行时 | `duration_ms` | +| `gateway.reload.completed` | 热重载成功完成时 | `duration_ms` | +| `gateway.reload.failed` | 热重载失败时 | `duration_ms`, `error`; severity 为 `error` | + +### MCP + +| 事件名 | 触发时机 | 主要详情 | +| ------ | -------- | -------- | +| `mcp.server.connecting` | MCP manager 准备连接某个 server 前 | `server`, `type`, `url`, `command` | +| `mcp.server.connected` | MCP server 连接成功并完成工具列表初始化后 | `server`, `type`, `url`, `command`, `tool_count` | +| `mcp.server.failed` | MCP server 连接失败,或 manager 已关闭导致无法连接时 | `server`, `type`, `url`, `command`, `error`; severity 为 `error` | +| `mcp.tool.discovered` | MCP server 的某个工具被发现并注册时 | `server`, `type`, `url`, `command`, `tool` | +| `mcp.tool.call.start` | MCP tool wrapper 开始执行一次远端工具调用前 | `server`, `tool`; 如果在 agent turn 内触发,scope 会带上对应 turn/chat 信息 | +| `mcp.tool.call.end` | MCP tool wrapper 完成一次远端工具调用后,包括失败结果 | `server`, `tool`, `duration_ms`, `is_error`, `error` | + +## 日志字段 + +所有事件日志都会尽量包含稳定 envelope 字段: + +- `event_id` +- `event_kind` +- `severity` +- `event_time` +- `source_component` +- `source_name` +- `agent_id` +- `session_key` +- `turn_id` +- `channel` +- `account` +- `chat_id` +- `topic_id` +- `space_id` +- `space_type` +- `chat_type` +- `sender_id` +- `message_id` +- `trace_id` +- `parent_turn_id` +- `request_id` +- `reply_to_id` + +agent 事件还会追加 payload 摘要字段: + +| 事件 | 摘要字段 | +| ---- | -------- | +| `agent.turn.start` | `user_len`, `media_count` | +| `agent.turn.end` | `status`, `iterations_total`, `duration_ms`, `final_len` | +| `agent.llm.request` | `model`, `messages`, `tools`, `max_tokens` | +| `agent.llm.delta` | `content_delta_len`, `reasoning_delta_len` | +| `agent.llm.response` | `content_len`, `tool_calls`, `has_reasoning` | +| `agent.llm.retry` | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` | +| `agent.context.compress` | `reason`, `dropped_messages`, `remaining_messages` | +| `agent.session.summarize` | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` | +| `agent.tool.exec_start` | `tool`, `args_count` | +| `agent.tool.exec_end` | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` | +| `agent.tool.exec_skipped` | `tool`, `reason` | +| `agent.steering.injected` | `count`, `total_content_len` | +| `agent.follow_up.queued` | `source_tool`, `content_len` | +| `agent.interrupt.received` | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` | +| `agent.subturn.spawn` | `child_agent_id`, `label` | +| `agent.subturn.end` | `child_agent_id`, `status` | +| `agent.subturn.result_delivered` | `target_channel`, `target_chat_id`, `content_len` | +| `agent.subturn.orphan` | `parent_turn_id`, `child_turn_id`, `reason` | +| `agent.error` | `stage`, `error` | + +## 可打印的事件域 + +当前 runtime event kind 定义在 `pkg/events/kind.go`。事件日志配置可以选择这些域: + +- `agent.*`:agent turn、LLM、tool、context、steering、interrupt、subturn、error。 +- `channel.*`:channel lifecycle、webhook 注册、outbound queued/sent/failed、rate limited。 +- `bus.*`:publish failed、close started/drained/completed。 +- `gateway.*`:start、ready、shutdown、reload started/completed/failed。 +- `mcp.*`:server connecting/connected/failed、tool discovered、tool call start/end。 + +默认事件日志示例见 [`../../config/config.example.json`](../../config/config.example.json)。 diff --git a/docs/architecture/session-system.md b/docs/architecture/session-system.md new file mode 100644 index 000000000..b87f9c38e --- /dev/null +++ b/docs/architecture/session-system.md @@ -0,0 +1,255 @@ +# Session System + +> Back to [README](../README.md) + +This document describes the runtime session system used by PicoClaw to: + +- map inbound messages onto stable conversation scopes +- persist message history and summaries +- preserve compatibility with legacy `agent:...` session keys while the runtime uses opaque canonical keys + +This document covers the core runtime path in `pkg/session`, `pkg/memory`, and `pkg/agent`. +It does not describe launcher login cookies or dashboard authentication sessions in `web/backend/middleware`. + +## Responsibilities + +The session system has four jobs: + +1. Decide which messages should share the same conversation context. +2. Persist that context durably across turns and restarts. +3. Expose a small `SessionStore` interface to the agent loop. +4. Keep older session-key formats working during storage and routing migrations. + +## Main Components + +| Layer | Files | Responsibility | +| --- | --- | --- | +| Session contract | `pkg/session/session_store.go` | Defines the `SessionStore` interface used by the agent loop. | +| Legacy backend | `pkg/session/manager.go` | Stores one JSON file per session. Still used as a fallback. | +| Session adapter | `pkg/session/jsonl_backend.go` | Adapts `pkg/memory.Store` to `SessionStore`, including alias and scope metadata support. | +| Durable storage | `pkg/memory/jsonl.go` | Append-only JSONL storage plus `.meta.json` sidecar metadata. | +| Scope and key building | `pkg/session/scope.go`, `pkg/session/key.go`, `pkg/session/allocator.go` | Builds structured scopes, opaque canonical keys, and legacy aliases from routing results. | +| Runtime integration | `pkg/agent/instance.go`, `pkg/agent/agent.go`, `pkg/agent/agent_message.go` | Initializes the store, allocates session scope, and persists metadata before turns run. | + +## Session Data Model + +The structured session identity is represented by `session.SessionScope`: + +| Field | Meaning | +| --- | --- | +| `Version` | Schema version. Current value is `ScopeVersionV1`. | +| `AgentID` | Routed agent handling the turn. | +| `Channel` | Normalized inbound channel name. | +| `Account` | Normalized account or bot identifier. | +| `Dimensions` | Ordered list of active partition dimensions such as `chat` or `sender`. | +| `Values` | Concrete normalized values for each selected dimension. | + +Only four dimensions are currently recognized by the allocator: + +- `space` +- `chat` +- `topic` +- `sender` + +The default config uses: + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +That means one shared conversation per chat unless a dispatch rule overrides it. + +## Canonical Keys And Legacy Aliases + +The runtime now prefers opaque canonical keys: + +```text +sk_v1_ +``` + +These keys are built from a canonical scope signature in `pkg/session/key.go`. +The goal is to make storage keys stable while decoupling them from any specific legacy text format. + +For compatibility, the allocator also emits legacy aliases such as: + +```text +agent:main:direct:user123 +agent:main:slack:channel:c001 +agent:main:pico:direct:pico:session-123 +``` + +These aliases matter because older sessions, tests, and some tools still refer to the legacy shape. +The JSONL backend resolves aliases back to the canonical key before reads and writes. + +The agent loop also preserves explicit incoming session keys when the caller already supplied one of the recognized explicit formats: + +- opaque canonical key +- legacy `agent:...` key + +That behavior lives in `pkg/agent/agent_utils.go:resolveScopeKey`. + +## Allocation Flow + +The end-to-end flow for a normal inbound message is: + +```text +InboundMessage + -> RouteResolver.ResolveRoute(...) + -> session.AllocateRouteSession(...) + -> resolveScopeKey(...) + -> ensureSessionMetadata(...) + -> AgentLoop turn execution + -> SessionStore read/write operations +``` + +More concretely: + +1. `pkg/agent/agent_message.go` resolves the agent route from normalized inbound context. +2. `session.AllocateRouteSession` converts the route's `SessionPolicy` plus inbound context into a structured `SessionScope`. +3. The allocator builds: + - `SessionKey`: canonical routed session key + - `SessionAliases`: compatibility aliases for that routed scope + - `MainSessionKey`: agent-level main session key + - `MainAliases`: legacy alias for the main session +4. `runAgentLoop` persists scope metadata and aliases through `ensureSessionMetadata`. +5. During later reads or writes, `JSONLBackend.ResolveSessionKey` maps aliases back onto the canonical key. + +The main session key is separate from routed chat sessions. +It is mainly used for agent-level or system-style flows that need one stable per-agent conversation, for example `processSystemMessage`. + +## Scope Construction Rules + +`pkg/session/allocator.go` builds scope values from normalized inbound context. +Important rules: + +- `space` becomes `:` +- `chat` becomes `:` +- `topic` becomes `topic:` +- `sender` is canonicalized through `session.identity_links` before being stored + +There are two special cases worth calling out. + +### Telegram forum isolation + +Telegram forum topics must stay isolated even when the configured dimensions only mention `chat`. +To preserve that behavior, the allocator appends `/` to the `chat` value for Telegram forum messages unless `topic` is already an explicit dimension. + +Example: + +```text +group:-1001234567890/42 +group:-1001234567890/99 +``` + +Those produce different session keys. + +### Identity links + +`session.identity_links` lets multiple sender identifiers collapse into one canonical identity. +Both dispatch matching and session allocation use that mapping so that the same person can keep one conversation even if their raw sender IDs differ across channels or accounts. + +## Storage Format + +The default runtime backend is `pkg/memory.JSONLStore`, wrapped by `session.JSONLBackend`. + +Each session uses two files: + +```text +{sanitized_key}.jsonl +{sanitized_key}.meta.json +``` + +The files store: + +- `.jsonl`: one `providers.Message` per line, append-only +- `.meta.json`: summary, timestamps, line counts, logical truncation offset, scope, aliases + +`SessionMeta` currently includes: + +- `Key` +- `Summary` +- `Skip` +- `Count` +- `CreatedAt` +- `UpdatedAt` +- `Scope` +- `Aliases` + +## Write And Crash Semantics + +The JSONL store is designed around append-first durability and stale-over-loss recovery: + +- `AddMessage` and `AddFullMessage` append one JSON line, `fsync`, then update metadata. +- `TruncateHistory` is logical first: it only advances `meta.Skip`. +- `Compact` physically rewrites the JSONL file to remove skipped lines. +- `SetHistory` and `Compact` write metadata before rewriting JSONL so a crash may temporarily expose old data, but should not lose data. +- Corrupt JSONL lines are skipped during reads instead of failing the entire session. + +`JSONLBackend.Save` maps onto `store.Compact(...)`. +In other words, `Save` is no longer "flush dirty memory to disk"; it is now "reclaim dead lines after logical truncation". + +## Concurrency Model + +`pkg/memory.JSONLStore` uses a fixed 64-shard mutex array keyed by session hash. +That gives per-session serialization without keeping an unbounded mutex map in memory. + +The legacy `SessionManager` uses a single in-memory map guarded by an RW mutex. + +Both backends satisfy the same `SessionStore` interface, which is why the agent loop does not need storage-specific code. + +## Compatibility And Migration + +`pkg/agent/instance.go:initSessionStore` prefers the JSONL backend. + +Startup sequence: + +1. Create `memory.NewJSONLStore(dir)`. +2. Run `memory.MigrateFromJSON(...)` to import legacy `.json` sessions. +3. Wrap the store with `session.NewJSONLBackend(store)`. +4. If JSONL initialization or migration fails, fall back to `session.NewSessionManager(dir)`. + +This fallback is intentional: a partial migration would be worse than staying on the legacy store for one run. + +### Alias promotion + +When canonical metadata is first created, `EnsureSessionMetadata` may promote history from a non-empty legacy alias into the canonical session. +That promotion only happens when the canonical session is still empty, so active canonical history is not overwritten. + +This is how the system preserves old histories such as: + +- legacy direct-message keys +- older Pico direct-session keys + +while moving the runtime onto opaque canonical keys. + +## Other SessionStore Implementations + +`pkg/agent/subturn.go` defines an `ephemeralSessionStore`. +It satisfies the same `SessionStore` interface, but keeps data in memory only and is destroyed when the sub-turn ends. + +That lets SubTurn reuse the same session-facing APIs without writing child-session history into the parent's durable storage. + +## Operational Consumers + +The session system is consumed by more than the agent loop: + +- `web/backend/api/session.go` reads JSONL metadata and legacy JSON sessions to expose session history in the launcher UI. +- `pkg/agent/steering.go` can recover scope metadata for active steering flows. +- tooling and tests can still refer to legacy aliases because alias resolution is handled below the agent loop. + +## Related Files + +- `pkg/session/session_store.go` +- `pkg/session/manager.go` +- `pkg/session/jsonl_backend.go` +- `pkg/session/scope.go` +- `pkg/session/key.go` +- `pkg/session/allocator.go` +- `pkg/memory/jsonl.go` +- `pkg/agent/instance.go` +- `pkg/agent/agent.go` +- `pkg/agent/agent_message.go` diff --git a/docs/architecture/session-system.zh.md b/docs/architecture/session-system.zh.md new file mode 100644 index 000000000..8de4e515c --- /dev/null +++ b/docs/architecture/session-system.zh.md @@ -0,0 +1,254 @@ +# Session 系统 + +> 返回 [README](../README.md) + +本文说明 PicoClaw 运行时的 Session 系统如何完成以下事情: + +- 把入站消息映射到稳定的会话作用域 +- 持久化消息历史与摘要 +- 在运行时使用不透明 canonical key 的同时,继续兼容旧的 `agent:...` session key + +本文覆盖 `pkg/session`、`pkg/memory` 和 `pkg/agent` 中的核心运行时链路。 +它不讨论 `web/backend/middleware` 中 launcher 登录 Cookie 或 dashboard 鉴权 session。 + +## 职责 + +Session 系统承担四件事: + +1. 决定哪些消息应该共享同一段上下文。 +2. 让这段上下文能跨 turn、跨进程重启持久存在。 +3. 向 agent loop 暴露一个足够小的 `SessionStore` 抽象。 +4. 在存储层和路由层迁移期间继续兼容旧 session key。 + +## 主要组件 + +| 层次 | 文件 | 作用 | +| --- | --- | --- | +| Session 抽象 | `pkg/session/session_store.go` | 定义 agent loop 依赖的 `SessionStore` 接口。 | +| 旧后端 | `pkg/session/manager.go` | 每个 session 一个 JSON 文件的旧实现,仍作为回退方案保留。 | +| Session 适配层 | `pkg/session/jsonl_backend.go` | 把 `pkg/memory.Store` 适配成 `SessionStore`,并支持 alias 与 scope metadata。 | +| 持久化存储 | `pkg/memory/jsonl.go` | Append-only JSONL 存储与 `.meta.json` 元数据侧文件。 | +| Scope / Key 构建 | `pkg/session/scope.go`、`pkg/session/key.go`、`pkg/session/allocator.go` | 从路由结果生成结构化 scope、不透明 canonical key 和 legacy alias。 | +| 运行时集成 | `pkg/agent/instance.go`、`pkg/agent/loop.go`、`pkg/agent/loop_message.go` | 初始化存储、分配 session scope,并在 turn 执行前落 metadata。 | + +## Session 数据模型 + +结构化的会话身份由 `session.SessionScope` 表示: + +| 字段 | 含义 | +| --- | --- | +| `Version` | Scope 模式版本,当前为 `ScopeVersionV1`。 | +| `AgentID` | 处理该 turn 的路由 agent。 | +| `Channel` | 归一化后的入站 channel 名称。 | +| `Account` | 归一化后的 bot / account 标识。 | +| `Dimensions` | 当前启用的隔离维度顺序,例如 `chat` 或 `sender`。 | +| `Values` | 每个维度对应的具体归一化值。 | + +Allocator 当前只识别四个维度: + +- `space` +- `chat` +- `topic` +- `sender` + +默认配置是: + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +也就是默认按 chat 共享上下文;如果 dispatch rule 覆盖了维度,则以 rule 为准。 + +## Canonical Key 与 Legacy Alias + +运行时现在优先使用不透明 canonical key: + +```text +sk_v1_ +``` + +它由 `pkg/session/key.go` 中的 scope signature 计算得到。 +这样可以让存储 key 稳定,同时不再把持久化格式和某一种旧文本 key 绑定死。 + +为了兼容旧数据,allocator 还会生成 legacy alias,例如: + +```text +agent:main:direct:user123 +agent:main:slack:channel:c001 +agent:main:pico:direct:pico:session-123 +``` + +这些 alias 很重要,因为旧 session、部分测试以及某些工具仍然会引用这种格式。 +JSONL backend 会在读写前先把 alias 解析回 canonical key。 + +此外,如果调用方已经显式传入了受支持的 session key,agent loop 会保留它,不强行改成新分配的 routed key。 +这条逻辑在 `pkg/agent/loop_utils.go:resolveScopeKey` 中: + +- 不透明 canonical key +- legacy `agent:...` key + +都属于“显式 key”。 + +## 分配流程 + +普通入站消息的完整链路如下: + +```text +InboundMessage + -> RouteResolver.ResolveRoute(...) + -> session.AllocateRouteSession(...) + -> resolveScopeKey(...) + -> ensureSessionMetadata(...) + -> AgentLoop turn 执行 + -> SessionStore 读写 +``` + +具体来说: + +1. `pkg/agent/loop_message.go` 先用归一化后的 inbound context 解析 agent route。 +2. `session.AllocateRouteSession` 把 route 的 `SessionPolicy` 和 inbound context 组合成结构化 `SessionScope`。 +3. Allocator 会生成: + - `SessionKey`:当前路由会话的 canonical key + - `SessionAliases`:该路由会话的兼容 alias + - `MainSessionKey`:agent 级主会话 key + - `MainAliases`:主会话对应的 legacy alias +4. `runAgentLoop` 通过 `ensureSessionMetadata` 持久化 scope metadata 和 alias。 +5. 后续读写时,`JSONLBackend.ResolveSessionKey` 会先把 alias 映射回 canonical key。 + +`MainSessionKey` 和普通聊天会话是分开的。 +它主要服务于 agent 级、系统级的上下文场景,比如 `processSystemMessage`。 + +## Scope 构建规则 + +`pkg/session/allocator.go` 会从归一化后的 inbound context 生成 scope 值。 +关键规则如下: + +- `space` 变成 `:` +- `chat` 变成 `:` +- `topic` 变成 `topic:` +- `sender` 会先经过 `session.identity_links` 归一化再写入 + +其中有两个需要单独记住的特殊规则。 + +### Telegram forum 隔离 + +Telegram forum topic 必须默认保持隔离,即使配置只写了 `chat` 维度。 +为此,如果消息来自 Telegram forum 且策略里没有显式包含 `topic`,allocator 会把 `/` 拼到 `chat` 值后面。 + +例如: + +```text +group:-1001234567890/42 +group:-1001234567890/99 +``` + +这两者会得到不同的 session key。 + +### Identity links + +`session.identity_links` 可以把多个 sender 标识折叠为一个 canonical identity。 +dispatch 匹配和 session 分配都会使用这套映射,因此同一个人即使跨 channel 或 account 使用不同原始 sender ID,也可以继续落到同一段上下文里。 + +## 存储格式 + +默认运行时后端是 `pkg/memory.JSONLStore`,外面包了一层 `session.JSONLBackend`。 + +每个 session 使用两类文件: + +```text +{sanitized_key}.jsonl +{sanitized_key}.meta.json +``` + +各自保存: + +- `.jsonl`:一行一个 `providers.Message`,append-only +- `.meta.json`:摘要、时间戳、行数、逻辑截断偏移、scope、aliases + +`SessionMeta` 当前包含: + +- `Key` +- `Summary` +- `Skip` +- `Count` +- `CreatedAt` +- `UpdatedAt` +- `Scope` +- `Aliases` + +## 写入与崩溃语义 + +JSONL store 的设计核心是“追加优先、宁可暂时读到旧数据也不要丢数据”: + +- `AddMessage` / `AddFullMessage` 先追加一行 JSON,再 `fsync`,最后更新 metadata。 +- `TruncateHistory` 先做逻辑截断,本质上只是推进 `meta.Skip`。 +- `Compact` 才会真正重写 JSONL 文件,把被跳过的旧行物理移除。 +- `SetHistory` 和 `Compact` 都会先写 metadata 再改写 JSONL;如果中途崩溃,最多短时间暴露旧数据,不应丢数据。 +- 读取 JSONL 时如果碰到损坏行,会跳过该行,而不是让整个 session 读取失败。 + +`JSONLBackend.Save` 对应到底层的 `store.Compact(...)`。 +也就是说,`Save` 在新实现里不再是“把内存脏数据刷盘”,而是“在逻辑截断后回收无效行占用的磁盘空间”。 + +## 并发模型 + +`pkg/memory.JSONLStore` 使用固定 64 分片 mutex,按 session key 的 hash 做串行化。 +这样既能做到“按 session 串行”,又不会因为 session 数量增长而把 mutex map 做成无界结构。 + +旧的 `SessionManager` 则是一个内存 map 加 RW mutex。 + +这两个实现都满足同一个 `SessionStore` 接口,所以 agent loop 不需要写任何存储后端特化逻辑。 + +## 兼容与迁移 + +`pkg/agent/instance.go:initSessionStore` 会优先初始化 JSONL 后端。 + +启动过程如下: + +1. 创建 `memory.NewJSONLStore(dir)`。 +2. 执行 `memory.MigrateFromJSON(...)`,把旧 `.json` session 迁入新格式。 +3. 用 `session.NewJSONLBackend(store)` 包装。 +4. 如果 JSONL 初始化或迁移失败,则回退到 `session.NewSessionManager(dir)`。 + +这个回退是刻意设计的:做一半的迁移,比整轮继续使用旧后端更危险。 + +### Alias 提升 + +第一次为 canonical key 建 metadata 时,`EnsureSessionMetadata` 会尝试把某个非空 legacy alias 的历史提升到 canonical session。 +但这件事只会在 canonical session 仍然为空时发生,因此不会覆盖已经存在的 canonical 历史。 + +这保证了系统在迁移到 opaque key 的同时,仍能保留旧历史,例如: + +- 旧的 direct-message key +- 旧的 Pico direct-session key + +## 其他 SessionStore 实现 + +`pkg/agent/subturn.go` 里定义了 `ephemeralSessionStore`。 +它同样实现 `SessionStore`,但只存在于内存里,在 sub-turn 结束时销毁。 + +这样 SubTurn 就能复用相同的 session 接口,而不会把子任务历史写进父会话的持久存储。 + +## 运行时消费者 + +Session 系统不只被 agent loop 使用: + +- `web/backend/api/session.go` 会读取 JSONL metadata 和旧 JSON session,并把历史暴露给 launcher UI。 +- `pkg/agent/steering.go` 可以在 steering 场景下恢复 scope metadata。 +- 因为 alias 解析发生在 agent loop 之下,测试和工具仍然可以继续使用 legacy alias。 + +## 相关文件 + +- `pkg/session/session_store.go` +- `pkg/session/manager.go` +- `pkg/session/jsonl_backend.go` +- `pkg/session/scope.go` +- `pkg/session/key.go` +- `pkg/session/allocator.go` +- `pkg/memory/jsonl.go` +- `pkg/agent/instance.go` +- `pkg/agent/loop.go` +- `pkg/agent/loop_message.go` diff --git a/docs/architecture/steering.md b/docs/architecture/steering.md new file mode 100644 index 000000000..1a993fdb3 --- /dev/null +++ b/docs/architecture/steering.md @@ -0,0 +1,205 @@ +# Steering + +Steering allows injecting messages into an already-running agent loop, interrupting it between tool calls without waiting for the entire cycle to complete. + +## How it works + +When the agent is executing a sequence of tool calls (e.g. the model requested 3 tools in a single turn), steering checks the queue **after each tool** completes. If it finds queued messages: + +1. The remaining tools are **skipped** and receive `"Skipped due to queued user message."` as their result +2. The steering messages are **injected into the conversation context** +3. The model is called again with the updated context, including the user's steering message + +``` +User ──► Steer("change approach") + │ +Agent Loop ▼ + ├─ tool[0] ✔ (executed) + ├─ [polling] → steering found! + ├─ tool[1] ✘ (skipped) + ├─ tool[2] ✘ (skipped) + └─ new LLM turn with steering message +``` + +## Scoped queues + +Steering is now isolated per resolved session scope, not stored in a single +global queue. + +- The active turn writes and reads from its own scope key (usually the routed session key such as `agent::...`) +- `Steer()` still works outside an active turn through a legacy fallback queue +- `Continue()` first dequeues messages for the requested session scope, then falls back to the legacy queue for backwards compatibility + +This prevents a message arriving from another chat, DM peer, or routed agent +session from being injected into the wrong conversation. + +## Configuration + +In `config.json`, under `agents.defaults`: + +```json +{ + "agents": { + "defaults": { + "steering_mode": "one-at-a-time" + } + } +} +``` + +### Modes + +| Value | Behavior | +|-------|----------| +| `"one-at-a-time"` | **(default)** Dequeues only one message per polling cycle. If there are 3 messages in the queue, they are processed one at a time across 3 successive iterations. | +| `"all"` | Drains the entire queue in a single poll. All pending messages are injected into the context together. | + +The environment variable `PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE` can be used as an alternative. + +## Go API + +### Steer — Send a steering message + +```go +err := agentLoop.Steer(providers.Message{ + Role: "user", + Content: "change direction, focus on X instead", +}) +if err != nil { + // Queue is full (MaxQueueSize=10) or not initialized +} +``` + +The message is enqueued in a thread-safe manner. Returns an error if the queue is full or not initialized. It will be picked up at the next polling point (after the current tool finishes). + +### SteeringMode / SetSteeringMode + +```go +// Read the current mode +mode := agentLoop.SteeringMode() // SteeringOneAtATime | SteeringAll + +// Change it at runtime +agentLoop.SetSteeringMode(agent.SteeringAll) +``` + +### Continue — Resume an idle agent + +When the agent is idle (it has finished processing and its last message was from the assistant), `Continue` checks if there are steering messages in the queue and uses them to start a new cycle: + +```go +response, err := agentLoop.Continue(ctx, sessionKey, channel, chatID) +if err != nil { + // Error (e.g. "no default agent available") +} +if response == "" { + // No steering messages in queue, the agent stays idle +} +``` + +`Continue` internally uses `SkipInitialSteeringPoll: true` to avoid double-dequeuing the same messages (since it already extracted them and passes them directly as input). + +`Continue` also resolves the target agent from the provided session key, so +agent-scoped sessions continue on the correct agent instead of always using +the default one. + +## Polling points in the loop + +Steering is checked at the following points in the agent cycle: + +1. **At loop start** — before the first LLM call, to catch messages enqueued during setup +2. **After every tool completes** — including the first and the last. If steering is found and there are remaining tools, they are all skipped immediately +3. **After a direct LLM response** — if a new steering message arrived while the model was generating a non-tool response, the loop continues instead of returning a stale answer +4. **Right before the turn is finalized** — if steering arrived at the very end of the turn, the agent immediately starts a continuation turn instead of leaving the message orphaned in the queue + +## Why remaining tools are skipped + +When a steering message is detected, all remaining tools in the batch are skipped rather than executed. The alternative — let all tools finish and inject the steering message afterwards — was considered and rejected. Here is why. + +### Preventing unwanted side effects + +Tools can have **irreversible side effects**. If the user says "no, wait" while the agent is mid-batch, executing the remaining tools means those side effects happen anyway: + +| Tool batch | Steering message | With skip | Without skip | +|---|---|---|---| +| `[web_search, send_email]` | "don't send it" | Email **not** sent | Email sent, damage done | +| `[query_db, write_file, spawn_agent]` | "use another database" | Only the query runs | File written + subagent spawned, all wasted | +| `[search₁, search₂, search₃, write_file]` | user changes topic entirely | 1 search | 3 searches + file write, all irrelevant | + +### Avoiding wasted time + +Tools that take seconds (web fetches, API calls, database queries) would all run to completion before the agent sees the user's correction. In a batch of 3 tools each taking 3-4 seconds, that's 10+ seconds of work that will be discarded. + +With skipping, the agent reacts as soon as the current tool finishes — typically within a few seconds instead of waiting for the entire batch. + +### The LLM gets full context + +Skipped tools receive an explicit error result (`"Skipped due to queued user message."`), so the model knows exactly which actions were not performed. It can then decide whether to re-execute them with the new context, or take a different path entirely. + +### Trade-off: sequential execution + +Skipping requires tools to run **sequentially** (the previous implementation ran them in parallel). This introduces latency when the LLM requests multiple independent tools in a single turn. In practice, most batches contain 1-2 tools, so the impact is minimal compared to the benefit of being able to stop unwanted actions. + +## Skipped tool result format + +When steering interrupts a batch, each tool that was not executed receives a `tool` result with: + +``` +Content: "Skipped due to queued user message." +``` + +This is saved to the session via `AddFullMessage` and sent to the model, so it is aware that some requested actions were not performed. + +## Full flow example + +``` +1. User: "search for info on X, write a file, and send me a message" + +2. LLM responds with 3 tool calls: [web_search, write_file, message] + +3. web_search is executed → result saved + +4. [polling] → User called Steer("no, search for Y instead") + +5. write_file is skipped → "Skipped due to queued user message." + message is skipped → "Skipped due to queued user message." + +6. Message "search for Y instead" injected into context + +7. LLM receives the full updated context and responds accordingly +``` + +## Automatic bus drain + +When the agent loop (`Run()`) starts, it reads inbound messages from a shared message bus. The routing logic determines how each message is handled: + +1. **No active turn for the message's session** — the message is dispatched to a **worker goroutine** that processes the full turn (LLM calls, tool execution, steering drain) +2. **An active turn already exists for the same session** — the message is enqueued directly into that session's **steering queue** via `enqueueSteeringMessage`. No background drain goroutine is needed +3. **Non-routable message** (e.g. `system`) — processed synchronously in the main loop + +This design enables **parallel processing of messages from different sessions** while keeping same-session messages strictly sequential. Key implications: + +- Messages from different users/channels are processed **concurrently** (up to `max_parallel_turns`) +- Messages from the same session are **serialized** — subsequent messages go to the steering queue +- Users don't need to do anything special — their messages are automatically captured as steering when the agent is busy for their session +- Audio messages are transcribed within the worker that processes the turn, so the agent receives text +- `system` inbound messages are processed immediately and do not trigger steering + +## Steering with media + +Steering messages can include `Media` refs, just like normal inbound user +messages. + +- The original `media://` refs are preserved in session history via `AddFullMessage` +- Before the next provider call, steering messages go through the normal media resolution pipeline +- Image refs are converted to data URLs for multimodal providers; non-image refs are resolved the same way as standard inbound media + +This applies both to in-turn steering and to idle-session continuation through +`Continue()`. + +## Notes + +- Steering **does not interrupt** a tool that is currently executing. It waits for the current tool to finish, then checks the queue. +- With `one-at-a-time` mode, if multiple messages are enqueued rapidly, they will be processed one per iteration. This gives the model the opportunity to react to each message individually. +- With `all` mode, all pending messages are combined into a single injection. Useful when you want the agent to receive all the context at once. +- The steering queue has a maximum capacity of 10 messages (`MaxQueueSize`). `Steer()` returns an error when the queue is full. In the bus drain path, the error is logged as a warning and the message is effectively dropped. +- Manual `Steer()` calls made outside an active turn still go to the legacy fallback queue, so older integrations keep working. diff --git a/docs/architecture/subturn.md b/docs/architecture/subturn.md new file mode 100644 index 000000000..31a56902c --- /dev/null +++ b/docs/architecture/subturn.md @@ -0,0 +1,283 @@ +# 🔄 SubTurn Mechanism + +> Back to [README](../README.md) + +## Overview + +The `SubTurn` mechanism is a core feature in PicoClaw that allows tools to spawn isolated, nested agent loops to handle complex sub-tasks. + +By using a SubTurn, an agent can break down a problem and run a separate LLM invocation in an independent, ephemeral session. This ensures that intermediate reasoning, background tasks, or sub-agent outputs do not pollute the main conversation history. + +## Core Capabilities + +- **Context Isolation**: Each SubTurn uses an `ephemeralSessionStore`. Its message history does not leak into the parent task and is destroyed upon completion. The ephemeral session holds at most **50 messages**; older messages are automatically truncated when this limit is reached. +- **Depth & Concurrency Limits**: Prevents infinite loops and resource exhaustion. + - **Maximum Depth**: Up to 3 nested levels. + - **Maximum Concurrency**: Up to 5 concurrent sub-turns per parent turn (managed via a semaphore with a 30-second timeout). +- **Context Protection**: Supports soft context limits (`MaxContextRunes`). It proactively truncates old messages (while preserving system prompts and recent context) before hitting the provider's hard context window limit. +- **Error Recovery**: Automatically detects and recovers from provider context length exceeded errors and truncation errors by compressing history and retrying. + +## Configuration (`SubTurnConfig`) + +When spawning a SubTurn, you must provide a `SubTurnConfig`: + +| Field | Type | Description | +| :--- | :--- | :--- | +| `Model` | `string` | The LLM model to use for the sub-turn (e.g., `gpt-4o-mini`). **Required.** | +| `Tools` | `[]tools.Tool` | Tools granted to the sub-turn. If empty, it inherits the parent's tools. | +| `SystemPrompt` | `string` | The task description for the sub-turn. Sent as the first user message to the LLM (not as a system prompt override). | +| `ActualSystemPrompt` | `string` | Optional explicit system prompt to replace the agent's default. Leave empty to inherit the parent agent's system prompt. | +| `MaxTokens` | `int` | Maximum tokens for the generated response. | +| `Async` | `bool` | Controls the result delivery mode (Synchronous vs. Asynchronous). | +| `Critical` | `bool` | If `true`, the sub-turn continues running even if the parent finishes gracefully. | +| `Timeout` | `time.Duration` | Maximum execution time (default: 5 minutes). | +| `MaxContextRunes`| `int` | Soft context limit. `0` = auto-calculate (75% of model's context window, recommended), `-1` = no limit (disable soft truncation, rely only on hard context error recovery), `>0` = use specified rune limit. | + +> **Note:** The `Async` flag does **not** make the call non-blocking. It only controls whether the result is also delivered to the parent's `pendingResults` channel. Both modes block the caller until the sub-turn completes. For true non-blocking execution, the caller must spawn the sub-turn in a separate goroutine. + +## Execution Modes + +### Synchronous (`Async: false`) + +This is the standard mode where the caller needs the result immediately to proceed. + +- The caller blocks until the sub-turn completes. +- The result is **only** returned directly via the function return value. +- It is **not** delivered to the parent's pending results channel. + +**Example:** +```go +cfg := agent.SubTurnConfig{ + Model: "gpt-4o-mini", + SystemPrompt: "Analyze the provided codebase...", + Async: false, +} +result, err := agent.SpawnSubTurn(ctx, cfg) +// Process result immediately +``` + +### Asynchronous (`Async: true`) + +Used for "fire-and-forget" operations or parallel processing where the parent turn collects results later. + +- The result is delivered to the parent turn's `pendingResults` channel. +- The result is **also** returned via the function return value (for consistency). +- The parent's Agent Loop will poll this channel in subsequent iterations and automatically inject the results into the ongoing conversation context as `[SubTurn Result]`. + +**Example:** +```go +cfg := agent.SubTurnConfig{ + Model: "gpt-4o-mini", + SystemPrompt: "Run a background security scan...", + Async: true, +} +result, err := agent.SpawnSubTurn(ctx, cfg) +// The result will also be injected into the parent loop later via channel +``` + +## Error Recovery and Retries + +SubTurns implement automatic retry mechanisms for transient errors: + +| Error Type | Max Retries | Recovery Action | +|:-----------|:------------|:----------------| +| Context Length Exceeded | 2 | Force compress history and retry | +| Response Truncated (`finish_reason="truncated"`) | 2 | Inject recovery prompt and retry | + +### Truncation Recovery +When the LLM response is truncated (`finish_reason="truncated"`), SubTurn automatically: +1. Detects the truncation from `turnState.lastFinishReason` +2. Injects a recovery prompt: "Your previous response was truncated due to length. Please provide a shorter, complete response..." +3. Retries up to 2 times + +### Context Error Recovery +When the provider returns a context length error (e.g., `context_length_exceeded`): +1. Force compresses the message history (drops oldest 50% of conversation) +2. Retries with the compressed context +3. Up to 2 retries before failing + +## Lifecycle and Cancellation + +SubTurns operate within an independent context but maintain a structural link to their parent `turnState`. + +### Graceful Parent Finish +When the parent task finishes naturally (`Finish(false)`): +- **Non-critical** sub-turns receive a signal to exit gracefully without throwing an error. +- **Critical** (`Critical: true`) sub-turns continue running in the background. Once finished, their results are emitted as **Orphan Results** so the data is not lost. + +### Hard Abort +When the parent task is forcefully aborted (e.g., user interrupts with `/stop`): +- A cascading cancellation is triggered, instantly terminating all child and grandchild sub-turns. +- The root turn's session history rolls back to the snapshot taken at turn start (`initialHistoryLength`), preventing dirty context. SubTurns are not affected by this rollback as they use ephemeral sessions that are discarded anyway. + +## Agent Loop Integration + +### Message Routing and Steering + +When a message enters the `Run()` loop, the agent determines whether to start a new worker or enqueue to steering: + +- If **no active turn** exists for the message's session key, the session is atomically reserved and a **worker goroutine** is spawned. The worker processes the full turn lifecycle: `processMessage` → tool execution → steering drain → `Continue` for queued messages. +- If an **active turn already exists** for the same session, the message is enqueued directly into that session's steering queue. It will be picked up by the existing worker's steering drain loop. + +This ensures that: +- Messages from **different sessions** are processed **in parallel** (up to `max_parallel_turns` concurrent workers) +- Messages from the **same session** are strictly **serialized** — they go to the steering queue and are processed sequentially within the active turn +- No background drain goroutine is needed; steering is handled by the worker itself after processing + +### Pending Result Polling + +The agent loop polls for async SubTurn results at two points per iteration: +1. **Before the LLM call**: injects any arrived results as `[SubTurn Result]` messages into the conversation context. +2. **After all tool executions**: polls again during the tool loop to catch results that arrived during tool execution. +3. **After the final iteration**: one last poll before the turn ends to avoid losing late-arriving results. + +### Turn State Tracking + +All active turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`, keyed by session key). A reservation sentinel is stored atomically via `LoadOrStore` before the worker starts, then replaced with the real `*turnState` when `runTurn` registers. This prevents a TOCTOU race where multiple messages for the same session could spawn concurrent workers. The sentinel is cleaned up by the worker's deferred cleanup. This allows `HardAbort` and `/subagents` observability commands to find and operate on active turns. + +## Runtime Event Integration + +SubTurns emit runtime events through `pkg/events` for observability and debugging: + +| Event Kind | When Emitted | Payload | +|:------|:-------------|:--------| +| `agent.subturn.spawn` | Sub-turn successfully initialized | `SubTurnSpawnPayload{AgentID, Label, ParentTurnID}` | +| `agent.subturn.end` | Sub-turn finishes (success or error) | `SubTurnEndPayload{AgentID, Status}` | +| `agent.subturn.result_delivered` | Async result successfully delivered to parent | `SubTurnResultDeliveredPayload{TargetChannel, TargetChatID, ContentLen}` | +| `agent.subturn.orphan` | Result cannot be delivered (parent finished or channel full) | `SubTurnOrphanPayload{ParentTurnID, ChildTurnID, Reason}` | + +## API Reference + +### SpawnSubTurn (Public Entry Point) + +```go +func SpawnSubTurn(ctx context.Context, cfg SubTurnConfig) (*tools.ToolResult, error) +``` + +This is the exported package-level entry point for agent-internal code (e.g., tests, direct invocations). It retrieves `AgentLoop` and `turnState` from context and delegates to the internal `spawnSubTurn`. + +**Requirements:** +- `AgentLoop` must be injected into context via `WithAgentLoop()` +- Parent `turnState` must exist in context (automatically set when called from tools) + +**Returns:** +- `*tools.ToolResult`: Contains `ForLLM` field with the sub-turn's output +- `error`: One of the defined error types or context errors + +### AgentLoopSpawner (Interface Implementation) + +```go +type AgentLoopSpawner struct { al *AgentLoop } + +func (s *AgentLoopSpawner) SpawnSubTurn(ctx context.Context, cfg tools.SubTurnConfig) (*tools.ToolResult, error) +``` + +This implements the `tools.SubTurnSpawner` interface for use by tools that need to spawn sub-turns without a direct import of the `agent` package (avoiding circular dependencies). It converts `tools.SubTurnConfig` → `agent.SubTurnConfig` before delegating to the internal `spawnSubTurn`. + +### NewSubTurnSpawner + +```go +func NewSubTurnSpawner(al *AgentLoop) *AgentLoopSpawner +``` + +Creates a new spawner instance for the given AgentLoop. Pass the returned value to `SpawnTool.SetSpawner()` or `SubagentTool.SetSpawner()` during tool registration. + +### Continue + +```go +func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error) +``` + +Resumes an idle agent turn by dequeuing steering messages for the given session and running them through the agent loop. Returns the response string if processing occurred, or empty string if no steering messages were pending. Uses session-aware active turn checking — it only blocks if a turn is active for the *same* session, not for unrelated sessions. + +## Context Propagation + +SubTurn relies on context values for proper operation: + +| Context Key | Purpose | +|:------------|:--------| +| `agentLoopKey` | Stores `*AgentLoop` for tool access and SubTurn spawning | +| `turnStateKey` | Stores `*turnState` for hierarchy tracking and result delivery | + +### Injecting Dependencies + +```go +// Before calling tools that may spawn SubTurns +ctx = WithAgentLoop(ctx, agentLoop) +ctx = withTurnState(ctx, turnState) +``` + +### Independent Child Context + +**Important**: The child SubTurn uses an **independent context** derived from `context.Background()`, not from the parent context. This design choice: + +- Allows critical SubTurns to continue after parent cancellation +- Prevents parent timeout from affecting child execution +- Child has its own timeout for self-protection (`Timeout` config or 5 minutes default) + +## Error Types + +| Error | Condition | +|:------|:----------| +| `ErrDepthLimitExceeded` | SubTurn depth exceeds 3 levels | +| `ErrInvalidSubTurnConfig` | Required field `Model` is empty | +| `ErrConcurrencyTimeout` | All 5 concurrency slots occupied for 30+ seconds | +| Context errors | Parent context cancelled during semaphore acquisition | + +## Thread Safety + +SubTurns are designed for concurrent execution: + +- **Parent-child relationships**: Managed under mutex (`parentTS.mu.Lock()`) +- **Active turn tracking**: Uses `sync.Map` for concurrent access to `activeTurnStates` +- **ID generation**: Uses `atomic.Int64` for unique SubTurn IDs (format: `subturn-N`, globally monotonic per `AgentLoop` instance) +- **Result delivery**: Reads parent state under lock, releases before channel send (small race window acceptable) + +## Orphan Results + +An orphan result occurs when: +1. Parent turn finishes before the SubTurn completes +2. The `pendingResults` channel is full (buffer size: 16) + +When a result becomes orphan: +- `agent.subturn.orphan` is emitted to the runtime event bus +- The result is **NOT** delivered to the LLM context +- External systems can listen to this event for custom handling + +### Preventing Orphan Results +- Use `Critical: true` for important SubTurns that must complete +- Monitor `agent.subturn.orphan` for observability +- Consider the 16-buffer limit when spawning many async SubTurns + +## Tool Inheritance + +### When `cfg.Tools` is empty: +- SubTurn inherits **all** tools from the parent agent +- Tools are registered in a new `ToolRegistry` instance +- Tool TTL is managed independently from parent + +### When `cfg.Tools` is specified: +- Only the specified tools are available to the SubTurn +- Parent tools are **NOT** merged +- Use this to restrict SubTurn capabilities for security or focus + +**Example - Restricted SubTurn:** +```go +cfg := agent.SubTurnConfig{ + Model: "gpt-4o-mini", + Tools: []tools.Tool{readOnlyTool}, // Only read-only access + SystemPrompt: "Analyze the file structure...", +} +``` + +## Reference + +| Constant | Value | +|:---------|:------| +| `maxSubTurnDepth` | 3 | +| `maxConcurrentSubTurns` | 5 | +| `concurrencyTimeout` | 30s | +| `defaultSubTurnTimeout` | 5m | +| `maxEphemeralHistorySize` | 50 messages | +| `pendingResults` buffer | 16 | +| `MaxContextRunes` default | 75% of model context window | diff --git a/docs/channels/dingtalk/README.fr.md b/docs/channels/dingtalk/README.fr.md new file mode 100644 index 000000000..ea0d45194 --- /dev/null +++ b/docs/channels/dingtalk/README.fr.md @@ -0,0 +1,36 @@ +> Retour au [README](../../project/README.fr.md) + +# DingTalk + +DingTalk est la plateforme de communication d'entreprise d'Alibaba, très populaire dans les milieux professionnels chinois. Elle utilise un SDK de streaming pour maintenir des connexions persistantes. + +## Configuration + +```json +{ + "channel_list": { + "dingtalk": { + "enabled": true, + "type": "dingtalk", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +| Champ | Type | Requis | Description | +| ------------- | ------ | ------ | ---------------------------------------------------------------- | +| enabled | bool | Oui | Activer ou non le canal DingTalk | +| client_id | string | Oui | Client ID de l'application DingTalk | +| client_secret | string | Oui | Client Secret de l'application DingTalk | +| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs | + +## Procédure de configuration + +1. Rendez-vous sur la [plateforme ouverte DingTalk](https://open.dingtalk.com/) +2. Créez une application interne d'entreprise +3. Obtenez le Client ID et le Client Secret depuis les paramètres de l'application +4. Configurez OAuth et les abonnements aux événements (si nécessaire) +5. Renseignez le Client ID et le Client Secret dans le fichier de configuration diff --git a/docs/channels/dingtalk/README.ja.md b/docs/channels/dingtalk/README.ja.md new file mode 100644 index 000000000..4796038f9 --- /dev/null +++ b/docs/channels/dingtalk/README.ja.md @@ -0,0 +1,36 @@ +> [README](../../project/README.ja.md) に戻る + +# DingTalk + +DingTalkはアリババの企業向けコミュニケーションプラットフォームで、中国のビジネス環境で広く利用されています。ストリーミング SDK を使用して持続的な接続を維持します。 + +## 設定 + +```json +{ + "channel_list": { + "dingtalk": { + "enabled": true, + "type": "dingtalk", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ------------- | ------ | ---- | -------------------------------------------- | +| enabled | bool | はい | DingTalk チャンネルを有効にするかどうか | +| client_id | string | はい | DingTalk アプリケーションの Client ID | +| client_secret | string | はい | DingTalk アプリケーションの Client Secret | +| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 | + +## セットアップ手順 + +1. [DingTalk オープンプラットフォーム](https://open.dingtalk.com/) にアクセスする +2. 企業内部アプリケーションを作成する +3. アプリケーション設定から Client ID と Client Secret を取得する +4. OAuth とイベントサブスクリプションを設定する(必要な場合) +5. Client ID と Client Secret を設定ファイルに入力する diff --git a/docs/channels/dingtalk/README.md b/docs/channels/dingtalk/README.md new file mode 100644 index 000000000..ed220ac63 --- /dev/null +++ b/docs/channels/dingtalk/README.md @@ -0,0 +1,36 @@ +> Back to [README](../../../README.md) + +# DingTalk + +DingTalk is Alibaba's enterprise communication platform, widely used in Chinese workplaces. It uses a streaming SDK to maintain persistent connections. + +## Configuration + +```json +{ + "channel_list": { + "dingtalk": { + "enabled": true, + "type": "dingtalk", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| ------------- | ------ | -------- | -------------------------------------------------------- | +| enabled | bool | Yes | Whether to enable the DingTalk channel | +| client_id | string | Yes | Client ID of the DingTalk application | +| client_secret | string | Yes | Client Secret of the DingTalk application | +| allow_from | array | No | User ID whitelist; empty means all users are allowed | + +## Setup + +1. Go to the [DingTalk Open Platform](https://open.dingtalk.com/) +2. Create an internal enterprise application +3. Obtain the Client ID and Client Secret from the application settings +4. Configure OAuth and event subscriptions (if needed) +5. Fill in the Client ID and Client Secret in the configuration file diff --git a/docs/channels/dingtalk/README.pt-br.md b/docs/channels/dingtalk/README.pt-br.md new file mode 100644 index 000000000..c4a3da804 --- /dev/null +++ b/docs/channels/dingtalk/README.pt-br.md @@ -0,0 +1,36 @@ +> Voltar ao [README](../../project/README.pt-br.md) + +# DingTalk + +DingTalk é a plataforma de comunicação empresarial da Alibaba, amplamente utilizada no ambiente corporativo chinês. Ela usa um SDK de streaming para manter conexões persistentes. + +## Configuração + +```json +{ + "channel_list": { + "dingtalk": { + "enabled": true, + "type": "dingtalk", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ------------- | ------ | ----------- | ---------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal DingTalk deve ser habilitado | +| client_id | string | Sim | Client ID do aplicativo DingTalk | +| client_secret | string | Sim | Client Secret do aplicativo DingTalk | +| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos | + +## Configuração passo a passo + +1. Acesse a [Plataforma Aberta DingTalk](https://open.dingtalk.com/) +2. Crie um aplicativo interno corporativo +3. Obtenha o Client ID e o Client Secret nas configurações do aplicativo +4. Configure OAuth e assinaturas de eventos (se necessário) +5. Preencha o Client ID e o Client Secret no arquivo de configuração diff --git a/docs/channels/dingtalk/README.vi.md b/docs/channels/dingtalk/README.vi.md new file mode 100644 index 000000000..83550a14e --- /dev/null +++ b/docs/channels/dingtalk/README.vi.md @@ -0,0 +1,36 @@ +> Quay lại [README](../../project/README.vi.md) + +# DingTalk + +DingTalk là nền tảng giao tiếp doanh nghiệp của Alibaba, được sử dụng rộng rãi trong môi trường làm việc tại Trung Quốc. Nền tảng này sử dụng SDK streaming để duy trì kết nối liên tục. + +## Cấu hình + +```json +{ + "channel_list": { + "dingtalk": { + "enabled": true, + "type": "dingtalk", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ------------- | ------ | -------- | ---------------------------------------------------------------- | +| enabled | bool | Có | Có bật kênh DingTalk hay không | +| client_id | string | Có | Client ID của ứng dụng DingTalk | +| client_secret | string | Có | Client Secret của ứng dụng DingTalk | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả | + +## Quy trình thiết lập + +1. Truy cập [Nền tảng mở DingTalk](https://open.dingtalk.com/) +2. Tạo một ứng dụng nội bộ doanh nghiệp +3. Lấy Client ID và Client Secret từ cài đặt ứng dụng +4. Cấu hình OAuth và đăng ký sự kiện (nếu cần) +5. Điền Client ID và Client Secret vào file cấu hình diff --git a/docs/channels/dingtalk/README.zh.md b/docs/channels/dingtalk/README.zh.md index 1e445d0b0..7c672c383 100644 --- a/docs/channels/dingtalk/README.zh.md +++ b/docs/channels/dingtalk/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../project/README.zh.md) + # 钉钉 钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。 @@ -6,9 +8,10 @@ ```json { - "channels": { + "channel_list": { "dingtalk": { "enabled": true, + "type": "dingtalk", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "allow_from": [] diff --git a/docs/channels/discord/README.fr.md b/docs/channels/discord/README.fr.md new file mode 100644 index 000000000..951eb59be --- /dev/null +++ b/docs/channels/discord/README.fr.md @@ -0,0 +1,40 @@ +> Retour au [README](../../project/README.fr.md) + +# Discord + +Discord est une application gratuite de chat vocal, vidéo et textuel conçue pour les communautés. PicoClaw se connecte aux serveurs Discord via l'API Bot Discord, avec prise en charge de la réception et de l'envoi de messages. + +## Configuration + +```json +{ + "channel_list": { + "discord": { + "enabled": true, + "type": "discord", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "group_trigger": { + "mention_only": false + } + } + } +} +``` + +| Champ | Type | Requis | Description | +| ------------- | ------ | ------ | --------------------------------------------------------------------------- | +| enabled | bool | Oui | Activer ou non le canal Discord | +| token | string | Oui | Token du bot Discord | +| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs | +| group_trigger | object | Non | Paramètres de déclenchement de groupe (exemple : { "mention_only": false }) | + +## Configuration initiale + +1. Accéder au [Portail des développeurs Discord](https://discord.com/developers/applications) et créer une nouvelle application +2. Activer les Intents : + - Message Content Intent + - Server Members Intent +3. Obtenir le Token du bot +4. Renseigner le Token du bot dans le fichier de configuration +5. Inviter le bot sur le serveur et lui accorder les permissions nécessaires (ex. envoyer des messages, lire l'historique des messages) diff --git a/docs/channels/discord/README.ja.md b/docs/channels/discord/README.ja.md new file mode 100644 index 000000000..212abc1a3 --- /dev/null +++ b/docs/channels/discord/README.ja.md @@ -0,0 +1,40 @@ +> [README](../../project/README.ja.md) に戻る + +# Discord + +Discord はコミュニティ向けに設計された無料の音声・ビデオ・テキストチャットアプリケーションです。PicoClaw は Discord Bot API を通じて Discord サーバーに接続し、メッセージの受信と送信をサポートします。 + +## 設定 + +```json +{ + "channel_list": { + "discord": { + "enabled": true, + "type": "discord", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "group_trigger": { + "mention_only": false + } + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ------------- | ------ | ------ | ----------------------------------------------------------------- | +| enabled | bool | はい | Discord チャンネルを有効にするかどうか | +| token | string | はい | Discord ボットトークン | +| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 | +| group_trigger | object | いいえ | グループトリガー設定(例: { "mention_only": false }) | + +## セットアップ手順 + +1. [Discord 開発者ポータル](https://discord.com/developers/applications) にアクセスして新しいアプリケーションを作成する +2. Intents を有効にする: + - Message Content Intent + - Server Members Intent +3. Bot トークンを取得する +4. 設定ファイルに Bot トークンを入力する +5. ボットをサーバーに招待し、必要な権限を付与する(例: メッセージの送信、メッセージ履歴の読み取りなど) diff --git a/docs/channels/discord/README.md b/docs/channels/discord/README.md new file mode 100644 index 000000000..741bc64a1 --- /dev/null +++ b/docs/channels/discord/README.md @@ -0,0 +1,70 @@ +> Back to [README](../../../README.md) + +# Discord + +Discord is a free voice, video, and text chat application designed for communities. PicoClaw connects to Discord servers via the Discord Bot API, supporting both receiving and sending messages. + +## Configuration + +```json +{ + "agents": { + "defaults": { + "tool_feedback": { + "enabled": true, + "max_args_length": 300 + } + } + }, + "channel_list": { + "discord": { + "enabled": true, + "type": "discord", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "placeholder": { + "enabled": true, + "text": ["Thinking... 💭"] + }, + "group_trigger": { + "mention_only": false + }, + "reasoning_channel_id": "" + } + } +} +``` + +| Field | Type | Required | Description | +| -------------------- | ------ | -------- | --------------------------------------------------------------------------- | +| enabled | bool | Yes | Whether to enable the Discord channel | +| token | string | Yes | Discord Bot Token | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | +| placeholder | object | No | Placeholder message config shown while the agent is working | +| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) | +| reasoning_channel_id | string | No | Optional target channel ID for reasoning/thinking output | + +## Visible Execution Feedback + +Discord can show three different kinds of "working" feedback: + +1. Typing indicator: automatic, no extra config needed. +2. Placeholder message: enable `channel_list.discord.placeholder.enabled` to send a visible `Thinking...` message that is later edited into the final reply. +3. Tool execution feedback: enable `agents.defaults.tool_feedback.enabled` to send a short message before each tool call, for example: + +```text +🔧 `web_search` +Checking the latest PicoClaw release notes before I answer. +``` + +If you only see `Bot is typing`, check that `placeholder.enabled` or `tool_feedback.enabled` is actually set in your runtime config. + +## Setup + +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) and create a new application +2. Enable Intents: + - Message Content Intent + - Server Members Intent +3. Obtain the Bot Token +4. Fill in the Bot Token in the configuration file +5. Invite the bot to your server and grant the necessary permissions (e.g. Send Messages, Read Message History) diff --git a/docs/channels/discord/README.pt-br.md b/docs/channels/discord/README.pt-br.md new file mode 100644 index 000000000..32d828b76 --- /dev/null +++ b/docs/channels/discord/README.pt-br.md @@ -0,0 +1,40 @@ +> Voltar ao [README](../../project/README.pt-br.md) + +# Discord + +Discord é um aplicativo gratuito de chat de voz, vídeo e texto projetado para comunidades. O PicoClaw se conecta a servidores Discord via Discord Bot API, com suporte para receber e enviar mensagens. + +## Configuração + +```json +{ + "channel_list": { + "discord": { + "enabled": true, + "type": "discord", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "group_trigger": { + "mention_only": false + } + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ------------- | ------ | ----------- | --------------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal Discord deve ser habilitado | +| token | string | Sim | Token do Bot Discord | +| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários | +| group_trigger | object | Não | Configurações de gatilho de grupo (exemplo: { "mention_only": false }) | + +## Configuração inicial + +1. Acesse o [Portal de Desenvolvedores do Discord](https://discord.com/developers/applications) e crie uma nova aplicação +2. Habilite os Intents: + - Message Content Intent + - Server Members Intent +3. Obtenha o Token do Bot +4. Preencha o Token do Bot no arquivo de configuração +5. Convide o bot para o servidor e conceda as permissões necessárias (ex. enviar mensagens, ler histórico de mensagens) diff --git a/docs/channels/discord/README.vi.md b/docs/channels/discord/README.vi.md new file mode 100644 index 000000000..e9ad6f5cc --- /dev/null +++ b/docs/channels/discord/README.vi.md @@ -0,0 +1,40 @@ +> Quay lại [README](../../project/README.vi.md) + +# Discord + +Discord là ứng dụng chat thoại, video và văn bản miễn phí được thiết kế cho cộng đồng. PicoClaw kết nối với máy chủ Discord qua Discord Bot API, hỗ trợ nhận và gửi tin nhắn. + +## Cấu hình + +```json +{ + "channel_list": { + "discord": { + "enabled": true, + "type": "discord", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "group_trigger": { + "mention_only": false + } + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ------------- | ------ | -------- | --------------------------------------------------------------------------- | +| enabled | bool | Có | Có bật kênh Discord hay không | +| token | string | Có | Token Bot Discord | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả | +| group_trigger | object | Không | Cài đặt kích hoạt nhóm (ví dụ: { "mention_only": false }) | + +## Hướng dẫn thiết lập + +1. Truy cập [Discord Developer Portal](https://discord.com/developers/applications) và tạo ứng dụng mới +2. Bật các Intents: + - Message Content Intent + - Server Members Intent +3. Lấy Bot Token +4. Điền Bot Token vào file cấu hình +5. Mời bot vào máy chủ và cấp các quyền cần thiết (ví dụ: gửi tin nhắn, đọc lịch sử tin nhắn) diff --git a/docs/channels/discord/README.zh.md b/docs/channels/discord/README.zh.md index 6d3c502cf..d6785ac3b 100644 --- a/docs/channels/discord/README.zh.md +++ b/docs/channels/discord/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../project/README.zh.md) + # Discord Discord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器,支持接收和发送消息。 @@ -6,9 +8,10 @@ Discord 是一个专为社区设计的免费语音、视频和文本聊天应用 ```json { - "channels": { + "channel_list": { "discord": { "enabled": true, + "type": "discord", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], "group_trigger": { diff --git a/docs/channels/feishu/README.fr.md b/docs/channels/feishu/README.fr.md new file mode 100644 index 000000000..0d82c9655 --- /dev/null +++ b/docs/channels/feishu/README.fr.md @@ -0,0 +1,53 @@ +> Retour au [README](../../project/README.fr.md) + +# Feishu + +Feishu (nom international : Lark) est une plateforme de collaboration d'entreprise de ByteDance. Elle prend en charge les marchés chinois et mondiaux via des connexions WebSocket pilotées par événements. + +## Configuration + +```json +{ + "channel_list": { + "feishu": { + "enabled": true, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +| Champ | Type | Requis | Description | +| --------------------- | ------ | ------ | --------------------------------------------------------------------------- | +| enabled | bool | Oui | Activer ou non le canal Feishu | +| app_id | string | Oui | App ID de l'application Feishu (commence par `cli_`) | +| app_secret | string | Oui | App Secret de l'application Feishu | +| encrypt_key | string | Non | Clé de chiffrement pour les callbacks d'événements | +| verification_token | string | Non | Token utilisé pour la vérification des événements Webhook | +| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs | +| random_reaction_emoji | array | Non | Liste d'emojis de réaction aléatoires ; vide utilise le "Pin" par défaut | + +## Configuration initiale + +1. Accéder à la [plateforme ouverte Feishu](https://open.feishu.cn/) et créer une application +2. Activer la capacité **Bot** dans les paramètres de l'application +3. Créer une version et publier l'application (la configuration prend effet après la publication) +4. Obtenir l'**App ID** (commence par `cli_`) et l'**App Secret** +5. Renseigner l'App ID et l'App Secret dans le fichier de configuration PicoClaw +6. Exécuter `picoclaw gateway` pour démarrer le service +7. Rechercher le nom du bot dans Feishu et commencer une conversation + +> PicoClaw se connecte à Feishu en mode WebSocket/SDK — aucune adresse de callback publique ni URL Webhook n'est requise. +> +> `encrypt_key` et `verification_token` sont optionnels ; l'activation du chiffrement des événements est recommandée pour les environnements de production. +> +> Pour les références d'emojis personnalisés, voir : [Liste des emojis Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) + +## Limitations de plateforme + +> ⚠️ **Le canal Feishu ne prend pas en charge les appareils 32 bits.** Le SDK Feishu ne fournit que des builds 64 bits. Les architectures 32 bits (armv6, armv7, mipsle, etc.) ne peuvent pas utiliser le canal Feishu. Pour la messagerie sur des appareils 32 bits, utilisez Telegram, Discord ou OneBot. diff --git a/docs/channels/feishu/README.ja.md b/docs/channels/feishu/README.ja.md new file mode 100644 index 000000000..c19e9fbec --- /dev/null +++ b/docs/channels/feishu/README.ja.md @@ -0,0 +1,53 @@ +> [README](../../project/README.ja.md) に戻る + +# 飛書(Feishu) + +飛書(国際名:Lark)は ByteDance が提供するエンタープライズコラボレーションプラットフォームです。イベント駆動型の WebSocket 接続を通じて、中国および世界市場の両方をサポートします。 + +## 設定 + +```json +{ + "channel_list": { + "feishu": { + "enabled": true, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| --------------------- | ------ | ------ | ----------------------------------------------------------------- | +| enabled | bool | はい | 飛書チャンネルを有効にするかどうか | +| app_id | string | はい | 飛書アプリケーションの App ID(`cli_` で始まる) | +| app_secret | string | はい | 飛書アプリケーションの App Secret | +| encrypt_key | string | いいえ | イベントコールバックの暗号化キー | +| verification_token | string | いいえ | Webhook イベント検証に使用するトークン | +| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 | +| random_reaction_emoji | array | いいえ | ランダムに追加する絵文字のリスト。空の場合はデフォルトの "Pin" を使用 | + +## セットアップ手順 + +1. [飛書オープンプラットフォーム](https://open.feishu.cn/) にアクセスしてアプリケーションを作成する +2. アプリケーション設定で**ボット**機能を有効にする +3. バージョンを作成してアプリケーションを公開する(公開後に設定が有効になる) +4. **App ID**(`cli_` で始まる)と **App Secret** を取得する +5. PicoClaw 設定ファイルに App ID と App Secret を入力する +6. `picoclaw gateway` を実行してサービスを起動する +7. 飛書でボット名を検索して会話を始める + +> PicoClaw は WebSocket/SDK モードで飛書に接続するため、公開コールバックアドレスや Webhook URL の設定は不要です。 +> +> `encrypt_key` と `verification_token` はオプションですが、本番環境ではイベント暗号化を有効にすることを推奨します。 +> +> カスタム絵文字の参考:[飛書絵文字リスト](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) + +## プラットフォーム制限 + +> ⚠️ **飛書チャネルは 32 ビットデバイスをサポートしていません。** 飛書 SDK は 64 ビットビルドのみ提供しています。armv6 / armv7 / mipsle などの 32 ビットアーキテクチャでは飛書チャネルを使用できません。32 ビットデバイスでのメッセージングには、Telegram、Discord、または OneBot をご利用ください。 diff --git a/docs/channels/feishu/README.md b/docs/channels/feishu/README.md new file mode 100644 index 000000000..fca71c94d --- /dev/null +++ b/docs/channels/feishu/README.md @@ -0,0 +1,53 @@ +> Back to [README](../../../README.md) + +# Feishu + +Feishu (international name: Lark) is an enterprise collaboration platform by ByteDance. It supports both Chinese and global markets through event-driven WebSocket connections. + +## Configuration + +```json +{ + "channel_list": { + "feishu": { + "enabled": true, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------------------------------------ | +| enabled | bool | Yes | Whether to enable the Feishu channel | +| app_id | string | Yes | App ID of the Feishu application (starts with `cli_`) | +| app_secret | string | Yes | App Secret of the Feishu application | +| encrypt_key | string | No | Encryption key for event callbacks | +| verification_token | string | No | Token used for Webhook event verification | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | +| random_reaction_emoji | array | No | List of random reaction emojis; empty uses the default "Pin" | + +## Setup + +1. Go to the [Feishu Open Platform](https://open.feishu.cn/) and create an application +2. Enable the **Bot** capability in the application settings +3. Create a version and publish the application (configuration takes effect only after publishing) +4. Obtain the **App ID** (starts with `cli_`) and **App Secret** +5. Fill in the App ID and App Secret in the PicoClaw configuration file +6. Run `picoclaw gateway` to start the service +7. Search for the bot name in Feishu and start a conversation + +> PicoClaw connects to Feishu using WebSocket/SDK mode — no public callback address or Webhook URL is required. +> +> `encrypt_key` and `verification_token` are optional; enabling event encryption is recommended for production environments. +> +> For custom emoji references, see: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) + +## Platform Limitations + +> ⚠️ **Feishu channel does not support 32-bit devices.** The Feishu SDK only provides 64-bit builds. Devices running armv6, armv7, mipsle, or other 32-bit architectures cannot use the Feishu channel. For messaging on 32-bit devices, use Telegram, Discord, or OneBot instead. diff --git a/docs/channels/feishu/README.pt-br.md b/docs/channels/feishu/README.pt-br.md new file mode 100644 index 000000000..73ab981e0 --- /dev/null +++ b/docs/channels/feishu/README.pt-br.md @@ -0,0 +1,53 @@ +> Voltar ao [README](../../project/README.pt-br.md) + +# Feishu + +Feishu (nome internacional: Lark) é uma plataforma de colaboração empresarial da ByteDance. Suporta os mercados chinês e global por meio de conexões WebSocket orientadas a eventos. + +## Configuração + +```json +{ + "channel_list": { + "feishu": { + "enabled": true, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| --------------------- | ------ | ----------- | -------------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal Feishu deve ser habilitado | +| app_id | string | Sim | App ID da aplicação Feishu (começa com `cli_`) | +| app_secret | string | Sim | App Secret da aplicação Feishu | +| encrypt_key | string | Não | Chave de criptografia para callbacks de eventos | +| verification_token | string | Não | Token usado para verificação de eventos Webhook | +| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários | +| random_reaction_emoji | array | Não | Lista de emojis de reação aleatórios; vazio usa o "Pin" padrão | + +## Configuração inicial + +1. Acesse a [Plataforma Aberta Feishu](https://open.feishu.cn/) e crie uma aplicação +2. Habilite a capacidade de **Bot** nas configurações da aplicação +3. Crie uma versão e publique a aplicação (a configuração entra em vigor após a publicação) +4. Obtenha o **App ID** (começa com `cli_`) e o **App Secret** +5. Preencha o App ID e o App Secret no arquivo de configuração do PicoClaw +6. Execute `picoclaw gateway` para iniciar o serviço +7. Pesquise o nome do bot no Feishu e inicie uma conversa + +> O PicoClaw se conecta ao Feishu usando o modo WebSocket/SDK — nenhum endereço de callback público ou URL de Webhook é necessário. +> +> `encrypt_key` e `verification_token` são opcionais; recomenda-se habilitar a criptografia de eventos em ambientes de produção. +> +> Para referências de emojis personalizados, consulte: [Lista de Emojis do Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) + +## Limitações de Plataforma + +> ⚠️ **O canal Feishu não suporta dispositivos 32 bits.** O SDK do Feishu fornece apenas builds 64 bits. Arquiteturas 32 bits (armv6, armv7, mipsle, etc.) não podem usar o canal Feishu. Para mensagens em dispositivos 32 bits, use Telegram, Discord ou OneBot. diff --git a/docs/channels/feishu/README.vi.md b/docs/channels/feishu/README.vi.md new file mode 100644 index 000000000..1db4c1146 --- /dev/null +++ b/docs/channels/feishu/README.vi.md @@ -0,0 +1,53 @@ +> Quay lại [README](../../project/README.vi.md) + +# Feishu + +Feishu (tên quốc tế: Lark) là nền tảng cộng tác doanh nghiệp của ByteDance. Hỗ trợ cả thị trường Trung Quốc và toàn cầu thông qua kết nối WebSocket theo hướng sự kiện. + +## Cấu hình + +```json +{ + "channel_list": { + "feishu": { + "enabled": true, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| --------------------- | ------ | -------- | ------------------------------------------------------------------------ | +| enabled | bool | Có | Có bật kênh Feishu hay không | +| app_id | string | Có | App ID của ứng dụng Feishu (bắt đầu bằng `cli_`) | +| app_secret | string | Có | App Secret của ứng dụng Feishu | +| encrypt_key | string | Không | Khóa mã hóa cho callback sự kiện | +| verification_token | string | Không | Token dùng để xác minh sự kiện Webhook | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả | +| random_reaction_emoji | array | Không | Danh sách emoji phản ứng ngẫu nhiên; để trống dùng "Pin" mặc định | + +## Hướng dẫn thiết lập + +1. Truy cập [Nền tảng Mở Feishu](https://open.feishu.cn/) và tạo ứng dụng +2. Bật khả năng **Bot** trong cài đặt ứng dụng +3. Tạo phiên bản và xuất bản ứng dụng (cấu hình có hiệu lực sau khi xuất bản) +4. Lấy **App ID** (bắt đầu bằng `cli_`) và **App Secret** +5. Điền App ID và App Secret vào file cấu hình PicoClaw +6. Chạy `picoclaw gateway` để khởi động dịch vụ +7. Tìm kiếm tên bot trong Feishu và bắt đầu trò chuyện + +> PicoClaw kết nối với Feishu bằng chế độ WebSocket/SDK — không cần cấu hình địa chỉ callback công khai hay Webhook URL. +> +> `encrypt_key` và `verification_token` là tùy chọn; nên bật mã hóa sự kiện trong môi trường sản xuất. +> +> Tham khảo emoji tùy chỉnh: [Danh sách Emoji Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce) + +## Giới hạn nền tảng + +> ⚠️ **Kênh Feishu không hỗ trợ thiết bị 32 bit.** SDK Feishu chỉ cung cấp bản build 64 bit. Các kiến trúc 32 bit (armv6, armv7, mipsle, v.v.) không thể sử dụng kênh Feishu. Để nhắn tin trên thiết bị 32 bit, hãy dùng Telegram, Discord hoặc OneBot. diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md index 3fafffb7d..afe117286 100644 --- a/docs/channels/feishu/README.zh.md +++ b/docs/channels/feishu/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../project/README.zh.md) + # 飞书 飞书(国际版名称:Lark)是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。 @@ -6,34 +8,41 @@ ```json { - "channels": { + "channel_list": { "feishu": { "enabled": true, + "type": "feishu", "app_id": "cli_xxx", "app_secret": "xxx", "encrypt_key": "", "verification_token": "", - "allow_from": [] + "allow_from": [], + "is_lark": false } } } ``` -| 字段 | 类型 | 必填 | 描述 | -| ------------------ | ------ | ---- | -------------------------------- | -| enabled | bool | 是 | 是否启用飞书频道 | -| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) | -| app_secret | string | 是 | 飞书应用的 App Secret | -| encrypt_key | string | 否 | 事件回调加密密钥 | -| verification_token | string | 否 | 用于Webhook事件验证的Token | -| allow_from | array | 否 | 用户ID白名单,空表示所有用户 | -| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" | +| 字段 | 类型 | 必填 | 描述 | +| --------------------- | ------ | ---- | ------------------------------------------------------------------------------------------------ | +| enabled | bool | 是 | 是否启用飞书频道 | +| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) | +| app_secret | string | 是 | 飞书应用的 App Secret | +| encrypt_key | string | 否 | 事件回调加密密钥 | +| verification_token | string | 否 | 用于Webhook事件验证的Token | +| allow_from | array | 否 | 用户ID白名单,空表示所有用户 | +| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" | +| is_lark | bool | 否 | 是否使用 Lark 国际版域名(`open.larksuite.com`),默认为 `false`(使用飞书域名 `open.feishu.cn`) | ## 设置流程 -1. 前往 [飞书开放平台](https://open.feishu.cn/)创建应用程序 +1. 前往 [飞书开放平台](https://open.feishu.cn/)(国际版用户请前往 [Lark 开放平台](https://open.larksuite.com/))创建应用程序 2. 获取 App ID 和 App Secret 3. 配置事件订阅和Webhook URL 4. 设置加密(可选,生产环境建议启用) 5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中 6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)) + +## 平台限制 + +> ⚠️ **飞书通道不支持 32 位设备。** 飞书官方 SDK 仅提供 64 位构建,armv6 / armv7 / mipsle 等 32 位架构无法使用飞书通道。如需在 32 位设备上接入即时通讯,请改用 Telegram、Discord 或 OneBot 等通道。 diff --git a/docs/channels/line/README.fr.md b/docs/channels/line/README.fr.md new file mode 100644 index 000000000..c37e1c3a0 --- /dev/null +++ b/docs/channels/line/README.fr.md @@ -0,0 +1,41 @@ +> Retour au [README](../../project/README.fr.md) + +# Line + +PicoClaw prend en charge LINE via l'API LINE Messaging avec des callbacks webhook. + +## Configuration + +```json +{ + "channel_list": { + "line": { + "enabled": true, + "type": "line", + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +| Champ | Type | Requis | Description | +| -------------------- | ------ | ------ | ------------------------------------------------------------------------ | +| enabled | bool | Oui | Activer ou non le canal LINE | +| channel_secret | string | Oui | Channel Secret de l'API LINE Messaging | +| channel_access_token | string | Oui | Channel Access Token de l'API LINE Messaging | +| webhook_path | string | Non | Chemin du webhook (par défaut : /webhook/line) | +| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs | + +## Procédure de configuration + +1. Rendez-vous sur la [LINE Developers Console](https://developers.line.biz/console/) et créez un fournisseur de services ainsi qu'un canal Messaging API +2. Obtenez le Channel Secret et le Channel Access Token +3. Configurez le webhook : + - LINE exige que les webhooks utilisent HTTPS. Vous devez donc déployer un serveur compatible HTTPS ou utiliser un outil de proxy inverse comme ngrok pour exposer votre serveur local sur Internet + - PicoClaw utilise un serveur HTTP Gateway partagé pour recevoir les callbacks webhook de tous les canaux, écoutant par défaut sur 127.0.0.1:18790 + - Définissez l'URL du webhook sur `https://your-domain.com/webhook/line`, puis configurez un proxy inverse de votre domaine externe vers le Gateway local (port par défaut 18790) + - Activez le webhook et vérifiez l'URL +4. Renseignez le Channel Secret et le Channel Access Token dans le fichier de configuration diff --git a/docs/channels/line/README.ja.md b/docs/channels/line/README.ja.md new file mode 100644 index 000000000..ed374c5e3 --- /dev/null +++ b/docs/channels/line/README.ja.md @@ -0,0 +1,41 @@ +> [README](../../project/README.ja.md) に戻る + +# Line + +PicoClaw は LINE Messaging API と Webhook コールバックを通じて LINE をサポートします。 + +## 設定 + +```json +{ + "channel_list": { + "line": { + "enabled": true, + "type": "line", + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| -------------------- | ------ | ------ | ------------------------------------------------------------------ | +| enabled | bool | はい | LINE チャンネルを有効にするかどうか | +| channel_secret | string | はい | LINE Messaging API の Channel Secret | +| channel_access_token | string | はい | LINE Messaging API の Channel Access Token | +| webhook_path | string | いいえ | Webhook のパス(デフォルト: /webhook/line) | +| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 | + +## セットアップ手順 + +1. [LINE Developers Console](https://developers.line.biz/console/) にアクセスし、サービスプロバイダーと Messaging API チャンネルを作成する +2. Channel Secret と Channel Access Token を取得する +3. Webhook を設定する: + - LINE は Webhook に HTTPS が必要なため、HTTPS 対応サーバーをデプロイするか、ngrok などのリバースプロキシツールを使用してローカルサーバーをインターネットに公開する必要があります + - PicoClaw は共有の Gateway HTTP サーバーを使用してすべてのチャンネルの Webhook コールバックを受信します。デフォルトのリッスンアドレスは 127.0.0.1:18790 です + - Webhook URL を `https://your-domain.com/webhook/line` に設定し、外部ドメインをローカルの Gateway(デフォルトポート 18790)にリバースプロキシする + - Webhook を有効にして URL を検証する +4. Channel Secret と Channel Access Token を設定ファイルに入力する diff --git a/docs/channels/line/README.md b/docs/channels/line/README.md new file mode 100644 index 000000000..12da74546 --- /dev/null +++ b/docs/channels/line/README.md @@ -0,0 +1,41 @@ +> Back to [README](../../../README.md) + +# Line + +PicoClaw supports LINE through the LINE Messaging API with webhook callbacks. + +## Configuration + +```json +{ + "channel_list": { + "line": { + "enabled": true, + "type": "line", + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| -------------------- | ------ | -------- | ------------------------------------------------------------------ | +| enabled | bool | Yes | Whether to enable the LINE channel | +| channel_secret | string | Yes | Channel Secret for the LINE Messaging API | +| channel_access_token | string | Yes | Channel Access Token for the LINE Messaging API | +| webhook_path | string | No | Webhook path (default: /webhook/line) | +| allow_from | array | No | User ID whitelist; empty means all users are allowed | + +## Setup + +1. Go to the [LINE Developers Console](https://developers.line.biz/console/) and create a provider and a Messaging API channel +2. Obtain the Channel Secret and Channel Access Token +3. Configure the webhook: + - LINE requires webhooks to use HTTPS, so you need to deploy a server with HTTPS support, or use a reverse proxy tool like ngrok to expose your local server to the internet + - PicoClaw uses a shared Gateway HTTP server to receive webhook callbacks for all channels, listening on 127.0.0.1:18790 by default + - Set the Webhook URL to `https://your-domain.com/webhook/line`, then reverse-proxy your external domain to the local Gateway (default port 18790) + - Enable the webhook and verify the URL +4. Fill in the Channel Secret and Channel Access Token in the configuration file diff --git a/docs/channels/line/README.pt-br.md b/docs/channels/line/README.pt-br.md new file mode 100644 index 000000000..5feea3153 --- /dev/null +++ b/docs/channels/line/README.pt-br.md @@ -0,0 +1,41 @@ +> Voltar ao [README](../../project/README.pt-br.md) + +# Line + +O PicoClaw suporta o LINE por meio da LINE Messaging API com callbacks de webhook. + +## Configuração + +```json +{ + "channel_list": { + "line": { + "enabled": true, + "type": "line", + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| -------------------- | ------ | ----------- | ---------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal LINE deve ser habilitado | +| channel_secret | string | Sim | Channel Secret da LINE Messaging API | +| channel_access_token | string | Sim | Channel Access Token da LINE Messaging API | +| webhook_path | string | Não | Caminho do webhook (padrão: /webhook/line) | +| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos | + +## Configuração passo a passo + +1. Acesse o [LINE Developers Console](https://developers.line.biz/console/) e crie um provedor de serviços e um canal Messaging API +2. Obtenha o Channel Secret e o Channel Access Token +3. Configure o webhook: + - O LINE exige que os webhooks usem HTTPS, portanto é necessário implantar um servidor com suporte a HTTPS ou usar uma ferramenta de proxy reverso como o ngrok para expor seu servidor local à internet + - O PicoClaw usa um servidor HTTP Gateway compartilhado para receber callbacks de webhook de todos os canais, escutando em 127.0.0.1:18790 por padrão + - Defina a URL do webhook como `https://your-domain.com/webhook/line` e configure um proxy reverso do seu domínio externo para o Gateway local (porta padrão 18790) + - Ative o webhook e verifique a URL +4. Preencha o Channel Secret e o Channel Access Token no arquivo de configuração diff --git a/docs/channels/line/README.vi.md b/docs/channels/line/README.vi.md new file mode 100644 index 000000000..e834610e8 --- /dev/null +++ b/docs/channels/line/README.vi.md @@ -0,0 +1,41 @@ +> Quay lại [README](../../project/README.vi.md) + +# Line + +PicoClaw hỗ trợ LINE thông qua LINE Messaging API kết hợp với webhook callback. + +## Cấu hình + +```json +{ + "channel_list": { + "line": { + "enabled": true, + "type": "line", + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| -------------------- | ------ | -------- | ---------------------------------------------------------------------- | +| enabled | bool | Có | Có bật kênh LINE hay không | +| channel_secret | string | Có | Channel Secret của LINE Messaging API | +| channel_access_token | string | Có | Channel Access Token của LINE Messaging API | +| webhook_path | string | Không | Đường dẫn webhook (mặc định: /webhook/line) | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả | + +## Quy trình thiết lập + +1. Truy cập [LINE Developers Console](https://developers.line.biz/console/) và tạo một nhà cung cấp dịch vụ cùng một kênh Messaging API +2. Lấy Channel Secret và Channel Access Token +3. Cấu hình webhook: + - LINE yêu cầu webhook phải sử dụng HTTPS, vì vậy bạn cần triển khai máy chủ hỗ trợ HTTPS hoặc dùng công cụ reverse proxy như ngrok để expose máy chủ cục bộ ra internet + - PicoClaw sử dụng máy chủ HTTP Gateway dùng chung để nhận webhook callback cho tất cả các kênh, mặc định lắng nghe tại 127.0.0.1:18790 + - Đặt Webhook URL thành `https://your-domain.com/webhook/line`, sau đó reverse proxy tên miền bên ngoài về Gateway cục bộ (cổng mặc định 18790) + - Bật webhook và xác minh URL +4. Điền Channel Secret và Channel Access Token vào file cấu hình diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md index a36f622c2..5b353de1b 100644 --- a/docs/channels/line/README.zh.md +++ b/docs/channels/line/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../project/README.zh.md) + # Line PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的支持。 @@ -6,9 +8,10 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的 ```json { - "channels": { + "channel_list": { "line": { "enabled": true, + "type": "line", "channel_secret": "YOUR_CHANNEL_SECRET", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", "webhook_path": "/webhook/line", diff --git a/docs/channels/maixcam/README.fr.md b/docs/channels/maixcam/README.fr.md new file mode 100644 index 000000000..23f8c11cc --- /dev/null +++ b/docs/channels/maixcam/README.fr.md @@ -0,0 +1,36 @@ +> Retour au [README](../../project/README.fr.md) + +# MaixCam + +MaixCam est un canal dédié à la connexion aux caméras AI Sipeed MaixCAM et MaixCAM2. Il utilise des sockets TCP pour une communication bidirectionnelle et prend en charge les scénarios de déploiement d'IA en périphérie. + +## Configuration + +```json +{ + "channel_list": { + "maixcam": { + "enabled": true, + "type": "maixcam", + "host": "0.0.0.0", + "port": 18790, + "allow_from": [] + } + } +} +``` + +| Champ | Type | Requis | Description | +| ---------- | ------ | ------ | --------------------------------------------------------------------------- | +| enabled | bool | Oui | Activer ou non le canal MaixCam | +| host | string | Oui | Adresse d'écoute du serveur TCP | +| port | int | Oui | Port d'écoute du serveur TCP | +| allow_from | array | Non | Liste blanche d'identifiants d'appareils ; vide signifie tous les appareils | + +## Cas d'utilisation + +Le canal MaixCam permet à PicoClaw de fonctionner comme backend IA pour les appareils en périphérie : + +- **Surveillance intelligente** : MaixCAM envoie des images ; PicoClaw les analyse via des modèles de vision +- **Contrôle IoT** : Les appareils envoient des données de capteurs ; PicoClaw coordonne les réponses +- **IA hors ligne** : Déployer PicoClaw sur un réseau local pour une inférence à faible latence diff --git a/docs/channels/maixcam/README.ja.md b/docs/channels/maixcam/README.ja.md new file mode 100644 index 000000000..adec19445 --- /dev/null +++ b/docs/channels/maixcam/README.ja.md @@ -0,0 +1,36 @@ +> [README](../../project/README.ja.md) に戻る + +# MaixCam + +MaixCam は、Sipeed MaixCAM および MaixCAM2 AI カメラデバイスへの接続専用チャンネルです。TCP ソケットを使用した双方向通信を実装し、エッジ AI デプロイメントシナリオをサポートします。 + +## 設定 + +```json +{ + "channel_list": { + "maixcam": { + "enabled": true, + "type": "maixcam", + "host": "0.0.0.0", + "port": 18790, + "allow_from": [] + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ---------- | ------ | ------ | ------------------------------------------------------------- | +| enabled | bool | はい | MaixCam チャンネルを有効にするかどうか | +| host | string | はい | TCP サーバーのリッスンアドレス | +| port | int | はい | TCP サーバーのリッスンポート | +| allow_from | array | いいえ | 許可するデバイスIDのリスト。空の場合はすべてのデバイスを許可 | + +## ユースケース + +MaixCam チャンネルにより、PicoClaw はエッジデバイスの AI バックエンドとして機能できます: + +- **スマート監視**:MaixCAM が画像フレームを送信し、PicoClaw がビジョンモデルで分析する +- **IoT 制御**:デバイスがセンサーデータを送信し、PicoClaw がレスポンスを調整する +- **オフライン AI**:ローカルネットワークに PicoClaw をデプロイして低遅延推論を実現する diff --git a/docs/channels/maixcam/README.md b/docs/channels/maixcam/README.md new file mode 100644 index 000000000..f5efe53a4 --- /dev/null +++ b/docs/channels/maixcam/README.md @@ -0,0 +1,36 @@ +> Back to [README](../../../README.md) + +# MaixCam + +MaixCam is a dedicated channel for connecting to Sipeed MaixCAM and MaixCAM2 AI camera devices. It uses TCP sockets for bidirectional communication and supports edge AI deployment scenarios. + +## Configuration + +```json +{ + "channel_list": { + "maixcam": { + "enabled": true, + "type": "maixcam", + "host": "0.0.0.0", + "port": 18790, + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| ---------- | ------ | -------- | ---------------------------------------------------------------- | +| enabled | bool | Yes | Whether to enable the MaixCam channel | +| host | string | Yes | TCP server listening address | +| port | int | Yes | TCP server listening port | +| allow_from | array | No | Allowlist of device IDs; empty means all devices are allowed | + +## Use Cases + +The MaixCam channel enables PicoClaw to act as an AI backend for edge devices: + +- **Smart Surveillance**: MaixCAM sends image frames; PicoClaw analyzes them using vision models +- **IoT Control**: Devices send sensor data; PicoClaw coordinates responses +- **Offline AI**: Deploy PicoClaw on a local network for low-latency inference diff --git a/docs/channels/maixcam/README.pt-br.md b/docs/channels/maixcam/README.pt-br.md new file mode 100644 index 000000000..dd606ff53 --- /dev/null +++ b/docs/channels/maixcam/README.pt-br.md @@ -0,0 +1,36 @@ +> Voltar ao [README](../../project/README.pt-br.md) + +# MaixCam + +MaixCam é um canal dedicado para conectar dispositivos de câmera AI Sipeed MaixCAM e MaixCAM2. Utiliza sockets TCP para comunicação bidirecional e suporta cenários de implantação de IA na borda. + +## Configuração + +```json +{ + "channel_list": { + "maixcam": { + "enabled": true, + "type": "maixcam", + "host": "0.0.0.0", + "port": 18790, + "allow_from": [] + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ---------- | ------ | ----------- | -------------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal MaixCam deve ser habilitado | +| host | string | Sim | Endereço de escuta do servidor TCP | +| port | int | Sim | Porta de escuta do servidor TCP | +| allow_from | array | Não | Lista de IDs de dispositivos permitidos; vazio significa todos os dispositivos | + +## Casos de uso + +O canal MaixCam permite que o PicoClaw atue como backend de IA para dispositivos de borda: + +- **Vigilância inteligente**: MaixCAM envia quadros de imagem; PicoClaw os analisa usando modelos de visão +- **Controle IoT**: Dispositivos enviam dados de sensores; PicoClaw coordena as respostas +- **IA offline**: Implante o PicoClaw em uma rede local para inferência de baixa latência diff --git a/docs/channels/maixcam/README.vi.md b/docs/channels/maixcam/README.vi.md new file mode 100644 index 000000000..09aba3540 --- /dev/null +++ b/docs/channels/maixcam/README.vi.md @@ -0,0 +1,36 @@ +> Quay lại [README](../../project/README.vi.md) + +# MaixCam + +MaixCam là kênh chuyên dụng để kết nối với các thiết bị camera AI Sipeed MaixCAM và MaixCAM2. Sử dụng TCP socket để giao tiếp hai chiều và hỗ trợ các kịch bản triển khai AI tại biên. + +## Cấu hình + +```json +{ + "channel_list": { + "maixcam": { + "enabled": true, + "type": "maixcam", + "host": "0.0.0.0", + "port": 18790, + "allow_from": [] + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ---------- | ------ | -------- | ------------------------------------------------------------------------ | +| enabled | bool | Có | Có bật kênh MaixCam hay không | +| host | string | Có | Địa chỉ lắng nghe của máy chủ TCP | +| port | int | Có | Cổng lắng nghe của máy chủ TCP | +| allow_from | array | Không | Danh sách trắng ID thiết bị; để trống nghĩa là cho phép tất cả thiết bị | + +## Trường hợp sử dụng + +Kênh MaixCam cho phép PicoClaw hoạt động như backend AI cho các thiết bị biên: + +- **Giám sát thông minh**: MaixCAM gửi khung hình ảnh; PicoClaw phân tích bằng mô hình thị giác +- **Điều khiển IoT**: Thiết bị gửi dữ liệu cảm biến; PicoClaw điều phối phản hồi +- **AI ngoại tuyến**: Triển khai PicoClaw trên mạng nội bộ để suy luận độ trễ thấp diff --git a/docs/channels/maixcam/README.zh.md b/docs/channels/maixcam/README.zh.md index 8d53d4bef..2b4fdb87a 100644 --- a/docs/channels/maixcam/README.zh.md +++ b/docs/channels/maixcam/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../project/README.zh.md) + # MaixCam MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的通道。它采用 TCP 套接字实现双向通信,支持边缘 AI 部署场景。 @@ -6,21 +8,24 @@ MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的 ```json { - "channels": { + "channel_list": { "maixcam": { "enabled": true, - "server_address": "0.0.0.0:8899", + "type": "maixcam", + "host": "0.0.0.0", + "port": 18790, "allow_from": [] } } } ``` -| 字段 | 类型 | 必填 | 描述 | -| -------------- | ------ | ---- | -------------------------------- | -| enabled | bool | 是 | 是否启用 MaixCam 频道 | -| server_address | string | 是 | TCP 服务器监听地址和端口 | -| allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 | +| 字段 | 类型 | 必填 | 描述 | +| ---------- | ------ | ---- | -------------------------------- | +| enabled | bool | 是 | 是否启用 MaixCam 频道 | +| host | string | 是 | TCP 服务器监听地址 | +| port | int | 是 | TCP 服务器监听端口 | +| allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 | ## 使用场景 diff --git a/docs/channels/matrix/README.fr.md b/docs/channels/matrix/README.fr.md new file mode 100644 index 000000000..5ff329a28 --- /dev/null +++ b/docs/channels/matrix/README.fr.md @@ -0,0 +1,65 @@ +> Retour au [README](../../project/README.fr.md) + +# Guide de configuration du canal Matrix + +## 1. Exemple de configuration + +Ajoutez ceci à `config.json` : + +```json +{ + "channel_list": { + "matrix": { + "enabled": true, + "type": "matrix", + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "device_id": "", + "join_on_invite": true, + "allow_from": [], + "group_trigger": { + "mention_only": true + }, + "placeholder": { + "enabled": true, + "text": "Thinking..." + }, + "reasoning_channel_id": "", + "message_format": "richtext" + } + } +} +``` + +## 2. Référence des champs + +| Champ | Type | Requis | Description | +|----------------------|----------|--------|-------------| +| enabled | bool | Oui | Activer ou désactiver le canal Matrix | +| homeserver | string | Oui | URL du homeserver Matrix (par exemple `https://matrix.org`) | +| user_id | string | Oui | ID utilisateur Matrix du bot (par exemple `@bot:matrix.org`) | +| access_token | string | Oui | Jeton d'accès du bot | +| device_id | string | Non | ID d'appareil Matrix optionnel | +| join_on_invite | bool | Non | Rejoindre automatiquement les salons invités | +| allow_from | []string | Non | Liste blanche d'utilisateurs (IDs Matrix) | +| group_trigger | object | Non | Stratégie de déclenchement de groupe (`mention_only` / `prefixes`) | +| placeholder | object | Non | Configuration du message de remplacement | +| reasoning_channel_id | string | Non | Canal cible pour la sortie de raisonnement | +| message_format | string | Non | Format de sortie : `"richtext"` (défaut) rend le markdown en HTML ; `"plain"` envoie du texte brut uniquement | + +## 3. Fonctionnalités actuellement supportées + +- Envoi/réception de messages texte avec rendu markdown (gras, italique, titres, blocs de code, etc.) +- Format de message configurable (`richtext` / `plain`) +- Téléchargement d'images/audio/vidéo/fichiers entrants (MediaStore en priorité, chemin local en secours) +- Normalisation de l'audio entrant dans le flux de transcription existant (`[audio: ...]`) +- Upload et envoi d'images/audio/vidéo/fichiers sortants +- Règles de déclenchement de groupe (y compris le mode mention uniquement) +- État de frappe (`m.typing`) +- Message de remplacement + remplacement de la réponse finale +- Rejoindre automatiquement les salons invités (peut être désactivé) + +## 4. TODO + +- Améliorations des métadonnées des médias riches (par exemple taille et miniatures des images/vidéos) diff --git a/docs/channels/matrix/README.ja.md b/docs/channels/matrix/README.ja.md new file mode 100644 index 000000000..adb14a1f9 --- /dev/null +++ b/docs/channels/matrix/README.ja.md @@ -0,0 +1,65 @@ +> [README](../../project/README.ja.md) に戻る + +# Matrix チャンネル設定ガイド + +## 1. 設定例 + +`config.json` に以下を追加してください: + +```json +{ + "channel_list": { + "matrix": { + "enabled": true, + "type": "matrix", + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "device_id": "", + "join_on_invite": true, + "allow_from": [], + "group_trigger": { + "mention_only": true + }, + "placeholder": { + "enabled": true, + "text": "Thinking..." + }, + "reasoning_channel_id": "", + "message_format": "richtext" + } + } +} +``` + +## 2. フィールドリファレンス + +| フィールド | 型 | 必須 | 説明 | +|----------------------|----------|------|------| +| enabled | bool | はい | Matrix チャンネルの有効/無効 | +| homeserver | string | はい | Matrix ホームサーバー URL(例:`https://matrix.org`) | +| user_id | string | はい | ボットの Matrix ユーザー ID(例:`@bot:matrix.org`) | +| access_token | string | はい | ボットのアクセストークン | +| device_id | string | いいえ | オプションの Matrix デバイス ID | +| join_on_invite | bool | いいえ | 招待されたルームに自動参加 | +| allow_from | []string | いいえ | ユーザーホワイトリスト(Matrix ユーザー ID) | +| group_trigger | object | いいえ | グループトリガー戦略(`mention_only` / `prefixes`) | +| placeholder | object | いいえ | プレースホルダーメッセージ設定 | +| reasoning_channel_id | string | いいえ | 推論出力のターゲットチャンネル | +| message_format | string | いいえ | 出力形式:`"richtext"`(デフォルト)は markdown を HTML としてレンダリング;`"plain"` はプレーンテキストのみ送信 | + +## 3. 現在サポートされている機能 + +- markdown レンダリング付きテキストメッセージ送受信(太字、斜体、見出し、コードブロックなど) +- 設定可能なメッセージ形式(`richtext` / `plain`) +- 受信画像/音声/動画/ファイルのダウンロード(MediaStore 優先、ローカルパスフォールバック) +- 受信音声の既存文字起こしフローへの正規化(`[audio: ...]`) +- 送信画像/音声/動画/ファイルのアップロードと送信 +- グループトリガールール(メンションのみモードを含む) +- タイピング状態(`m.typing`) +- プレースホルダーメッセージ + 最終返信の置き換え +- 招待されたルームへの自動参加(無効化可能) + +## 4. TODO + +- リッチメディアメタデータの改善(例:画像/動画のサイズとサムネイル) diff --git a/docs/channels/matrix/README.md b/docs/channels/matrix/README.md index c213aa80b..0239928bc 100644 --- a/docs/channels/matrix/README.md +++ b/docs/channels/matrix/README.md @@ -1,3 +1,5 @@ +> Back to [README](../../../README.md) + # Matrix Channel Configuration Guide ## 1. Example Configuration @@ -6,9 +8,10 @@ Add this to `config.json`: ```json { - "channels": { + "channel_list": { "matrix": { "enabled": true, + "type": "matrix", "homeserver": "https://matrix.org", "user_id": "@your-bot:matrix.org", "access_token": "YOUR_MATRIX_ACCESS_TOKEN", @@ -20,9 +23,12 @@ Add this to `config.json`: }, "placeholder": { "enabled": true, - "text": "Thinking..." + "text": ["Thinking...", "Processing...", "Typing..."] }, - "reasoning_channel_id": "" + "reasoning_channel_id": "", + "message_format": "richtext", + "crypto_database_path": "", + "crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY" } } } @@ -40,12 +46,23 @@ Add this to `config.json`: | join_on_invite | bool | No | Auto-join invited rooms | | allow_from | []string | No | User whitelist (Matrix user IDs) | | group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) | -| placeholder | object | No | Placeholder message config | +| placeholder | object | No | Placeholder message config (see below) | | reasoning_channel_id | string | No | Target channel for reasoning output | +| message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only | +| crypto_database_path | string | No | Path to store the crypto database (uses workspace path `~/.picoclaw/workspace` if empty) | +| crypto_passphrase | string | No | Serialization key for encrypting session keys in the database; must remain unchanged once set | + +### Placeholder Config + +| Field | Type | Required | Description | +|---------|----------------|----------|-------------| +| enabled | bool | No | Enable placeholder messages (default: false) | +| text | string/[]string | No | Placeholder text(s). Can be a single string or array of strings. If multiple texts are provided, one is randomly selected at runtime. Default: "Thinking..." | ## 3. Currently Supported -- Text message send/receive +- Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.) +- Configurable message format (`richtext` / `plain`) - Incoming image/audio/video/file download (MediaStore first, local path fallback) - Incoming audio normalization into existing transcription flow (`[audio: ...]`) - Outgoing image/audio/video/file upload and send @@ -53,6 +70,7 @@ Add this to `config.json`: - Typing state (`m.typing`) - Placeholder message + final reply replacement - Auto-join invited rooms (can be disabled) +- End-to-end encryption (E2EE) support for encrypted messages ## 4. TODO diff --git a/docs/channels/matrix/README.pt-br.md b/docs/channels/matrix/README.pt-br.md new file mode 100644 index 000000000..4f606f3ed --- /dev/null +++ b/docs/channels/matrix/README.pt-br.md @@ -0,0 +1,65 @@ +> Voltar ao [README](../../project/README.pt-br.md) + +# Guia de Configuração do Canal Matrix + +## 1. Exemplo de Configuração + +Adicione isto ao `config.json`: + +```json +{ + "channel_list": { + "matrix": { + "enabled": true, + "type": "matrix", + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "device_id": "", + "join_on_invite": true, + "allow_from": [], + "group_trigger": { + "mention_only": true + }, + "placeholder": { + "enabled": true, + "text": "Thinking..." + }, + "reasoning_channel_id": "", + "message_format": "richtext" + } + } +} +``` + +## 2. Referência de Campos + +| Campo | Tipo | Obrigatório | Descrição | +|----------------------|----------|-------------|-----------| +| enabled | bool | Sim | Habilitar ou desabilitar o canal Matrix | +| homeserver | string | Sim | URL do homeserver Matrix (por exemplo `https://matrix.org`) | +| user_id | string | Sim | ID de usuário Matrix do bot (por exemplo `@bot:matrix.org`) | +| access_token | string | Sim | Token de acesso do bot | +| device_id | string | Não | ID de dispositivo Matrix opcional | +| join_on_invite | bool | Não | Entrar automaticamente em salas convidadas | +| allow_from | []string | Não | Lista branca de usuários (IDs Matrix) | +| group_trigger | object | Não | Estratégia de gatilho de grupo (`mention_only` / `prefixes`) | +| placeholder | object | Não | Configuração de mensagem de espaço reservado | +| reasoning_channel_id | string | Não | Canal alvo para saída de raciocínio | +| message_format | string | Não | Formato de saída: `"richtext"` (padrão) renderiza markdown como HTML; `"plain"` envia apenas texto simples | + +## 3. Suporte Atual + +- Envio/recebimento de mensagens de texto com renderização markdown (negrito, itálico, cabeçalhos, blocos de código, etc.) +- Formato de mensagem configurável (`richtext` / `plain`) +- Download de imagens/áudio/vídeo/arquivos recebidos (MediaStore primeiro, fallback para caminho local) +- Normalização de áudio recebido no fluxo de transcrição existente (`[audio: ...]`) +- Upload e envio de imagens/áudio/vídeo/arquivos de saída +- Regras de gatilho de grupo (incluindo modo somente menção) +- Estado de digitação (`m.typing`) +- Mensagem de espaço reservado + substituição de resposta final +- Entrada automática em salas convidadas (pode ser desabilitado) + +## 4. TODO + +- Melhorias nos metadados de mídia rica (por exemplo tamanho e miniaturas de imagens/vídeos) diff --git a/docs/channels/matrix/README.vi.md b/docs/channels/matrix/README.vi.md new file mode 100644 index 000000000..27f2ce746 --- /dev/null +++ b/docs/channels/matrix/README.vi.md @@ -0,0 +1,65 @@ +> Quay lại [README](../../project/README.vi.md) + +# Hướng dẫn Cấu hình Kênh Matrix + +## 1. Cấu hình Mẫu + +Thêm vào `config.json`: + +```json +{ + "channel_list": { + "matrix": { + "enabled": true, + "type": "matrix", + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "device_id": "", + "join_on_invite": true, + "allow_from": [], + "group_trigger": { + "mention_only": true + }, + "placeholder": { + "enabled": true, + "text": "Thinking..." + }, + "reasoning_channel_id": "", + "message_format": "richtext" + } + } +} +``` + +## 2. Tham chiếu Trường + +| Trường | Kiểu | Bắt buộc | Mô tả | +|----------------------|----------|----------|-------| +| enabled | bool | Có | Bật hoặc tắt kênh Matrix | +| homeserver | string | Có | URL homeserver Matrix (ví dụ `https://matrix.org`) | +| user_id | string | Có | ID người dùng Matrix của bot (ví dụ `@bot:matrix.org`) | +| access_token | string | Có | Token truy cập của bot | +| device_id | string | Không | ID thiết bị Matrix tùy chọn | +| join_on_invite | bool | Không | Tự động tham gia phòng được mời | +| allow_from | []string | Không | Danh sách trắng người dùng (ID Matrix) | +| group_trigger | object | Không | Chiến lược kích hoạt nhóm (`mention_only` / `prefixes`) | +| placeholder | object | Không | Cấu hình tin nhắn giữ chỗ | +| reasoning_channel_id | string | Không | Kênh đích cho đầu ra suy luận | +| message_format | string | Không | Định dạng đầu ra: `"richtext"` (mặc định) render markdown thành HTML; `"plain"` chỉ gửi văn bản thuần | + +## 3. Tính năng Hiện tại + +- Gửi/nhận tin nhắn văn bản với render markdown (đậm, nghiêng, tiêu đề, khối code, v.v.) +- Định dạng tin nhắn có thể cấu hình (`richtext` / `plain`) +- Tải xuống hình ảnh/âm thanh/video/tệp đến (MediaStore trước, fallback đường dẫn cục bộ) +- Chuẩn hóa âm thanh đến vào luồng phiên âm hiện có (`[audio: ...]`) +- Tải lên và gửi hình ảnh/âm thanh/video/tệp đi +- Quy tắc kích hoạt nhóm (bao gồm chế độ chỉ đề cập) +- Trạng thái đang gõ (`m.typing`) +- Tin nhắn giữ chỗ + thay thế phản hồi cuối cùng +- Tự động tham gia phòng được mời (có thể tắt) + +## 4. TODO + +- Cải thiện metadata phương tiện phong phú (ví dụ kích thước và hình thu nhỏ hình ảnh/video) diff --git a/docs/channels/matrix/README.zh.md b/docs/channels/matrix/README.zh.md index efbc13093..97634e2e6 100644 --- a/docs/channels/matrix/README.zh.md +++ b/docs/channels/matrix/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../project/README.zh.md) + # Matrix 通道配置指南 ## 1. 配置示例 @@ -6,9 +8,10 @@ ```json { - "channels": { + "channel_list": { "matrix": { "enabled": true, + "type": "matrix", "homeserver": "https://matrix.org", "user_id": "@your-bot:matrix.org", "access_token": "YOUR_MATRIX_ACCESS_TOKEN", @@ -20,9 +23,12 @@ }, "placeholder": { "enabled": true, - "text": "Thinking... 💭" + "text": ["Thinking...", "Processing...", "Typing..."] }, - "reasoning_channel_id": "" + "reasoning_channel_id": "", + "message_format": "richtext", + "crypto_database_path": "", + "crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY" } } } @@ -42,6 +48,16 @@ | group_trigger | object | 否 | 群聊触发策略(支持 `mention_only` / `prefixes`) | | placeholder | object | 否 | 占位消息配置 | | reasoning_channel_id | string | 否 | 思维链输出目标通道 | +| message_format | string | 否 | 消息格式:`richtext`(富文本)或 `plain`(纯文本) | +| crypto_database_path | string | 否 | 加密数据库存储路径(为空时使用工作空间路径 `~/.picoclaw/workspace`) | +| crypto_passphrase | string | 否 | 加密数据库中 session key 的序列化密钥;设置后不能更改 | + +### 占位消息配置 (Placeholder) + +| 字段 | 类型 | 必填 | 说明 | +|---------|-----------------|------|------| +| enabled | bool | 否 | 是否启用占位消息(默认:false) | +| text | string/[]string | 否 | 占位文本。可以是单个字符串或字符串数组。如果提供多个文本,运行时会随机选择一个。默认:"Thinking..." | ## 3. 当前支持 @@ -53,6 +69,7 @@ - Typing 状态(`m.typing`) - 占位消息(`Thinking... 💭`)+ 最终回复替换 - 自动加入邀请房间(可关闭) +- 端对端加密(E2EE)消息支持 ## 4. TODO diff --git a/docs/channels/onebot/README.fr.md b/docs/channels/onebot/README.fr.md new file mode 100644 index 000000000..8a2aec8d2 --- /dev/null +++ b/docs/channels/onebot/README.fr.md @@ -0,0 +1,34 @@ +> Retour au [README](../../project/README.fr.md) + +# OneBot + +OneBot est un standard de protocole ouvert pour les bots QQ, fournissant une interface unifiée pour diverses implémentations de bots QQ (par exemple go-cqhttp, Mirai). Il utilise WebSocket pour la communication. + +## Configuration + +```json +{ + "channel_list": { + "onebot": { + "enabled": true, + "type": "onebot", + "ws_url": "ws://localhost:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Champ | Type | Requis | Description | +| ------------ | ------ | ------ | -------------------------------------------------------------------- | +| enabled | bool | Oui | Activer ou non le canal OneBot | +| ws_url | string | Oui | URL WebSocket du serveur OneBot | +| access_token | string | Non | Jeton d'accès pour la connexion au serveur OneBot | +| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs | + +## Procédure de configuration + +1. Déployez une implémentation compatible OneBot (par exemple napcat) +2. Configurez l'implémentation OneBot pour activer le service WebSocket et définir un jeton d'accès (si nécessaire) +3. Renseignez l'URL WebSocket et le jeton d'accès dans le fichier de configuration diff --git a/docs/channels/onebot/README.ja.md b/docs/channels/onebot/README.ja.md new file mode 100644 index 000000000..d2616e582 --- /dev/null +++ b/docs/channels/onebot/README.ja.md @@ -0,0 +1,34 @@ +> [README](../../project/README.ja.md) に戻る + +# OneBot + +OneBot は QQ ボット向けのオープンプロトコル標準で、複数の QQ ボット実装(例: go-cqhttp、Mirai)に統一されたインターフェースを提供します。通信には WebSocket を使用します。 + +## 設定 + +```json +{ + "channel_list": { + "onebot": { + "enabled": true, + "type": "onebot", + "ws_url": "ws://localhost:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ------------ | ------ | ------ | ---------------------------------------------------------------- | +| enabled | bool | はい | OneBot チャンネルを有効にするかどうか | +| ws_url | string | はい | OneBot サーバーの WebSocket URL | +| access_token | string | いいえ | OneBot サーバーへの接続に使用するアクセストークン | +| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 | + +## セットアップ手順 + +1. OneBot 互換の実装(例: napcat)をデプロイする +2. OneBot 実装で WebSocket サービスを有効にし、アクセストークンを設定する(必要な場合) +3. WebSocket URL とアクセストークンを設定ファイルに入力する diff --git a/docs/channels/onebot/README.md b/docs/channels/onebot/README.md new file mode 100644 index 000000000..7dd1e3c88 --- /dev/null +++ b/docs/channels/onebot/README.md @@ -0,0 +1,34 @@ +> Back to [README](../../../README.md) + +# OneBot + +OneBot is an open protocol standard for QQ bots, providing a unified interface for various QQ bot implementations (e.g. go-cqhttp, Mirai). It uses WebSocket for communication. + +## Configuration + +```json +{ + "channel_list": { + "onebot": { + "enabled": true, + "type": "onebot", + "ws_url": "ws://localhost:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ---------------------------------------------------------------- | +| enabled | bool | Yes | Whether to enable the OneBot channel | +| ws_url | string | Yes | WebSocket URL of the OneBot server | +| access_token | string | No | Access token for connecting to the OneBot server | +| allow_from | array | No | User ID whitelist; empty means all users are allowed | + +## Setup + +1. Deploy a OneBot-compatible implementation (e.g. napcat) +2. Configure the OneBot implementation to enable the WebSocket service and set an access token (if needed) +3. Fill in the WebSocket URL and access token in the configuration file diff --git a/docs/channels/onebot/README.pt-br.md b/docs/channels/onebot/README.pt-br.md new file mode 100644 index 000000000..2e037361f --- /dev/null +++ b/docs/channels/onebot/README.pt-br.md @@ -0,0 +1,34 @@ +> Voltar ao [README](../../project/README.pt-br.md) + +# OneBot + +OneBot é um padrão de protocolo aberto para bots QQ, fornecendo uma interface unificada para diversas implementações de bots QQ (ex.: go-cqhttp, Mirai). Utiliza WebSocket para comunicação. + +## Configuração + +```json +{ + "channel_list": { + "onebot": { + "enabled": true, + "type": "onebot", + "ws_url": "ws://localhost:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ------------ | ------ | ----------- | -------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal OneBot deve ser habilitado | +| ws_url | string | Sim | URL WebSocket do servidor OneBot | +| access_token | string | Não | Token de acesso para conexão ao servidor OneBot | +| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos | + +## Configuração passo a passo + +1. Implante uma implementação compatível com OneBot (ex.: napcat) +2. Configure a implementação OneBot para habilitar o serviço WebSocket e definir um token de acesso (se necessário) +3. Preencha a URL WebSocket e o token de acesso no arquivo de configuração diff --git a/docs/channels/onebot/README.vi.md b/docs/channels/onebot/README.vi.md new file mode 100644 index 000000000..3dfcf8161 --- /dev/null +++ b/docs/channels/onebot/README.vi.md @@ -0,0 +1,34 @@ +> Quay lại [README](../../project/README.vi.md) + +# OneBot + +OneBot là tiêu chuẩn giao thức mở dành cho bot QQ, cung cấp giao diện thống nhất cho nhiều triển khai bot QQ khác nhau (ví dụ: go-cqhttp, Mirai). Nó sử dụng WebSocket để giao tiếp. + +## Cấu hình + +```json +{ + "channel_list": { + "onebot": { + "enabled": true, + "type": "onebot", + "ws_url": "ws://localhost:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ------------ | ------ | -------- | -------------------------------------------------------------------- | +| enabled | bool | Có | Có bật kênh OneBot hay không | +| ws_url | string | Có | URL WebSocket của máy chủ OneBot | +| access_token | string | Không | Token truy cập để kết nối với máy chủ OneBot | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả | + +## Quy trình thiết lập + +1. Triển khai một bản triển khai tương thích OneBot (ví dụ: napcat) +2. Cấu hình bản triển khai OneBot để bật dịch vụ WebSocket và đặt token truy cập (nếu cần) +3. Điền URL WebSocket và token truy cập vào file cấu hình diff --git a/docs/channels/onebot/README.zh.md b/docs/channels/onebot/README.zh.md index 6195f1c98..4e5210b82 100644 --- a/docs/channels/onebot/README.zh.md +++ b/docs/channels/onebot/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../project/README.zh.md) + # OneBot OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器人实现(例如 go-cqhttp、Mirai)提供了统一的接口。它使用 WebSocket 进行通信。 @@ -6,9 +8,10 @@ OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器 ```json { - "channels": { + "channel_list": { "onebot": { "enabled": true, + "type": "onebot", "ws_url": "ws://localhost:8080", "access_token": "", "allow_from": [] diff --git a/docs/channels/qq/README.fr.md b/docs/channels/qq/README.fr.md new file mode 100644 index 000000000..2202fa09d --- /dev/null +++ b/docs/channels/qq/README.fr.md @@ -0,0 +1,55 @@ +> Retour au [README](../../project/README.fr.md) + +# QQ + +PicoClaw prend en charge QQ via l'API Bot officielle de la plateforme ouverte QQ. + +## Configuration + +```json +{ + "channel_list": { + "qq": { + "enabled": true, + "type": "qq", + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +| Champ | Type | Requis | Description | +| ---------- | ------ | ------ | --------------------------------------------------------------------------- | +| enabled | bool | Oui | Activer ou non le canal QQ | +| app_id | string | Oui | App ID de l'application bot QQ | +| app_secret | string | Oui | App Secret de l'application bot QQ | +| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs | + +## Configuration initiale + +### Configuration rapide (recommandée) + +La plateforme ouverte QQ propose une entrée de création en un clic : + +1. Ouvrir [QQ Bot Quick Create](https://q.qq.com/qqbot/openclaw/index.html) et se connecter en scannant le QR code +2. Le système crée automatiquement un bot — copier l'**App ID** et l'**App Secret** +3. Renseigner les identifiants dans le fichier de configuration PicoClaw +4. Exécuter `picoclaw gateway` pour démarrer le service +5. Ouvrir QQ et commencer à discuter avec le bot + +> L'App Secret n'est affiché qu'une seule fois — sauvegardez-le immédiatement. Le consulter à nouveau forcera une réinitialisation. +> +> Les bots créés via l'entrée rapide sont réservés à l'usage personnel du créateur et ne prennent pas en charge les discussions de groupe. Pour la prise en charge des groupes, configurez le mode sandbox sur la [plateforme ouverte QQ](https://q.qq.com/). + +### Configuration manuelle + +1. Se connecter à la [plateforme ouverte QQ](https://q.qq.com/) avec son compte QQ et s'inscrire en tant que développeur +2. Créer un bot QQ et personnaliser son avatar et son nom +3. Obtenir l'**App ID** et l'**App Secret** dans les paramètres du bot +4. Renseigner les identifiants dans le fichier de configuration PicoClaw +5. Exécuter `picoclaw gateway` pour démarrer le service +6. Rechercher votre bot dans QQ et commencer à discuter + +> Pendant le développement, il est recommandé d'activer le mode sandbox et d'y ajouter les utilisateurs et groupes de test pour le débogage. diff --git a/docs/channels/qq/README.ja.md b/docs/channels/qq/README.ja.md new file mode 100644 index 000000000..d9e86a061 --- /dev/null +++ b/docs/channels/qq/README.ja.md @@ -0,0 +1,55 @@ +> [README](../../project/README.ja.md) に戻る + +# QQ + +PicoClaw は QQ オープンプラットフォームの公式 Bot API を通じて QQ をサポートします。 + +## 設定 + +```json +{ + "channel_list": { + "qq": { + "enabled": true, + "type": "qq", + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ---------- | ------ | ------ | ------------------------------------------------------------- | +| enabled | bool | はい | QQ チャンネルを有効にするかどうか | +| app_id | string | はい | QQ ボットアプリケーションの App ID | +| app_secret | string | はい | QQ ボットアプリケーションの App Secret | +| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 | + +## セットアップ手順 + +### クイックセットアップ(推奨) + +QQ オープンプラットフォームにはワンクリック作成エントリーが用意されています: + +1. [QQ ボットクイック作成](https://q.qq.com/qqbot/openclaw/index.html) を開き、QR コードをスキャンしてログインする +2. システムが自動的にボットを作成するので、**App ID** と **App Secret** をコピーする +3. PicoClaw 設定ファイルに認証情報を入力する +4. `picoclaw gateway` を実行してサービスを起動する +5. QQ を開いてボットとの会話を始める + +> App Secret は一度しか表示されません。すぐに保存してください。再度表示しようとすると強制的にリセットされます。 +> +> クイックエントリーで作成したボットは作成者本人のみが使用でき、グループチャットには対応していません。グループチャット機能が必要な場合は、[QQ オープンプラットフォーム](https://q.qq.com/) でサンドボックスモードを設定してください。 + +### 手動セットアップ + +1. QQ アカウントで [QQ オープンプラットフォーム](https://q.qq.com/) にログインし、開発者アカウントを登録する +2. QQ ボットを作成し、アバターと名前をカスタマイズする +3. ボット設定から **App ID** と **App Secret** を取得する +4. PicoClaw 設定ファイルに認証情報を入力する +5. `picoclaw gateway` を実行してサービスを起動する +6. QQ でボットを検索して会話を始める + +> 開発段階ではサンドボックスモードを有効にし、テストユーザーとグループをサンドボックスに追加してデバッグすることを推奨します。 diff --git a/docs/channels/qq/README.md b/docs/channels/qq/README.md new file mode 100644 index 000000000..bc8ccf837 --- /dev/null +++ b/docs/channels/qq/README.md @@ -0,0 +1,55 @@ +> Back to [README](../../../README.md) + +# QQ + +PicoClaw provides QQ support via the official Bot API from the QQ Open Platform. + +## Configuration + +```json +{ + "channel_list": { + "qq": { + "enabled": true, + "type": "qq", + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| ---------- | ------ | -------- | -------------------------------------------------------- | +| enabled | bool | Yes | Whether to enable the QQ channel | +| app_id | string | Yes | App ID of the QQ bot application | +| app_secret | string | Yes | App Secret of the QQ bot application | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | + +## Setup + +### Quick Setup (Recommended) + +The QQ Open Platform provides a one-click creation entry: + +1. Open [QQ Bot Quick Create](https://q.qq.com/qqbot/openclaw/index.html) and log in by scanning the QR code +2. The system automatically creates a bot — copy the **App ID** and **App Secret** +3. Fill in the credentials in the PicoClaw configuration file +4. Run `picoclaw gateway` to start the service +5. Open QQ and start chatting with the bot + +> The App Secret is only shown once — save it immediately. Viewing it again will force a reset. +> +> Bots created via the quick entry are for the creator's personal use only and do not support group chats. For group chat support, configure sandbox mode on the [QQ Open Platform](https://q.qq.com/). + +### Manual Setup + +1. Log in to the [QQ Open Platform](https://q.qq.com/) with your QQ account and register as a developer +2. Create a QQ bot and customize its avatar and name +3. Obtain the **App ID** and **App Secret** from the bot settings +4. Fill in the credentials in the PicoClaw configuration file +5. Run `picoclaw gateway` to start the service +6. Search for your bot in QQ and start chatting + +> During development, it is recommended to enable sandbox mode and add test users and groups to the sandbox for debugging. diff --git a/docs/channels/qq/README.pt-br.md b/docs/channels/qq/README.pt-br.md new file mode 100644 index 000000000..b0a7e5568 --- /dev/null +++ b/docs/channels/qq/README.pt-br.md @@ -0,0 +1,55 @@ +> Voltar ao [README](../../project/README.pt-br.md) + +# QQ + +O PicoClaw oferece suporte ao QQ via API Bot oficial da Plataforma Aberta QQ. + +## Configuração + +```json +{ + "channel_list": { + "qq": { + "enabled": true, + "type": "qq", + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ---------- | ------ | ----------- | -------------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal QQ deve ser habilitado | +| app_id | string | Sim | App ID da aplicação bot QQ | +| app_secret | string | Sim | App Secret da aplicação bot QQ | +| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários | + +## Configuração inicial + +### Configuração rápida (recomendada) + +A Plataforma Aberta QQ oferece uma entrada de criação com um clique: + +1. Abra o [QQ Bot Quick Create](https://q.qq.com/qqbot/openclaw/index.html) e faça login escaneando o QR code +2. O sistema cria o bot automaticamente — copie o **App ID** e o **App Secret** +3. Preencha as credenciais no arquivo de configuração do PicoClaw +4. Execute `picoclaw gateway` para iniciar o serviço +5. Abra o QQ e comece a conversar com o bot + +> O App Secret é exibido apenas uma vez — salve-o imediatamente. Visualizá-lo novamente forçará uma redefinição. +> +> Bots criados pela entrada rápida são apenas para uso pessoal do criador e não suportam chats em grupo. Para suporte a grupos, configure o modo sandbox na [Plataforma Aberta QQ](https://q.qq.com/). + +### Configuração manual + +1. Faça login na [Plataforma Aberta QQ](https://q.qq.com/) com sua conta QQ e registre-se como desenvolvedor +2. Crie um bot QQ e personalize seu avatar e nome +3. Obtenha o **App ID** e o **App Secret** nas configurações do bot +4. Preencha as credenciais no arquivo de configuração do PicoClaw +5. Execute `picoclaw gateway` para iniciar o serviço +6. Pesquise seu bot no QQ e comece a conversar + +> Durante o desenvolvimento, recomenda-se habilitar o modo sandbox e adicionar usuários e grupos de teste ao sandbox para depuração. diff --git a/docs/channels/qq/README.vi.md b/docs/channels/qq/README.vi.md new file mode 100644 index 000000000..cf940d05d --- /dev/null +++ b/docs/channels/qq/README.vi.md @@ -0,0 +1,55 @@ +> Quay lại [README](../../project/README.vi.md) + +# QQ + +PicoClaw hỗ trợ QQ thông qua API Bot chính thức của Nền tảng Mở QQ. + +## Cấu hình + +```json +{ + "channel_list": { + "qq": { + "enabled": true, + "type": "qq", + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ---------- | ------ | -------- | ------------------------------------------------------------------------ | +| enabled | bool | Có | Có bật kênh QQ hay không | +| app_id | string | Có | App ID của ứng dụng bot QQ | +| app_secret | string | Có | App Secret của ứng dụng bot QQ | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả | + +## Hướng dẫn thiết lập + +### Thiết lập nhanh (Khuyến nghị) + +Nền tảng Mở QQ cung cấp lối vào tạo bot một chạm: + +1. Mở [QQ Bot Quick Create](https://q.qq.com/qqbot/openclaw/index.html) và đăng nhập bằng cách quét mã QR +2. Hệ thống tự động tạo bot — sao chép **App ID** và **App Secret** +3. Điền thông tin xác thực vào file cấu hình PicoClaw +4. Chạy `picoclaw gateway` để khởi động dịch vụ +5. Mở QQ và bắt đầu trò chuyện với bot + +> App Secret chỉ hiển thị một lần — hãy lưu lại ngay. Xem lại sẽ buộc phải đặt lại. +> +> Bot được tạo qua lối vào nhanh chỉ dành cho người tạo sử dụng cá nhân và chưa hỗ trợ chat nhóm. Để hỗ trợ chat nhóm, hãy cấu hình chế độ sandbox trên [Nền tảng Mở QQ](https://q.qq.com/). + +### Tạo thủ công + +1. Đăng nhập vào [Nền tảng Mở QQ](https://q.qq.com/) bằng tài khoản QQ và đăng ký tài khoản nhà phát triển +2. Tạo bot QQ, tùy chỉnh ảnh đại diện và tên +3. Lấy **App ID** và **App Secret** trong cài đặt bot +4. Điền thông tin xác thực vào file cấu hình PicoClaw +5. Chạy `picoclaw gateway` để khởi động dịch vụ +6. Tìm kiếm bot của bạn trong QQ và bắt đầu trò chuyện + +> Trong giai đoạn phát triển, nên bật chế độ sandbox và thêm người dùng, nhóm thử nghiệm vào sandbox để gỡ lỗi. diff --git a/docs/channels/qq/README.zh.md b/docs/channels/qq/README.zh.md index bd774960f..dc40f6225 100644 --- a/docs/channels/qq/README.zh.md +++ b/docs/channels/qq/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../project/README.zh.md) + # QQ PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。 @@ -6,27 +8,50 @@ PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。 ```json { - "channels": { + "channel_list": { "qq": { "enabled": true, + "type": "qq", "app_id": "YOUR_APP_ID", "app_secret": "YOUR_APP_SECRET", - "allow_from": [] + "allow_from": [], + "max_base64_file_size_mib": 0 } } } ``` -| 字段 | 类型 | 必填 | 描述 | -| ---------- | ------ | ---- | -------------------------------- | -| enabled | bool | 是 | 是否启用 QQ Channel | -| app_id | string | 是 | QQ 机器人应用的 App ID | -| app_secret | string | 是 | QQ 机器人应用的 App Secret | -| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | +| 字段 | 类型 | 必填 | 描述 | +| -------------------- | ------ | ---- | ------------------------------------------------------------ | +| enabled | bool | 是 | 是否启用 QQ Channel | +| app_id | string | 是 | QQ 机器人应用的 App ID | +| app_secret | string | 是 | QQ 机器人应用的 App Secret | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | +| max_base64_file_size_mib | int | 否 | 本地文件转 base64 上传的最大体积,单位 MiB;`0` 表示不限制。仅影响本地文件,不影响 URL 直传 | ## 设置流程 -1. 前往 [QQ 开放平台](https://q.qq.com/) 创建一个机器人 -2. 通过仪表盘获取 App ID 和 App Secret -3. 开启机器人沙箱模式, 将用户和群添加到沙箱中 -4. 将 App ID 和 App Secret 填入配置文件中 +### 快捷方式(推荐) + +QQ 开放平台提供了一键创建入口: + +1. 打开 [QQ 机器人快速创建](https://q.qq.com/qqbot/openclaw/index.html),扫码登录 +2. 系统自动创建机器人,复制 **App ID** 和 **App Secret** +3. 将凭证填入 PicoClaw 配置文件 +4. 运行 `picoclaw gateway` 启动服务 +5. 打开 QQ,与机器人开始对话 + +> App Secret 仅显示一次,请立即保存。再次查看将强制重置。 +> +> 通过快捷入口创建的机器人仅供创建人使用,暂不支持群聊。如需群聊功能,请在 [QQ 开放平台](https://q.qq.com/) 配置沙箱模式。 + +### 手动创建 + +1. 使用 QQ 账号登录 [QQ 开放平台](https://q.qq.com/),注册开发者账号 +2. 创建 QQ 机器人,自定义头像和名称 +3. 在机器人设置中获取 **App ID** 和 **App Secret** +4. 将凭证填入 PicoClaw 配置文件 +5. 运行 `picoclaw gateway` 启动服务 +6. 在 QQ 中搜索你的机器人,开始对话 + +> 开发阶段建议开启沙箱模式,将测试用户和群添加到沙箱中进行调试。 diff --git a/docs/channels/slack/README.fr.md b/docs/channels/slack/README.fr.md new file mode 100644 index 000000000..be533052a --- /dev/null +++ b/docs/channels/slack/README.fr.md @@ -0,0 +1,36 @@ +> Retour au [README](../../project/README.fr.md) + +# Slack + +Slack est l'une des principales plateformes de messagerie instantanée pour les entreprises. PicoClaw utilise le Socket Mode de Slack pour une communication bidirectionnelle en temps réel, sans nécessiter la configuration d'un endpoint webhook public. + +## Configuration + +```json +{ + "channel_list": { + "slack": { + "enabled": true, + "type": "slack", + "bot_token": "xoxb-...", + "app_token": "xapp-...", + "allow_from": [] + } + } +} +``` + +| Champ | Type | Requis | Description | +| ---------- | ------ | ------ | ---------------------------------------------------------------------------- | +| enabled | bool | Oui | Activer ou non le canal Slack | +| bot_token | string | Oui | Bot User OAuth Token du bot Slack (commence par xoxb-) | +| app_token | string | Oui | App Level Token Socket Mode de l'application Slack (commence par xapp-) | +| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs | + +## Procédure de configuration + +1. Rendez-vous sur [Slack API](https://api.slack.com/) et créez une nouvelle application Slack +2. Activez le Socket Mode et obtenez l'App Level Token +3. Ajoutez des Bot Token Scopes (par exemple `chat:write`, `im:history`, etc.) +4. Installez l'application dans votre espace de travail et obtenez le Bot User OAuth Token +5. Renseignez le Bot Token et l'App Token dans le fichier de configuration diff --git a/docs/channels/slack/README.ja.md b/docs/channels/slack/README.ja.md new file mode 100644 index 000000000..38cfc0134 --- /dev/null +++ b/docs/channels/slack/README.ja.md @@ -0,0 +1,36 @@ +> [README](../../project/README.ja.md) に戻る + +# Slack + +Slack は世界をリードする企業向けインスタントメッセージングプラットフォームです。PicoClaw は Slack の Socket Mode を使用してリアルタイムの双方向通信を実現しており、公開 Webhook エンドポイントの設定は不要です。 + +## 設定 + +```json +{ + "channel_list": { + "slack": { + "enabled": true, + "type": "slack", + "bot_token": "xoxb-...", + "app_token": "xapp-...", + "allow_from": [] + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| ---------- | ------ | ------ | ------------------------------------------------------------------------ | +| enabled | bool | はい | Slack チャンネルを有効にするかどうか | +| bot_token | string | はい | Slack ボットの Bot User OAuth Token(xoxb- で始まる) | +| app_token | string | はい | Slack アプリの Socket Mode App Level Token(xapp- で始まる) | +| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 | + +## セットアップ手順 + +1. [Slack API](https://api.slack.com/) にアクセスして新しい Slack アプリを作成する +2. Socket Mode を有効にして App Level Token を取得する +3. Bot Token Scopes を追加する(例: `chat:write`、`im:history` など) +4. アプリをワークスペースにインストールして Bot User OAuth Token を取得する +5. Bot Token と App Token を設定ファイルに入力する diff --git a/docs/channels/slack/README.md b/docs/channels/slack/README.md new file mode 100644 index 000000000..4f1014511 --- /dev/null +++ b/docs/channels/slack/README.md @@ -0,0 +1,36 @@ +> Back to [README](../../../README.md) + +# Slack + +Slack is a leading enterprise instant messaging platform. PicoClaw uses Slack's Socket Mode for real-time bidirectional communication, with no need to configure a public webhook endpoint. + +## Configuration + +```json +{ + "channel_list": { + "slack": { + "enabled": true, + "type": "slack", + "bot_token": "xoxb-...", + "app_token": "xapp-...", + "allow_from": [] + } + } +} +``` + +| Field | Type | Required | Description | +| ---------- | ------ | -------- | ------------------------------------------------------------------------ | +| enabled | bool | Yes | Whether to enable the Slack channel | +| bot_token | string | Yes | Bot User OAuth Token for the Slack bot (starts with xoxb-) | +| app_token | string | Yes | Socket Mode App Level Token for the Slack app (starts with xapp-) | +| allow_from | array | No | User ID whitelist; empty means all users are allowed | + +## Setup + +1. Go to [Slack API](https://api.slack.com/) and create a new Slack app +2. Enable Socket Mode and obtain the App Level Token +3. Add Bot Token Scopes (e.g. `chat:write`, `im:history`, etc.) +4. Install the app to your workspace and obtain the Bot User OAuth Token +5. Fill in the Bot Token and App Token in the configuration file diff --git a/docs/channels/slack/README.pt-br.md b/docs/channels/slack/README.pt-br.md new file mode 100644 index 000000000..d2676d44a --- /dev/null +++ b/docs/channels/slack/README.pt-br.md @@ -0,0 +1,36 @@ +> Voltar ao [README](../../project/README.pt-br.md) + +# Slack + +O Slack é uma das principais plataformas de mensagens instantâneas para empresas. O PicoClaw usa o Socket Mode do Slack para comunicação bidirecional em tempo real, sem necessidade de configurar um endpoint de webhook público. + +## Configuração + +```json +{ + "channel_list": { + "slack": { + "enabled": true, + "type": "slack", + "bot_token": "xoxb-...", + "app_token": "xapp-...", + "allow_from": [] + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| ---------- | ------ | ----------- | ---------------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal Slack deve ser habilitado | +| bot_token | string | Sim | Bot User OAuth Token do bot Slack (começa com xoxb-) | +| app_token | string | Sim | App Level Token do Socket Mode do aplicativo Slack (começa com xapp-) | +| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos | + +## Configuração passo a passo + +1. Acesse o [Slack API](https://api.slack.com/) e crie um novo aplicativo Slack +2. Ative o Socket Mode e obtenha o App Level Token +3. Adicione Bot Token Scopes (ex.: `chat:write`, `im:history`, etc.) +4. Instale o aplicativo no seu workspace e obtenha o Bot User OAuth Token +5. Preencha o Bot Token e o App Token no arquivo de configuração diff --git a/docs/channels/slack/README.vi.md b/docs/channels/slack/README.vi.md new file mode 100644 index 000000000..3bbbe3132 --- /dev/null +++ b/docs/channels/slack/README.vi.md @@ -0,0 +1,36 @@ +> Quay lại [README](../../project/README.vi.md) + +# Slack + +Slack là nền tảng nhắn tin tức thì hàng đầu dành cho doanh nghiệp. PicoClaw sử dụng Socket Mode của Slack để giao tiếp hai chiều theo thời gian thực, không cần cấu hình endpoint webhook công khai. + +## Cấu hình + +```json +{ + "channel_list": { + "slack": { + "enabled": true, + "type": "slack", + "bot_token": "xoxb-...", + "app_token": "xapp-...", + "allow_from": [] + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| ---------- | ------ | -------- | ---------------------------------------------------------------------------- | +| enabled | bool | Có | Có bật kênh Slack hay không | +| bot_token | string | Có | Bot User OAuth Token của Slack bot (bắt đầu bằng xoxb-) | +| app_token | string | Có | App Level Token Socket Mode của ứng dụng Slack (bắt đầu bằng xapp-) | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả | + +## Quy trình thiết lập + +1. Truy cập [Slack API](https://api.slack.com/) và tạo một ứng dụng Slack mới +2. Bật Socket Mode và lấy App Level Token +3. Thêm Bot Token Scopes (ví dụ: `chat:write`, `im:history`, v.v.) +4. Cài đặt ứng dụng vào workspace và lấy Bot User OAuth Token +5. Điền Bot Token và App Token vào file cấu hình diff --git a/docs/channels/slack/README.zh.md b/docs/channels/slack/README.zh.md index 58ebcb566..8ecfe88bf 100644 --- a/docs/channels/slack/README.zh.md +++ b/docs/channels/slack/README.zh.md @@ -1,3 +1,5 @@ +> 返回 [README](../../project/README.zh.md) + # Slack Slack 是全球领先的企业级即时通讯平台。PicoClaw 采用 Slack 的 Socket Mode 实现实时双向通信,无需配置公开的 Webhook 端点。 @@ -6,9 +8,10 @@ Slack 是全球领先的企业级即时通讯平台。PicoClaw 采用 Slack 的 ```json { - "channels": { + "channel_list": { "slack": { "enabled": true, + "type": "slack", "bot_token": "xoxb-...", "app_token": "xapp-...", "allow_from": [] diff --git a/docs/channels/telegram/README.fr.md b/docs/channels/telegram/README.fr.md new file mode 100644 index 000000000..51db2082f --- /dev/null +++ b/docs/channels/telegram/README.fr.md @@ -0,0 +1,56 @@ +> Retour au [README](../../project/README.fr.md) + +# Telegram + +Le canal Telegram utilise le long polling via l'API Bot Telegram pour une communication basée sur les bots. Il prend en charge les messages texte, les pièces jointes multimédias (photos, messages vocaux, audio, documents), la transcription vocale via Groq Whisper et la gestion des commandes intégrée. + +## Configuration + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + "allow_from": ["123456789"], + "proxy": "", + "use_markdown_v2": false + } + } +} +``` + +| Champ | Type | Requis | Description | +| --------------- | ------ | ------ | ------------------------------------------------------------------------ | +| enabled | bool | Oui | Activer ou non le canal Telegram | +| token | string | Oui | Token de l'API Bot Telegram | +| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs | +| proxy | string | Non | URL du proxy pour se connecter à l'API Telegram (ex. http://127.0.0.1:7890) | +| use_markdown_v2 | bool | Non | Activer le formatage Telegram MarkdownV2 | + +## Configuration initiale + +1. Rechercher `@BotFather` dans Telegram +2. Envoyer la commande `/newbot` et suivre les instructions pour créer un nouveau bot +3. Obtenir le Token de l'API HTTP +4. Renseigner le Token dans le fichier de configuration +5. (Optionnel) Configurer `allow_from` pour restreindre les identifiants utilisateur autorisés à interagir (les IDs peuvent être obtenus via `@userinfobot`) + +## Formatage avancées + +Vous pouvez définir `use_markdown_v2: true` pour activer les options de formatage améliorées. Cela permet au bot d'utiliser toutes les fonctionnalités de Telegram MarkdownV2, y compris les styles imbriqués, les spoilers et les blocs de largeur fixe personnalisés. + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": true + } + } +} +``` diff --git a/docs/channels/telegram/README.ja.md b/docs/channels/telegram/README.ja.md new file mode 100644 index 000000000..03303f255 --- /dev/null +++ b/docs/channels/telegram/README.ja.md @@ -0,0 +1,56 @@ +> [README](../../project/README.ja.md) に戻る + +# Telegram + +Telegram チャンネルは、Telegram Bot API を使用したロングポーリングによるボットベースの通信を実装しています。テキストメッセージ、メディア添付ファイル(写真、音声、オーディオ、ドキュメント)、Groq Whisper による音声文字起こし、および組み込みコマンドハンドラーをサポートしています。 + +## 設定 + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + "allow_from": ["123456789"], + "proxy": "", + "use_markdown_v2": false + } + } +} +``` + +| フィールド | 型 | 必須 | 説明 | +| --------------- | ------ | ---- | ----------------------------------------------------------------- | +| enabled | bool | はい | Telegram チャンネルを有効にするかどうか | +| token | string | はい | Telegram Bot API トークン | +| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 | +| proxy | string | いいえ | Telegram API への接続に使用するプロキシ URL (例: http://127.0.0.1:7890) | +| use_markdown_v2 | bool | いいえ | Telegram MarkdownV2 フォーマットを有効にする | + +## セットアップ手順 + +1. Telegram で `@BotFather` を検索する +2. `/newbot` コマンドを送信し、指示に従って新しいボットを作成する +3. HTTP API トークンを取得する +4. 設定ファイルにトークンを入力する +5. (任意) `allow_from` を設定して、対話を許可するユーザー ID を制限する(ID は `@userinfobot` で取得可能) + +## 高度なフォーマット + +`use_markdown_v2: true` を設定することで、增强されたフォーマットオプションを有効にできます。これにより、ボットは Telegram MarkdownV2 の全機能(ネストされたスタイル、スポイラー、カスタム固定幅ブロックなど)を利用できます。 + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": true + } + } +} +``` diff --git a/docs/channels/telegram/README.md b/docs/channels/telegram/README.md new file mode 100644 index 000000000..a4138009e --- /dev/null +++ b/docs/channels/telegram/README.md @@ -0,0 +1,80 @@ +> Back to [README](../../../README.md) + +# Telegram + +The Telegram channel uses long polling via the Telegram Bot API for bot-based communication. It supports text messages, media attachments (photos, voice, audio, documents), voice transcription ([setup](../../guides/providers.md#voice-transcription)), and built-in command handling. + +## Configuration + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + "allow_from": ["123456789"], + "proxy": "", + "use_markdown_v2": false + } + } +} +``` + +| Field | Type | Required | Description | +| ---------------- | ------ | -------- | ------------------------------------------------------------------ | +| enabled | bool | Yes | Whether to enable the Telegram channel | +| token | string | Yes | Telegram Bot API Token | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | +| proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) | +| use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting | + +## Setup + +1. Search for `@BotFather` in Telegram +2. Send the `/newbot` command and follow the prompts to create a new bot +3. Obtain the HTTP API Token +4. Fill in the Token in the configuration file +5. (Optional) Configure `allow_from` to restrict which user IDs can interact (you can get IDs via `@userinfobot`) + +## Built-in Commands + +Telegram auto-registers PicoClaw's top-level bot commands at startup, including `/start`, `/help`, `/show`, `/list`, and `/use`. + +Skill-related commands: + +- `/list skills` lists the installed skills visible to the current agent. +- `/list mcp` lists configured MCP servers and whether they are deferred/connected. +- `/show mcp ` lists the active tools for a connected MCP server. +- `/use ` forces a skill for a single request. +- `/use ` arms the skill for your next message in the same chat. +- `/use clear` clears a pending skill override. + +Examples: + +```text +/list skills +/list mcp +/show mcp github +/use git explain how to squash the last 3 commits +/use git +explain how to squash the last 3 commits +``` + +## Advanced Formatting + +You 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. + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": true + } + } +} +``` diff --git a/docs/channels/telegram/README.pt-br.md b/docs/channels/telegram/README.pt-br.md new file mode 100644 index 000000000..4af8d7a25 --- /dev/null +++ b/docs/channels/telegram/README.pt-br.md @@ -0,0 +1,56 @@ +> Voltar ao [README](../../project/README.pt-br.md) + +# Telegram + +O canal Telegram utiliza long polling via a API de Bot do Telegram para comunicação baseada em bots. Suporta mensagens de texto, anexos de mídia (fotos, voz, áudio, documentos), transcrição de voz via Groq Whisper e tratamento de comandos integrado. + +## Configuração + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + "allow_from": ["123456789"], + "proxy": "", + "use_markdown_v2": false + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +| --------------- | ------ | ----------- | -------------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal Telegram deve ser habilitado | +| token | string | Sim | Token da API de Bot do Telegram | +| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários | +| proxy | string | Não | URL do proxy para conexão com a API do Telegram (ex. http://127.0.0.1:7890) | +| use_markdown_v2 | bool | Não | Habilitar formatação Telegram MarkdownV2 | + +## Configuração inicial + +1. Pesquise por `@BotFather` no Telegram +2. Envie o comando `/newbot` e siga as instruções para criar um novo bot +3. Obtenha o Token da API HTTP +4. Preencha o Token no arquivo de configuração +5. (Opcional) Configure `allow_from` para restringir quais IDs de usuário podem interagir (os IDs podem ser obtidos via `@userinfobot`) + +## Formatação Avançada + +Você pode definir `use_markdown_v2: true` para habilitar opções de formatação aprimoradas. Isso permite que o bot utilize todos os recursos do Telegram MarkdownV2, incluindo estilos aninhados, spoilers e blocos de largura fixa personalizados. + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": true + } + } +} +``` diff --git a/docs/channels/telegram/README.vi.md b/docs/channels/telegram/README.vi.md new file mode 100644 index 000000000..c6a276754 --- /dev/null +++ b/docs/channels/telegram/README.vi.md @@ -0,0 +1,56 @@ +> Quay lại [README](../../project/README.vi.md) + +# Telegram + +Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp dựa trên bot. Hỗ trợ tin nhắn văn bản, tệp đính kèm đa phương tiện (ảnh, giọng nói, âm thanh, tài liệu), chuyển giọng nói thành văn bản qua Groq Whisper và xử lý lệnh tích hợp sẵn. + +## Cấu hình + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + "allow_from": ["123456789"], + "proxy": "", + "use_markdown_v2": false + } + } +} +``` + +| Trường | Kiểu | Bắt buộc | Mô tả | +| -------------- | ------ | -------- | ------------------------------------------------------------------------ | +| enabled | bool | Có | Có bật kênh Telegram hay không | +| token | string | Có | Token API Bot Telegram | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả | +| proxy | string | Không | URL proxy để kết nối với Telegram API (ví dụ: http://127.0.0.1:7890) | +| use_markdown_v2 | bool | Không | Bật định dạng Telegram MarkdownV2 | + +## Hướng dẫn thiết lập + +1. Tìm kiếm `@BotFather` trong Telegram +2. Gửi lệnh `/newbot` và làm theo hướng dẫn để tạo bot mới +3. Lấy Token API HTTP +4. Điền Token vào file cấu hình +5. (Tùy chọn) Cấu hình `allow_from` để giới hạn ID người dùng được phép tương tác (có thể lấy ID qua `@userinfobot`) + +## Định dạng nâng cao + +Bạn có thể đặt `use_markdown_v2: true` để bật các tùy chọn định dạng nâng cao. Điều này cho phép bot sử dụng toàn bộ các tính năng của Telegram MarkdownV2, bao gồm các kiểu lồng nhau, spoiler và các khối chiều rộng cố định tùy chỉnh. + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": true + } + } +} +``` diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md index d453c68fa..543e16e47 100644 --- a/docs/channels/telegram/README.zh.md +++ b/docs/channels/telegram/README.zh.md @@ -1,28 +1,33 @@ +> 返回 [README](../../project/README.zh.md) + # Telegram -Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、通过 Groq Whisper 进行语音转录以及内置命令处理器。 +Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、语音转录(配置见[提供商与模型配置](../../guides/providers.zh.md#语音转录)),以及内置命令处理器。 ## 配置 ```json { - "channels": { + "channel_list": { "telegram": { "enabled": true, + "type": "telegram", "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], - "proxy": "" + "proxy": "", + "use_markdown_v2": false } } } ``` -| 字段 | 类型 | 必填 | 描述 | -| ---------- | ------ | ---- | --------------------------------------------------------- | -| enabled | bool | 是 | 是否启用 Telegram 频道 | -| token | string | 是 | Telegram 机器人 API Token | -| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | -| proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) | +| 字段 | 类型 | 必填 | 描述 | +| ---------------- | ------ | ---- | --------------------------------------------------------- | +| enabled | bool | 是 | 是否启用 Telegram 频道 | +| token | string | 是 | Telegram 机器人 API Token | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | +| proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) | +| use_markdown_v2 | bool | 否 | 启用 Telegram MarkdownV2 格式化 | ## 设置流程 @@ -31,3 +36,41 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器 3. 获取 HTTP API Token 4. 将 Token 填入配置文件中 5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID) + +## 内置命令 + +Telegram 会在启动时自动注册 PicoClaw 的顶级 Bot 命令,包括 `/start`、`/help`、`/show`、`/list` 和 `/use`。 + +与技能相关的命令: + +- `/list skills`:列出当前 Agent 可见的已安装技能。 +- `/use `:只在本次请求中强制使用指定技能。 +- `/use `:为同一聊天中的下一条消息预先启用该技能。 +- `/use clear`:清除待应用的技能覆盖。 + +示例: + +```text +/list skills +/use git explain how to squash the last 3 commits +/use git +explain how to squash the last 3 commits +``` + +## 高级格式化 + +您可以设置 `use_markdown_v2: true` 来启用增强的格式化选项。这允许机器人使用 Telegram MarkdownV2 的全部功能,包括嵌套样式、剧透和自定义等宽代码块。 + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": true + } + } +} +``` diff --git a/docs/channels/vk/README.md b/docs/channels/vk/README.md new file mode 100644 index 000000000..5e0c72bce --- /dev/null +++ b/docs/channels/vk/README.md @@ -0,0 +1,198 @@ +# VK (VKontakte) + +The VK channel uses Bots Long Poll API for bot-based communication with VK social network. It supports text messages, media attachments (photos, videos, audio, documents, stickers), and group chat interactions. + +## Configuration + +```json +{ + "channel_list": { + "vk": { + "enabled": true, + "type": "vk", + "token": "NOT_HERE", + "group_id": 123456789, + "allow_from": ["123456789"], + "group_trigger": { + "mention_only": false, + "prefixes": ["/bot", "!bot"] + } + } + } +} +``` + +| Field | Type | Required | Description | +| ---------------- | ------ | -------- | ------------------------------------------------------------------ | +| enabled | bool | Yes | Whether to enable the VK channel | +| token | string | Yes | Set to `NOT_HERE` - token is stored securely (see Token Storage) | +| group_id | int | Yes | VK Community ID (Group ID) | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | +| group_trigger | object | No | Configuration for group chat triggers | + +### Token Storage + +For security reasons, the VK access token should not be stored directly in the configuration file. Instead: + +1. Set `token` to `"NOT_HERE"` in the configuration +2. Store the actual token using one of these methods: + - **Environment variable**: Set `PICOCLAW_CHANNELS_VK_TOKEN` environment variable + - **Secure storage**: Use PicoClaw's secure token storage mechanism + +Example using environment variable: +```bash +export PICOCLAW_CHANNELS_VK_TOKEN="vk1.a.abc123..." +``` + +### Group Trigger Configuration + +| Field | Type | Description | +| ------------ | -------- | ------------------------------------------------------------------ | +| mention_only | bool | Only respond when bot is mentioned in group chats | +| prefixes | []string | List of prefixes that trigger bot response in group chats | + +## Setup + +### 1. Create a VK Community + +1. Go to [VK](https://vk.com) and log in +2. Create a new community or use an existing one +3. Note your Community ID (found in the community URL, e.g., `public123456789`) + +### 2. Enable Messages + +1. Go to your community page +2. Click "Manage" → "Messages" → "Community Messages" +3. Enable community messages + +### 3. Create Access Token + +1. Go to "Manage" → "API usage" → "Access tokens" +2. Click "Create token" +3. Select the following permissions: + - `messages` - Access to messages + - `photos` - Access to photos (optional) + - `docs` - Access to documents (optional) +4. Copy the generated access token +5. Store the token securely (see Token Storage section below) + +### 4. Configure PicoClaw + +1. Add the token to your PicoClaw configuration +2. Set the `group_id` to your community ID (numeric value) +3. (Optional) Configure `allow_from` to restrict which user IDs can interact + +## Features + +### Supported Message Types + +- **Text messages**: Full support for text messages +- **Photos**: Photos are displayed as `[photo]` placeholder +- **Videos**: Videos are displayed as `[video]` placeholder +- **Audio**: Audio files are displayed as `[audio]` placeholder +- **Voice messages**: Voice messages are displayed as `[voice]` placeholder and support transcription +- **Documents**: Documents are displayed as `[document: filename]` +- **Stickers**: Stickers are displayed as `[sticker]` placeholder + +### Voice Support + +The VK channel supports both voice message reception and text-to-speech capabilities: + +- **ASR (Automatic Speech Recognition)**: Voice messages can be transcribed to text using configured voice models +- **TTS (Text-to-Speech)**: Text responses can be converted to voice messages + +To enable voice transcription, configure a voice model in your providers setup. See [Voice Transcription](../../guides/providers.md#voice-transcription) for details. + +### Group Chat Support + +The VK channel supports group chats with configurable triggers: + +- **Mention-only mode**: Bot only responds when mentioned +- **Prefix mode**: Bot responds to messages starting with specified prefixes +- **Permissive mode**: Bot responds to all messages (default) + +### Message Length + +VK has a maximum message length of 4000 characters. PicoClaw automatically splits longer messages into multiple parts. + +## Example Configuration + +### Basic Configuration + +```json +{ + "channel_list": { + "vk": { + "enabled": true, + "type": "vk", + "token": "NOT_HERE", + "group_id": 123456789 + } + } +} +``` + +### With User Whitelist + +```json +{ + "channel_list": { + "vk": { + "enabled": true, + "type": "vk", + "token": "NOT_HERE", + "group_id": 123456789, + "allow_from": ["123456789", "987654321"] + } + } +} +``` + +### With Group Chat Triggers + +```json +{ + "channel_list": { + "vk": { + "enabled": true, + "type": "vk", + "token": "NOT_HERE", + "group_id": 123456789, + "group_trigger": { + "prefixes": ["/bot", "!bot"] + } + } + } +} +``` + +## Troubleshooting + +### Bot Not Responding + +1. Check that the access token is valid +2. Verify that the `group_id` is correct +3. Ensure the user ID is in `allow_from` if configured +4. Check PicoClaw logs for error messages + +### Permission Errors + +Make sure the access token has the necessary permissions: +- `messages` - Required for sending and receiving messages +- `photos` - Optional, for handling photo attachments +- `docs` - Optional, for handling document attachments + +### Group Chat Issues + +If the bot doesn't respond in group chats: +1. Check `group_trigger` configuration +2. Try using a prefix to trigger the bot +3. Check if the bot has permission to read group messages + +## API Reference + +The VK channel uses the [VK SDK for Go](https://github.com/SevereCloud/vksdk) library, which supports VK API version 5.199. + +For more information about VK API, see: +- [VK API Documentation](https://dev.vk.com/en) +- [VK Bots Long Poll API](https://dev.vk.com/en/api/bots-long-poll/getting-started) diff --git a/docs/channels/wecom/README.fr.md b/docs/channels/wecom/README.fr.md new file mode 100644 index 000000000..843943bdf --- /dev/null +++ b/docs/channels/wecom/README.fr.md @@ -0,0 +1,149 @@ +> Retour au [README](../../project/README.fr.md) + +# WeCom + +PicoClaw expose WeCom en tant que canal unique `channels.wecom`, basé sur l'API WebSocket officielle WeCom AI Bot. +Ce canal remplace l'ancienne séparation `wecom`, `wecom_app` et `wecom_aibot` par un modèle de configuration unifié. + +> Aucune URL de callback webhook publique n'est requise. PicoClaw établit une connexion WebSocket sortante vers WeCom. + +## Fonctionnalités prises en charge + +- Chat privé et chat de groupe +- Réponses en streaming côté canal via le protocole WeCom AI Bot +- Messages entrants : texte, voix, image, fichier, vidéo et messages mixtes +- Réponses sortantes : texte et médias (`image`, `file`, `voice`, `video`) +- Onboarding par QR code via l'interface Web ou le CLI +- Liste blanche partagée et routage `reasoning_channel_id` + +--- + +## Démarrage rapide + +### Option 1 : Liaison QR via l'interface Web (recommandé) + +Ouvrez l'interface Web, accédez à **Channels → WeCom** et cliquez sur le bouton de liaison QR. Scannez le QR code avec WeCom et confirmez dans l'application — les identifiants sont enregistrés automatiquement. + +

+Liaison QR WeCom dans l'interface Web +

+ +### Option 2 : Connexion QR via le CLI + +Exécutez : + +```bash +picoclaw auth wecom +``` + +La commande : +1. Demande un QR code à WeCom et l'affiche dans le terminal +2. Affiche également un **lien QR code** que vous pouvez ouvrir dans un navigateur si le QR du terminal est difficile à scanner +3. Attend la confirmation — après le scan, vous devez également **confirmer la connexion dans l'application WeCom** +4. En cas de succès, écrit `bot_id` et `secret` dans `channels.wecom` et sauvegarde la configuration + +Le délai d'expiration par défaut est de **5 minutes**. Utilisez `--timeout` pour l'étendre : + +```bash +picoclaw auth wecom --timeout 10m +``` + +> ⚠️ Scanner le QR code ne suffit pas — vous devez également appuyer sur **Confirmer** dans l'application WeCom, sinon la commande expirera. + +### Option 3 : Configuration manuelle + +Si vous disposez déjà d'un `bot_id` et d'un `secret` depuis la plateforme WeCom AI Bot, configurez directement : + +```json +{ + "channel_list": { + "wecom": { + "enabled": true, + "type": "wecom", + "bot_id": "YOUR_BOT_ID", + "secret": "YOUR_SECRET", + "websocket_url": "wss://openws.work.weixin.qq.com", + "send_thinking_message": true, + "allow_from": [], + "reasoning_channel_id": "" + } + } +} +``` + +--- + +## Configuration + +| Champ | Type | Défaut | Description | +| ----- | ---- | ------ | ----------- | +| `enabled` | bool | `false` | Activer le canal WeCom. | +| `bot_id` | string | — | Identifiant WeCom AI Bot. Requis lorsque le canal est activé. | +| `secret` | string | — | Secret WeCom AI Bot. Stocké chiffré dans `.security.yml`. Requis lorsque le canal est activé. | +| `websocket_url` | string | `wss://openws.work.weixin.qq.com` | Point de terminaison WebSocket WeCom. | +| `send_thinking_message` | bool | `true` | Envoyer un message `Processing...` avant le début de la réponse en streaming. | +| `allow_from` | array | `[]` | Liste blanche des expéditeurs. Vide signifie autoriser tous les expéditeurs. | +| `reasoning_channel_id` | string | `""` | ID de chat optionnel pour router la sortie de raisonnement vers une conversation séparée. | + +### Variables d'environnement + +Tous les champs peuvent être remplacés par des variables d'environnement avec le préfixe `PICOCLAW_CHANNELS_WECOM_` : + +| Variable d'environnement | Champ correspondant | +| ------------------------ | ------------------- | +| `PICOCLAW_CHANNELS_WECOM_ENABLED` | `enabled` | +| `PICOCLAW_CHANNELS_WECOM_BOT_ID` | `bot_id` | +| `PICOCLAW_CHANNELS_WECOM_SECRET` | `secret` | +| `PICOCLAW_CHANNELS_WECOM_WEBSOCKET_URL` | `websocket_url` | +| `PICOCLAW_CHANNELS_WECOM_SEND_THINKING_MESSAGE` | `send_thinking_message` | +| `PICOCLAW_CHANNELS_WECOM_ALLOW_FROM` | `allow_from` | +| `PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID` | `reasoning_channel_id` | + +--- + +## Comportement à l'exécution + +- PicoClaw maintient un tour WeCom actif pour que les réponses en streaming puissent continuer sur le même flux lorsque c'est possible. +- Les réponses en streaming ont une durée maximale de **5,5 minutes** et un intervalle d'envoi minimum de **500 ms**. +- Si le streaming n'est plus disponible, les réponses basculent vers la livraison par push actif. +- Les associations de routes de chat expirent après **30 minutes** d'inactivité. +- Les médias entrants sont téléchargés dans le stockage média local avant d'être transmis à l'agent. +- Les médias sortants sont uploadés vers WeCom en tant que fichier temporaire, puis envoyés comme message média. +- Les messages en double sont détectés et supprimés (tampon circulaire des 1000 derniers identifiants de messages). + +--- + +## Migration depuis l'ancienne configuration WeCom + +| Configuration précédente | Migration | +| ------------------------ | --------- | +| `channels.wecom` (bot webhook) | Remplacer par `channels.wecom` avec `bot_id` + `secret`. | +| `channels.wecom_app` | Supprimer. Utiliser `channels.wecom` à la place. | +| `channels.wecom_aibot` | Déplacer `bot_id` et `secret` vers `channels.wecom`. | +| `token`, `encoding_aes_key`, `webhook_url`, `webhook_path` | Plus utilisés. Supprimer de la configuration. | +| `corp_id`, `corp_secret`, `agent_id` | Plus utilisés. Supprimer de la configuration. | +| `welcome_message`, `processing_message`, `max_steps` | Ne font plus partie de la configuration du canal WeCom. | + +--- + +## Dépannage + +### La liaison QR expire + +- Après avoir scanné le QR code, vous devez également **confirmer la connexion dans l'application WeCom**. Le scan seul ne suffit pas. +- Relancez avec un `--timeout` plus long : `picoclaw auth wecom --timeout 10m` +- Si le QR code dans le terminal est difficile à scanner, utilisez le **lien QR code** affiché en dessous pour l'ouvrir dans un navigateur. + +### QR code expiré + +- Le QR code a une durée de validité limitée. Relancez `picoclaw auth wecom` pour en obtenir un nouveau. + +### Échec de la connexion WebSocket + +- Vérifiez que `bot_id` et `secret` sont corrects. +- Confirmez que l'hôte peut atteindre `wss://openws.work.weixin.qq.com` (WebSocket sortant, aucun port entrant nécessaire). + +### Les réponses n'arrivent pas + +- Vérifiez si `allow_from` bloque l'expéditeur. +- Vérifiez que `channels.wecom.bot_id` et `channels.wecom.secret` sont définis et non vides. diff --git a/docs/channels/wecom/README.ja.md b/docs/channels/wecom/README.ja.md new file mode 100644 index 000000000..459a922a6 --- /dev/null +++ b/docs/channels/wecom/README.ja.md @@ -0,0 +1,149 @@ +> [README](../../project/README.ja.md) に戻る + +# WeCom + +PicoClaw は WeCom を公式 WeCom AI Bot WebSocket API に基づく単一の `channels.wecom` チャンネルとして公開します。 +従来の `wecom`、`wecom_app`、`wecom_aibot` の分割を統一された設定モデルに置き換えました。 + +> パブリックな Webhook コールバック URL は不要です。PicoClaw は WeCom へのアウトバウンド WebSocket 接続を確立します。 + +## サポートされる機能 + +- ダイレクトチャットとグループチャット +- WeCom AI Bot プロトコルによるチャンネル側ストリーミング返信 +- テキスト、音声、画像、ファイル、動画、ミックスメッセージの受信 +- テキストおよびメディア返信の送信(`image`、`file`、`voice`、`video`) +- Web UI または CLI による QR コードオンボーディング +- 共有許可リストと `reasoning_channel_id` ルーティング + +--- + +## クイックスタート + +### オプション 1:Web UI QR バインディング(推奨) + +Web UI を開き、**Channels → WeCom** に移動して、QR バインディングボタンをクリックします。WeCom で QR コードをスキャンし、アプリ内で確認すると、認証情報が自動的に保存されます。 + +

+Web UI での WeCom QR バインディング +

+ +### オプション 2:CLI QR ログイン + +実行: + +```bash +picoclaw auth wecom +``` + +コマンドの動作: +1. WeCom に QR コードをリクエストし、ターミナルに表示します +2. ターミナルの QR コードがスキャンしにくい場合に備え、ブラウザで開ける **QR コードリンク** も表示します +3. 確認をポーリングします — スキャン後、**WeCom アプリ内でログインを確認** する必要があります +4. 成功すると、`bot_id` と `secret` を `channels.wecom` に書き込み、設定を保存します + +デフォルトのタイムアウトは **5 分** です。`--timeout` で延長できます: + +```bash +picoclaw auth wecom --timeout 10m +``` + +> ⚠️ QR コードのスキャンだけでは不十分です — WeCom アプリ内で **確認** をタップする必要があります。そうしないとコマンドがタイムアウトします。 + +### オプション 3:手動設定 + +WeCom AI Bot プラットフォームから `bot_id` と `secret` を既にお持ちの場合、直接設定できます: + +```json +{ + "channel_list": { + "wecom": { + "enabled": true, + "type": "wecom", + "bot_id": "YOUR_BOT_ID", + "secret": "YOUR_SECRET", + "websocket_url": "wss://openws.work.weixin.qq.com", + "send_thinking_message": true, + "allow_from": [], + "reasoning_channel_id": "" + } + } +} +``` + +--- + +## 設定 + +| フィールド | 型 | デフォルト | 説明 | +| ---------- | -- | ---------- | ---- | +| `enabled` | bool | `false` | WeCom チャンネルを有効にする。 | +| `bot_id` | string | — | WeCom AI Bot 識別子。有効時に必須。 | +| `secret` | string | — | WeCom AI Bot シークレット。`.security.yml` に暗号化して保存。有効時に必須。 | +| `websocket_url` | string | `wss://openws.work.weixin.qq.com` | WeCom WebSocket エンドポイント。 | +| `send_thinking_message` | bool | `true` | ストリーミング返信の開始前に `Processing...` メッセージを送信する。 | +| `allow_from` | array | `[]` | 送信者許可リスト。空の場合はすべての送信者を許可。 | +| `reasoning_channel_id` | string | `""` | 推論・思考出力を別の会話にルーティングするためのオプションのチャット ID。 | + +### 環境変数 + +すべてのフィールドは `PICOCLAW_CHANNELS_WECOM_` プレフィックスの環境変数で上書きできます: + +| 環境変数 | 対応フィールド | +| -------- | -------------- | +| `PICOCLAW_CHANNELS_WECOM_ENABLED` | `enabled` | +| `PICOCLAW_CHANNELS_WECOM_BOT_ID` | `bot_id` | +| `PICOCLAW_CHANNELS_WECOM_SECRET` | `secret` | +| `PICOCLAW_CHANNELS_WECOM_WEBSOCKET_URL` | `websocket_url` | +| `PICOCLAW_CHANNELS_WECOM_SEND_THINKING_MESSAGE` | `send_thinking_message` | +| `PICOCLAW_CHANNELS_WECOM_ALLOW_FROM` | `allow_from` | +| `PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID` | `reasoning_channel_id` | + +--- + +## ランタイム動作 + +- PicoClaw はアクティブな WeCom ターンを維持し、可能な限り同じストリームでストリーミング返信を継続します。 +- ストリーミング返信の最大持続時間は **5.5 分**、最小送信間隔は **500ms** です。 +- ストリーミングが利用できなくなった場合、返信はアクティブプッシュ配信にフォールバックします。 +- チャットルートの関連付けは **30 分** の非アクティブ後に期限切れになります。 +- 受信メディアはエージェントに渡される前にローカルメディアストアにダウンロードされます。 +- 送信メディアは WeCom に一時ファイルとしてアップロードされ、メディアメッセージとして送信されます。 +- 重複メッセージは検出され抑制されます(最新 1000 件のメッセージ ID のリングバッファ)。 + +--- + +## レガシー WeCom 設定からの移行 + +| 以前の設定 | 移行方法 | +| ---------- | -------- | +| `channels.wecom`(Webhook ボット) | `bot_id` + `secret` を使用する `channels.wecom` に置き換える。 | +| `channels.wecom_app` | 削除して `channels.wecom` を使用する。 | +| `channels.wecom_aibot` | `bot_id` と `secret` を `channels.wecom` に移動する。 | +| `token`、`encoding_aes_key`、`webhook_url`、`webhook_path` | 使用されなくなりました。設定から削除してください。 | +| `corp_id`、`corp_secret`、`agent_id` | 使用されなくなりました。設定から削除してください。 | +| `welcome_message`、`processing_message`、`max_steps` | WeCom チャンネル設定の一部ではなくなりました。 | + +--- + +## トラブルシューティング + +### QR バインディングがタイムアウトする + +- QR コードをスキャンした後、**WeCom アプリ内でログインを確認** する必要があります。スキャンだけでは不十分です。 +- より長い `--timeout` で再実行してください:`picoclaw auth wecom --timeout 10m` +- ターミナルの QR コードがスキャンしにくい場合は、その下に表示される **QR コードリンク** を使用してブラウザで開いてください。 + +### QR コードの有効期限切れ + +- QR コードには有効期限があります。`picoclaw auth wecom` を再実行して新しいものを取得してください。 + +### WebSocket 接続の失敗 + +- `bot_id` と `secret` が正しいことを確認してください。 +- ホストが `wss://openws.work.weixin.qq.com` に到達できることを確認してください(アウトバウンド WebSocket、インバウンドポートは不要)。 + +### 返信が届かない + +- `allow_from` が送信者をブロックしていないか確認してください。 +- `channels.wecom.bot_id` と `channels.wecom.secret` が設定されており、空でないことを確認してください。 diff --git a/docs/channels/wecom/README.md b/docs/channels/wecom/README.md new file mode 100644 index 000000000..bb94d7431 --- /dev/null +++ b/docs/channels/wecom/README.md @@ -0,0 +1,149 @@ +> Back to [README](../../../README.md) + +# WeCom + +PicoClaw exposes WeCom as a single `channels.wecom` channel built on the official WeCom AI Bot WebSocket API. +This replaces the legacy `wecom`, `wecom_app`, and `wecom_aibot` split with one unified configuration model. + +> No public webhook callback URL is required. PicoClaw opens an outbound WebSocket connection to WeCom. + +## What This Channel Supports + +- Direct chat and group chat delivery +- Channel-side streaming replies over WeCom's AI Bot protocol +- Incoming text, voice, image, file, video, and mixed messages +- Outbound text and media replies (`image`, `file`, `voice`, `video`) +- QR-based onboarding via Web UI or CLI +- Shared allowlist and `reasoning_channel_id` routing + +--- + +## Quick Start + +### Option 1: Web UI QR Binding (Recommended) + +Open the Web UI, navigate to **Channels → WeCom**, and click the QR binding button. Scan the QR code with WeCom and confirm in the app — credentials are saved automatically. + +

+WeCom QR binding in Web UI +

+ +### Option 2: CLI QR Login + +Run: + +```bash +picoclaw auth wecom +``` + +The command: +1. Requests a QR code from WeCom and prints it in the terminal +2. Also prints a **QR Code Link** you can open in a browser if the terminal QR is hard to scan +3. Polls for confirmation — after scanning, you must also **confirm the login inside the WeCom app** +4. On success, writes `bot_id` and `secret` into `channels.wecom` and saves the config + +The default timeout is **5 minutes**. Use `--timeout` to extend it: + +```bash +picoclaw auth wecom --timeout 10m +``` + +> ⚠️ Scanning the QR code is not enough — you must also tap **Confirm** inside the WeCom app, otherwise the command will time out. + +### Option 3: Configure Manually + +If you already have a `bot_id` and `secret` from the WeCom AI Bot platform, configure directly: + +```json +{ + "channel_list": { + "wecom": { + "enabled": true, + "type": "wecom", + "bot_id": "YOUR_BOT_ID", + "secret": "YOUR_SECRET", + "websocket_url": "wss://openws.work.weixin.qq.com", + "send_thinking_message": true, + "allow_from": [], + "reasoning_channel_id": "" + } + } +} +``` + +--- + +## Configuration + +| Field | Type | Default | Description | +| ----- | ---- | ------- | ----------- | +| `enabled` | bool | `false` | Enable the WeCom channel. | +| `bot_id` | string | — | WeCom AI Bot identifier. Required when enabled. | +| `secret` | string | — | WeCom AI Bot secret. Stored encrypted in `.security.yml`. Required when enabled. | +| `websocket_url` | string | `wss://openws.work.weixin.qq.com` | WeCom WebSocket endpoint. | +| `send_thinking_message` | bool | `true` | Send a `Processing...` message before the streamed reply begins. | +| `allow_from` | array | `[]` | Sender allowlist. Empty means allow all senders. | +| `reasoning_channel_id` | string | `""` | Optional chat ID to route reasoning/thinking output to a separate conversation. | + +### Environment Variables + +All fields can be overridden via environment variables with the prefix `PICOCLAW_CHANNELS_WECOM_`: + +| Environment Variable | Corresponding Field | +| -------------------- | ------------------- | +| `PICOCLAW_CHANNELS_WECOM_ENABLED` | `enabled` | +| `PICOCLAW_CHANNELS_WECOM_BOT_ID` | `bot_id` | +| `PICOCLAW_CHANNELS_WECOM_SECRET` | `secret` | +| `PICOCLAW_CHANNELS_WECOM_WEBSOCKET_URL` | `websocket_url` | +| `PICOCLAW_CHANNELS_WECOM_SEND_THINKING_MESSAGE` | `send_thinking_message` | +| `PICOCLAW_CHANNELS_WECOM_ALLOW_FROM` | `allow_from` | +| `PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID` | `reasoning_channel_id` | + +--- + +## Runtime Behavior + +- PicoClaw maintains an active WeCom turn so streaming replies can continue on the same stream when possible. +- Streaming replies have a maximum duration of **5.5 minutes** and a minimum send interval of **500ms**. +- If streaming is no longer available, replies fall back to active push delivery. +- Chat route associations expire after **30 minutes** of inactivity. +- Incoming media is downloaded into the local media store before being passed to the agent. +- Outbound media is uploaded to WeCom as a temporary file and then sent as a media message. +- Duplicate messages are detected and suppressed (ring buffer of last 1000 message IDs). + +--- + +## Migration from Legacy WeCom Config + +| Previous config | Migration | +| --------------- | --------- | +| `channels.wecom` (webhook bot) | Replace with `channels.wecom` using `bot_id` + `secret`. | +| `channels.wecom_app` | Remove. Use `channels.wecom` instead. | +| `channels.wecom_aibot` | Move `bot_id` and `secret` to `channels.wecom`. | +| `token`, `encoding_aes_key`, `webhook_url`, `webhook_path` | No longer used. Remove from config. | +| `corp_id`, `corp_secret`, `agent_id` | No longer used. Remove from config. | +| `welcome_message`, `processing_message`, `max_steps` | No longer part of the WeCom channel config. | + +--- + +## Troubleshooting + +### QR binding times out + +- After scanning the QR code, you must also **confirm the login inside the WeCom app**. Scanning alone is not enough. +- Re-run with a larger `--timeout`: `picoclaw auth wecom --timeout 10m` +- If the QR code in the terminal is hard to scan, use the **QR Code Link** printed below it to open in a browser. + +### QR code expired + +- The QR code has a limited validity. Re-run `picoclaw auth wecom` to get a fresh one. + +### WebSocket connection fails + +- Verify `bot_id` and `secret` are correct. +- Confirm the host can reach `wss://openws.work.weixin.qq.com` (outbound WebSocket, no inbound port needed). + +### Replies do not arrive + +- Check whether `allow_from` is blocking the sender. +- Check that `channels.wecom.bot_id` and `channels.wecom.secret` are set and non-empty. diff --git a/docs/channels/wecom/README.pt-br.md b/docs/channels/wecom/README.pt-br.md new file mode 100644 index 000000000..07a5e23b9 --- /dev/null +++ b/docs/channels/wecom/README.pt-br.md @@ -0,0 +1,149 @@ +> Voltar ao [README](../../project/README.pt-br.md) + +# WeCom + +O PicoClaw expõe o WeCom como um único canal `channels.wecom`, construído sobre a API WebSocket oficial do WeCom AI Bot. +Isso substitui a antiga separação `wecom`, `wecom_app` e `wecom_aibot` por um modelo de configuração unificado. + +> Nenhuma URL de callback webhook pública é necessária. O PicoClaw estabelece uma conexão WebSocket de saída para o WeCom. + +## Funcionalidades Suportadas + +- Chat direto e chat em grupo +- Respostas em streaming pelo protocolo WeCom AI Bot +- Mensagens recebidas: texto, voz, imagem, arquivo, vídeo e mensagens mistas +- Respostas enviadas: texto e mídia (`image`, `file`, `voice`, `video`) +- Onboarding por QR code via Web UI ou CLI +- Lista de permissões compartilhada e roteamento `reasoning_channel_id` + +--- + +## Início Rápido + +### Opção 1: Vinculação QR via Web UI (Recomendado) + +Abra a Web UI, navegue até **Channels → WeCom** e clique no botão de vinculação QR. Escaneie o QR code com o WeCom e confirme no aplicativo — as credenciais são salvas automaticamente. + +

+Vinculação QR do WeCom na Web UI +

+ +### Opção 2: Login QR via CLI + +Execute: + +```bash +picoclaw auth wecom +``` + +O comando: +1. Solicita um QR code ao WeCom e o exibe no terminal +2. Também exibe um **Link do QR Code** que você pode abrir no navegador se o QR do terminal for difícil de escanear +3. Aguarda a confirmação — após escanear, você também deve **confirmar o login dentro do aplicativo WeCom** +4. Em caso de sucesso, grava `bot_id` e `secret` em `channels.wecom` e salva a configuração + +O timeout padrão é de **5 minutos**. Use `--timeout` para estendê-lo: + +```bash +picoclaw auth wecom --timeout 10m +``` + +> ⚠️ Escanear o QR code não é suficiente — você também deve tocar em **Confirmar** dentro do aplicativo WeCom, caso contrário o comando expirará. + +### Opção 3: Configuração Manual + +Se você já possui um `bot_id` e `secret` da plataforma WeCom AI Bot, configure diretamente: + +```json +{ + "channel_list": { + "wecom": { + "enabled": true, + "type": "wecom", + "bot_id": "YOUR_BOT_ID", + "secret": "YOUR_SECRET", + "websocket_url": "wss://openws.work.weixin.qq.com", + "send_thinking_message": true, + "allow_from": [], + "reasoning_channel_id": "" + } + } +} +``` + +--- + +## Configuração + +| Campo | Tipo | Padrão | Descrição | +| ----- | ---- | ------ | --------- | +| `enabled` | bool | `false` | Ativar o canal WeCom. | +| `bot_id` | string | — | Identificador do WeCom AI Bot. Obrigatório quando ativado. | +| `secret` | string | — | Secret do WeCom AI Bot. Armazenado criptografado em `.security.yml`. Obrigatório quando ativado. | +| `websocket_url` | string | `wss://openws.work.weixin.qq.com` | Endpoint WebSocket do WeCom. | +| `send_thinking_message` | bool | `true` | Enviar uma mensagem `Processing...` antes do início da resposta em streaming. | +| `allow_from` | array | `[]` | Lista de permissões de remetentes. Vazio significa permitir todos os remetentes. | +| `reasoning_channel_id` | string | `""` | ID de chat opcional para rotear a saída de raciocínio para uma conversa separada. | + +### Variáveis de Ambiente + +Todos os campos podem ser substituídos via variáveis de ambiente com o prefixo `PICOCLAW_CHANNELS_WECOM_`: + +| Variável de Ambiente | Campo Correspondente | +| -------------------- | -------------------- | +| `PICOCLAW_CHANNELS_WECOM_ENABLED` | `enabled` | +| `PICOCLAW_CHANNELS_WECOM_BOT_ID` | `bot_id` | +| `PICOCLAW_CHANNELS_WECOM_SECRET` | `secret` | +| `PICOCLAW_CHANNELS_WECOM_WEBSOCKET_URL` | `websocket_url` | +| `PICOCLAW_CHANNELS_WECOM_SEND_THINKING_MESSAGE` | `send_thinking_message` | +| `PICOCLAW_CHANNELS_WECOM_ALLOW_FROM` | `allow_from` | +| `PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID` | `reasoning_channel_id` | + +--- + +## Comportamento em Tempo de Execução + +- O PicoClaw mantém um turno WeCom ativo para que as respostas em streaming possam continuar no mesmo fluxo quando possível. +- As respostas em streaming têm uma duração máxima de **5,5 minutos** e um intervalo mínimo de envio de **500ms**. +- Se o streaming não estiver mais disponível, as respostas recorrem à entrega por push ativo. +- As associações de rotas de chat expiram após **30 minutos** de inatividade. +- A mídia recebida é baixada para o armazenamento de mídia local antes de ser passada ao agente. +- A mídia enviada é carregada para o WeCom como um arquivo temporário e então enviada como uma mensagem de mídia. +- Mensagens duplicadas são detectadas e suprimidas (buffer circular dos últimos 1000 IDs de mensagens). + +--- + +## Migração da Configuração Legada do WeCom + +| Configuração anterior | Migração | +| --------------------- | -------- | +| `channels.wecom` (bot webhook) | Substituir por `channels.wecom` usando `bot_id` + `secret`. | +| `channels.wecom_app` | Remover. Usar `channels.wecom` no lugar. | +| `channels.wecom_aibot` | Mover `bot_id` e `secret` para `channels.wecom`. | +| `token`, `encoding_aes_key`, `webhook_url`, `webhook_path` | Não mais utilizados. Remover da configuração. | +| `corp_id`, `corp_secret`, `agent_id` | Não mais utilizados. Remover da configuração. | +| `welcome_message`, `processing_message`, `max_steps` | Não fazem mais parte da configuração do canal WeCom. | + +--- + +## Solução de Problemas + +### A vinculação QR expira + +- Após escanear o QR code, você também deve **confirmar o login dentro do aplicativo WeCom**. Escanear sozinho não é suficiente. +- Execute novamente com um `--timeout` maior: `picoclaw auth wecom --timeout 10m` +- Se o QR code no terminal for difícil de escanear, use o **Link do QR Code** exibido abaixo dele para abrir no navegador. + +### QR code expirado + +- O QR code tem validade limitada. Execute novamente `picoclaw auth wecom` para obter um novo. + +### Falha na conexão WebSocket + +- Verifique se `bot_id` e `secret` estão corretos. +- Confirme que o host pode alcançar `wss://openws.work.weixin.qq.com` (WebSocket de saída, nenhuma porta de entrada necessária). + +### As respostas não chegam + +- Verifique se `allow_from` está bloqueando o remetente. +- Verifique se `channels.wecom.bot_id` e `channels.wecom.secret` estão definidos e não vazios. diff --git a/docs/channels/wecom/README.vi.md b/docs/channels/wecom/README.vi.md new file mode 100644 index 000000000..4769fd6d6 --- /dev/null +++ b/docs/channels/wecom/README.vi.md @@ -0,0 +1,149 @@ +> Quay lại [README](../../project/README.vi.md) + +# WeCom + +PicoClaw cung cấp WeCom dưới dạng một kênh duy nhất `channels.wecom`, được xây dựng trên API WebSocket chính thức của WeCom AI Bot. +Điều này thay thế việc phân tách cũ `wecom`, `wecom_app` và `wecom_aibot` bằng một mô hình cấu hình thống nhất. + +> Không cần URL callback webhook công khai. PicoClaw thiết lập kết nối WebSocket đi ra tới WeCom. + +## Tính năng được hỗ trợ + +- Chat trực tiếp và chat nhóm +- Phản hồi streaming qua giao thức WeCom AI Bot +- Nhận tin nhắn văn bản, giọng nói, hình ảnh, tệp, video và tin nhắn hỗn hợp +- Gửi phản hồi văn bản và phương tiện (`image`, `file`, `voice`, `video`) +- Đăng ký qua mã QR bằng Web UI hoặc CLI +- Danh sách cho phép chung và định tuyến `reasoning_channel_id` + +--- + +## Bắt đầu nhanh + +### Tùy chọn 1: Liên kết QR qua Web UI (Khuyến nghị) + +Mở Web UI, điều hướng đến **Channels → WeCom** và nhấp vào nút liên kết QR. Quét mã QR bằng WeCom và xác nhận trong ứng dụng — thông tin đăng nhập được lưu tự động. + +

+Liên kết QR WeCom trong Web UI +

+ +### Tùy chọn 2: Đăng nhập QR qua CLI + +Chạy: + +```bash +picoclaw auth wecom +``` + +Lệnh thực hiện: +1. Yêu cầu mã QR từ WeCom và hiển thị trong terminal +2. Đồng thời in ra một **Liên kết mã QR** mà bạn có thể mở trong trình duyệt nếu mã QR trên terminal khó quét +3. Chờ xác nhận — sau khi quét, bạn cũng phải **xác nhận đăng nhập trong ứng dụng WeCom** +4. Khi thành công, ghi `bot_id` và `secret` vào `channels.wecom` và lưu cấu hình + +Thời gian chờ mặc định là **5 phút**. Sử dụng `--timeout` để kéo dài: + +```bash +picoclaw auth wecom --timeout 10m +``` + +> ⚠️ Quét mã QR là chưa đủ — bạn cũng phải nhấn **Xác nhận** trong ứng dụng WeCom, nếu không lệnh sẽ hết thời gian chờ. + +### Tùy chọn 3: Cấu hình thủ công + +Nếu bạn đã có `bot_id` và `secret` từ nền tảng WeCom AI Bot, hãy cấu hình trực tiếp: + +```json +{ + "channel_list": { + "wecom": { + "enabled": true, + "type": "wecom", + "bot_id": "YOUR_BOT_ID", + "secret": "YOUR_SECRET", + "websocket_url": "wss://openws.work.weixin.qq.com", + "send_thinking_message": true, + "allow_from": [], + "reasoning_channel_id": "" + } + } +} +``` + +--- + +## Cấu hình + +| Trường | Kiểu | Mặc định | Mô tả | +| ------ | ---- | -------- | ----- | +| `enabled` | bool | `false` | Kích hoạt kênh WeCom. | +| `bot_id` | string | — | Mã định danh WeCom AI Bot. Bắt buộc khi được kích hoạt. | +| `secret` | string | — | Secret của WeCom AI Bot. Được lưu mã hóa trong `.security.yml`. Bắt buộc khi được kích hoạt. | +| `websocket_url` | string | `wss://openws.work.weixin.qq.com` | Điểm cuối WebSocket của WeCom. | +| `send_thinking_message` | bool | `true` | Gửi tin nhắn `Processing...` trước khi phản hồi streaming bắt đầu. | +| `allow_from` | array | `[]` | Danh sách cho phép người gửi. Để trống nghĩa là cho phép tất cả. | +| `reasoning_channel_id` | string | `""` | ID chat tùy chọn để định tuyến đầu ra suy luận đến một cuộc hội thoại riêng. | + +### Biến môi trường + +Tất cả các trường có thể được ghi đè bằng biến môi trường với tiền tố `PICOCLAW_CHANNELS_WECOM_`: + +| Biến môi trường | Trường tương ứng | +| ---------------- | ---------------- | +| `PICOCLAW_CHANNELS_WECOM_ENABLED` | `enabled` | +| `PICOCLAW_CHANNELS_WECOM_BOT_ID` | `bot_id` | +| `PICOCLAW_CHANNELS_WECOM_SECRET` | `secret` | +| `PICOCLAW_CHANNELS_WECOM_WEBSOCKET_URL` | `websocket_url` | +| `PICOCLAW_CHANNELS_WECOM_SEND_THINKING_MESSAGE` | `send_thinking_message` | +| `PICOCLAW_CHANNELS_WECOM_ALLOW_FROM` | `allow_from` | +| `PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID` | `reasoning_channel_id` | + +--- + +## Hành vi khi chạy + +- PicoClaw duy trì một lượt WeCom đang hoạt động để phản hồi streaming có thể tiếp tục trên cùng một luồng khi có thể. +- Phản hồi streaming có thời lượng tối đa **5,5 phút** và khoảng cách gửi tối thiểu **500ms**. +- Nếu streaming không còn khả dụng, phản hồi sẽ chuyển sang gửi push chủ động. +- Các liên kết tuyến chat hết hạn sau **30 phút** không hoạt động. +- Phương tiện nhận được sẽ được tải xuống bộ lưu trữ phương tiện cục bộ trước khi chuyển cho agent. +- Phương tiện gửi đi được tải lên WeCom dưới dạng tệp tạm thời, sau đó gửi dưới dạng tin nhắn phương tiện. +- Tin nhắn trùng lặp được phát hiện và loại bỏ (bộ đệm vòng của 1000 ID tin nhắn gần nhất). + +--- + +## Di chuyển từ cấu hình WeCom cũ + +| Cấu hình trước đây | Di chuyển | +| ------------------- | --------- | +| `channels.wecom` (bot webhook) | Thay thế bằng `channels.wecom` sử dụng `bot_id` + `secret`. | +| `channels.wecom_app` | Xóa. Sử dụng `channels.wecom` thay thế. | +| `channels.wecom_aibot` | Di chuyển `bot_id` và `secret` sang `channels.wecom`. | +| `token`, `encoding_aes_key`, `webhook_url`, `webhook_path` | Không còn sử dụng. Xóa khỏi cấu hình. | +| `corp_id`, `corp_secret`, `agent_id` | Không còn sử dụng. Xóa khỏi cấu hình. | +| `welcome_message`, `processing_message`, `max_steps` | Không còn là một phần của cấu hình kênh WeCom. | + +--- + +## Khắc phục sự cố + +### Liên kết QR hết thời gian chờ + +- Sau khi quét mã QR, bạn cũng phải **xác nhận đăng nhập trong ứng dụng WeCom**. Chỉ quét là chưa đủ. +- Chạy lại với `--timeout` lớn hơn: `picoclaw auth wecom --timeout 10m` +- Nếu mã QR trên terminal khó quét, hãy sử dụng **Liên kết mã QR** được in bên dưới để mở trong trình duyệt. + +### Mã QR đã hết hạn + +- Mã QR có thời hạn hiệu lực giới hạn. Chạy lại `picoclaw auth wecom` để lấy mã mới. + +### Kết nối WebSocket thất bại + +- Kiểm tra xem `bot_id` và `secret` có chính xác không. +- Xác nhận máy chủ có thể kết nối đến `wss://openws.work.weixin.qq.com` (WebSocket đi ra, không cần cổng đến). + +### Phản hồi không đến + +- Kiểm tra xem `allow_from` có đang chặn người gửi không. +- Kiểm tra rằng `channels.wecom.bot_id` và `channels.wecom.secret` đã được thiết lập và không trống. diff --git a/docs/channels/wecom/README.zh.md b/docs/channels/wecom/README.zh.md new file mode 100644 index 000000000..8303a8f8a --- /dev/null +++ b/docs/channels/wecom/README.zh.md @@ -0,0 +1,149 @@ +> 返回 [README](../../project/README.zh.md) + +# 企业微信(WeCom) + +PicoClaw 将企业微信整合为单一的 `channels.wecom` 渠道,基于腾讯官方企业微信 AI Bot WebSocket API 实现。 +原有的 `wecom`、`wecom_app`、`wecom_aibot` 三个独立渠道已合并为统一配置模型。 + +> 本渠道无需公网 Webhook 回调地址。PicoClaw 主动向企业微信建立出站 WebSocket 连接。 + +## 支持的功能 + +- 单聊和群聊消息收发 +- 基于企业微信 AI Bot 协议的流式回复 +- 接收文本、语音、图片、文件、视频及混合消息 +- 发送文本及媒体消息(`image`、`file`、`voice`、`video`) +- 通过 Web UI 或 CLI 扫码绑定 +- 发送者白名单和 `reasoning_channel_id` 路由 + +--- + +## 快速开始 + +### 方式一:Web UI 扫码绑定(推荐) + +打开 Web UI,进入 **Channels → WeCom**,点击扫码绑定按钮。用企业微信扫码并在 App 内确认,凭据自动保存。 + +

+Web UI 企业微信扫码绑定 +

+ +### 方式二:CLI 扫码登录 + +运行: + +```bash +picoclaw auth wecom +``` + +命令执行流程: +1. 向企业微信请求二维码并在终端打印 +2. 同时打印一个**二维码链接**,终端二维码不清晰时可在浏览器中打开 +3. 轮询确认状态——扫码后还需要在**企业微信 App 内点击确认** +4. 成功后将 `bot_id` 和 `secret` 写入 `channels.wecom` 并保存配置 + +默认超时为 **5 分钟**,可通过 `--timeout` 延长: + +```bash +picoclaw auth wecom --timeout 10m +``` + +> ⚠️ 仅扫描二维码还不够——必须在企业微信 App 内点击**确认**,否则命令会超时。 + +### 方式三:手动配置 + +如果已有企业微信 AI Bot 的 `bot_id` 和 `secret`,可直接配置: + +```json +{ + "channel_list": { + "wecom": { + "enabled": true, + "type": "wecom", + "bot_id": "YOUR_BOT_ID", + "secret": "YOUR_SECRET", + "websocket_url": "wss://openws.work.weixin.qq.com", + "send_thinking_message": true, + "allow_from": [], + "reasoning_channel_id": "" + } + } +} +``` + +--- + +## 配置项说明 + +| 字段 | 类型 | 默认值 | 说明 | +| ---- | ---- | ------ | ---- | +| `enabled` | bool | `false` | 启用企业微信渠道。 | +| `bot_id` | string | — | 企业微信 AI Bot 标识符。启用时必填。 | +| `secret` | string | — | 企业微信 AI Bot 密钥。加密存储于 `.security.yml`。启用时必填。 | +| `websocket_url` | string | `wss://openws.work.weixin.qq.com` | 企业微信 WebSocket 端点。 | +| `send_thinking_message` | bool | `true` | 在流式回复开始前发送"处理中..."提示消息。 | +| `allow_from` | array | `[]` | 发送者白名单。为空时允许所有人。 | +| `reasoning_channel_id` | string | `""` | 可选,将推理/思考内容路由到指定会话 ID。 | + +### 环境变量 + +所有字段均可通过 `PICOCLAW_CHANNELS_WECOM_` 前缀的环境变量覆盖: + +| 环境变量 | 对应字段 | +| -------- | -------- | +| `PICOCLAW_CHANNELS_WECOM_ENABLED` | `enabled` | +| `PICOCLAW_CHANNELS_WECOM_BOT_ID` | `bot_id` | +| `PICOCLAW_CHANNELS_WECOM_SECRET` | `secret` | +| `PICOCLAW_CHANNELS_WECOM_WEBSOCKET_URL` | `websocket_url` | +| `PICOCLAW_CHANNELS_WECOM_SEND_THINKING_MESSAGE` | `send_thinking_message` | +| `PICOCLAW_CHANNELS_WECOM_ALLOW_FROM` | `allow_from` | +| `PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID` | `reasoning_channel_id` | + +--- + +## 运行时行为 + +- PicoClaw 维护活跃的企业微信 Turn,流式回复尽可能在同一流上继续。 +- 流式回复最大持续时长为 **5.5 分钟**,最小发送间隔为 **500ms**。 +- 流式不可用时,回复降级为主动推送。 +- 会话路由关联在 **30 分钟**无活动后过期。 +- 接收到的媒体文件先下载到本地媒体存储,再传递给 Agent。 +- 发送媒体时先上传为企业微信临时文件,再作为媒体消息发送。 +- 自动检测并过滤重复消息(环形缓冲区,最多记录 1000 条消息 ID)。 + +--- + +## 从旧版企业微信配置迁移 + +| 旧配置 | 迁移方式 | +| ------ | -------- | +| `channels.wecom`(Webhook 机器人) | 改用 `channels.wecom`,填写 `bot_id` + `secret`。 | +| `channels.wecom_app` | 删除,改用 `channels.wecom`。 | +| `channels.wecom_aibot` | 将 `bot_id` 和 `secret` 移至 `channels.wecom`。 | +| `token`、`encoding_aes_key`、`webhook_url`、`webhook_path` | 已废弃,从配置中删除。 | +| `corp_id`、`corp_secret`、`agent_id` | 已废弃,从配置中删除。 | +| `welcome_message`、`processing_message`、`max_steps` | 已不属于企业微信渠道配置,删除即可。 | + +--- + +## 常见问题 + +### 扫码绑定超时 + +- 扫码后必须在**企业微信 App 内点击确认**,仅扫码不够。 +- 使用更长的超时重试:`picoclaw auth wecom --timeout 10m` +- 终端二维码不清晰时,使用命令打印的**二维码链接**在浏览器中打开。 + +### 二维码已过期 + +- 二维码有效期有限,重新运行 `picoclaw auth wecom` 获取新二维码。 + +### WebSocket 连接失败 + +- 检查 `bot_id` 和 `secret` 是否正确。 +- 确认设备可以访问 `wss://openws.work.weixin.qq.com`(出站 WebSocket,无需开放入站端口)。 + +### 收不到回复 + +- 检查 `allow_from` 是否屏蔽了发送者。 +- 确认 `channels.wecom.bot_id` 和 `channels.wecom.secret` 已填写且非空。 diff --git a/docs/channels/wecom/wecom_aibot/README.zh.md b/docs/channels/wecom/wecom_aibot/README.zh.md deleted file mode 100644 index d210528af..000000000 --- a/docs/channels/wecom/wecom_aibot/README.zh.md +++ /dev/null @@ -1,116 +0,0 @@ -# 企业微信智能机器人 (AI Bot) - -企业微信智能机器人(AI Bot)是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议,并支持超时后通过 `response_url` 主动推送最终回复。 - -## 与其他 WeCom 通道的对比 - -| 特性 | WeCom Bot | WeCom App | **WeCom AI Bot** | -|------|-----------|-----------|-----------------| -| 私聊 | ✅ | ✅ | ✅ | -| 群聊 | ✅ | ❌ | ✅ | -| 流式输出 | ❌ | ❌ | ✅ | -| 超时主动推送 | ❌ | ✅ | ✅ | -| 配置复杂度 | 低 | 高 | 中 | - -## 配置 - -```json -{ - "channels": { - "wecom_aibot": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-aibot", - "allow_from": [], - "welcome_message": "你好!有什么可以帮助你的吗?", - "max_steps": 10 - } - } -} -``` - -| 字段 | 类型 | 必填 | 描述 | -| ---------------- | ------ | ---- | -------------------------------------------------- | -| token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 | -| encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 | -| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-aibot) | -| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 | -| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 | -| reply_timeout | int | 否 | 回复超时时间(秒,默认:5) | -| max_steps | int | 否 | Agent 最大执行步骤数(默认:10) | - -## 设置流程 - -1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin) -2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot -3. 在 AI Bot 配置页面,填写"消息接收"信息: - - **URL**:`http://:18791/webhook/wecom-aibot` - - **Token**:随机生成或自定义 - - **EncodingAESKey**:点击"随机生成",得到 43 字符密钥 -4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存(企业微信会发送验证请求) - -> [!TIP] -> 服务器需要能被企业微信服务器访问。如在内网/本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。 - -## 流式响应协议 - -WeCom AI Bot 使用"流式拉取"协议,区别于普通 Webhook 的一次性回复: - -``` -用户发消息 - │ - ▼ -PicoClaw 立即返回 {finish: false}(Agent 开始处理) - │ - ▼ -企业微信每隔约 1 秒拉取一次 {msgtype: "stream", stream: {id: "..."}} - │ - ├─ Agent 未完成 → 返回 {finish: false}(继续等待) - │ - └─ Agent 完成 → 返回 {finish: true, content: "回答内容"} -``` - -**超时处理**(任务超过 30 秒): - -若 Agent 处理时间超过约 30 秒(企业微信最大轮询窗口为 6 分钟),PicoClaw 会: - -1. 立即关闭流,向用户显示「⏳ 正在处理中,请稍候,结果将稍后发送。」 -2. Agent 继续在后台运行 -3. Agent 完成后,通过消息中携带的 `response_url` 将最终回复主动推送给用户 - -> `response_url` 由企业微信颁发,有效期 1 小时,只可使用一次,无需加密,直接 POST markdown 消息体即可。 - -## 欢迎语 - -配置 `welcome_message` 后,当用户打开与 AI Bot 的聊天窗口时(`enter_chat` 事件),PicoClaw 会自动回复该欢迎语。留空则静默忽略。 - -```json -"welcome_message": "你好!我是 PicoClaw AI 助手,有什么可以帮你?" -``` - -## 常见问题 - -### 回调 URL 验证失败 - -- 确认服务器防火墙已开放对应端口(默认 18791) -- 确认 `token` 与 `encoding_aes_key` 填写正确 -- 检查 PicoClaw 日志是否收到了来自企业微信的 GET 请求 - -### 消息没有回复 - -- 检查 `allow_from` 是否意外限制了发送者 -- 查看日志中是否出现 `context canceled` 或 Agent 错误 -- 确认 Agent 配置(`model_name` 等)正确 - -### 超长任务没有收到最终推送 - -- 确认消息回调中携带了 `response_url`(仅企业微信新版 AI Bot 支持) -- 确认服务器能主动访问外网(需向 `response_url` POST 请求) -- 查看日志关键词 `response_url mode` 和 `Sending reply via response_url` - -## 参考文档 - -- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/100719) -- [流式响应协议说明](https://developer.work.weixin.qq.com/document/path/100719) -- [response_url 主动回复](https://developer.work.weixin.qq.com/document/path/101138) diff --git a/docs/channels/wecom/wecom_app/README.zh.md b/docs/channels/wecom/wecom_app/README.zh.md deleted file mode 100644 index 0a9858107..000000000 --- a/docs/channels/wecom/wecom_app/README.zh.md +++ /dev/null @@ -1,45 +0,0 @@ -# 企业微信自建应用 - -企业微信自建应用是指企业在企业微信中创建的应用,主要用于企业内部使用。通过企业微信自建应用,企业可以实现与员工的高效沟通和协作,提高工作效率。 - -## 配置 - -```json -{ - "channels": { - "wecom_app": { - "enabled": true, - "corp_id": "wwxxxxxxxxxxxxxxxx", - "corp_secret": "YOUR_CORP_SECRET", - "agent_id": 1000002, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_path": "/webhook/wecom-app", - "allow_from": [], - "reply_timeout": 5 - } - } -} -``` - -| 字段 | 类型 | 必填 | 描述 | -| ---------------- | ------ | ---- | ---------------------------------------- | -| corp_id | string | 是 | 企业 ID | -| corp_secret | string | 是 | 应用程序密钥 | -| agent_id | int | 是 | 应用程序代理 ID | -| token | string | 是 | 回调验证令牌 | -| encoding_aes_key | string | 是 | 43 字符 AES 密钥 | -| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app) | -| allow_from | array | 否 | 用户 ID 白名单 | -| reply_timeout | int | 否 | 回复超时时间(秒) | - -## 设置流程 - -1. 登录 [企业微信管理后台](https://work.weixin.qq.com/) -2. 进入“应用管理” -> “创建应用” -3. 获取企业 ID (CorpID) 和应用 Secret -4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey -5. 设置回调 URL 为 `http://:/webhook/wecom-app` -6. 将 CorpID, Secret, AgentID 等信息填入配置文件 - - 注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调,请把外部域名反向代理到 Gateway(默认端口 18790)。 diff --git a/docs/channels/wecom/wecom_bot/README.zh.md b/docs/channels/wecom/wecom_bot/README.zh.md deleted file mode 100644 index 63d9b84d6..000000000 --- a/docs/channels/wecom/wecom_bot/README.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# 企业微信机器人 - -企业微信机器人是企业微信提供的一种快速接入方式,可以通过 Webhook URL 接收消息。 - -## 配置 - -```json -{ - "channels": { - "wecom": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_path": "/webhook/wecom", - "allow_from": [], - "reply_timeout": 5 - } - } -} -``` - -| 字段 | 类型 | 必填 | 描述 | -| ---------------- | ------ | ---- | -------------------------------------------- | -| token | string | 是 | 签名验证代币 | -| encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 | -| webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL | -| webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom) | -| allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) | -| reply_timeout | int | 否 | 回复超时时间(单位:秒,默认值:5) | - -## 设置流程 - -1. 在企业微信群中添加机器人 -2. 获取 Webhook URL -3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey -4. 将相关信息填入配置文件 - - 注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调,请把外部域名反向代理到 Gateway(默认端口 18790)。 diff --git a/docs/channels/weixin/README.md b/docs/channels/weixin/README.md new file mode 100644 index 000000000..4e240d69b --- /dev/null +++ b/docs/channels/weixin/README.md @@ -0,0 +1,59 @@ +# 💬 Weixin (WeChat Personal) Channel + +PicoClaw supports connecting to your personal WeChat account using the official Tencent iLink API. + +## 🚀 Quick Onboarding + +The easiest way to set up the Weixin channel is using the interactive onboarding command: + +```bash +picoclaw auth weixin +``` + +This command will: +1. Request a QR code from the iLink API and display it in your terminal. +2. Wait for you to scan the QR code with your WeChat mobile app. +3. Upon approval, automatically save the generated access token to your `~/.picoclaw/config.json`. + +After onboarding, you can start the gateway: + +```bash +picoclaw gateway +``` + +--- + +## ⚙️ Configuration + +You can also manually configure the filter rules in `config.json` under the `channels.weixin` section. + +```json +{ + "channel_list": { + "weixin": { + "enabled": true, + "type": "weixin", + "token": "YOUR_WEIXIN_TOKEN", + "allow_from": [ + "user_id_1", + "user_id_2" + ], + "proxy": "" + } + } +} +``` + +### Configuration Fields + +| Field | Description | +|---|---| +| `enabled` | Set to `true` to enable the channel at startup. | +| `token` | The authentication token obtained via QR login. | +| `allow_from` | (Optional) List of WeChat User IDs permitted to interact with the bot. If empty, anyone who can send messages to the connected account can trigger the bot. | +| `proxy` | (Optional) HTTP proxy address (e.g. `http://localhost:7890`) for environments where connection to `ilinkai.weixin.qq.com` is restricted. | + +## ⚠️ Important Notes + +- **One Account Only**: The iLink token binds to a single session. Starting a new interaction generally invalidates older tokens if another device authorizes. +- **Message Rate Limits**: To avoid getting your account restricted by WeChat anti-spam systems, avoid loop triggers or high-frequency broadcasts. diff --git a/docs/channels/weixin/README.zh.md b/docs/channels/weixin/README.zh.md new file mode 100644 index 000000000..19a9f9fa2 --- /dev/null +++ b/docs/channels/weixin/README.zh.md @@ -0,0 +1,59 @@ +# 💬 微信个人号渠道 (Weixin) + +PicoClaw 支持使用腾讯官方 iLink API 连接您的个人微信账号。 + +## 🚀 快速激活 + +最简单的方法是使用交互式 onboarding 命令进行一键激活: + +```bash +picoclaw auth weixin +``` + +该命令将: +1. 从 iLink API 获取二维码并在终端中打印。 +2. 等待您使用手机微信 App 扫码。 +3. 扫码确认后,自动将生成的 Access Token 保存至您的 `~/.picoclaw/config.json` 中。 + +配置完成后,即可启动网关: + +```bash +picoclaw gateway +``` + +--- + +## ⚙️ 配置说明 + +您也可以在 `config.json` 的 `channels.weixin` 段目下进行手动维护。 + +```json +{ + "channel_list": { + "weixin": { + "enabled": true, + "type": "weixin", + "token": "YOUR_WEIXIN_TOKEN", + "allow_from": [ + "user_id_1", + "user_id_2" + ], + "proxy": "" + } + } +} +``` + +### 字段解析 + +| 字段 | 说明 | +|---|---| +| `enabled` | 设置为 `true` 以在启动时激活该频道。 | +| `token` | 通过扫码获取的认证令牌。 | +| `allow_from` | (可选) 允许与机器人交互的微信 User ID 列表。如果为空,任何能给此微信号发消息的人都可以触发机器人。 | +| `proxy` | (可选) HTTP 代理地址(例如 `http://localhost:7890`),适合网络访问受限环境。 | + +## ⚠️ 注意事项 + +- **单端绑定**: iLink 令牌通常与单个会话绑定。在其他地方重新扫码激活可能会导致旧令牌失效。 +- **频率控制**: 为避免触发微信的风控反垃圾机制,请避免设置死循环触发、高频广播等恶意行为。 diff --git a/docs/design/current-hardware-support-and-serial.zh.md b/docs/design/current-hardware-support-and-serial.zh.md new file mode 100644 index 000000000..bf91355c2 --- /dev/null +++ b/docs/design/current-hardware-support-and-serial.zh.md @@ -0,0 +1,91 @@ +# 当前硬件支持现状与串口 Tool 方案 + +## 现状结论 + +当前项目已有的硬件相关能力主要分为两条线: + +1. 设备事件监控 + - `pkg/devices` 已实现设备事件服务。 + - 当前只有 Linux USB 热插拔事件源 `pkg/devices/sources/usb_linux.go`。 + - 能力定位是“发现和通知”,不是“总线读写控制”。 + +2. 硬件控制 Tool + - `pkg/tools/hardware/i2c*.go`:I2C Tool,支持 `detect`、`scan`、`read`、`write`。 + - `pkg/tools/hardware/spi*.go`:SPI Tool,支持 `list`、`transfer`、`read`。 + - 这两类 Tool 当前都只在 Linux 主机上启用,直接依赖 `/dev/i2c-*` 与 `/dev/spidev*`。 + +因此,项目在“硬件支持能力”上已经具备: + +- Linux USB 设备插拔感知 +- Linux I2C 总线控制 +- Linux SPI 总线控制 + +但还缺少: + +- 串口/UART 控制 +- macOS / Windows 下可直接使用的硬件控制 Tool +- 面向统一硬件抽象的跨总线能力模型 + +## 本次新增 + +本次新增内建 `serial` Tool,并接入现有 Tool 体系: + +- 配置项:`tools.serial.enabled` +- Tool 注册:`pkg/agent/agent_init.go` +- Web 工具页:`/api/tools` 能展示与切换 `serial` +- 前端状态文案:新增 `requires_serial_platform` + +## Serial Tool 设计 + +`serial` 采用无状态调用模型,每次请求都自行打开和关闭端口,避免在 agent 回合之间维护串口会话状态。 + +支持动作: + +- `list`:枚举主机串口 +- `read`:从串口读取指定长度字节 +- `write`:向串口写入字节或文本 + +公共参数: + +- `port` +- `baud` +- `data_bits` +- `parity` +- `stop_bits` +- `timeout_ms` + +当前波特率实现边界: + +- Windows 允许配置工具层接受的范围 `50-4000000` +- Linux / macOS 当前仅支持标准 termios 波特率,实际支持到 `230400` +- 因此 `baud` 的跨平台可移植取值应优先使用 `230400` 及以下的常见标准速率 + +安全约束: + +- `write` 必须显式传 `confirm: true` +- 单次读写负载限制为 `4096` 字节 +- `port` 只接受白名单串口名: + - Linux / macOS 仅允许 `/dev/tty*`、`/dev/cu.*` 及对应简写设备名 + - Windows 仅允许 `COM\d+` 或 `\\.\COM\d+` + - 明确拒绝 `..`、普通文件绝对路径、盘符路径等非串口设备路径,避免路径穿越或误打开任意文件 + +## 跨平台实现边界 + +- Linux / macOS: + - 基于 `golang.org/x/sys/unix` 和 termios 配置串口参数。 + - 当前仅接入标准 termios 波特率映射,最高到 `230400`,尚未扩展 `460800`、`921600`、`1000000`、`2000000` 等更高速率。 + - 通过 `/dev/...` 枚举和访问设备。 + +- Windows: + - 基于 `kernel32` 串口 API 配置 `DCB` 和 `COMMTIMEOUTS`。 + - 当前读写仍使用同步 `ReadFile` / `WriteFile`;一旦 syscall 已进入执行,turn context cancellation 不能立即打断,只能等待 `COMMTIMEOUTS` 触发后返回。 + - 通过注册表 `HARDWARE\\DEVICEMAP\\SERIALCOMM` 枚举端口。 + +- 其他平台: + - `serial` Tool 显式返回 unsupported,不做静默降级。 + +## 后续建议 + +1. 如果需要持续交互式串口会话,建议再增加 session 型 Tool,而不是让 LLM 反复做短连接轮询。 +2. 如果后续要支持 CAN、GPIO、PWM,建议抽出统一的硬件 capability 描述层,而不是继续只靠 Tool 名称区分。 +3. 若需要生产级稳定性,建议补真实串口回环测试,至少覆盖 Linux PTY 和 Windows COM 模拟场景。 diff --git a/docs/design/hook-system-design.zh.md b/docs/design/hook-system-design.zh.md new file mode 100644 index 000000000..090437c20 --- /dev/null +++ b/docs/design/hook-system-design.zh.md @@ -0,0 +1,478 @@ +# PicoClaw Hook 系统设计(基于 `refactor/agent`) + +> 当前状态:本文是 hook 系统的早期设计记录。事件系统升级后,观察型 hook 的主路径已经切到 +> `pkg/events.Event`、`RuntimeEventObserver` 和进程 hook 的 `hook.runtime_event`。 +> 旧 `agent.Event`、`EventKind`、`hook.event` 兼容层已经删除。 + +## 背景 + +本设计围绕两个议题展开: + +- `#1316`:把 agent loop 重构为事件驱动、可中断、可追加、可观测 +- `#1796`:在 runtime event bus 稳定后,把 hooks 设计为事件 consumer,而不是重新发明一套事件模型 + +当前分支已经完成了第一步里的“事件系统基础”,但还没有真正的 hook 挂载层。因此这里的目标不是重新设计 event,而是在已有实现上补出一层可扩展、可拦截、可外挂的 HookManager。 + +## 外部项目对比 + +### OpenClaw + +OpenClaw 的扩展能力分成三层: + +- Internal hooks:目录发现,运行在 Gateway 进程内 +- Plugin hooks:插件在运行时注册 hook,也在进程内 +- Webhooks:外部系统通过 HTTP 触发 Gateway 动作,属于进程外 + +值得借鉴的点: + +- 有“项目内挂载”和“项目外挂载”两种路径 +- hook 是配置驱动,可启停 +- 外部入口有明确的安全边界和映射层 + +不建议直接照搬的点: + +- OpenClaw 的 hooks / plugin hooks / webhooks 是三套路由,PicoClaw 当前体量下会偏重 +- HTTP webhook 更适合“事件进入系统”,不适合作为“可同步拦截 agent loop”的基础机制 + +### pi-mono + +pi-mono 的核心思路更接近当前分支: + +- 扩展统一为 extension API +- 事件分为观察型和可变更型 +- 某些阶段允许 `transform` / `block` / `replace` +- 扩展代码主要是进程内执行 +- RPC mode 把 UI 交互桥接到进程外客户端 + +值得借鉴的点: + +- 不把“观察”和“拦截”混成一个接口 +- 允许返回结构化动作,而不是只有回调 +- 进程外通信只暴露必要协议,不把整个内部对象图泄露出去 + +## 当前分支现状 + +### 已有能力 + +当前分支已经具备 hook 系统的地基: + +- `pkg/events` 定义 runtime event envelope、kind、scope、source、severity 和 fan-out bus +- `pkg/agent/event_payloads.go` 保留 agent domain payload +- agent domain payload 保留在 `pkg/agent/event_payloads.go` +- `pkg/agent/loop.go` 中的 `runTurn()` 已在 turn、llm、tool、interrupt、follow-up、summary 等节点发射事件 +- `pkg/agent/steering.go` 已支持 steering、graceful interrupt、hard abort +- `pkg/agent/turn.go` 已维护 turn phase、恢复点、active turn、abort 状态 + +### 现有缺口 + +早期设计时的缺口包括 HookManager、Before/After LLM、Before/After Tool、审批型 hook +以及 sub-turn 接入。当前实现已经覆盖主 turn 的 HookManager、LLM/Tool 拦截和审批; +sub-turn 事件已接入 runtime event bus。 + +### 一个关键现实 + +`#1316` 文案里提到“只读并行、写入串行”的工具执行策略,但当前 `runTurn()` 实现已经先收敛成“顺序执行 + 每个工具后检查 steering / interrupt”。因此 hook 设计不应依赖未来的并行模型,而应该先兼容当前顺序执行,再为以后增加 `ReadOnlyIndicator` 留口子。 + +## 设计原则 + +- Hook 必须建立在 `pkg/events` runtime event bus 和 turn 上下文之上 +- runtime event bus 负责广播,HookManager 负责拦截,两者职责分离 +- 项目内挂载要简单,项目外挂载必须走 IPC +- 观察型 hook 不能阻塞 loop;拦截型 hook 必须有超时 +- 先覆盖主 turn,不把 sub-turn 一次做满 +- 不新增第二套用户事件命名系统,新观察点统一使用 `pkg/events.Kind` + +## 总体架构 + +分成三层: + +1. `pkg/events` runtime event bus + 负责广播只读事件,覆盖 agent、channel、gateway、bus、MCP 等运行时组件 + +2. `HookManager` + 负责管理 hook、排序、超时、错误隔离,并在 `runTurn()` 的明确检查点执行同步拦截 + +3. `HookMount` + 负责两种挂载方式: + - 进程内 Go hook + - 进程外 IPC hook + +换句话说: + +- runtime event bus 是“发生了什么” +- HookManager 是“谁能介入” +- HookMount 是“这些 hook 从哪里来” + +## Hook 分类 + +不建议把所有 hook 都设计成 `OnEvent(evt)`。 + +建议拆成两类。 + +### 1. 观察型 + +只消费事件,不修改流程: + +```go +type EventObserver interface { + OnRuntimeEvent(ctx context.Context, evt events.Event) error +} +``` + +这类 hook 直接订阅 runtime event bus 即可。 + +适用场景: + +- 审计日志 +- 指标上报 +- 调试 trace +- 将事件转发给外部 UI / TUI / Web 面板 + +### 2. 拦截型 + +只在少数明确节点触发,允许返回动作: + +```go +type LLMInterceptor interface { + BeforeLLM(ctx context.Context, req *LLMRequest) HookDecision[*LLMRequest] + AfterLLM(ctx context.Context, resp *LLMResponse) HookDecision[*LLMResponse] +} + +type ToolInterceptor interface { + BeforeTool(ctx context.Context, call *ToolCall) HookDecision[*ToolCall] + AfterTool(ctx context.Context, result *ToolResultView) HookDecision[*ToolResultView] +} + +type ToolApprover interface { + ApproveTool(ctx context.Context, req *ToolApprovalRequest) ApprovalDecision +} +``` + +这里的 `HookDecision` 统一支持: + +- `continue` +- `modify` +- `deny_tool` +- `abort_turn` +- `hard_abort` + +## 对外暴露的最小 hook 面 + +V1 不需要把所有 runtime event kind 都变成可拦截点。 + +建议只开放这些同步 hook: + +- `before_llm` +- `after_llm` +- `before_tool` +- `after_tool` +- `approve_tool` + +其余节点继续作为只读事件暴露: + +- `agent.turn.start` +- `agent.turn.end` +- `agent.llm.request` +- `agent.llm.response` +- `agent.tool.exec_start` +- `agent.tool.exec_end` +- `agent.tool.exec_skipped` +- `agent.steering.injected` +- `agent.follow_up.queued` +- `agent.interrupt.received` +- `agent.context.compress` +- `agent.session.summarize` +- `agent.error` + +`subturn_*` 在 V1 中保留名字,但不承诺一定触发,直到子 turn 迁移完成。 + +## 项目内挂载 + +内部挂载必须尽量低摩擦。 + +建议提供两种等价方式,底层都走 HookManager。 + +### 方式 A:代码显式挂载 + +```go +al.MountHook(hooks.Named("audit", &AuditHook{})) +``` + +适用于: + +- 仓内内建 hook +- 单元测试 +- feature flag 控制 + +### 方式 B:内建 registry + +```go +func init() { + hooks.RegisterBuiltin("audit", func() hooks.Hook { + return &AuditHook{} + }) +} +``` + +启动时根据配置启用: + +```json +{ + "hooks": { + "builtins": { + "audit": { "enabled": true } + } + } +} +``` + +这比 OpenClaw 的目录扫描更轻,也更贴合 Go 项目。 + +## 项目外挂载 + +这是本设计的硬要求。 + +建议 V1 采用: + +- `JSON-RPC over stdio` + +原因: + +- 跨平台最简单 +- 不依赖额外端口 +- 非常适合“由 PicoClaw 启动一个外部 hook 进程” +- 比 HTTP webhook 更适合同步拦截 + +### 外部 hook 进程模型 + +PicoClaw 启动外部进程,并在其 stdin/stdout 上跑协议。 + +配置示例: + +```json +{ + "hooks": { + "processes": { + "review-gate": { + "enabled": true, + "transport": "stdio", + "command": ["uvx", "picoclaw-hook-reviewer"], + "observe": ["turn_start", "turn_end", "tool_exec_end"], + "intercept": ["before_tool", "approve_tool"], + "timeout_ms": 5000 + } + } + } +} +``` + +### 协议边界 + +不要把内部 Go 结构体直接暴露给 IPC。 + +建议定义稳定的协议对象: + +- `HookHandshake` +- `HookEventNotification` +- `BeforeLLMRequest` +- `AfterLLMRequest` +- `BeforeToolRequest` +- `AfterToolRequest` +- `ApproveToolRequest` +- `HookDecision` + +其中: + +- 观察型事件用 notification,fire-and-forget +- 拦截型事件用 request/response,同步等待 + +### 为什么是 stdio,而不是直接用 HTTP webhook + +因为两者用途不同: + +- HTTP webhook 更适合“外部系统向 PicoClaw 投递事件” +- stdio/RPC 更适合“PicoClaw 在 turn 内同步询问外部 hook 是否改写 / 放行 / 拒绝” + +如果未来需要 OpenClaw 式 webhook,可以作为独立入口层,再把外部事件转成 inbound message 或 steering,而不是直接替代 hook IPC。 + +## Hook 执行顺序 + +建议统一排序规则: + +- 先内建 in-process hook +- 再外部 IPC hook +- 同组内按 `priority` 从小到大执行 + +原因: + +- 内建 hook 延迟更低,适合做基础规范化 +- 外部 hook 更适合做审批、审计、组织级策略 + +## 超时与错误策略 + +### 观察型 + +- 默认超时:`500ms` +- 超时或报错:记录日志,继续主流程 + +### 拦截型 + +- `before_llm` / `after_llm` / `before_tool` / `after_tool`:默认 `5s` +- `approve_tool`:默认 `60s` + +超时行为: + +- 普通拦截:`continue` +- 审批:`deny` + +这点应直接沿用 `#1316` 的安全倾向。 + +## 与当前分支的对接点 + +### 直接复用 + +- 事件定义:`pkg/agent/events.go` +- 事件广播:`pkg/agent/eventbus.go` +- 活跃 turn / interrupt / rollback:`pkg/agent/turn.go` +- 事件发射点:`pkg/agent/loop.go` + +### 需要新增 + +- `pkg/agent/hooks.go` + - Hook 接口 + - HookDecision / ApprovalDecision + - HookManager + +- `pkg/agent/hook_mount.go` + - 内建 hook 注册 + - 外部进程 hook 注册 + +- `pkg/agent/hook_ipc.go` + - stdio JSON-RPC bridge + +- `pkg/agent/hook_types.go` + - IPC 稳定载荷 + +### 需要改造 + +- `pkg/agent/loop.go` + - 在 LLM 和 tool 关键路径前后插入 HookManager 调用 + +- `pkg/tools/base.go` + - 可选新增 `ReadOnlyIndicator` + +- `pkg/tools/spawn.go` +- `pkg/tools/subagent.go` + - 先保留现状 + - 等 sub-turn 迁移后再接入 `subturn_*` hook + +## 一个更贴合当前分支的数据流 + +### 观察链路 + +```text +runTurn() -> emitEvent() -> runtime event bus -> observers +``` + +### 拦截链路 + +```text +runTurn() + -> HookManager.BeforeLLM() + -> Provider.Chat() + -> HookManager.AfterLLM() + -> HookManager.BeforeTool() + -> HookManager.ApproveTool() + -> tool.Execute() + -> HookManager.AfterTool() +``` + +也就是说: + +- observer 不改变现有 `emitEvent()` +- interceptor 直接插在 `runTurn()` 热路径 + +## 用户可见配置 + +建议新增: + +```json +{ + "hooks": { + "enabled": true, + "builtins": {}, + "processes": {}, + "defaults": { + "observer_timeout_ms": 500, + "interceptor_timeout_ms": 5000, + "approval_timeout_ms": 60000 + } + } +} +``` + +V1 不做复杂自动发现。 + +原因: + +- 当前分支重点是把地基打稳 +- 目录扫描、安装器、脚手架可以后置 +- 先让仓内和仓外都能挂上去,比“管理体验完整”更重要 + +## 推荐的 V1 范围 + +### 必做 + +- HookManager +- in-process 挂载 +- stdio IPC 挂载 +- observer hooks +- `before_tool` / `after_tool` / `approve_tool` +- `before_llm` / `after_llm` + +### 可后置 + +- hook CLI 管理命令 +- hook 自动发现 +- Unix socket / named pipe transport +- sub-turn hook 生命周期 +- read-only 并行分组 +- webhook 到 inbound message 的映射入口 + +## 分阶段落地 + +### Phase 1 + +- 引入 HookManager +- 支持 in-process observer + interceptor +- 先只接主 turn + +### Phase 2 + +- 引入 `stdio` 外部 hook 进程桥 +- 支持组织级审批 / 审计 / 参数改写 + +### Phase 3 + +- 把 `SubagentManager` 迁移到 `runTurn/sub-turn` +- 接通 `agent.subturn.spawn` / `agent.subturn.end` / `agent.subturn.result_delivered` + +### Phase 4 + +- 视需求补 `ReadOnlyIndicator` +- 在主 turn 和 sub-turn 上统一只读并行策略 + +## 最终结论 + +最适合 PicoClaw 当前分支的方案,不是直接复制 OpenClaw 的 hooks,也不是完整照搬 pi-mono 的 extension system,而是: + +- 以 `pkg/events` runtime event bus 为只读观察面 +- 以新增 `HookManager` 为同步拦截面 +- 项目内通过 Go 对象直接挂载 +- 项目外通过 `stdio JSON-RPC` 进程通信挂载 + +这样做有三个好处: + +- 和 `#1796` 一致,hooks 只是 runtime event bus 之上的消费层 +- 和当前 `refactor/agent` 实现一致,不需要推翻已有事件系统 +- 同时满足“仓内简单挂载”和“仓外进程通信挂载”两个硬需求 diff --git a/docs/design/provider-refactoring.md b/docs/design/provider-refactoring.md index a214d9857..3c31f610f 100644 --- a/docs/design/provider-refactoring.md +++ b/docs/design/provider-refactoring.md @@ -66,7 +66,7 @@ Problem: Agent needs to know both `provider` and `model`, adding complexity. Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design: 1. **Model-centric**: Users care about models, not providers -2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-sonnet-4.6` +2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4.6` 3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes ### 2.2 New Configuration Structure @@ -81,8 +81,8 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design: "api_key": "sk-xxx" }, { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-xxx" }, { @@ -128,7 +128,7 @@ type Config struct { type ModelConfig struct { // Required ModelName string `json:"model_name"` // user-facing name (alias) - Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.2 + Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.4 // Common config APIBase string `json:"api_base,omitempty"` @@ -154,7 +154,7 @@ Identify protocol via prefix in `model` field: | `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. | | `anthropic/` | Anthropic | Claude series specific | | `antigravity/` | Antigravity | Google Cloud Code Assist | -| `gemini/` | Gemini | Google Gemini native API (if needed) | +| `gemini/` | Gemini | Google Gemini native API | --- @@ -180,7 +180,7 @@ Identify protocol via prefix in `model` field: "model": "deepseek-chat" }, "coder": { - "model": "gpt-5.2", + "model": "gpt-5.4", "system_prompt": "You are a coding assistant..." }, "translator": { @@ -200,7 +200,7 @@ Each Agent only needs to specify `model` (corresponds to `model_name` in `model_ model_list: - model_name: gpt-4o litellm_params: - model: openai/gpt-5.2 + model: openai/gpt-5.4 api_key: xxx - model_name: my-custom litellm_params: diff --git a/docs/design/steering-spec.md b/docs/design/steering-spec.md new file mode 100644 index 000000000..5fd8360b3 --- /dev/null +++ b/docs/design/steering-spec.md @@ -0,0 +1,315 @@ +# Steering — Implementation Specification + +## Problem + +When the agent is running (executing a chain of tool calls), the user has no way to redirect it. They must wait for the full cycle to complete before sending a new message. This creates a poor experience when the agent takes a wrong direction — the user watches it waste time on tools that are no longer relevant. + +## Solution + +Steering introduces a **message queue** that external callers can push into at any time. The agent loop polls this queue at well-defined checkpoints. When a steering message is found, the agent: + +1. Stops executing further tools in the current batch +2. Injects the user's message into the conversation context +3. Calls the LLM again with the updated context + +The user's intent reaches the model **as soon as the current tool finishes**, not after the entire turn completes. + +## Architecture Overview + +```mermaid +graph TD + subgraph External Callers + TG[Telegram] + DC[Discord] + SL[Slack] + end + + subgraph AgentLoop + BUS[MessageBus] + ROUTE{Session Routing} + WP[Worker Pool] + SQ[steeringQueue] + RLI[runLLMIteration] + TE[Tool Execution Loop] + LLM[LLM Call] + end + + TG -->|PublishInbound| BUS + DC -->|PublishInbound| BUS + SL -->|PublishInbound| BUS + + BUS -->|ConsumeInbound| ROUTE + ROUTE -->|no active turn| WP + ROUTE -->|active turn exists| SQ + WP -->|Steer| SQ + WP -->|process| RLI + + RLI -->|1. initial poll| SQ + TE -->|2. poll after each tool| SQ + + SQ -->|pendingMessages| RLI + RLI -->|inject into context| LLM +``` + +### Message routing and worker pool + +Channels (Telegram, Discord, etc.) publish messages to the `MessageBus` via `PublishInbound`. The `Run()` loop consumes messages from the bus and routes each one based on its **session key**: + +- **No active turn for the session**: The session key is atomically reserved via `LoadOrStore(sessionKey, struct{}{})`, and a **worker goroutine** is spawned to process the full turn lifecycle. +- **Active turn exists for the session**: The message is enqueued directly into the steering queue via `enqueueSteeringMessage`. It will be picked up by the existing worker's steering drain loop. +- **Non-routable (system)**: Processed synchronously in the main loop. + +This enables **parallel processing of messages from different sessions** (up to `max_parallel_turns`) while keeping same-session messages strictly sequential. + +```mermaid +sequenceDiagram + participant Bus + participant Run + participant Worker + participant SQ + + Run->>Bus: ConsumeInbound() → msg + Run->>Run: resolveSteeringTarget(msg) → sessionKey + + alt no active turn + Run->>Run: LoadOrStore(sessionKey, sentinel) + Run->>Worker: spawn worker goroutine + Worker->>Worker: processMessage(msg) + Worker->>SQ: drain steering after turn + else active turn exists + Run->>SQ: enqueueSteeringMessage(msg) + end +``` + +## Data Structures + +### steeringQueue + +A thread-safe FIFO queue, private to the `agent` package. + +| Field | Type | Description | +|-------|------|-------------| +| `mu` | `sync.Mutex` | Protects all access to `queue` and `mode` | +| `queue` | `[]providers.Message` | Pending steering messages | +| `mode` | `SteeringMode` | Dequeue strategy | + +**Methods:** + +| Method | Description | +|--------|-------------| +| `push(msg) error` | Appends a message to the queue. Returns an error if the queue is full (`MaxQueueSize`) | +| `dequeue() []Message` | Removes and returns messages according to `mode`. Returns `nil` if empty | +| `len() int` | Returns the current queue length | +| `setMode(mode)` | Updates the dequeue strategy | +| `getMode() SteeringMode` | Returns the current mode | + +### SteeringMode + +| Value | Constant | Behavior | +|-------|----------|----------| +| `"one-at-a-time"` | `SteeringOneAtATime` | `dequeue()` returns only the **first** message. Remaining messages stay in the queue for subsequent polls. | +| `"all"` | `SteeringAll` | `dequeue()` drains the **entire** queue and returns all messages at once. | + +Default: `"one-at-a-time"`. + +### processOptions extension + +A new field was added to `processOptions`: + +| Field | Type | Description | +|-------|------|-------------| +| `SkipInitialSteeringPoll` | `bool` | When `true`, the initial steering poll at loop start is skipped. Used by `Continue()` to avoid double-dequeuing. | + +## Public API on AgentLoop + +| Method | Signature | Description | +|--------|-----------|-------------| +| `Steer` | `Steer(msg providers.Message) error` | Enqueues a steering message. Returns an error if the queue is full or not initialized. Thread-safe, can be called from any goroutine. | +| `SteeringMode` | `SteeringMode() SteeringMode` | Returns the current dequeue mode. | +| `SetSteeringMode` | `SetSteeringMode(mode SteeringMode)` | Changes the dequeue mode at runtime. | +| `Continue` | `Continue(ctx, sessionKey, channel, chatID) (string, error)` | Resumes an idle agent using pending steering messages for the given session. Returns `""` if queue is empty. Uses session-aware active turn checking (won't block on unrelated sessions). | + +## Integration into the Agent Loop + +### Where steering is wired + +The steering queue lives as a field on `AgentLoop`: + +``` +AgentLoop + ├── bus + ├── cfg + ├── registry + ├── steering *steeringQueue ← new + ├── ... +``` + +It is initialized in `NewAgentLoop` from `cfg.Agents.Defaults.SteeringMode`. + +### Detailed flow through runLLMIteration + +```mermaid +sequenceDiagram + participant User + participant AgentLoop + participant runLLMIteration + participant ToolExecution + participant LLM + + User->>AgentLoop: Steer(message) + Note over AgentLoop: steeringQueue.push(message) + + Note over runLLMIteration: ── iteration starts ── + + runLLMIteration->>AgentLoop: dequeueSteeringMessages()
[initial poll] + AgentLoop-->>runLLMIteration: [] (empty, or messages) + + alt pendingMessages not empty + runLLMIteration->>runLLMIteration: inject into messages[]
save to session + end + + runLLMIteration->>LLM: Chat(messages, tools) + LLM-->>runLLMIteration: response with toolCalls[0..N] + + loop for each tool call (sequential) + ToolExecution->>ToolExecution: execute tool[i] + ToolExecution->>ToolExecution: process result,
append to messages[] + + ToolExecution->>AgentLoop: dequeueSteeringMessages() + AgentLoop-->>ToolExecution: steeringMessages + + alt steering found + opt remaining tools > 0 + Note over ToolExecution: Mark tool[i+1..N-1] as
"Skipped due to queued user message." + end + Note over ToolExecution: steeringAfterTools = steeringMessages + Note over ToolExecution: break out of tool loop + end + end + + alt steeringAfterTools not empty + ToolExecution-->>runLLMIteration: pendingMessages = steeringAfterTools + Note over runLLMIteration: next iteration will inject
these before calling LLM + end + + Note over runLLMIteration: ── loop back to iteration start ── +``` + +### Polling checkpoints + +| # | Location | When | Purpose | +|---|----------|------|---------| +| 1 | Top of `runLLMIteration`, before first LLM call | Once, at loop entry | Catch messages enqueued while the agent was still setting up context | +| 2 | After every tool completes (including the first and the last) | Immediately after each tool's result is processed | Interrupt the batch as early as possible — if steering is found and there are remaining tools, they are all skipped | + +### What happens to skipped tools + +When steering interrupts a tool batch after tool `[i]` completes, all tools from `[i+1]` to `[N-1]` are **not executed**. Instead, a tool result message is generated for each: + +```json +{ + "role": "tool", + "content": "Skipped due to queued user message.", + "tool_call_id": "" +} +``` + +These results are: +- Appended to the conversation `messages[]` +- Saved to the session via `AddFullMessage` + +This ensures the LLM knows which of its requested actions were not performed. + +### Loop condition change + +The iteration loop condition was changed from: + +```go +for iteration < agent.MaxIterations +``` + +to: + +```go +for iteration < agent.MaxIterations || len(pendingMessages) > 0 +``` + +This allows **one extra iteration** when steering arrives right at the max iteration boundary, ensuring the steering message is always processed. + +### Tool execution: parallel → sequential + +**Before steering:** all tool calls in a batch were executed in parallel using `sync.WaitGroup`. + +**After steering:** tool calls execute **sequentially**. This is required because steering must be polled between individual tool completions. A parallel execution model would not allow interrupting mid-batch. + +> **Trade-off:** This introduces latency when the LLM requests multiple independent tools in a single turn. In practice, most batches contain 1-2 tools, so the impact is minimal. The benefit of being able to interrupt outweighs the cost. + +### Why skip remaining tools (instead of letting them finish) + +Two strategies were considered when a steering message is detected mid-batch: + +1. **Skip remaining tools** (chosen) — stop executing, mark the rest as skipped, inject steering +2. **Finish all tools, then inject** — let everything run, append steering afterwards + +Strategy 2 was rejected for three reasons: + +**Irreversible side effects.** Tools can send emails, write files, spawn subagents, or call external APIs. If the user says "stop" or "change direction", those actions have already happened and cannot be undone. + +| Tool batch | Steering | Skip (1) | Finish (2) | +|---|---|---|---| +| `[search, send_email]` | "don't send it" | Email not sent | Email sent | +| `[query, write_file, spawn]` | "wrong database" | Only query runs | File + subagent wasted | +| `[fetch₁, fetch₂, fetch₃, write]` | topic change | 1 fetch | 3 fetches + write, all discarded | + +**Wasted latency.** Tools like web fetches and API calls take seconds each. In a 3-tool batch averaging 3-4s per tool, the user would wait 10+ seconds for work that gets thrown away. + +**The LLM retains full awareness.** Skipped tools receive an explicit `"Skipped due to queued user message."` result, so the model knows what was not done and can decide whether to re-execute with the new context or take a different path. + +## The Continue() method + +`Continue` handles the case where the agent is **idle** (its last message was from the assistant) and the user has enqueued steering messages in the meantime. + +```mermaid +flowchart TD + A[Continue called] --> B{dequeueSteeringMessages} + B -->|empty| C["return ('', nil)"] + B -->|messages found| D[Combine message contents] + D --> E["runAgentLoop with
SkipInitialSteeringPoll: true"] + E --> F[Return response] +``` + +**Why `SkipInitialSteeringPoll: true`?** Because `Continue` already dequeued the messages itself. Without this flag, `runLLMIteration` would poll again at the start and find nothing (the queue is already empty), or worse, double-process if new messages arrived in the meantime. + +## Configuration + +```json +{ + "agents": { + "defaults": { + "steering_mode": "one-at-a-time", + "max_parallel_turns": 1 + } + } +} +``` + +| Field | Type | Default | Env var | Description | +|-------|------|---------|---------|-------------| +| `steering_mode` | `string` | `"one-at-a-time"` | `PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE` | How the steering queue is drained per poll | +| `max_parallel_turns` | `int` | `1` | `PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS` | Max concurrent turns. `0` or `1` = sequential; `>1` = parallel across sessions | + + +## Design decisions and trade-offs + +| Decision | Rationale | +|----------|-----------| +| Sequential tool execution | Required for per-tool steering polls. Parallel execution cannot be interrupted mid-batch. | +| Polling-based (not channel/signal) | Keeps the implementation simple. No need for `select` or signal channels. The polling cost is negligible (mutex lock + slice length check). | +| `one-at-a-time` as default | Gives the model a chance to react to each steering message individually. More predictable behavior than dumping all messages at once. | +| Skipped tools get explicit error results | The LLM protocol requires a tool result for every tool call in the assistant message. Omitting them would cause API errors. The skip message also informs the model about what was not done. | +| `Continue()` uses `SkipInitialSteeringPoll` | Prevents race conditions and double-dequeuing when resuming an idle agent. | +| Queue stored on `AgentLoop`, not `AgentInstance` | Steering is a loop-level concern (it affects the iteration flow), not a per-agent concern. All agents share the steering queue since `processMessage` is sequential. | +| Worker pool dispatch in `Run()` | Messages are dispatched to a worker pool instead of a single sequential loop. The session key is atomically reserved via `LoadOrStore` before the worker starts, preventing TOCTOU races. Messages from the same session are serialized; different sessions are processed in parallel (up to `max_parallel_turns`). | +| No bus drain goroutine | The old `drainBusToSteering` goroutine has been removed. The main `Run()` loop now checks `activeTurnStates` for each inbound message: if a turn is active for the session, the message is enqueued directly to the steering queue; otherwise a new worker is spawned. This eliminates the complexity of drain cancellation and requeuing. | +| Audio transcription in worker | Audio is transcribed within the worker that processes the turn, not in a separate drain goroutine. | +| `MaxQueueSize = 10` | Prevents unbounded memory growth if a user sends many messages while the agent is busy. Excess messages are dropped with a warning. | diff --git a/docs/guides/ANTIGRAVITY_USAGE.fr.md b/docs/guides/ANTIGRAVITY_USAGE.fr.md new file mode 100644 index 000000000..5672952d3 --- /dev/null +++ b/docs/guides/ANTIGRAVITY_USAGE.fr.md @@ -0,0 +1,72 @@ +> Retour au [README](../project/README.fr.md) + +# Utiliser le fournisseur Antigravity dans PicoClaw + +Ce guide explique comment configurer et utiliser le fournisseur **Antigravity** (Google Cloud Code Assist) dans PicoClaw. + +## Prérequis + +1. Un compte Google. +2. Google Cloud Code Assist activé (généralement disponible via l'intégration « Gemini for Google Cloud »). + +## 1. Authentification + +Pour vous authentifier avec Antigravity, exécutez la commande suivante : + +```bash +picoclaw auth login --provider antigravity +``` + +### Authentification manuelle (Headless/VPS) +Si vous exécutez PicoClaw sur un serveur (Coolify/Docker) et ne pouvez pas accéder à `localhost`, suivez ces étapes : +1. Exécutez la commande ci-dessus. +2. Copiez l'URL fournie et ouvrez-la dans votre navigateur local. +3. Complétez la connexion. +4. Votre navigateur sera redirigé vers une URL `localhost:51121` (qui ne se chargera pas). +5. **Copiez cette URL finale** depuis la barre d'adresse de votre navigateur. +6. **Collez-la dans le terminal** où PicoClaw attend. + +PicoClaw extraira automatiquement le code d'autorisation et terminera le processus. + +## 2. Gestion des modèles + +### Lister les modèles disponibles +Pour voir quels modèles sont accessibles à votre projet et vérifier leurs quotas : + +```bash +picoclaw auth models +``` + +### Changer de modèle +Vous pouvez modifier le modèle par défaut dans `~/.picoclaw/config.json` ou le remplacer via le CLI : + +```bash +# Remplacer pour une seule commande +picoclaw agent -m "Hello" --model claude-opus-4-6-thinking +``` + +## 3. Utilisation en production (Coolify/Docker) + +Si vous déployez via Coolify ou Docker, suivez ces étapes pour tester : + +1. **Variables d'environnement** : + * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash` +2. **Persistance de l'authentification** : + Si vous vous êtes connecté localement, vous pouvez copier vos identifiants vers le serveur : + ```bash + scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/ + ``` + *Alternativement*, exécutez la commande `auth login` une fois sur le serveur si vous avez un accès terminal. + +## 4. Dépannage + +* **Réponse vide** : Si un modèle renvoie une réponse vide, il peut être restreint pour votre projet. Essayez `gemini-3-flash` ou `claude-opus-4-6-thinking`. +* **429 Limite de débit** : Antigravity a des quotas stricts. PicoClaw affichera le « temps de réinitialisation » dans le message d'erreur si vous atteignez une limite. +* **404 Non trouvé** : Assurez-vous d'utiliser un ID de modèle provenant de la liste `picoclaw auth models`. Utilisez l'ID court (par ex. `gemini-3-flash`) et non le chemin complet. + +## 5. Résumé des modèles fonctionnels + +D'après les tests, les modèles suivants sont les plus fiables : +* `gemini-3-flash` (Rapide, haute disponibilité) +* `gemini-2.5-flash-lite` (Léger) +* `claude-opus-4-6-thinking` (Puissant, inclut le raisonnement) diff --git a/docs/guides/ANTIGRAVITY_USAGE.ja.md b/docs/guides/ANTIGRAVITY_USAGE.ja.md new file mode 100644 index 000000000..bd221ed1c --- /dev/null +++ b/docs/guides/ANTIGRAVITY_USAGE.ja.md @@ -0,0 +1,72 @@ +> [README](../project/README.ja.md) に戻る + +# PicoClaw で Antigravity プロバイダーを使用する + +このガイドでは、PicoClaw で **Antigravity**(Google Cloud Code Assist)プロバイダーをセットアップして使用する方法を説明します。 + +## 前提条件 + +1. Google アカウント。 +2. Google Cloud Code Assist が有効であること(通常「Gemini for Google Cloud」のオンボーディングから利用可能)。 + +## 1. 認証 + +Antigravity で認証するには、以下のコマンドを実行します: + +```bash +picoclaw auth login --provider antigravity +``` + +### 手動認証(ヘッドレス/VPS) +サーバー(Coolify/Docker)上で実行しており、`localhost` にアクセスできない場合は、以下の手順に従ってください: +1. 上記のコマンドを実行します。 +2. 表示された URL をコピーし、ローカルブラウザで開きます。 +3. ログインを完了します。 +4. ブラウザが `localhost:51121` URL にリダイレクトされます(ページは読み込めません)。 +5. **ブラウザのアドレスバーからその最終 URL をコピーします**。 +6. **PicoClaw が待機しているターミナルにそれを貼り付けます**。 + +PicoClaw が自動的に認証コードを抽出し、プロセスを完了します。 + +## 2. モデルの管理 + +### 利用可能なモデルの一覧 +プロジェクトがアクセスできるモデルとそのクォータを確認するには: + +```bash +picoclaw auth models +``` + +### モデルの切り替え +`~/.picoclaw/config.json` でデフォルトモデルを変更するか、CLI でオーバーライドできます: + +```bash +# 単一コマンドでオーバーライド +picoclaw agent -m "Hello" --model claude-opus-4-6-thinking +``` + +## 3. 実際の使用方法(Coolify/Docker) + +Coolify または Docker でデプロイしている場合、以下の手順でテストしてください: + +1. **環境変数**: + * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash` +2. **認証の永続化**: + ローカルでログイン済みの場合、認証情報をサーバーにコピーできます: + ```bash + scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/ + ``` + *または*、ターミナルアクセスがある場合、サーバー上で `auth login` コマンドを一度実行してください。 + +## 4. トラブルシューティング + +* **空のレスポンス**:モデルが空の応答を返す場合、プロジェクトで制限されている可能性があります。`gemini-3-flash` または `claude-opus-4-6-thinking` を試してください。 +* **429 レート制限**:Antigravity には厳格なクォータがあります。制限に達した場合、PicoClaw はエラーメッセージに「リセット時間」を表示します。 +* **404 Not Found**:`picoclaw auth models` リストのモデル ID を使用していることを確認してください。フルパスではなく、短い ID(例:`gemini-3-flash`)を使用してください。 + +## 5. 動作確認済みモデルのまとめ + +テストに基づき、以下のモデルが最も信頼性が高いです: +* `gemini-3-flash`(高速、高可用性) +* `gemini-2.5-flash-lite`(軽量) +* `claude-opus-4-6-thinking`(高性能、推論機能を含む) diff --git a/docs/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.md similarity index 100% rename from docs/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.md diff --git a/docs/guides/ANTIGRAVITY_USAGE.pt-br.md b/docs/guides/ANTIGRAVITY_USAGE.pt-br.md new file mode 100644 index 000000000..e5108916a --- /dev/null +++ b/docs/guides/ANTIGRAVITY_USAGE.pt-br.md @@ -0,0 +1,72 @@ +> Voltar ao [README](../project/README.pt-br.md) + +# Usando o provedor Antigravity no PicoClaw + +Este guia explica como configurar e usar o provedor **Antigravity** (Google Cloud Code Assist) no PicoClaw. + +## Pré-requisitos + +1. Uma conta Google. +2. Google Cloud Code Assist habilitado (geralmente disponível através da integração "Gemini for Google Cloud"). + +## 1. Autenticação + +Para se autenticar com o Antigravity, execute o seguinte comando: + +```bash +picoclaw auth login --provider antigravity +``` + +### Autenticação manual (Headless/VPS) +Se você está executando em um servidor (Coolify/Docker) e não consegue acessar `localhost`, siga estas etapas: +1. Execute o comando acima. +2. Copie a URL fornecida e abra-a no seu navegador local. +3. Complete o login. +4. Seu navegador será redirecionado para uma URL `localhost:51121` (que não carregará). +5. **Copie essa URL final** da barra de endereços do seu navegador. +6. **Cole-a de volta no terminal** onde o PicoClaw está aguardando. + +O PicoClaw extrairá automaticamente o código de autorização e completará o processo. + +## 2. Gerenciando modelos + +### Listar modelos disponíveis +Para ver quais modelos seu projeto tem acesso e verificar suas cotas: + +```bash +picoclaw auth models +``` + +### Trocar de modelo +Você pode alterar o modelo padrão em `~/.picoclaw/config.json` ou substituí-lo via CLI: + +```bash +# Substituir para um único comando +picoclaw agent -m "Hello" --model claude-opus-4-6-thinking +``` + +## 3. Uso em produção (Coolify/Docker) + +Se você está implantando via Coolify ou Docker, siga estas etapas para testar: + +1. **Variáveis de ambiente**: + * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash` +2. **Persistência da autenticação**: + Se você já fez login localmente, pode copiar suas credenciais para o servidor: + ```bash + scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/ + ``` + *Alternativamente*, execute o comando `auth login` uma vez no servidor se você tiver acesso ao terminal. + +## 4. Solução de problemas + +* **Resposta vazia**: Se um modelo retorna uma resposta vazia, ele pode estar restrito para o seu projeto. Tente `gemini-3-flash` ou `claude-opus-4-6-thinking`. +* **429 Limite de taxa**: O Antigravity possui cotas rigorosas. O PicoClaw exibirá o "tempo de redefinição" na mensagem de erro se você atingir um limite. +* **404 Não encontrado**: Certifique-se de que está usando um ID de modelo da lista `picoclaw auth models`. Use o ID curto (ex.: `gemini-3-flash`) e não o caminho completo. + +## 5. Resumo dos modelos funcionais + +Com base nos testes, os seguintes modelos são os mais confiáveis: +* `gemini-3-flash` (Rápido, alta disponibilidade) +* `gemini-2.5-flash-lite` (Leve) +* `claude-opus-4-6-thinking` (Poderoso, inclui raciocínio) diff --git a/docs/guides/ANTIGRAVITY_USAGE.vi.md b/docs/guides/ANTIGRAVITY_USAGE.vi.md new file mode 100644 index 000000000..54b4a6add --- /dev/null +++ b/docs/guides/ANTIGRAVITY_USAGE.vi.md @@ -0,0 +1,72 @@ +> Quay lại [README](../project/README.vi.md) + +# Sử dụng nhà cung cấp Antigravity trong PicoClaw + +Hướng dẫn này giải thích cách thiết lập và sử dụng nhà cung cấp **Antigravity** (Google Cloud Code Assist) trong PicoClaw. + +## Điều kiện tiên quyết + +1. Một tài khoản Google. +2. Đã kích hoạt Google Cloud Code Assist (thường có sẵn thông qua quy trình giới thiệu "Gemini for Google Cloud"). + +## 1. Xác thực + +Để xác thực với Antigravity, chạy lệnh sau: + +```bash +picoclaw auth login --provider antigravity +``` + +### Xác thực thủ công (Headless/VPS) +Nếu bạn đang chạy trên máy chủ (Coolify/Docker) và không thể truy cập `localhost`, hãy làm theo các bước sau: +1. Chạy lệnh ở trên. +2. Sao chép URL được cung cấp và mở nó trong trình duyệt cục bộ của bạn. +3. Hoàn tất đăng nhập. +4. Trình duyệt của bạn sẽ chuyển hướng đến URL `localhost:51121` (trang sẽ không tải được). +5. **Sao chép URL cuối cùng đó** từ thanh địa chỉ trình duyệt. +6. **Dán nó vào terminal** nơi PicoClaw đang chờ. + +PicoClaw sẽ tự động trích xuất mã ủy quyền và hoàn tất quy trình. + +## 2. Quản lý mô hình + +### Liệt kê các mô hình khả dụng +Để xem dự án của bạn có quyền truy cập vào những mô hình nào và kiểm tra hạn mức của chúng: + +```bash +picoclaw auth models +``` + +### Chuyển đổi mô hình +Bạn có thể thay đổi mô hình mặc định trong `~/.picoclaw/config.json` hoặc ghi đè qua CLI: + +```bash +# Ghi đè cho một lệnh duy nhất +picoclaw agent -m "Hello" --model claude-opus-4-6-thinking +``` + +## 3. Sử dụng thực tế (Coolify/Docker) + +Nếu bạn đang triển khai qua Coolify hoặc Docker, hãy làm theo các bước sau để kiểm tra: + +1. **Biến môi trường**: + * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash` +2. **Lưu trữ xác thực**: + Nếu bạn đã đăng nhập cục bộ, bạn có thể sao chép thông tin xác thực lên máy chủ: + ```bash + scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/ + ``` + *Hoặc*, chạy lệnh `auth login` một lần trên máy chủ nếu bạn có quyền truy cập terminal. + +## 4. Khắc phục sự cố + +* **Phản hồi trống**: Nếu một mô hình trả về phản hồi trống, nó có thể bị hạn chế cho dự án của bạn. Hãy thử `gemini-3-flash` hoặc `claude-opus-4-6-thinking`. +* **429 Giới hạn tốc độ**: Antigravity có hạn mức nghiêm ngặt. PicoClaw sẽ hiển thị "thời gian đặt lại" trong thông báo lỗi nếu bạn đạt đến giới hạn. +* **404 Không tìm thấy**: Đảm bảo bạn đang sử dụng ID mô hình từ danh sách `picoclaw auth models`. Sử dụng ID ngắn (ví dụ: `gemini-3-flash`) thay vì đường dẫn đầy đủ. + +## 5. Tóm tắt các mô hình hoạt động tốt + +Dựa trên kiểm tra, các mô hình sau đáng tin cậy nhất: +* `gemini-3-flash` (Nhanh, khả dụng cao) +* `gemini-2.5-flash-lite` (Nhẹ) +* `claude-opus-4-6-thinking` (Mạnh mẽ, bao gồm khả năng suy luận) diff --git a/docs/guides/ANTIGRAVITY_USAGE.zh.md b/docs/guides/ANTIGRAVITY_USAGE.zh.md new file mode 100644 index 000000000..b4dde6ea3 --- /dev/null +++ b/docs/guides/ANTIGRAVITY_USAGE.zh.md @@ -0,0 +1,72 @@ +> 返回 [README](../project/README.zh.md) + +# 在 PicoClaw 中使用 Antigravity 提供商 + +本指南介绍如何在 PicoClaw 中设置和使用 **Antigravity**(Google Cloud Code Assist)提供商。 + +## 前提条件 + +1. 一个 Google 账户。 +2. 已启用 Google Cloud Code Assist(通常通过"Gemini for Google Cloud"引导流程获取)。 + +## 1. 身份验证 + +要使用 Antigravity 进行身份验证,请运行以下命令: + +```bash +picoclaw auth login --provider antigravity +``` + +### 手动验证(无界面/VPS 环境) +如果你在服务器(Coolify/Docker)上运行且无法访问 `localhost`,请按照以下步骤操作: +1. 运行上述命令。 +2. 复制提供的 URL 并在本地浏览器中打开。 +3. 完成登录。 +4. 浏览器将重定向到 `localhost:51121` URL(页面将无法加载)。 +5. **从浏览器地址栏复制该最终 URL**。 +6. **将其粘贴回 PicoClaw 正在等待的终端中**。 + +PicoClaw 将自动提取授权码并完成流程。 + +## 2. 管理模型 + +### 列出可用模型 +查看你的项目可以访问哪些模型并检查其配额: + +```bash +picoclaw auth models +``` + +### 切换模型 +你可以在 `~/.picoclaw/config.json` 中更改默认模型,或通过 CLI 覆盖: + +```bash +# 为单个命令覆盖 +picoclaw agent -m "Hello" --model claude-opus-4-6-thinking +``` + +## 3. 实际使用(Coolify/Docker) + +如果你通过 Coolify 或 Docker 部署,请按照以下步骤进行测试: + +1. **环境变量**: + * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash` +2. **身份验证持久化**: + 如果你已在本地登录,可以将凭据复制到服务器: + ```bash + scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/ + ``` + *或者*,如果你有终端访问权限,可以在服务器上运行一次 `auth login` 命令。 + +## 4. 故障排除 + +* **空响应**:如果模型返回空回复,可能是该模型在你的项目中受到限制。请尝试 `gemini-3-flash` 或 `claude-opus-4-6-thinking`。 +* **429 速率限制**:Antigravity 有严格的配额限制。如果触发限制,PicoClaw 将在错误消息中显示"重置时间"。 +* **404 未找到**:确保你使用的是 `picoclaw auth models` 列表中的模型 ID。请使用短 ID(例如 `gemini-3-flash`),而非完整路径。 + +## 5. 可用模型总结 + +根据测试,以下模型最为可靠: +* `gemini-3-flash`(快速,高可用性) +* `gemini-2.5-flash-lite`(轻量级) +* `claude-opus-4-6-thinking`(强大,包含推理能力) diff --git a/docs/guides/README.md b/docs/guides/README.md new file mode 100644 index 000000000..1a50a5062 --- /dev/null +++ b/docs/guides/README.md @@ -0,0 +1,15 @@ +# Guides + +Task-oriented guides for setup, configuration, and common PicoClaw workflows. + +- [Docker & Quick Start Guide](docker.md): install and run PicoClaw with Docker or the launcher. +- [Configuration Guide](configuration.md): environment variables, workspace layout, routing, and sandbox settings. +- [Session Guide](session-guide.md): how session scope affects memory sharing, summaries, and isolation. +- [Routing Guide](routing-guide.md): agent dispatch, session overrides, and light-model routing. +- [Chat Apps Configuration](chat-apps.md): supported chat platforms and channel-specific setup paths. +- [Providers & Model Configuration](providers.md): `model_list`, providers, and model routing. +- [Spawn & Async Tasks](spawn-tasks.md): background work, long-running tasks, and sub-agent orchestration. +- [PicoClaw Hardware Compatibility List](hardware-compatibility.md): tested boards and platform notes. +- [Using Antigravity Provider in PicoClaw](ANTIGRAVITY_USAGE.md): Google Cloud Code Assist setup and usage. + +Translations usually live beside the English source when available. diff --git a/docs/guides/chat-apps.fr.md b/docs/guides/chat-apps.fr.md new file mode 100644 index 000000000..d9112c595 --- /dev/null +++ b/docs/guides/chat-apps.fr.md @@ -0,0 +1,683 @@ +# 💬 Configuration des Applications de Chat + +> Retour au [README](../project/README.fr.md) + +## 💬 Applications de Chat + +Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam. + +> **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é. + +| Canal | Difficulté | Description | Documentation | +| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **Telegram** | ⭐ Facile | Recommandé, transcription vocale, long polling (pas d'IP publique requise) | [Documentation](../channels/telegram/README.fr.md) | +| **Discord** | ⭐ Facile | Socket Mode, groupes/DM, écosystème bot riche | [Documentation](../channels/discord/README.fr.md) | +| **WhatsApp** | ⭐ Facile | Natif (scan QR) ou Bridge URL | [Documentation](#whatsapp) | +| **Weixin** | ⭐ Facile | Scan QR natif (API Tencent iLink) | [Documentation](#weixin) | +| **Slack** | ⭐ Facile | **Socket Mode** (pas d'IP publique requise), entreprise | [Documentation](../channels/slack/README.fr.md) | +| **Matrix** | ⭐⭐ Moyen | Protocole fédéré, auto-hébergement possible | [Documentation](../channels/matrix/README.fr.md) | +| **QQ** | ⭐⭐ Moyen | API bot officielle, communauté chinoise | [Documentation](../channels/qq/README.fr.md) | +| **DingTalk** | ⭐⭐ Moyen | Mode Stream (pas d'IP publique requise), entreprise | [Documentation](../channels/dingtalk/README.fr.md) | +| **LINE** | ⭐⭐⭐ Avancé | HTTPS Webhook requis | [Documentation](../channels/line/README.fr.md) | +| **WeCom (企业微信)** | ⭐⭐⭐ Avancé | Bot groupe (Webhook), app personnalisée (API), AI Bot | [Guide](../channels/wecom/README.fr.md) | +| **Feishu (飞书)** | ⭐⭐⭐ Avancé | Collaboration entreprise, fonctionnalités riches | [Documentation](../channels/feishu/README.fr.md) | +| **IRC** | ⭐⭐ Moyen | Serveur + configuration TLS | [Documentation](#irc) | +| **OneBot** | ⭐⭐ Moyen | Compatible NapCat/Go-CQHTTP, écosystème communautaire | [Documentation](../channels/onebot/README.fr.md) | +| **MaixCam** | ⭐ Facile | Canal d'intégration matérielle pour caméras AI Sipeed | [Documentation](../channels/maixcam/README.fr.md) | +| **Pico** | ⭐ Facile | Canal protocole natif PicoClaw | | + + +
+Telegram (Recommandé) + +**1. Créer un bot** + +* Ouvrez Telegram, recherchez `@BotFather` +* Envoyez `/newbot`, suivez les instructions +* Copiez le token + +**2. Configurer** + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +> Obtenez votre identifiant utilisateur via `@userinfobot` sur Telegram. + +**3. Lancer** + +```bash +picoclaw gateway +``` + +**4. Menu de commandes Telegram (enregistré automatiquement au démarrage)** + +PicoClaw 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`, `/use`, `/btw`) afin que le menu de commandes et le comportement à l'exécution restent synchronisés. +L'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. + +Si 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. + +Vous pouvez aussi gerer les competences installees directement depuis Telegram : + +- `/list skills` +- `/use ` +- `/use ` puis envoyer la vraie requete dans le message suivant +- `/use clear` +- `/btw ` pour poser une question annexe immediate sans modifier l'historique actif de la session ; `/btw` est traite comme une requete directe sans outils et n'entre pas dans le flux normal d'execution des outils + +
+ + +
+Discord + +**1. Créer un bot** + +* Allez sur +* Créez une application → Bot → Add Bot +* Copiez le token du bot + +**2. Activer les intents** + +* Dans les paramètres du Bot, activez **MESSAGE CONTENT INTENT** +* (Optionnel) Activez **SERVER MEMBERS INTENT** si vous prévoyez d'utiliser des listes d'autorisation basées sur les données des membres + +**3. Obtenir votre identifiant utilisateur** +* Paramètres Discord → Avancé → activez **Developer Mode** +* Clic droit sur votre avatar → **Copy User ID** + +**4. Configurer** + +```json +{ + "channel_list": { + "discord": { + "enabled": true, + "type": "discord", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Inviter le bot** + +* OAuth2 → URL Generator +* Scopes : `bot` +* Bot Permissions : `Send Messages`, `Read Message History` +* Ouvrez l'URL d'invitation générée et ajoutez le bot à votre serveur + +**Mode déclenchement en groupe (optionnel)** + +Par défaut, le bot répond à tous les messages dans un canal de serveur. Pour limiter les réponses aux @mentions uniquement, ajoutez : + +```json +{ + "channel_list": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +Vous pouvez également déclencher par préfixes de mots-clés (par ex. `!bot`) : + +```json +{ + "channel_list": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` + +**6. Lancer** + +```bash +picoclaw gateway +``` + +
+ + +
+WhatsApp (natif via whatsmeow) + +PicoClaw peut se connecter à WhatsApp de deux manières : + +- **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/...`). +- **Bridge :** Connectez-vous à un bridge WebSocket externe. Définissez `bridge_url` (par ex. `ws://localhost:3001`) et gardez `use_native` à false. + +**Configurer (natif)** + +```json +{ + "channel_list": { + "whatsapp": { + "enabled": true, + "type": "whatsapp", + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +Si `session_store_path` est vide, la session est stockée dans `/whatsapp/`. Lancez `picoclaw gateway` ; au premier lancement, scannez le code QR affiché dans le terminal avec WhatsApp → Appareils liés. + +
+ + +
+Weixin (WeChat Personnel) + +PicoClaw prend en charge la connexion à votre compte WeChat personnel via l'API officielle Tencent iLink. + +**1. Connexion** + +Lancez le flux de connexion interactif par QR code : +```bash +picoclaw auth weixin +``` +Scannez le QR code affiché avec votre application WeChat mobile. Une fois connecté, le token est sauvegardé dans votre configuration. + +**2. Configurer** + +(Optionnel) Ajoutez votre identifiant utilisateur WeChat dans `allow_from` pour restreindre qui peut envoyer des messages au bot : +```json +{ + "channel_list": { + "weixin": { + "enabled": true, + "type": "weixin", + "token": "YOUR_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**3. Lancer** +```bash +picoclaw gateway +``` + +
+ + +
+QQ + +**Configuration rapide (recommandée)** + +QQ Open Platform propose une page de configuration en un clic pour les bots compatibles OpenClaw : + +1. Ouvrez [QQ Bot Quick Start](https://q.qq.com/qqbot/openclaw/index.html) et scannez le QR code pour vous connecter +2. Un bot est créé automatiquement — copiez l'**App ID** et l'**App Secret** +3. Configurez PicoClaw : + +```json +{ + "channel_list": { + "qq": { + "enabled": true, + "type": "qq", + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +4. Lancez `picoclaw gateway` et ouvrez QQ pour discuter avec votre bot + +> L'App Secret n'est affiché qu'une seule fois. Enregistrez-le immédiatement — le consulter à nouveau forcera une réinitialisation. +> +> Les bots créés via la page de configuration rapide sont initialement réservés au créateur et ne prennent pas en charge les discussions de groupe. Pour activer l'accès en groupe, configurez le mode sandbox sur la [QQ Open Platform](https://q.qq.com/). + +**Configuration manuelle** + +Si vous préférez créer le bot manuellement : + +* Connectez-vous sur [QQ Open Platform](https://q.qq.com/) pour vous inscrire en tant que développeur +* Créez un bot QQ — personnalisez son avatar et son nom +* Copiez l'**App ID** et l'**App Secret** depuis les paramètres du bot +* Configurez comme indiqué ci-dessus et lancez `picoclaw gateway` + +
+ + +
+DingTalk + +**1. Créer un bot** + +* Allez sur [Open Platform](https://open.dingtalk.com/) +* Créez une application interne +* Copiez le Client ID et le Client Secret + +**2. Configurer** + +```json +{ + "channel_list": { + "dingtalk": { + "enabled": true, + "type": "dingtalk", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Définissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des identifiants DingTalk pour restreindre l'accès. + +**3. Lancer** + +```bash +picoclaw gateway +``` +
+ + +
+Matrix + +**1. Préparer le compte bot** + +* Utilisez votre homeserver préféré (par ex. `https://matrix.org` ou auto-hébergé) +* Créez un utilisateur bot et obtenez son access token + +**2. Configurer** + +```json +{ + "channel_list": { + "matrix": { + "enabled": true, + "type": "matrix", + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Lancer** + +```bash +picoclaw gateway +``` + +Pour toutes les options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), voir le [Guide de Configuration du Canal Matrix](../channels/matrix/README.md). + +
+ + +
+LINE + +**1. Créer un compte officiel LINE** + +- Allez sur [LINE Developers Console](https://developers.line.biz/) +- Créez un provider → Créez un canal Messaging API +- Copiez le **Channel Secret** et le **Channel Access Token** + +**2. Configurer** + +```json +{ + "channel_list": { + "line": { + "enabled": true, + "type": "line", + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +> Le webhook LINE est servi sur le serveur Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). + +**3. Configurer l'URL du Webhook** + +LINE nécessite HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel : + +```bash +# Exemple avec ngrok (le port par défaut du gateway est 18790) +ngrok http 18790 +``` + +Puis définissez l'URL du Webhook dans la console LINE Developers à `https://your-domain/webhook/line` et activez **Use webhook**. + +**4. Lancer** + +```bash +picoclaw gateway +``` + +> Dans les discussions de groupe, le bot ne répond que lorsqu'il est @mentionné. Les réponses citent le message original. + +
+ + +
+WeCom (企业微信) + +PicoClaw prend en charge trois types d'intégration WeCom : + +**Option 1 : WeCom Bot (Bot)** - Configuration plus facile, prend en charge les discussions de groupe +**Option 2 : WeCom App (Application personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement +**Option 3 : WeCom AI Bot (Bot IA)** - Bot IA officiel, réponses en streaming, prend en charge les discussions de groupe et privées + +Voir le [Guide de Configuration WeCom](../channels/wecom/README.fr.md) pour les instructions détaillées. + +**Configuration rapide - WeCom Bot :** + +**1. Créer un bot** + +* Allez dans la console d'administration WeCom → Discussion de groupe → Ajouter un bot de groupe +* Copiez l'URL du webhook (format : `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) + +**2. Configurer** + +```json +{ + "channel_list": { + "wecom": { + "enabled": true, + "type": "wecom", + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [] + } + } +} +``` + +> Le webhook WeCom est servi sur le serveur Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). + +**Configuration rapide - WeCom App :** + +**1. Créer une application** + +* Allez dans la console d'administration WeCom → Gestion des applications → Créer une application +* Copiez **AgentId** et **Secret** +* Allez sur la page "Mon entreprise", copiez **CorpID** + +**2. Configurer la réception des messages** + +* Dans les détails de l'application, cliquez sur "Recevoir les messages" → "Configurer l'API" +* Définissez l'URL à `http://your-server:18790/webhook/wecom-app` +* Générez **Token** et **EncodingAESKey** + +**3. Configurer** + +```json +{ + "channel_list": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [] + } + } +} +``` + +**4. Lancer** + +```bash +picoclaw gateway +``` + +> **Note** : Les callbacks webhook WeCom sont servis sur le port Gateway (par défaut 18790). Utilisez un reverse proxy pour HTTPS. + +**Configuration rapide - WeCom AI Bot :** + +**1. Créer un AI Bot** + +* Allez dans la console d'administration WeCom → Gestion des applications → AI Bot +* Dans les paramètres du AI Bot, configurez l'URL de callback : `http://your-server:18790/webhook/wecom-aibot` +* Copiez **Token** et cliquez sur "Générer aléatoirement" pour **EncodingAESKey** + +**2. Configurer** + +```json +{ + "channel_list": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Hello! How can I help you?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly." + } + } +} +``` + +**3. Lancer** + +```bash +picoclaw gateway +``` + +> **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`. + +
+ + +
+Feishu (飞书) + +PicoClaw se connecte à Feishu via le mode WebSocket/SDK — aucune URL webhook publique ni serveur de callback nécessaire. + +**1. Créer une application** + +* Allez sur [Feishu Open Platform](https://open.feishu.cn/) et créez une application +* Dans les paramètres de l'application, activez la capacité **Bot** +* Créez une version et publiez l'application (l'application doit être publiée pour prendre effet) +* Copiez l'**App ID** (commence par `cli_`) et l'**App Secret** + +**2. Configurer** + +```json +{ + "channel_list": { + "feishu": { + "enabled": true, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +Optionnel : `encrypt_key` et `verification_token` pour le chiffrement des événements (recommandé en production). + +**3. Lancer et discuter** + +```bash +picoclaw gateway +``` + +Ouvrez Feishu, recherchez le nom de votre bot et commencez à discuter. Vous pouvez aussi ajouter le bot à un groupe — utilisez `group_trigger.mention_only: true` pour ne répondre que lorsqu'il est @mentionné. + +Pour toutes les options, voir le [Guide de Configuration du Canal Feishu](../channels/feishu/README.fr.md). + +
+ + +
+Slack + +**1. Créer une application Slack** + +* Allez sur [Slack API](https://api.slack.com/apps) et créez une nouvelle application +* Sous **OAuth & Permissions**, ajoutez les scopes bot : `chat:write`, `app_mentions:read`, `im:history`, `im:read`, `im:write` +* Installez l'application dans votre workspace +* Copiez le **Bot Token** (`xoxb-...`) et l'**App-Level Token** (`xapp-...`, activez Socket Mode pour l'obtenir) + +**2. Configurer** + +```json +{ + "channel_list": { + "slack": { + "enabled": true, + "type": "slack", + "bot_token": "xoxb-YOUR-BOT-TOKEN", + "app_token": "xapp-YOUR-APP-TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Lancer** + +```bash +picoclaw gateway +``` + +
+ + +
+IRC + +**1. Configurer** + +```json +{ + "channel_list": { + "irc": { + "enabled": true, + "type": "irc", + "server": "irc.libera.chat:6697", + "tls": true, + "nick": "picoclaw-bot", + "channels": ["#your-channel"], + "password": "", + "allow_from": [] + } + } +} +``` + +Optionnel : `nickserv_password` pour l'authentification NickServ, `sasl_user`/`sasl_password` pour l'authentification SASL. + +**2. Lancer** + +```bash +picoclaw gateway +``` + +Le bot se connectera au serveur IRC et rejoindra les canaux spécifiés. + +
+ + +
+OneBot (QQ via protocole OneBot) + +OneBot est un protocole ouvert pour les bots QQ. PicoClaw se connecte à toute implémentation compatible OneBot v11 (par ex. [Lagrange](https://github.com/LagrangeDev/Lagrange.Core), [NapCat](https://github.com/NapNeko/NapCatQQ)) via WebSocket. + +**1. Configurer une implémentation OneBot** + +Installez et exécutez un framework de bot QQ compatible OneBot v11. Activez son serveur WebSocket. + +**2. Configurer** + +```json +{ + "channel_list": { + "onebot": { + "enabled": true, + "type": "onebot", + "ws_url": "ws://127.0.0.1:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Champ | Description | +|-------|-------------| +| `ws_url` | URL WebSocket de l'implémentation OneBot | +| `access_token` | Token d'accès pour l'authentification (si configuré dans OneBot) | +| `reconnect_interval` | Intervalle de reconnexion en secondes (par défaut : 5) | + +**3. Lancer** + +```bash +picoclaw gateway +``` + +
+ + +
+MaixCam + +**1. Préparer le matériel** + +* Obtenez un appareil [Sipeed MaixCam](https://wiki.sipeed.com/maixcam) + +**2. Configurer** + +```json +{ + "channel_list": { + "maixcam": { + "enabled": true, + "type": "maixcam", + "allow_from": [] + } + } +} +``` + +> MaixCam est une intégration matérielle Sipeed pour l'interaction IA embarquée. + +**3. Lancer** + +```bash +picoclaw gateway +``` + +
diff --git a/docs/guides/chat-apps.ja.md b/docs/guides/chat-apps.ja.md new file mode 100644 index 000000000..49c41a66e --- /dev/null +++ b/docs/guides/chat-apps.ja.md @@ -0,0 +1,672 @@ +# 💬 チャットアプリ設定 + +> [README](../project/README.ja.md) に戻る + +## 💬 チャットアプリ連携 + +PicoClaw は複数のチャットプラットフォームをサポートしており、Agent をどこにでも接続できます。 + +> **注意**: すべての Webhook ベースのチャネル(LINE、WeCom など)は、共有 Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)上で提供されます。チャネルごとにポートを設定する必要はありません。注意:飛書(Feishu)は WebSocket/SDK モードを使用し、共有 HTTP Webhook サーバーは使用しません。 + +### チャネル一覧 + +| チャネル | セットアップ難易度 | 特徴 | ドキュメント | +| -------------------- | ------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| **Telegram** | ⭐ 簡単 | 推奨、音声テキスト変換対応、ロングポーリング(公開 IP 不要) | [ドキュメント](../channels/telegram/README.ja.md) | +| **Discord** | ⭐ 簡単 | Socket Mode、グループ/DM 対応、Bot エコシステム充実 | [ドキュメント](../channels/discord/README.ja.md) | +| **WhatsApp** | ⭐ 簡単 | ネイティブ (QR スキャン) または Bridge URL | [ドキュメント](#whatsapp) | +| **微信 (Weixin)** | ⭐ 簡単 | ネイティブ QR スキャン(Tencent iLink API)| [ドキュメント](#weixin) | +| **Slack** | ⭐ 簡単 | **Socket Mode** (公開 IP 不要)、エンタープライズ対応 | [ドキュメント](../channels/slack/README.ja.md) | +| **Matrix** | ⭐⭐ 中程度 | フェデレーションプロトコル、セルフホスト対応 | [ドキュメント](../channels/matrix/README.ja.md) | +| **QQ** | ⭐⭐ 中程度 | 公式ボット API、中国コミュニティ向け | [ドキュメント](../channels/qq/README.ja.md) | +| **DingTalk** | ⭐⭐ 中程度 | Stream モード(公開 IP 不要)、企業向け | [ドキュメント](../channels/dingtalk/README.ja.md) | +| **LINE** | ⭐⭐⭐ やや難 | HTTPS Webhook が必要 | [ドキュメント](../channels/line/README.ja.md) | +| **WeCom (企業微信)** | ⭐⭐⭐ やや難 | グループ Bot (Webhook)、カスタムアプリ (API)、AI Bot 対応 | [ガイド](../channels/wecom/README.ja.md) | +| **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.ja.md) | +| **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | [ドキュメント](#irc) | +| **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.ja.md) | +| **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.ja.md) | +| **Pico** | ⭐ 簡単 | PicoClaw ネイティブプロトコルチャネル | | + +--- + + +
+Telegram(推奨) + +**1. Bot を作成** + +* Telegram を開き、`@BotFather` を検索 +* `/newbot` を送信し、プロンプトに従う +* Token をコピー + +**2. 設定** + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +> Telegram の `@userinfobot` から User ID を取得できます。 + +**3. 実行** + +```bash +picoclaw gateway +``` + +**4. Telegram コマンドメニュー(起動時に自動登録)** + +PicoClaw は統一されたコマンド定義を使用します。起動時に Telegram がサポートするコマンド(例: `/start`、`/help`、`/show`、`/list`、`/use`、`/btw`)を Bot コマンドメニューに自動登録し、メニュー表示と実際の動作を一致させます。 +Telegram 側はコマンドメニュー登録機能を保持し、汎用コマンドの実行は Agent Loop 内の commands executor で統一的に処理されます。 + +ネットワークや API の一時的なエラーで登録に失敗しても、チャネルの起動はブロックされません。システムがバックグラウンドで自動リトライします。 + +
+ + +
+Discord + +**1. Bot を作成** + +* にアクセス +* アプリケーションを作成 → Bot → Bot を追加 +* Bot Token をコピー + +**2. Intents を有効化** + +* Bot 設定で **MESSAGE CONTENT INTENT** を有効化 +* (オプション)メンバーデータに基づくホワイトリストが必要な場合は **SERVER MEMBERS INTENT** を有効化 + +**3. User ID を取得** + +* Discord 設定 → 詳細設定 → **開発者モード** を有効化 +* アバターを右クリック → **ユーザー ID をコピー** + +**4. 設定** + +```json +{ + "channel_list": { + "discord": { + "enabled": true, + "type": "discord", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Bot を招待** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* 生成された招待リンクを開き、Bot をサーバーに追加 + +**オプション:グループトリガーモード** + +デフォルトでは Bot はサーバーチャネル内のすべてのメッセージに応答します。@メンション時のみ応答するには: + +```json +{ + "channel_list": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +キーワードプレフィックスでトリガーすることもできます(例: `!bot`): + +```json +{ + "channel_list": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` + +**6. 実行** + +```bash +picoclaw gateway +``` + +
+ + +
+WhatsApp(ネイティブ whatsmeow) + +PicoClaw は 2 つの WhatsApp 接続方式をサポートしています: + +- **ネイティブ(推奨):** プロセス内で [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/...`)。 +- **Bridge:** 外部 WebSocket Bridge に接続。`bridge_url`(例: `ws://localhost:3001`)を設定し、`use_native` を false のままにします。 + +**設定(ネイティブ)** + +```json +{ + "channel_list": { + "whatsapp": { + "enabled": true, + "type": "whatsapp", + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +`session_store_path` が空の場合、セッションは `/whatsapp/` に保存されます。`picoclaw gateway` を実行し、初回実行時にターミナルに表示される QR コードをスキャンしてください(WhatsApp → リンクデバイス)。 + +
+ + +
+微信 (Weixin) + +PicoClaw は Tencent iLink 公式 API を使用して WeChat 個人アカウントへの接続をサポートしています。 + +**1. ログイン** + +インタラクティブな QR ログインフローを実行します: +```bash +picoclaw auth weixin +``` +WeChat モバイルアプリで表示された QR コードをスキャンしてください。ログイン成功後、トークンが設定ファイルに保存されます。 + +**2. 設定** + +(オプション)ボットと会話できるユーザーを制限するために `allow_from` に WeChat ユーザー ID を追加します: +```json +{ + "channel_list": { + "weixin": { + "enabled": true, + "type": "weixin", + "token": "YOUR_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**3. 実行** +```bash +picoclaw gateway +``` + +
+ + +
+Matrix + +**1. Bot アカウントを準備** + +* お好みの homeserver(例: `https://matrix.org` またはセルフホスト)を使用 +* Bot ユーザーを作成し、access token を取得 + +**2. 設定** + +```json +{ + "channel_list": { + "matrix": { + "enabled": true, + "type": "matrix", + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. 実行** + +```bash +picoclaw gateway +``` + +すべてのオプション(`device_id`、`join_on_invite`、`group_trigger`、`placeholder`、`reasoning_channel_id`)については [Matrix チャネル設定ガイド](../channels/matrix/README.md) を参照してください。 + +
+ + +
+QQ + +**クイックセットアップ(推奨)** + +QQ 開放プラットフォームでは、OpenClaw 互換ボットのワンクリックセットアップページが提供されています: + +1. [QQ Bot クイックスタート](https://q.qq.com/qqbot/openclaw/index.html) を開き、QR コードをスキャンしてログイン +2. ボットが自動的に作成されます — **App ID** と **App Secret** をコピー +3. PicoClaw を設定: + +```json +{ + "channel_list": { + "qq": { + "enabled": true, + "type": "qq", + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +4. `picoclaw gateway` を実行し、QQ を開いてボットとチャット + +> App Secret は一度しか表示されません。すぐに保存してください — 再度表示するとリセットされます。 +> +> クイックセットアップで作成されたボットは、最初は作成者のみが使用でき、グループチャットには対応していません。グループアクセスを有効にするには、[QQ 開放プラットフォーム](https://q.qq.com/) でサンドボックスモードを設定してください。 + +**手動セットアップ** + +ボットを手動で作成する場合: + +* [QQ 開放プラットフォーム](https://q.qq.com/) にログインして開発者登録 +* QQ ボットを作成 — アバターと名前をカスタマイズ +* ボット設定から **App ID** と **App Secret** をコピー +* 上記の設定を行い、`picoclaw gateway` を実行 + +
+ + +
+Slack + +**1. Slack App を作成** + +* [Slack API](https://api.slack.com/apps) にアクセスして新しいアプリを作成 +* **OAuth & Permissions** で Bot スコープを追加:`chat:write`、`app_mentions:read`、`im:history`、`im:read`、`im:write` +* アプリをワークスペースにインストール +* **Bot Token**(`xoxb-...`)と **App-Level Token**(`xapp-...`、Socket Mode を有効にして取得)をコピー + +**2. 設定** + +```json +{ + "channel_list": { + "slack": { + "enabled": true, + "type": "slack", + "bot_token": "xoxb-YOUR-BOT-TOKEN", + "app_token": "xapp-YOUR-APP-TOKEN", + "allow_from": [] + } + } +} +``` + +**3. 実行** + +```bash +picoclaw gateway +``` + +
+ + +
+IRC + +**1. 設定** + +```json +{ + "channel_list": { + "irc": { + "enabled": true, + "type": "irc", + "server": "irc.libera.chat:6697", + "tls": true, + "nick": "picoclaw-bot", + "channels": ["#your-channel"], + "password": "", + "allow_from": [] + } + } +} +``` + +オプション:NickServ 認証用の `nickserv_password`、SASL 認証用の `sasl_user`/`sasl_password`。 + +**2. 実行** + +```bash +picoclaw gateway +``` + +ボットは IRC サーバーに接続し、指定されたチャネルに参加します。 + +
+ + +
+DingTalk + +**1. Bot を作成** + +* [開放プラットフォーム](https://open.dingtalk.com/) にアクセス +* 内部アプリを作成 +* Client ID と Client Secret をコピー + +**2. 設定** + +```json +{ + "channel_list": { + "dingtalk": { + "enabled": true, + "type": "dingtalk", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> `allow_from` を空にするとすべてのユーザーを許可します。DingTalk ユーザー ID を指定してアクセスを制限することもできます。 + +**3. 実行** + +```bash +picoclaw gateway +``` + +
+ + +
+LINE + +**1. LINE 公式アカウントを作成** + +- [LINE Developers Console](https://developers.line.biz/) にアクセス +- Provider を作成 → Messaging API チャネルを作成 +- **Channel Secret** と **Channel Access Token** をコピー + +**2. 設定** + +```json +{ + "channel_list": { + "line": { + "enabled": true, + "type": "line", + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +> LINE Webhook は共有 Gateway サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)上で提供されます。 + +**3. Webhook URL を設定** + +LINE は HTTPS Webhook が必要です。リバースプロキシまたはトンネルを使用してください: + +```bash +# 例:ngrok を使用(Gateway デフォルトポートは 18790) +ngrok http 18790 +``` + +LINE Developers Console で Webhook URL を `https://your-domain/webhook/line` に設定し、**Use webhook** を有効にしてください。 + +**4. 実行** + +```bash +picoclaw gateway +``` + +> グループチャットでは、Bot は @メンション時のみ応答します。返信は元のメッセージを引用します。 + +
+ + +
+Feishu (飛書) + +PicoClaw は WebSocket/SDK モードで飛書に接続します — 公開 Webhook URL やコールバックサーバーは不要です。 + +**1. アプリを作成** + +* [飛書開放プラットフォーム](https://open.feishu.cn/) にアクセスしてアプリケーションを作成 +* アプリ設定で **ボット** 機能を有効化 +* バージョンを作成してアプリを公開(アプリは公開しないと有効になりません) +* **App ID**(`cli_` で始まる)と **App Secret** をコピー + +**2. 設定** + +```json +{ + "channel_list": { + "feishu": { + "enabled": true, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +オプション:`encrypt_key` と `verification_token` でイベント暗号化(本番環境推奨)。 + +**3. 実行してチャット** + +```bash +picoclaw gateway +``` + +飛書を開き、ボット名を検索してチャットを開始できます。ボットをグループに追加することもできます — `group_trigger.mention_only: true` を設定すると @メンション時のみ応答します。 + +詳細なオプションについては [飛書チャネル設定ガイド](../channels/feishu/README.ja.md) を参照してください。 + +
+ + +
+WeCom (企業微信) + +PicoClaw は 3 種類の WeCom 統合をサポートしています: + +**方式 1: グループ Bot (Bot)** — セットアップ簡単、グループチャット対応 +**方式 2: カスタムアプリ (App)** — より多機能、プロアクティブメッセージング、プライベートチャットのみ +**方式 3: AI Bot** — 公式 AI Bot、ストリーミング返信、グループ・プライベートチャット対応 + +詳細なセットアップ手順は [WeCom 設定ガイド](../channels/wecom/README.ja.md) を参照してください。 + +**クイックセットアップ — グループ Bot:** + +**1. Bot を作成** + +* WeCom 管理コンソール → グループチャット → グループ Bot を追加 +* Webhook URL をコピー(形式:`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) + +**2. 設定** + +```json +{ + "channel_list": { + "wecom": { + "enabled": true, + "type": "wecom", + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [] + } + } +} +``` + +> WeCom Webhook は共有 Gateway サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)上で提供されます。 + +**クイックセットアップ — カスタムアプリ:** + +**1. アプリを作成** + +* WeCom 管理コンソール → アプリ管理 → アプリを作成 +* **AgentId** と **Secret** をコピー +* 「マイ企業」ページで **CorpID** をコピー + +**2. メッセージ受信を設定** + +* アプリ詳細で「メッセージ受信」→「API を設定」をクリック +* URL を `http://your-server:18790/webhook/wecom-app` に設定 +* **Token** と **EncodingAESKey** を生成 + +**3. 設定** + +```json +{ + "channel_list": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [] + } + } +} +``` + +**4. 実行** + +```bash +picoclaw gateway +``` + +> **注意**: WeCom Webhook コールバックは Gateway ポート(デフォルト 18790)で提供されます。HTTPS にはリバースプロキシを使用してください。 + +**クイックセットアップ — AI Bot:** + +**1. AI Bot を作成** + +* WeCom 管理コンソール → アプリ管理 → AI Bot +* AI Bot 設定でコールバック URL を設定:`http://your-server:18790/webhook/wecom-aibot` +* **Token** をコピーし、「ランダム生成」をクリックして **EncodingAESKey** を取得 + +**2. 設定** + +```json +{ + "channel_list": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "こんにちは!何かお手伝いできますか?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly." + } + } +} +``` + +**3. 実行** + +```bash +picoclaw gateway +``` + +> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用しており、返信タイムアウトの心配はありません。長時間タスク(30 秒超)は自動的に `response_url` プッシュ配信に切り替わります。 + +
+ + +
+OneBot(OneBot プロトコル経由の QQ) + +OneBot は QQ ボット向けのオープンプロトコルです。PicoClaw は OneBot v11 互換の実装(例:[Lagrange](https://github.com/LagrangeDev/Lagrange.Core)、[NapCat](https://github.com/NapNeko/NapCatQQ))に WebSocket で接続します。 + +**1. OneBot 実装をセットアップ** + +OneBot v11 互換の QQ ボットフレームワークをインストールして実行します。WebSocket サーバーを有効にしてください。 + +**2. 設定** + +```json +{ + "channel_list": { + "onebot": { + "enabled": true, + "type": "onebot", + "ws_url": "ws://127.0.0.1:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| フィールド | 説明 | +|-------|-------------| +| `ws_url` | OneBot 実装の WebSocket URL | +| `access_token` | 認証用アクセストークン(OneBot 側で設定している場合) | +| `reconnect_interval` | 再接続間隔(秒)(デフォルト:5) | + +**3. 実行** + +```bash +picoclaw gateway +``` + +
+ + +
+MaixCam + +Sipeed AI カメラハードウェア向けの統合チャネルです。 + +```json +{ + "channel_list": { + "maixcam": { + "enabled": true, + "type": "maixcam" + } + } +} +``` + +```bash +picoclaw gateway +``` + +
diff --git a/docs/guides/chat-apps.md b/docs/guides/chat-apps.md new file mode 100644 index 000000000..62418f91a --- /dev/null +++ b/docs/guides/chat-apps.md @@ -0,0 +1,589 @@ +# 💬 Chat Apps Configuration + +> Back to [README](../README.md) + +## 💬 Chat Apps + +Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, or Pico (native protocol) + +> **Note**: Channels that rely on HTTP callbacks share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Socket/stream-based channels such as Feishu, DingTalk, and WeCom do not rely on the shared webhook server for inbound delivery. + +| Channel | Difficulty | Description | Documentation | +| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **Telegram** | ⭐ Easy | Recommended, voice-to-text, long polling (no public IP needed) | [Docs](../channels/telegram/README.md) | +| **Discord** | ⭐ Easy | Socket Mode, group/DM support, rich bot ecosystem | [Docs](../channels/discord/README.md) | +| **WhatsApp** | ⭐ Easy | Native (QR scan) or Bridge URL | [Docs](#whatsapp) | +| **Weixin** | ⭐ Easy | Native QR scan (Tencent iLink API) | [Docs](#weixin) | +| **Slack** | ⭐ Easy | **Socket Mode** (no public IP needed), enterprise | [Docs](../channels/slack/README.md) | +| **Matrix** | ⭐⭐ Medium | Federated protocol, self-hosting supported | [Docs](../channels/matrix/README.md) | +| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](../channels/qq/README.md) | +| **DingTalk** | ⭐⭐ Medium | Stream mode (no public IP needed), enterprise | [Docs](../channels/dingtalk/README.md) | +| **LINE** | ⭐⭐⭐ Advanced | HTTPS Webhook required | [Docs](../channels/line/README.md) | +| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Official AI Bot over WebSocket, streaming + media | [Docs](../channels/wecom/README.md) | +| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](../channels/feishu/README.md) | +| **IRC** | ⭐⭐ Medium | Server + TLS configuration | [Docs](#irc) | +| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](../channels/onebot/README.md) | +| **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](../channels/maixcam/README.md) | +| **Pico** | ⭐ Easy | Native PicoClaw protocol channel | | + + +
+Telegram (Recommended) + +**1. Create a bot** + +* Open Telegram, search `@BotFather` +* Send `/newbot`, follow prompts +* Copy the token + +**2. Configure** + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": false + } + } +} +``` + +> Get your user ID from `@userinfobot` on Telegram. + +**3. Run** + +```bash +picoclaw gateway +``` + +**4. Telegram command menu (auto-registered at startup)** + +PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) so command menu and runtime behavior stay in sync. +Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor. + +If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background. + +You can also inspect skills and MCP servers directly from Telegram: + +- `/list skills` +- `/list mcp` +- `/show mcp ` +- `/use ` +- `/use ` and then send the actual request in the next message +- `/use clear` +- `/btw ` to ask an immediate side question without changing the active session history; `/btw` is handled as a no-tool query and does not enter the normal tool-execution flow + +**4. Advanced Formatting** +You 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. + +
+ + +
+Discord + +**1. Create a bot** + +* Go to +* Create an application → Bot → Add Bot +* Copy the bot token + +**2. Enable intents** + +* In the Bot settings, enable **MESSAGE CONTENT INTENT** +* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data + +**3. Get your User ID** +* Discord Settings → Advanced → enable **Developer Mode** +* Right-click your avatar → **Copy User ID** + +**4. Configure** + +```json +{ + "channel_list": { + "discord": { + "enabled": true, + "type": "discord", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Invite the bot** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Open the generated invite URL and add the bot to your server + +**Optional: Group trigger mode** + +By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add: + +```json +{ + "channel_list": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +You can also trigger by keyword prefixes (e.g. `!bot`): + +```json +{ + "channel_list": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` + +**6. Run** + +```bash +picoclaw gateway +``` + +
+ + +
+WhatsApp (native via whatsmeow) + +PicoClaw can connect to WhatsApp in two ways: + +- **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/...`). +- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false. + +**Configure (native)** + +```json +{ + "channel_list": { + "whatsapp": { + "enabled": true, + "type": "whatsapp", + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +If `session_store_path` is empty, the session is stored in `/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices. + +
+ + +
+Weixin (WeChat Personal) + +PicoClaw supports connecting to your personal WeChat account using the official Tencent iLink API. + +**1. Login** + +Run the interactive QR login flow: +```bash +picoclaw auth weixin +``` +Scan the printed QR code with your WeChat mobile app. On success, the token is saved to your config. + +**2. Configure** + +(Optional) Update `allow_from` with your WeChat User ID to restrict who can message the bot: +```json +{ + "channel_list": { + "weixin": { + "enabled": true, + "type": "weixin", + "token": "YOUR_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**3. Run** +```bash +picoclaw gateway +``` + +
+ + +
+QQ + +**Quick setup (recommended)** + +QQ Open Platform provides a one-click setup page for OpenClaw-compatible bots: + +1. Open [QQ Bot Quick Start](https://q.qq.com/qqbot/openclaw/index.html) and scan the QR code to log in +2. A bot is created automatically — copy the **App ID** and **App Secret** +3. Configure PicoClaw: + +```json +{ + "channel_list": { + "qq": { + "enabled": true, + "type": "qq", + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +4. Run `picoclaw gateway` and open QQ to chat with your bot + +> The App Secret is only shown once. Save it immediately — viewing it again will force a reset. +> +> Bots created via the quick setup page are initially for the creator only and do not support group chats. To enable group access, configure sandbox mode on the [QQ Open Platform](https://q.qq.com/). + +**Manual setup** + +If you prefer to create the bot manually: + +* Log in at [QQ Open Platform](https://q.qq.com/) to register as a developer +* Create a QQ bot — customize its avatar and name +* Copy the **App ID** and **App Secret** from the bot settings +* Configure as shown above and run `picoclaw gateway` + +
+ + +
+DingTalk + +**1. Create a bot** + +* Go to [Open Platform](https://open.dingtalk.com/) +* Create an internal app +* Copy Client ID and Client Secret + +**2. Configure** + +```json +{ + "channel_list": { + "dingtalk": { + "enabled": true, + "type": "dingtalk", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access. + +**3. Run** + +```bash +picoclaw gateway +``` +
+ + +
+Matrix + +**1. Prepare bot account** + +* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted) +* Create a bot user and obtain its access token + +**2. Configure** + +```json +{ + "channel_list": { + "matrix": { + "enabled": true, + "type": "matrix", + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Run** + +```bash +picoclaw gateway +``` + +For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](../channels/matrix/README.md). + +
+ + +
+LINE + +**1. Create a LINE Official Account** + +- Go to [LINE Developers Console](https://developers.line.biz/) +- Create a provider → Create a Messaging API channel +- Copy **Channel Secret** and **Channel Access Token** + +**2. Configure** + +```json +{ + "channel_list": { + "line": { + "enabled": true, + "type": "line", + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). + +**3. Set up Webhook URL** + +LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel: + +```bash +# Example with ngrok (gateway default port is 18790) +ngrok http 18790 +``` + +Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**. + +**4. Run** + +```bash +picoclaw gateway +``` + +> In group chats, the bot responds only when @mentioned. Replies quote the original message. + +
+ + +
+WeCom (企业微信) + +PicoClaw now exposes WeCom as a single AI Bot channel over WebSocket. +No public webhook callback URL is required. + +See [WeCom Configuration Guide](../channels/wecom/README.md) for the full configuration reference and migration notes. + +**Quick Setup - Recommended** + +**1. Authenticate** + +```bash +picoclaw auth wecom +``` + +This command shows a QR code, waits for approval in WeCom, and writes `bot_id` + `secret` into `channels.wecom`. + +**2. Configure manually if needed** + +```json +{ + "channel_list": { + "wecom": { + "enabled": true, + "type": "wecom", + "bot_id": "YOUR_BOT_ID", + "secret": "YOUR_SECRET", + "websocket_url": "wss://openws.work.weixin.qq.com", + "send_thinking_message": true, + "allow_from": [], + "reasoning_channel_id": "" + } + } +} +``` + +**3. Run** + +```bash +picoclaw gateway +``` + +> Legacy `wecom_app` and `wecom_aibot` entries are replaced by the unified `channels.wecom` config in this branch. + +
+ + +
+Feishu (Lark) + +PicoClaw connects to Feishu via WebSocket/SDK mode — no public webhook URL or callback server needed. + +**1. Create an app** + +* Go to [Feishu Open Platform](https://open.feishu.cn/) and create an application +* In the app settings, enable the **Bot** capability +* Create a version and publish the app (the app must be published to take effect) +* Copy the **App ID** (starts with `cli_`) and **App Secret** + +**2. Configure** + +```json +{ + "channel_list": { + "feishu": { + "enabled": true, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +Optional fields: `encrypt_key` and `verification_token` for event encryption (recommended for production). + +**3. Run and chat** + +```bash +picoclaw gateway +``` + +Open Feishu, search for your bot name, and start chatting. You can also add the bot to a group — use `group_trigger.mention_only: true` to only respond when @mentioned. + +For full options, see [Feishu Channel Configuration Guide](../channels/feishu/README.md). + +
+ + +
+Slack + +**1. Create a Slack app** + +* Go to [Slack API](https://api.slack.com/apps) and create a new app +* Under **OAuth & Permissions**, add bot scopes: `chat:write`, `app_mentions:read`, `im:history`, `im:read`, `im:write` +* Install the app to your workspace +* Copy the **Bot Token** (`xoxb-...`) and **App-Level Token** (`xapp-...`, enable Socket Mode to get this) + +**2. Configure** + +```json +{ + "channel_list": { + "slack": { + "enabled": true, + "type": "slack", + "bot_token": "xoxb-YOUR-BOT-TOKEN", + "app_token": "xapp-YOUR-APP-TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Run** + +```bash +picoclaw gateway +``` + +
+ + +
+IRC + +**1. Configure** + +```json +{ + "channel_list": { + "irc": { + "enabled": true, + "type": "irc", + "server": "irc.libera.chat:6697", + "tls": true, + "nick": "picoclaw-bot", + "channels": ["#your-channel"], + "password": "", + "allow_from": [] + } + } +} +``` + +Optional: `nickserv_password` for NickServ authentication, `sasl_user`/`sasl_password` for SASL auth. + +**2. Run** + +```bash +picoclaw gateway +``` + +The bot will connect to the IRC server and join the specified channels. + +
+ + +
+OneBot (QQ via OneBot protocol) + +OneBot is an open protocol for QQ bots. PicoClaw connects to any OneBot v11 compatible implementation (e.g., [Lagrange](https://github.com/LagrangeDev/Lagrange.Core), [NapCat](https://github.com/NapNeko/NapCatQQ)) via WebSocket. + +**1. Set up a OneBot implementation** + +Install and run a OneBot v11 compatible QQ bot framework. Enable its WebSocket server. + +**2. Configure** + +```json +{ + "channel_list": { + "onebot": { + "enabled": true, + "type": "onebot", + "ws_url": "ws://127.0.0.1:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Field | Description | +|-------|-------------| +| `ws_url` | WebSocket URL of the OneBot implementation | +| `access_token` | Access token for authentication (if configured in OneBot) | +| `reconnect_interval` | Reconnect interval in seconds (default: 5) | + +**3. Run** + +```bash +picoclaw gateway +``` + +
diff --git a/docs/guides/chat-apps.ms.md b/docs/guides/chat-apps.ms.md new file mode 100644 index 000000000..6bfa7565e --- /dev/null +++ b/docs/guides/chat-apps.ms.md @@ -0,0 +1,447 @@ +# 💬 Konfigurasi Aplikasi Sembang + +> Kembali ke [README](../project/README.ms.md) + +## 💬 Aplikasi Sembang + +Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, atau Pico (protokol asli) + +> **Nota**: Semua saluran berasaskan webhook (LINE, WeCom, dan sebagainya) diservis pada satu pelayan HTTP Gateway yang dikongsi (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Tiada port khusus per saluran untuk dikonfigurasikan. Nota: Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP webhook yang dikongsi. + +| Saluran | Penyediaan | +| ---------------- | ------------------------------------------ | +| **Telegram** | Mudah (hanya token) | +| **Discord** | Mudah (token bot + intents) | +| **WhatsApp** | Mudah (asli: imbas QR; atau bridge URL) | +| **Matrix** | Sederhana (homeserver + access token bot) | +| **QQ** | Mudah (AppID + AppSecret) | +| **DingTalk** | Sederhana (kelayakan aplikasi) | +| **LINE** | Sederhana (kelayakan + webhook URL) | +| **WeCom AI Bot** | Sederhana (Token + kunci AES) | +| **Feishu** | Sederhana (App ID + Secret, mod WebSocket) | +| **Slack** | Sederhana (Bot token + App token) | +| **IRC** | Sederhana (pelayan + konfigurasi TLS) | +| **OneBot** | Sederhana (QQ melalui protokol OneBot) | +| **MaixCam** | Mudah (integrasi perkakasan Sipeed) | +| **Pico** | Protokol PicoClaw asli | + +
+Telegram (Disyorkan) + +**1. Cipta bot** + +* Buka Telegram, cari `@BotFather` +* Hantar `/newbot`, ikut arahan +* Salin token + +**2. Konfigurasi** + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": false, + } + } +} +``` + +> Dapatkan user ID anda daripada `@userinfobot` di Telegram. + +**3. Jalankan** + +```bash +picoclaw gateway +``` + +**4. Menu arahan Telegram (auto-register semasa startup)** + +PicoClaw kini menyimpan definisi arahan dalam satu registry bersama. Semasa startup, Telegram akan mendaftarkan arahan bot yang disokong secara automatik (contohnya `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) supaya menu arahan dan tingkah laku runtime sentiasa selari. +Pendaftaran menu arahan Telegram kekal sebagai UX penemuan setempat saluran; pelaksanaan arahan generik dikendalikan secara berpusat dalam gelung agen melalui commands executor. + +Jika pendaftaran arahan gagal (ralat sementara rangkaian/API), saluran tetap akan bermula dan PicoClaw akan mencuba semula pendaftaran di latar belakang. + +Anda juga boleh mengurus skill yang dipasang terus dari Telegram: + +- `/list skills` +- `/use ` +- `/use ` kemudian hantar permintaan sebenar dalam mesej seterusnya +- `/use clear` +- `/btw ` untuk bertanya soalan sampingan segera tanpa mengubah sejarah sesi aktif; `/btw` dikendalikan sebagai pertanyaan langsung tanpa tool dan tidak memasuki aliran pelaksanaan tool biasa + +**4. Pemformatan Lanjutan** +Anda boleh menetapkan `use_markdown_v2: true` untuk mengaktifkan pilihan pemformatan yang lebih maju. Ini membolehkan bot menggunakan keseluruhan set ciri Telegram MarkdownV2, termasuk gaya bersarang, spoiler, dan blok lebar tetap tersuai. + +
+ +
+Discord + +**1. Cipta bot** + +* Pergi ke +* Cipta aplikasi → Bot → Add Bot +* Salin token bot + +**2. Aktifkan intents** + +* Dalam tetapan Bot, aktifkan **MESSAGE CONTENT INTENT** +* (Pilihan) Aktifkan **SERVER MEMBERS INTENT** jika anda bercadang menggunakan allow list berasaskan data ahli + +**3. Dapatkan User ID anda** +* Discord Settings → Advanced → aktifkan **Developer Mode** +* Klik kanan avatar anda → **Copy User ID** + +**4. Konfigurasi** + +```json +{ + "channel_list": { + "discord": { + "enabled": true, + "type": "discord", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Jemput bot** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Buka URL jemputan yang dijana dan tambahkan bot ke pelayan anda + +**Pilihan: Mod trigger kumpulan** + +Secara lalai bot membalas semua mesej dalam saluran pelayan. Untuk mengehadkan balasan kepada @mention sahaja, tambah: + +```json +{ + "channel_list": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +Anda juga boleh mencetuskan dengan awalan kata kunci (contohnya `!bot`): + +```json +{ + "channel_list": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` + +**6. Jalankan** + +```bash +picoclaw gateway +``` + +
+ +
+WhatsApp (asli melalui whatsmeow) + +PicoClaw boleh menyambung ke WhatsApp dalam dua cara: + +- **Asli (disyorkan):** Dalam proses menggunakan [whatsmeow](https://github.com/tulir/whatsmeow). Tiada bridge berasingan. Tetapkan `"use_native": true` dan biarkan `bridge_url` kosong. Pada larian pertama, imbas kod QR dengan WhatsApp (Linked Devices). Sesi disimpan di bawah workspace anda (contohnya `workspace/whatsapp/`). Saluran asli ini adalah **pilihan** untuk memastikan binari lalai kekal kecil; bina dengan `-tags whatsapp_native` (contohnya `make build-whatsapp-native` atau `go build -tags whatsapp_native ./cmd/...`). +- **Bridge:** Sambung ke bridge WebSocket luaran. Tetapkan `bridge_url` (contohnya `ws://localhost:3001`) dan biarkan `use_native` sebagai false. + +**Konfigurasi (asli)** + +```json +{ + "channel_list": { + "whatsapp": { + "enabled": true, + "type": "whatsapp", + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +Jika `session_store_path` kosong, sesi akan disimpan dalam `/whatsapp/`. Jalankan `picoclaw gateway`; pada larian pertama, imbas kod QR yang dipaparkan dalam terminal menggunakan WhatsApp → Linked Devices. + +
+ +
+QQ + +**1. Cipta bot** + +- Pergi ke [QQ Open Platform](https://q.qq.com/#) +- Cipta aplikasi → Dapatkan **AppID** dan **AppSecret** + +**2. Konfigurasi** + +```json +{ + "channel_list": { + "qq": { + "enabled": true, + "type": "qq", + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> Tetapkan `allow_from` kepada kosong untuk membenarkan semua pengguna, atau nyatakan nombor QQ untuk mengehadkan akses. + +**3. Jalankan** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Cipta bot** + +* Pergi ke [Open Platform](https://open.dingtalk.com/) +* Cipta aplikasi dalaman +* Salin Client ID dan Client Secret + +**2. Konfigurasi** + +```json +{ + "channel_list": { + "dingtalk": { + "enabled": true, + "type": "dingtalk", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Tetapkan `allow_from` kepada kosong untuk membenarkan semua pengguna, atau nyatakan user ID DingTalk untuk mengehadkan akses. + +**3. Jalankan** + +```bash +picoclaw gateway +``` +
+ +
+Matrix + +**1. Sediakan akaun bot** + +* Gunakan homeserver pilihan anda (contohnya `https://matrix.org` atau self-hosted) +* Cipta pengguna bot dan dapatkan access tokennya + +**2. Konfigurasi** + +```json +{ + "channel_list": { + "matrix": { + "enabled": true, + "type": "matrix", + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Jalankan** + +```bash +picoclaw gateway +``` + +Untuk pilihan penuh (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), lihat [Panduan Konfigurasi Saluran Matrix](../channels/matrix/README.md). + +
+ +
+LINE + +**1. Cipta Akaun Rasmi LINE** + +- Pergi ke [LINE Developers Console](https://developers.line.biz/) +- Cipta provider → Cipta saluran Messaging API +- Salin **Channel Secret** dan **Channel Access Token** + +**2. Konfigurasi** + +```json +{ + "channel_list": { + "line": { + "enabled": true, + "type": "line", + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +> Webhook LINE diservis pada pelayan Gateway yang dikongsi (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). + +**3. Tetapkan Webhook URL** + +LINE memerlukan HTTPS untuk webhook. Gunakan reverse proxy atau tunnel: + +```bash +# Contoh dengan ngrok (port lalai gateway ialah 18790) +ngrok http 18790 +``` + +Kemudian tetapkan Webhook URL dalam LINE Developers Console kepada `https://your-domain/webhook/line` dan aktifkan **Use webhook**. + +**4. Jalankan** + +```bash +picoclaw gateway +``` + +> Dalam sembang kumpulan, bot hanya membalas apabila @disebut. Balasan akan memetik mesej asal. + +
+ +
+WeCom (企业微信) + +PicoClaw menyokong tiga jenis integrasi WeCom: + +**Pilihan 1: WeCom Bot (Bot)** - Penyediaan lebih mudah, menyokong sembang kumpulan +**Pilihan 2: WeCom App (Custom App)** - Lebih banyak ciri, pemesejan proaktif, sembang peribadi sahaja +**Pilihan 3: WeCom AI Bot (AI Bot)** - AI Bot rasmi, balasan streaming, menyokong sembang kumpulan & peribadi + +Lihat [Panduan Konfigurasi WeCom](../channels/wecom/README.zh.md) untuk arahan penyediaan terperinci. + +**Quick Setup - WeCom Bot:** + +**1. Cipta bot** + +* Pergi ke WeCom Admin Console → Group Chat → Add Group Bot +* Salin webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) + +**2. Konfigurasi** + +```json +{ + "channel_list": { + "wecom": { + "enabled": true, + "type": "wecom", + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [] + } + } +} +``` + +> Webhook WeCom diservis pada pelayan Gateway yang dikongsi (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). + +**Quick Setup - WeCom App:** + +**1. Cipta aplikasi** + +* Pergi ke WeCom Admin Console → App Management → Create App +* Salin **AgentId** dan **Secret** +* Pergi ke halaman "My Company", salin **CorpID** + +**2. Konfigurasi penerimaan mesej** + +* Dalam butiran aplikasi, klik "Receive Message" → "Set API" +* Tetapkan URL kepada `http://your-server:18790/webhook/wecom-app` +* Jana **Token** dan **EncodingAESKey** + +**3. Konfigurasi** + +```json +{ + "channel_list": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [] + } + } +} +``` + +**4. Jalankan** + +```bash +picoclaw gateway +``` + +> **Nota**: Callback webhook WeCom diservis pada port Gateway (lalai 18790). Gunakan reverse proxy untuk HTTPS. + +**Quick Setup - WeCom AI Bot:** + +**1. Cipta AI Bot** + +* Pergi ke WeCom Admin Console → App Management → AI Bot +* Dalam tetapan AI Bot, konfigurasikan callback URL: `http://your-server:18791/webhook/wecom-aibot` +* Salin **Token** dan klik "Random Generate" untuk **EncodingAESKey** + +**2. Konfigurasi** + +```json +{ + "channel_list": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Hello! How can I help you?" + } + } +} +``` + +**3. Jalankan** + +```bash +picoclaw gateway +``` + +> **Nota**: WeCom AI Bot menggunakan protokol streaming pull — tiada isu timeout balasan. Tugasan panjang (>30 saat) akan bertukar secara automatik kepada penghantaran push `response_url`. + +
diff --git a/docs/guides/chat-apps.pt-br.md b/docs/guides/chat-apps.pt-br.md new file mode 100644 index 000000000..6d4fbdc23 --- /dev/null +++ b/docs/guides/chat-apps.pt-br.md @@ -0,0 +1,697 @@ +# 💬 Configuração de Aplicativos de Chat + +> Voltar ao [README](../project/README.pt-br.md) + +## 💬 Aplicativos de Chat + +Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam + +> **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. + +| Canal | Dificuldade | Descrição | Documentação | +| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **Telegram** | ⭐ Fácil | Recomendado, voz para texto, long polling (sem IP público) | [Documentação](../channels/telegram/README.pt-br.md) | +| **Discord** | ⭐ Fácil | Socket Mode, suporte a grupos/DM, ecossistema bot rico | [Documentação](../channels/discord/README.pt-br.md) | +| **WhatsApp** | ⭐ Fácil | Nativo (scan QR) ou Bridge URL | [Documentação](#whatsapp) | +| **Weixin** | ⭐ Fácil | Scan QR nativo (API Tencent iLink) | [Documentação](#weixin) | +| **Slack** | ⭐ Fácil | **Socket Mode** (sem IP público), empresarial | [Documentação](../channels/slack/README.pt-br.md) | +| **Matrix** | ⭐⭐ Médio | Protocolo federado, suporte a auto-hospedagem | [Documentação](../channels/matrix/README.pt-br.md) | +| **QQ** | ⭐⭐ Médio | API bot oficial, comunidade chinesa | [Documentação](../channels/qq/README.pt-br.md) | +| **DingTalk** | ⭐⭐ Médio | Modo Stream (sem IP público), empresarial | [Documentação](../channels/dingtalk/README.pt-br.md) | +| **LINE** | ⭐⭐⭐ Avançado | HTTPS Webhook obrigatório | [Documentação](../channels/line/README.pt-br.md) | +| **WeCom (企业微信)** | ⭐⭐⭐ Avançado | Bot de grupo (Webhook), app personalizado (API), AI Bot | [Guia](../channels/wecom/README.pt-br.md) | +| **Feishu (飞书)** | ⭐⭐⭐ Avançado | Colaboração empresarial, rico em recursos | [Documentação](../channels/feishu/README.pt-br.md) | +| **IRC** | ⭐⭐ Médio | Servidor + configuração TLS | [Documentação](#irc) | +| **OneBot** | ⭐⭐ Médio | Compatível com NapCat/Go-CQHTTP, ecossistema comunitário | [Documentação](../channels/onebot/README.pt-br.md) | +| **MaixCam** | ⭐ Fácil | Canal de integração de hardware para câmeras AI Sipeed | [Documentação](../channels/maixcam/README.pt-br.md) | +| **Pico** | ⭐ Fácil | Canal de protocolo nativo PicoClaw | | + + +
+Telegram (Recomendado) + +**1. Criar um bot** + +* Abra o Telegram, pesquise `@BotFather` +* Envie `/newbot`, siga as instruções +* Copie o token + +**2. Configurar** + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +> Obtenha seu ID de usuário com `@userinfobot` no Telegram. + +**3. Executar** + +```bash +picoclaw gateway +``` + +**4. Menu de comandos do Telegram (registrado automaticamente na inicialização)** + +O 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`, `/use`, `/btw`) para que o menu de comandos e o comportamento em tempo de execução permaneçam sincronizados. +O 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. + +Se 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. + +Voce tambem pode gerenciar skills instaladas diretamente pelo Telegram: + +- `/list skills` +- `/use ` +- `/use ` e depois enviar a solicitacao real na proxima mensagem +- `/use clear` +- `/btw ` para fazer uma pergunta lateral imediata sem alterar o historico ativo da sessao; `/btw` e tratado como uma consulta direta sem ferramentas e nao entra no fluxo normal de execucao de ferramentas + +
+ + +
+Discord + +**1. Criar um bot** + +* Acesse +* Crie um aplicativo → Bot → Add Bot +* Copie o token do bot + +**2. Habilitar intents** + +* Nas configurações do Bot, habilite **MESSAGE CONTENT INTENT** +* (Opcional) Habilite **SERVER MEMBERS INTENT** se planeja usar listas de permissão baseadas em dados de membros + +**3. Obter seu User ID** +* Configurações do Discord → Avançado → habilite **Developer Mode** +* Clique com o botão direito no seu avatar → **Copy User ID** + +**4. Configurar** + +```json +{ + "channel_list": { + "discord": { + "enabled": true, + "type": "discord", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Convidar o bot** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Abra a URL de convite gerada e adicione o bot ao seu servidor + +**Opcional: Modo de ativação em grupo** + +Por padrão, o bot responde a todas as mensagens em um canal do servidor. Para restringir respostas apenas a @menções, adicione: + +```json +{ + "channel_list": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +Você também pode ativar por prefixos de palavras-chave (ex.: `!bot`): + +```json +{ + "channel_list": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` + +**6. Executar** + +```bash +picoclaw gateway +``` + +
+ + +
+WhatsApp (nativo via whatsmeow) + +O PicoClaw pode se conectar ao WhatsApp de duas formas: + +- **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/...`). +- **Bridge:** Conecte-se a um bridge WebSocket externo. Defina `bridge_url` (ex.: `ws://localhost:3001`) e mantenha `use_native` como false. + +**Configurar (nativo)** + +```json +{ + "channel_list": { + "whatsapp": { + "enabled": true, + "type": "whatsapp", + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +Se `session_store_path` estiver vazio, a sessão é armazenada em `/whatsapp/`. Execute `picoclaw gateway`; na primeira execução, escaneie o QR code impresso no terminal com WhatsApp → Dispositivos Vinculados. + +
+ + +
+Weixin (WeChat Pessoal) + +O PicoClaw suporta conexão com sua conta pessoal do WeChat usando a API oficial Tencent iLink. + +**1. Login** + +Execute o fluxo de login interativo por QR code: +```bash +picoclaw auth weixin +``` +Escaneie o QR code exibido com seu aplicativo WeChat mobile. Após o login bem-sucedido, o token é salvo na sua configuração. + +**2. Configurar** + +(Opcional) Adicione seu ID de usuário WeChat em `allow_from` para restringir quem pode enviar mensagens ao bot: +```json +{ + "channel_list": { + "weixin": { + "enabled": true, + "type": "weixin", + "token": "YOUR_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**3. Executar** +```bash +picoclaw gateway +``` + +
+ + +
+QQ + +**Configuração rápida (recomendada)** + +A QQ Open Platform oferece uma página de configuração com um clique para bots compatíveis com OpenClaw: + +1. Abra o [QQ Bot Quick Start](https://q.qq.com/qqbot/openclaw/index.html) e escaneie o QR code para fazer login +2. Um bot é criado automaticamente — copie o **App ID** e o **App Secret** +3. Configure o PicoClaw: + +```json +{ + "channel_list": { + "qq": { + "enabled": true, + "type": "qq", + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +4. Execute `picoclaw gateway` e abra o QQ para conversar com seu bot + +> O App Secret é exibido apenas uma vez. Salve-o imediatamente — visualizá-lo novamente forçará uma redefinição. +> +> Bots criados pela página de configuração rápida são inicialmente apenas para o criador e não suportam chats de grupo. Para habilitar o acesso em grupo, configure o modo sandbox na [QQ Open Platform](https://q.qq.com/). + +**Configuração manual** + +Se preferir criar o bot manualmente: + +* Faça login na [QQ Open Platform](https://q.qq.com/) para se registrar como desenvolvedor +* Crie um bot QQ — personalize seu avatar e nome +* Copie o **App ID** e o **App Secret** nas configurações do bot +* Configure conforme mostrado acima e execute `picoclaw gateway` + +
+ + +
+DingTalk + +**1. Criar um bot** + +* Acesse a [Open Platform](https://open.dingtalk.com/) +* Crie um aplicativo interno +* Copie o Client ID e o Client Secret + +**2. Configurar** + +```json +{ + "channel_list": { + "dingtalk": { + "enabled": true, + "type": "dingtalk", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Defina `allow_from` como vazio para permitir todos os usuários, ou especifique IDs de usuário DingTalk para restringir o acesso. + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ + +
+MaixCam + +Canal de integração projetado especificamente para hardware de câmera AI Sipeed. + +```json +{ + "channel_list": { + "maixcam": { + "enabled": true, + "type": "maixcam" + } + } +} +``` + +```bash +picoclaw gateway +``` + +
+ + + +
+Matrix + +**1. Preparar conta do bot** + +* Use seu homeserver preferido (ex.: `https://matrix.org` ou auto-hospedado) +* Crie um usuário bot e obtenha seu access token + +**2. Configurar** + +```json +{ + "channel_list": { + "matrix": { + "enabled": true, + "type": "matrix", + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Executar** + +```bash +picoclaw gateway +``` + +Para opções completas (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), veja o [Guia de Configuração do Canal Matrix](../channels/matrix/README.md). + +
+ + +
+LINE + +**1. Criar uma Conta Oficial LINE** + +- Acesse o [LINE Developers Console](https://developers.line.biz/) +- Crie um provider → Crie um canal Messaging API +- Copie o **Channel Secret** e o **Channel Access Token** + +**2. Configurar** + +```json +{ + "channel_list": { + "line": { + "enabled": true, + "type": "line", + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +> O webhook do LINE é servido no servidor Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). + +**3. Configurar URL do Webhook** + +O LINE requer HTTPS para webhooks. Use um proxy reverso ou túnel: + +```bash +# Exemplo com ngrok (porta padrão do gateway é 18790) +ngrok http 18790 +``` + +Em seguida, defina a URL do Webhook no LINE Developers Console como `https://your-domain/webhook/line` e habilite **Use webhook**. + +**4. Executar** + +```bash +picoclaw gateway +``` + +> Em chats de grupo, o bot responde apenas quando @mencionado. As respostas citam a mensagem original. + +
+ + +
+WeCom (企业微信) + +O PicoClaw suporta três tipos de integração WeCom: + +**Opção 1: WeCom Bot (Bot)** - Configuração mais fácil, suporta chats de grupo +**Opção 2: WeCom App (App Personalizado)** - Mais recursos, mensagens proativas, apenas chat privado +**Opção 3: WeCom AI Bot (AI Bot)** - AI Bot oficial, respostas em streaming, suporta chat de grupo e privado + +Veja o [Guia de Configuração do WeCom](../channels/wecom/README.pt-br.md) para instruções detalhadas de configuração. + +**Configuração Rápida - WeCom Bot:** + +**1. Criar um bot** + +* Acesse o Console de Administração WeCom → Chat de Grupo → Adicionar Bot de Grupo +* Copie a URL do webhook (formato: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) + +**2. Configurar** + +```json +{ + "channel_list": { + "wecom": { + "enabled": true, + "type": "wecom", + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [] + } + } +} +``` + +> O webhook do WeCom é servido no servidor Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). + +**Configuração Rápida - WeCom App:** + +**1. Criar um aplicativo** + +* Acesse o Console de Administração WeCom → Gerenciamento de Apps → Criar App +* Copie o **AgentId** e o **Secret** +* Acesse a página "Minha Empresa", copie o **CorpID** + +**2. Configurar recebimento de mensagens** + +* Nos detalhes do App, clique em "Receber Mensagem" → "Configurar API" +* Defina a URL como `http://your-server:18790/webhook/wecom-app` +* Gere o **Token** e o **EncodingAESKey** + +**3. Configurar** + +```json +{ + "channel_list": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [] + } + } +} +``` + +**4. Executar** + +```bash +picoclaw gateway +``` + +> **Nota**: Os callbacks de webhook do WeCom são servidos na porta do Gateway (padrão 18790). Use um proxy reverso para HTTPS. + +**Configuração Rápida - WeCom AI Bot:** + +**1. Criar um AI Bot** + +* Acesse o Console de Administração WeCom → Gerenciamento de Apps → AI Bot +* Nas configurações do AI Bot, configure a URL de callback: `http://your-server:18790/webhook/wecom-aibot` +* Copie o **Token** e clique em "Gerar Aleatoriamente" para o **EncodingAESKey** + +**2. Configurar** + +```json +{ + "channel_list": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Hello! How can I help you?" + } + } +} +``` + +**3. Executar** + +```bash +picoclaw gateway +``` + +> **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. + +
+ + +
+Feishu (Lark) + +O PicoClaw se conecta ao Feishu via modo WebSocket/SDK — não é necessário URL de webhook público nem servidor de callback. + +**1. Criar um aplicativo** + +* Acesse a [Feishu Open Platform](https://open.feishu.cn/) e crie um aplicativo +* Nas configurações do aplicativo, habilite a capacidade **Bot** +* Crie uma versão e publique o aplicativo (o aplicativo deve ser publicado para funcionar) +* Copie o **App ID** (começa com `cli_`) e o **App Secret** + +**2. Configurar** + +```json +{ + "channel_list": { + "feishu": { + "enabled": true, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +Opcional: `encrypt_key` e `verification_token` para criptografia de eventos (recomendado para produção). + +**3. Executar e conversar** + +```bash +picoclaw gateway +``` + +Abra o Feishu, pesquise o nome do seu bot e comece a conversar. Você também pode adicionar o bot a um grupo — use `group_trigger.mention_only: true` para responder apenas quando @mencionado. + +Para opções completas, veja o [Guia de Configuração do Canal Feishu](../channels/feishu/README.pt-br.md). + +
+ + +
+Slack + +**1. Criar um aplicativo Slack** + +* Acesse a [Slack API](https://api.slack.com/apps) e crie um novo aplicativo +* Em **OAuth & Permissions**, adicione os escopos do bot: `chat:write`, `app_mentions:read`, `im:history`, `im:read`, `im:write` +* Instale o aplicativo no seu workspace +* Copie o **Bot Token** (`xoxb-...`) e o **App-Level Token** (`xapp-...`, habilite Socket Mode para obtê-lo) + +**2. Configurar** + +```json +{ + "channel_list": { + "slack": { + "enabled": true, + "type": "slack", + "bot_token": "xoxb-YOUR-BOT-TOKEN", + "app_token": "xapp-YOUR-APP-TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ + +
+IRC + +**1. Configurar** + +```json +{ + "channel_list": { + "irc": { + "enabled": true, + "type": "irc", + "server": "irc.libera.chat:6697", + "tls": true, + "nick": "picoclaw-bot", + "channels": ["#your-channel"], + "password": "", + "allow_from": [] + } + } +} +``` + +Opcional: `nickserv_password` para autenticação NickServ, `sasl_user`/`sasl_password` para autenticação SASL. + +**2. Executar** + +```bash +picoclaw gateway +``` + +O bot se conectará ao servidor IRC e entrará nos canais especificados. + +
+ + +
+OneBot (QQ via protocolo OneBot) + +OneBot é um protocolo aberto para bots QQ. O PicoClaw se conecta a qualquer implementação compatível com OneBot v11 (ex.: [Lagrange](https://github.com/LagrangeDev/Lagrange.Core), [NapCat](https://github.com/NapNeko/NapCatQQ)) via WebSocket. + +**1. Configurar uma implementação OneBot** + +Instale e execute um framework de bot QQ compatível com OneBot v11. Habilite seu servidor WebSocket. + +**2. Configurar** + +```json +{ + "channel_list": { + "onebot": { + "enabled": true, + "type": "onebot", + "ws_url": "ws://127.0.0.1:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Campo | Descrição | +|-------|-----------| +| `ws_url` | URL WebSocket da implementação OneBot | +| `access_token` | Token de acesso para autenticação (se configurado no OneBot) | +| `reconnect_interval` | Intervalo de reconexão em segundos (padrão: 5) | + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+MaixCam + +Canal de integração projetado especificamente para hardware de câmera AI Sipeed. + +```json +{ + "channel_list": { + "maixcam": { + "enabled": true, + "type": "maixcam" + } + } +} +``` + +```bash +picoclaw gateway +``` + +
diff --git a/docs/guides/chat-apps.vi.md b/docs/guides/chat-apps.vi.md new file mode 100644 index 000000000..8d0b4ee32 --- /dev/null +++ b/docs/guides/chat-apps.vi.md @@ -0,0 +1,698 @@ +# 💬 Cấu Hình Ứng Dụng Chat + +> Quay lại [README](../project/README.vi.md) + +## 💬 Ứng Dụng Chat + +Trò 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 + +> **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. + +| Kênh | Độ khó | Mô tả | Tài liệu | +| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **Telegram** | ⭐ Dễ | Khuyến nghị, chuyển giọng nói thành văn bản, long polling (không cần IP công khai) | [Tài liệu](../channels/telegram/README.vi.md) | +| **Discord** | ⭐ Dễ | Socket Mode, hỗ trợ nhóm/DM, hệ sinh thái bot phong phú | [Tài liệu](../channels/discord/README.vi.md) | +| **WhatsApp** | ⭐ Dễ | Bản địa (quét QR) hoặc Bridge URL | [Tài liệu](#whatsapp) | +| **Weixin** | ⭐ Dễ | Quét QR gốc (API Tencent iLink) | [Tài liệu](#weixin) | +| **Slack** | ⭐ Dễ | **Socket Mode** (không cần IP công khai), doanh nghiệp | [Tài liệu](../channels/slack/README.vi.md) | +| **Matrix** | ⭐⭐ Trung bình | Giao thức liên kết, hỗ trợ tự lưu trữ | [Tài liệu](../channels/matrix/README.vi.md) | +| **QQ** | ⭐⭐ Trung bình | API bot chính thức, cộng đồng Trung Quốc | [Tài liệu](../channels/qq/README.vi.md) | +| **DingTalk** | ⭐⭐ Trung bình | Chế độ Stream (không cần IP công khai), doanh nghiệp | [Tài liệu](../channels/dingtalk/README.vi.md) | +| **LINE** | ⭐⭐⭐ Nâng cao | Yêu cầu HTTPS Webhook | [Tài liệu](../channels/line/README.vi.md) | +| **WeCom (企业微信)** | ⭐⭐⭐ Nâng cao | Bot nhóm (Webhook), ứng dụng tùy chỉnh (API), AI Bot | [Hướng dẫn](../channels/wecom/README.vi.md) | +| **Feishu (飞书)** | ⭐⭐⭐ Nâng cao | Cộng tác doanh nghiệp, nhiều tính năng | [Tài liệu](../channels/feishu/README.vi.md) | +| **IRC** | ⭐⭐ Trung bình | Máy chủ + cấu hình TLS | [Tài liệu](#irc) | +| **OneBot** | ⭐⭐ Trung bình | Tương thích NapCat/Go-CQHTTP, hệ sinh thái cộng đồng | [Tài liệu](../channels/onebot/README.vi.md) | +| **MaixCam** | ⭐ Dễ | Kênh tích hợp phần cứng cho camera AI Sipeed | [Tài liệu](../channels/maixcam/README.vi.md) | +| **Pico** | ⭐ Dễ | Kênh giao thức bản địa PicoClaw | | + + +
+Telegram (Khuyến nghị) + +**1. Tạo bot** + +* Mở Telegram, tìm `@BotFather` +* Gửi `/newbot`, làm theo hướng dẫn +* Sao chép token + +**2. Cấu hình** + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +> Lấy user ID của bạn từ `@userinfobot` trên Telegram. + +**3. Chạy** + +```bash +picoclaw gateway +``` + +**4. Menu lệnh Telegram (tự động đăng ký khi khởi động)** + +PicoClaw 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`, `/use`, `/btw`) để menu lệnh và hành vi runtime luôn đồng bộ. +Đă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ế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. + +Ban cung co the quan ly skill da cai dat truc tiep tu Telegram: + +- `/list skills` +- `/use ` +- `/use ` roi gui yeu cau that o tin nhan tiep theo +- `/use clear` +- `/btw ` de hoi them mot cau ngoai le ngay lap tuc ma khong thay doi lich su phien dang hoat dong; `/btw` duoc xu ly nhu mot truy van truc tiep khong dung cong cu va khong di vao luong thuc thi cong cu thong thuong + +
+ + +
+Discord + +**1. Tạo bot** + +* Truy cập +* Tạo ứng dụng → Bot → Add Bot +* Sao chép bot token + +**2. Bật intents** + +* Trong cài đặt Bot, bật **MESSAGE CONTENT INTENT** +* (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 + +**3. Lấy User ID** +* Cài đặt Discord → Nâng cao → bật **Developer Mode** +* Nhấp chuột phải vào avatar → **Copy User ID** + +**4. Cấu hình** + +```json +{ + "channel_list": { + "discord": { + "enabled": true, + "type": "discord", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Mời bot** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Mở URL mời được tạo và thêm bot vào server của bạn + +**Tùy chọn: Chế độ kích hoạt nhóm** + +Mặ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: + +```json +{ + "channel_list": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +Bạn cũng có thể kích hoạt bằng tiền tố từ khóa (ví dụ: `!bot`): + +```json +{ + "channel_list": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` + +**6. Chạy** + +```bash +picoclaw gateway +``` + +
+ + +
+WhatsApp (native qua whatsmeow) + +PicoClaw có thể kết nối WhatsApp theo hai cách: + +- **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/...`). +- **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. + +**Cấu hình (native)** + +```json +{ + "channel_list": { + "whatsapp": { + "enabled": true, + "type": "whatsapp", + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +Nếu `session_store_path` trống, phiên được lưu tại `/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. + +
+ + +
+Weixin (WeChat Cá nhân) + +PicoClaw hỗ trợ kết nối với tài khoản WeChat cá nhân của bạn thông qua API chính thức Tencent iLink. + +**1. Đăng nhập** + +Chạy luồng đăng nhập QR tương tác: +```bash +picoclaw auth weixin +``` +Quét mã QR được in ra bằng ứng dụng WeChat trên điện thoại. Sau khi đăng nhập thành công, token sẽ được lưu vào cấu hình. + +**2. Cấu hình** + +(Tùy chọn) Thêm ID người dùng WeChat vào `allow_from` để giới hạn ai có thể nhắn tin với bot: +```json +{ + "channel_list": { + "weixin": { + "enabled": true, + "type": "weixin", + "token": "YOUR_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**3. Chạy** +```bash +picoclaw gateway +``` + +
+ + +
+QQ + +**Thiết lập nhanh (khuyến nghị)** + +QQ Open Platform cung cấp trang thiết lập một chạm cho bot tương thích OpenClaw: + +1. Mở [QQ Bot Quick Start](https://q.qq.com/qqbot/openclaw/index.html) và quét mã QR để đăng nhập +2. Bot được tạo tự động — sao chép **App ID** và **App Secret** +3. Cấu hình PicoClaw: + +```json +{ + "channel_list": { + "qq": { + "enabled": true, + "type": "qq", + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +4. Chạy `picoclaw gateway` và mở QQ để trò chuyện với bot của bạn + +> App Secret chỉ hiển thị một lần. Lưu ngay lập tức — xem lại sẽ buộc phải đặt lại. +> +> Bot được tạo qua trang thiết lập nhanh ban đầu chỉ dành cho người tạo và không hỗ trợ chat nhóm. Để bật quyền truy cập nhóm, cấu hình chế độ sandbox trên [QQ Open Platform](https://q.qq.com/). + +**Thiết lập thủ công** + +Nếu bạn muốn tạo bot thủ công: + +* Đăng nhập tại [QQ Open Platform](https://q.qq.com/) để đăng ký làm nhà phát triển +* Tạo bot QQ — tùy chỉnh avatar và tên +* Sao chép **App ID** và **App Secret** từ cài đặt bot +* Cấu hình như trên và chạy `picoclaw gateway` + +
+ + +
+DingTalk + +**1. Tạo bot** + +* Truy cập [Open Platform](https://open.dingtalk.com/) +* Tạo ứng dụng nội bộ +* Sao chép Client ID và Client Secret + +**2. Cấu hình** + +```json +{ + "channel_list": { + "dingtalk": { + "enabled": true, + "type": "dingtalk", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Đặ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. + +**3. Chạy** + +```bash +picoclaw gateway +``` + +
+ + +
+MaixCam + +Kênh tích hợp được thiết kế đặc biệt cho phần cứng camera AI Sipeed. + +```json +{ + "channel_list": { + "maixcam": { + "enabled": true, + "type": "maixcam" + } + } +} +``` + +```bash +picoclaw gateway +``` + +
+ + + +
+Matrix + +**1. Chuẩn bị tài khoản bot** + +* Sử dụng homeserver ưa thích (ví dụ: `https://matrix.org` hoặc tự host) +* Tạo user bot và lấy access token + +**2. Cấu hình** + +```json +{ + "channel_list": { + "matrix": { + "enabled": true, + "type": "matrix", + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Chạy** + +```bash +picoclaw gateway +``` + +Để 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](../channels/matrix/README.md). + +
+ + +
+LINE + +**1. Tạo Tài Khoản LINE Official** + +- Truy cập [LINE Developers Console](https://developers.line.biz/) +- Tạo provider → Tạo kênh Messaging API +- Sao chép **Channel Secret** và **Channel Access Token** + +**2. Cấu hình** + +```json +{ + "channel_list": { + "line": { + "enabled": true, + "type": "line", + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +> 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`). + +**3. Thiết lập Webhook URL** + +LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel: + +```bash +# Ví dụ với ngrok (port mặc định gateway là 18790) +ngrok http 18790 +``` + +Sau đó đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**. + +**4. Chạy** + +```bash +picoclaw gateway +``` + +> 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. + +
+ + +
+WeCom (企业微信) + +PicoClaw hỗ trợ ba loại tích hợp WeCom: + +**Tùy chọn 1: WeCom Bot (Bot)** - Thiết lập dễ hơn, hỗ trợ chat nhóm +**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 +**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 + +Xem [Hướng Dẫn Cấu Hình WeCom](../channels/wecom/README.vi.md) để biết hướng dẫn thiết lập chi tiết. + +**Thiết Lập Nhanh - WeCom Bot:** + +**1. Tạo bot** + +* Truy cập Console Quản Trị WeCom → Chat Nhóm → Thêm Bot Nhóm +* Sao chép URL webhook (định dạng: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) + +**2. Cấu hình** + +```json +{ + "channel_list": { + "wecom": { + "enabled": true, + "type": "wecom", + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [] + } + } +} +``` + +> 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`). + +**Thiết Lập Nhanh - WeCom App:** + +**1. Tạo ứng dụng** + +* Truy cập Console Quản Trị WeCom → Quản Lý App → Tạo App +* Sao chép **AgentId** và **Secret** +* Truy cập trang "Công Ty Của Tôi", sao chép **CorpID** + +**2. Cấu hình nhận tin nhắn** + +* Trong chi tiết App, nhấp "Nhận Tin Nhắn" → "Cấu Hình API" +* Đặt URL thành `http://your-server:18790/webhook/wecom-app` +* Tạo **Token** và **EncodingAESKey** + +**3. Cấu hình** + +```json +{ + "channel_list": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [] + } + } +} +``` + +**4. Chạy** + +```bash +picoclaw gateway +``` + +> **Lưu ý**: Callback webhook WeCom được phục vụ trên port Gateway (mặc định 18790). Sử dụng reverse proxy cho HTTPS. + +**Thiết Lập Nhanh - WeCom AI Bot:** + +**1. Tạo AI Bot** + +* Truy cập Console Quản Trị WeCom → Quản Lý App → AI Bot +* Trong cài đặt AI Bot, cấu hình callback URL: `http://your-server:18790/webhook/wecom-aibot` +* Sao chép **Token** và nhấp "Tạo Ngẫu Nhiên" cho **EncodingAESKey** + +**2. Cấu hình** + +```json +{ + "channel_list": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Hello! How can I help you?", + "processing_message": "⏳ Processing, please wait. The results will be sent shortly." + } + } +} +``` + +**3. Chạy** + +```bash +picoclaw gateway +``` + +> **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. + +
+ + +
+Feishu (Lark) + +PicoClaw kết nối với Feishu qua chế độ WebSocket/SDK — không cần URL webhook công khai hay máy chủ callback. + +**1. Tạo ứng dụng** + +* Truy cập [Feishu Open Platform](https://open.feishu.cn/) và tạo ứng dụng +* Trong cài đặt ứng dụng, bật khả năng **Bot** +* Tạo phiên bản và xuất bản ứng dụng (ứng dụng phải được xuất bản mới có hiệu lực) +* Sao chép **App ID** (bắt đầu bằng `cli_`) và **App Secret** + +**2. Cấu hình** + +```json +{ + "channel_list": { + "feishu": { + "enabled": true, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +Tùy chọn: `encrypt_key` và `verification_token` để mã hóa sự kiện (khuyến nghị cho môi trường production). + +**3. Chạy và trò chuyện** + +```bash +picoclaw gateway +``` + +Mở Feishu, tìm tên bot của bạn và bắt đầu trò chuyện. Bạn cũng có thể thêm bot vào nhóm — sử dụng `group_trigger.mention_only: true` để chỉ phản hồi khi được @mention. + +Để xem đầy đủ các tùy chọn, xem [Hướng Dẫn Cấu Hình Kênh Feishu](../channels/feishu/README.vi.md). + +
+ + +
+Slack + +**1. Tạo ứng dụng Slack** + +* Truy cập [Slack API](https://api.slack.com/apps) và tạo ứng dụng mới +* Trong **OAuth & Permissions**, thêm các scope bot: `chat:write`, `app_mentions:read`, `im:history`, `im:read`, `im:write` +* Cài đặt ứng dụng vào workspace của bạn +* Sao chép **Bot Token** (`xoxb-...`) và **App-Level Token** (`xapp-...`, bật Socket Mode để lấy token này) + +**2. Cấu hình** + +```json +{ + "channel_list": { + "slack": { + "enabled": true, + "type": "slack", + "bot_token": "xoxb-YOUR-BOT-TOKEN", + "app_token": "xapp-YOUR-APP-TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Chạy** + +```bash +picoclaw gateway +``` + +
+ + +
+IRC + +**1. Cấu hình** + +```json +{ + "channel_list": { + "irc": { + "enabled": true, + "type": "irc", + "server": "irc.libera.chat:6697", + "tls": true, + "nick": "picoclaw-bot", + "channels": ["#your-channel"], + "password": "", + "allow_from": [] + } + } +} +``` + +Tùy chọn: `nickserv_password` để xác thực NickServ, `sasl_user`/`sasl_password` để xác thực SASL. + +**2. Chạy** + +```bash +picoclaw gateway +``` + +Bot sẽ kết nối đến máy chủ IRC và tham gia các kênh đã chỉ định. + +
+ + +
+OneBot (QQ qua giao thức OneBot) + +OneBot là giao thức mở cho bot QQ. PicoClaw kết nối với bất kỳ triển khai tương thích OneBot v11 nào (ví dụ: [Lagrange](https://github.com/LagrangeDev/Lagrange.Core), [NapCat](https://github.com/NapNeko/NapCatQQ)) qua WebSocket. + +**1. Thiết lập triển khai OneBot** + +Cài đặt và chạy framework bot QQ tương thích OneBot v11. Bật máy chủ WebSocket của nó. + +**2. Cấu hình** + +```json +{ + "channel_list": { + "onebot": { + "enabled": true, + "type": "onebot", + "ws_url": "ws://127.0.0.1:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| Trường | Mô tả | +|--------|-------| +| `ws_url` | URL WebSocket của triển khai OneBot | +| `access_token` | Token truy cập để xác thực (nếu đã cấu hình trong OneBot) | +| `reconnect_interval` | Khoảng thời gian kết nối lại tính bằng giây (mặc định: 5) | + +**3. Chạy** + +```bash +picoclaw gateway +``` + +
+ +
+MaixCam + +Kênh tích hợp được thiết kế đặc biệt cho phần cứng camera AI Sipeed. + +```json +{ + "channel_list": { + "maixcam": { + "enabled": true, + "type": "maixcam" + } + } +} +``` + +```bash +picoclaw gateway +``` + +
diff --git a/docs/guides/chat-apps.zh.md b/docs/guides/chat-apps.zh.md new file mode 100644 index 000000000..b5891dc69 --- /dev/null +++ b/docs/guides/chat-apps.zh.md @@ -0,0 +1,612 @@ +# 💬 聊天应用配置 + +> 返回 [README](../project/README.zh.md) + +## 💬 聊天应用集成 (Chat Apps) + +PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。 + +> **注意**: 依赖 HTTP 回调的渠道共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。飞书、钉钉、企业微信这类 Socket/Stream 模式渠道不依赖共享 webhook 服务器来接收入站消息。 + +### 核心渠道 + +| 渠道 | 设置难度 | 特性说明 | 文档链接 | +| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](../channels/telegram/README.zh.md) | +| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](../channels/discord/README.zh.md) | +| **WhatsApp** | ⭐ 简单 | 原生 (QR 扫码) 或 Bridge URL | [查看文档](#whatsapp) | +| **微信 (Weixin)** | ⭐ 简单 | 原生扫码(腾讯 iLink API) | [查看文档](#weixin) | +| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](../channels/slack/README.zh.md) | +| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](../channels/matrix/README.zh.md) | +| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](../channels/qq/README.zh.md) | +| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](../channels/dingtalk/README.zh.md) | +| **LINE** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](../channels/line/README.zh.md) | +| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 官方 AI Bot WebSocket 接入,支持流式回复和媒体消息 | [查看文档](../channels/wecom/README.zh.md) | +| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](../channels/feishu/README.zh.md) | +| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) | +| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) | +| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](../channels/maixcam/README.zh.md) | +| **Pico** | ⭐ 简单 | PicoClaw 原生协议通道 | | + +--- + + +
+Telegram(推荐) + +**1. 创建 Bot** + +* 打开 Telegram,搜索 `@BotFather` +* 发送 `/newbot`,按提示操作 +* 复制 Token + +**2. 配置** + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +> 通过 Telegram 上的 `@userinfobot` 获取你的 User ID。 + +**3. 运行** + +```bash +picoclaw gateway +``` + +**4. Telegram 命令菜单(启动时自动注册)** + +PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`、`/use`、`/btw`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 +Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。 + +如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。 + +你也可以直接在 Telegram 中管理已安装技能: + +- `/list skills` +- `/use ` +- `/use `,然后在下一条消息里发送真正的请求 +- `/use clear` +- `/btw `,用于发起一个不改动当前会话历史的即时旁支提问;`/btw` 会按一次无工具的直接问答处理,不会进入常规的工具执行流程 + +
+ + +
+Discord + +**1. 创建 Bot** + +* 前往 +* 创建应用 → Bot → 添加 Bot +* 复制 Bot Token + +**2. 启用 Intents** + +* 在 Bot 设置中启用 **MESSAGE CONTENT INTENT** +* (可选)启用 **SERVER MEMBERS INTENT**(如需基于成员数据的白名单) + +**3. 获取 User ID** + +* Discord 设置 → 高级 → 启用 **开发者模式** +* 右键点击头像 → **复制用户 ID** + +**4. 配置** + +```json +{ + "channel_list": { + "discord": { + "enabled": true, + "type": "discord", + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. 邀请 Bot** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* 打开生成的邀请链接,将 Bot 添加到服务器 + +**可选:群组触发模式** + +默认情况下 Bot 会回复服务器频道中的所有消息。如需仅在 @提及时回复: + +```json +{ + "channel_list": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +也可通过关键词前缀触发(如 `!bot`): + +```json +{ + "channel_list": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` + +**6. 运行** + +```bash +picoclaw gateway +``` + +
+ + +
+WhatsApp(原生 whatsmeow) + +PicoClaw 支持两种 WhatsApp 连接方式: + +- **原生(推荐):** 进程内使用 [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/...`)。 +- **Bridge:** 连接外部 WebSocket Bridge。设置 `bridge_url`(如 `ws://localhost:3001`),保持 `use_native` 为 false。 + +**配置(原生)** + +```json +{ + "channel_list": { + "whatsapp": { + "enabled": true, + "type": "whatsapp", + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +如果 `session_store_path` 为空,会话存储在 `/whatsapp/`。运行 `picoclaw gateway`;首次运行时在终端扫描 QR 码(WhatsApp → 关联设备)。 + +
+ + +
+微信 (Weixin) + +PicoClaw 通过腾讯 iLink 官方 API 支持连接微信个人号。 + +**1. 登录** + +运行交互式扫码登录流程: +```bash +picoclaw auth weixin +``` +用微信手机端扫描打印出的二维码。登录成功后,token 会自动保存到配置文件。 + +**2. 配置** + +(可选)在 `allow_from` 中填入你的微信用户 ID,限制可以与机器人对话的用户: +```json +{ + "channel_list": { + "weixin": { + "enabled": true, + "type": "weixin", + "token": "YOUR_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**3. 运行** +```bash +picoclaw gateway +``` + +
+ + +
+Matrix + +**1. 准备 Bot 账号** + +* 使用你的 homeserver(如 `https://matrix.org` 或自建) +* 创建 Bot 用户并获取 access token + +**2. 配置** + +```json +{ + "channel_list": { + "matrix": { + "enabled": true, + "type": "matrix", + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. 运行** + +```bash +picoclaw gateway +``` + +完整选项(`device_id`、`join_on_invite`、`group_trigger`、`placeholder`、`reasoning_channel_id`)请参考 [Matrix 渠道配置指南](../channels/matrix/README.md)。 + +
+ + +
+QQ + +**快速设置(推荐)** + +QQ 开放平台提供了一键创建 OpenClaw 兼容机器人的页面: + +1. 打开 [QQ 机器人快速创建](https://q.qq.com/qqbot/openclaw/index.html),扫码登录 +2. 机器人自动创建 — 复制 **App ID** 和 **App Secret** +3. 配置 PicoClaw: + +```json +{ + "channel_list": { + "qq": { + "enabled": true, + "type": "qq", + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +4. 运行 `picoclaw gateway`,打开 QQ 与机器人聊天 + +> App Secret 仅显示一次,请立即保存 — 再次查看将强制重置。 +> +> 通过快速创建页面创建的机器人初始仅限创建者使用,不支持群聊。如需启用群聊访问,请在 [QQ 开放平台](https://q.qq.com/) 配置沙箱模式。 + +**手动设置** + +如果你更喜欢手动创建机器人: + +* 登录 [QQ 开放平台](https://q.qq.com/) 注册成为开发者 +* 创建 QQ 机器人 — 自定义头像和名称 +* 从机器人设置中复制 **App ID** 和 **App Secret** +* 按上述方式配置并运行 `picoclaw gateway` + +
+ + +
+Slack + +**1. 创建 Slack App** + +* 前往 [Slack API](https://api.slack.com/apps) 创建新应用 +* 在 **OAuth & Permissions** 中添加 Bot 权限范围:`chat:write`、`app_mentions:read`、`im:history`、`im:read`、`im:write` +* 将应用安装到你的工作区 +* 复制 **Bot Token**(`xoxb-...`)和 **App-Level Token**(`xapp-...`,启用 Socket Mode 后获取) + +**2. 配置** + +```json +{ + "channel_list": { + "slack": { + "enabled": true, + "type": "slack", + "bot_token": "xoxb-YOUR-BOT-TOKEN", + "app_token": "xapp-YOUR-APP-TOKEN", + "allow_from": [] + } + } +} +``` + +**3. 运行** + +```bash +picoclaw gateway +``` + +
+ + +
+IRC + +**1. 配置** + +```json +{ + "channel_list": { + "irc": { + "enabled": true, + "type": "irc", + "server": "irc.libera.chat:6697", + "tls": true, + "nick": "picoclaw-bot", + "channels": ["#your-channel"], + "password": "", + "allow_from": [] + } + } +} +``` + +可选:`nickserv_password` 用于 NickServ 认证,`sasl_user`/`sasl_password` 用于 SASL 认证。 + +**2. 运行** + +```bash +picoclaw gateway +``` + +Bot 将连接到 IRC 服务器并加入指定的频道。 + +
+ + +
+钉钉 (DingTalk) + +**1. 创建 Bot** + +* 前往 [开放平台](https://open.dingtalk.com/) +* 创建内部应用 +* 复制 Client ID 和 Client Secret + +**2. 配置** + +```json +{ + "channel_list": { + "dingtalk": { + "enabled": true, + "type": "dingtalk", + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> `allow_from` 留空表示允许所有用户,或指定钉钉用户 ID 限制访问。 + +**3. 运行** + +```bash +picoclaw gateway +``` + +
+ + +
+LINE + +**1. 创建 LINE Official Account** + +- 前往 [LINE Developers Console](https://developers.line.biz/) +- 创建 Provider → 创建 Messaging API Channel +- 复制 **Channel Secret** 和 **Channel Access Token** + +**2. 配置** + +```json +{ + "channel_list": { + "line": { + "enabled": true, + "type": "line", + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +> LINE Webhook 挂载在共享 Gateway 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。 + +**3. 设置 Webhook URL** + +LINE 要求 HTTPS Webhook。使用反向代理或隧道: + +```bash +# 示例:使用 ngrok(Gateway 默认端口 18790) +ngrok http 18790 +``` + +然后在 LINE Developers Console 中将 Webhook URL 设置为 `https://your-domain/webhook/line` 并启用 **Use webhook**。 + +**4. 运行** + +```bash +picoclaw gateway +``` + +> 在群聊中,Bot 仅在被 @提及时回复。回复会引用原始消息。 + +
+ + +
+飞书 (Feishu) + +PicoClaw 通过 WebSocket/SDK 模式连接飞书 — 无需公网 Webhook URL 或回调服务器。 + +**1. 创建应用** + +* 前往 [飞书开放平台](https://open.feishu.cn/) 创建应用 +* 在应用设置中启用 **机器人** 能力 +* 创建版本并发布应用(应用必须发布后才能生效) +* 复制 **App ID**(以 `cli_` 开头)和 **App Secret** + +**2. 配置** + +```json +{ + "channel_list": { + "feishu": { + "enabled": true, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +可选:`encrypt_key` 和 `verification_token` 用于事件加密(生产环境推荐)。 + +**3. 运行并聊天** + +```bash +picoclaw gateway +``` + +打开飞书,搜索你的机器人名称即可开始聊天。也可以将机器人添加到群组 — 使用 `group_trigger.mention_only: true` 设置为仅在 @提及时回复。 + +完整选项请参考 [飞书渠道配置指南](../channels/feishu/README.zh.md)。 + +
+ + +
+企业微信 (WeCom) + +PicoClaw 现在将企业微信统一为一个基于 WebSocket 的 AI Bot 渠道。 +它不再需要公网 webhook 回调地址。 + +完整配置说明和迁移说明请参考 [企业微信配置指南](../channels/wecom/README.zh.md)。 + +**推荐快速接入** + +**1. 认证** + +```bash +picoclaw auth wecom +``` + +该命令会显示二维码,等待你在企业微信里确认,然后把 `bot_id` 和 `secret` 写入 `channels.wecom`。 + +**2. 如需手动配置** + +```json +{ + "channel_list": { + "wecom": { + "enabled": true, + "type": "wecom", + "bot_id": "YOUR_BOT_ID", + "secret": "YOUR_SECRET", + "websocket_url": "wss://openws.work.weixin.qq.com", + "send_thinking_message": true, + "allow_from": [], + "reasoning_channel_id": "" + } + } +} +``` + +**3. 运行** + +```bash +picoclaw gateway +``` + +> 这个分支中旧的 `wecom_app` 和 `wecom_aibot` 配置已经被统一的 `channels.wecom` 替代。 + +
+ + +
+OneBot(通过 OneBot 协议连接 QQ) + +OneBot 是 QQ 机器人的开放协议。PicoClaw 通过 WebSocket 连接任何 OneBot v11 兼容实现(如 [Lagrange](https://github.com/LagrangeDev/Lagrange.Core)、[NapCat](https://github.com/NapNeko/NapCatQQ))。 + +**1. 设置 OneBot 实现** + +安装并运行 OneBot v11 兼容的 QQ 机器人框架,启用其 WebSocket 服务器。 + +**2. 配置** + +```json +{ + "channel_list": { + "onebot": { + "enabled": true, + "type": "onebot", + "ws_url": "ws://127.0.0.1:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| 字段 | 说明 | +|------|------| +| `ws_url` | OneBot 实现的 WebSocket URL | +| `access_token` | 认证用的访问令牌(如果在 OneBot 中配置了的话) | +| `reconnect_interval` | 重连间隔(秒)(默认:5) | + +**3. 运行** + +```bash +picoclaw gateway +``` + +
+ + +
+MaixCam + +专为 Sipeed AI 摄像头硬件设计的集成通道。 + +```json +{ + "channel_list": { + "maixcam": { + "enabled": true, + "type": "maixcam" + } + } +} +``` + +```bash +picoclaw gateway +``` + +
diff --git a/docs/guides/configuration.fr.md b/docs/guides/configuration.fr.md new file mode 100644 index 000000000..786a0c28f --- /dev/null +++ b/docs/guides/configuration.fr.md @@ -0,0 +1,402 @@ +# ⚙️ Guide de Configuration + +> Retour au [README](../project/README.fr.md) + +## ⚙️ Configuration + +Fichier de configuration : `~/.picoclaw/config.json` + +### Variables d'Environnement + +Vous 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. + +| Variable | Description | Chemin par défaut | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `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` | +| `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` | + +**Exemples :** + +```bash +# Run picoclaw using a specific config file +# The workspace path will be read from within that config file +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# Run picoclaw with all its data stored in /opt/picoclaw +# Config will be loaded from the default ~/.picoclaw/config.json +# Workspace will be created at /opt/picoclaw/workspace +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# Use both for a fully customized setup +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + +### Niveau de Log du Gateway + +`gateway.log_level` contrôle la verbosité des logs du Gateway, configurable dans `config.json` : + +```json +{ + "gateway": { + "log_level": "warn" + } +} +``` + +La valeur par défaut est `warn`. Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. + +Peut également être surchargé via la variable d'environnement : `PICOCLAW_LOG_LEVEL=info` + +### Structure du Workspace + +PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) : + +``` +~/.picoclaw/workspace/ +├── sessions/ # Sessions de conversation et historique +├── memory/ # Mémoire à long terme (MEMORY.md) +├── state/ # État persistant (dernier canal, etc.) +├── cron/ # Base de données des tâches planifiées +├── skills/ # Compétences personnalisées +├── AGENT.md # Guide de comportement de l'agent +├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min) +├── SOUL.md # Âme de l'agent +└── USER.md # Préférences utilisateur +``` + +> **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. + +### Sources de Compétences + +Par défaut, les compétences sont chargées depuis : + +1. `~/.picoclaw/workspace/skills` (workspace) +2. `~/.picoclaw/skills` (global) +3. `/skills` (intégré) + +Pour les configurations avancées/de test, vous pouvez remplacer la racine des compétences builtin avec : + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + +### Utiliser les Commandes Depuis les Canaux de Chat + +Une fois les compétences installées, vous pouvez aussi les inspecter et les activer directement depuis un canal de chat : + +- `/list skills` affiche les noms des compétences installées visibles pour l'agent courant. +- `/use ` force une compétence pour une seule requête. +- `/use ` prépare cette compétence pour votre prochain message dans la meme conversation. +- `/use clear` annule une surcharge de compétence en attente creee via `/use `. +- `/btw ` pose une question annexe immediate sans modifier l'historique courant de la session. `/btw` est traite comme une requete directe sans outils et n'entre pas dans le flux normal d'execution des outils. + +Exemples : + +```text +/list skills +/use git explique comment squash les 3 derniers commits +/btw rappelle-moi ce qu'on a deja decide pour le plan de deploiement +/use italiapersonalfinance +dammi le ultime news +``` + +### Politique Unifiée d'Exécution des Commandes + +- Les commandes slash génériques sont exécutées via un chemin unique dans `pkg/agent/loop.go` via `commands.Executor`. +- 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 au démarrage les commandes prises en charge, comme `/start`, `/help`, `/show`, `/list`, `/use` et `/btw`. +- Une commande slash inconnue (par exemple `/foo`) passe au traitement LLM normal. +- 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. + +### 🔒 Sandbox de Sécurité + +PicoClaw 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é. + +#### Configuration par Défaut + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Option | Par défaut | Description | +| ----------------------- | ----------------------- | ------------------------------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Répertoire de travail de l'agent | +| `restrict_to_workspace` | `true` | Restreindre l'accès fichiers/commandes au workspace | + +#### Outils Protégés + +Lorsque `restrict_to_workspace: true`, les outils suivants sont sandboxés : + +| Outil | Fonction | Restriction | +| ------------- | --------------------- | ---------------------------------------------- | +| `read_file` | Lire des fichiers | Uniquement les fichiers dans le workspace | +| `write_file` | Écrire des fichiers | Uniquement les fichiers dans le workspace | +| `list_dir` | Lister les répertoires| Uniquement les répertoires dans le workspace | +| `edit_file` | Modifier des fichiers | Uniquement les fichiers dans le workspace | +| `append_file` | Ajouter aux fichiers | Uniquement les fichiers dans le workspace | +| `exec` | Exécuter des commandes| Les chemins de commande doivent être dans le workspace | + +#### Protection Exec Supplémentaire + +Même avec `restrict_to_workspace: false`, l'outil `exec` bloque ces commandes dangereuses : + +* `rm -rf`, `del /f`, `rmdir /s` — Suppression en masse +* `format`, `mkfs`, `diskpart` — Formatage de disque +* `dd if=` — Imagerie de disque +* Écriture vers `/dev/sd[a-z]` — Écritures directes sur disque +* `shutdown`, `reboot`, `poweroff` — Arrêt du système +* Fork bomb `:(){ :|:& };:` + +### Contrôle d'Accès aux Fichiers + +| Clé de configuration | Type | Par défaut | Description | +|----------------------|------|------------|-------------| +| `tools.allow_read_paths` | string[] | `[]` | Chemins supplémentaires autorisés en lecture en dehors du workspace | +| `tools.allow_write_paths` | string[] | `[]` | Chemins supplémentaires autorisés en écriture en dehors du workspace | + +### Sécurité Exec + +| Clé de configuration | Type | Par défaut | Description | +|----------------------|------|------------|-------------| +| `tools.exec.allow_remote` | bool | `false` | Autoriser l'outil exec depuis les canaux distants (Telegram/Discord etc.) | +| `tools.exec.enable_deny_patterns` | bool | `true` | Activer l'interception des commandes dangereuses | +| `tools.exec.custom_deny_patterns` | string[] | `[]` | Patterns regex personnalisés à bloquer | +| `tools.exec.custom_allow_patterns` | string[] | `[]` | Patterns regex personnalisés à autoriser | + +> **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. + +#### Limitation Connue : Processus Enfants des Outils de Build + +Le 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. + +Cela 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. + +Pour les environnements à haut risque : + +* Examinez les scripts de build avant l'exécution. +* Préférez l'approbation/revue manuelle pour les workflows de compilation et d'exécution. +* 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é. + +#### Exemples d'Erreurs + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Désactiver les Restrictions (Risque de Sécurité) + +Si vous avez besoin que l'agent accède à des chemins en dehors du workspace : + +**Méthode 1 : Fichier de configuration** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Méthode 2 : Variable d'environnement** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **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. + +#### Cohérence des Limites de Sécurité + +Le paramètre `restrict_to_workspace` s'applique de manière cohérente à tous les chemins d'exécution : + +| Chemin d'exécution | Limite de sécurité | +| ------------------ | -------------------------------- | +| Main Agent | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Hérite de la même restriction ✅ | +| Heartbeat tasks | Hérite de la même restriction ✅ | + +Tous 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. + +### Heartbeat (Tâches Périodiques) + +PicoClaw peut effectuer des tâches périodiques automatiquement. Créez un fichier `HEARTBEAT.md` dans votre workspace : + +```markdown +# Periodic Tasks + +- Check my email for important messages +- Review my calendar for upcoming events +- Check the weather forecast +``` + +L'agent lira ce fichier toutes les 30 minutes (configurable) et exécutera toutes les tâches en utilisant les outils disponibles. + +#### Tâches Asynchrones avec Spawn + +Pour les tâches longues (recherche web, appels API), utilisez l'outil `spawn` pour créer un **subagent** : + +```markdown +# Tâches Périodiques + +## Tâches Rapides (répondre directement) + +- Indiquer l'heure actuelle + +## Tâches Longues (utiliser spawn pour l'asynchrone) + +- Rechercher les actualités IA sur le web et résumer +- Vérifier les e-mails et signaler les messages importants +``` + +**Comportements clés :** + +| Fonctionnalité | Description | +| ---------------- | ------------------------------------------------------------------ | +| **spawn** | Crée un subagent asynchrone, ne bloque pas le heartbeat | +| **Contexte indépendant** | Le subagent a son propre contexte, sans historique de session | +| **message tool** | Le subagent communique directement avec l'utilisateur | +| **Non-bloquant** | Après le spawn, le heartbeat continue vers la tâche suivante | + +#### Flux de Communication du Subagent + +``` +Heartbeat déclenché + ↓ +Agent lit HEARTBEAT.md + ↓ +Tâche longue : spawn subagent + ↓ ↓ +Continue tâche suivante Subagent travaille indépendamment + ↓ ↓ +Toutes tâches terminées Subagent utilise "message" tool + ↓ ↓ +Répond HEARTBEAT_OK Utilisateur reçoit le résultat +``` + +**Configuration :** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Option | Défaut | Description | +| ---------- | ------ | ---------------------------------------- | +| `enabled` | `true` | Activer/désactiver le heartbeat | +| `interval` | `30` | Intervalle en minutes (minimum : 5) | + +**Variables d'environnement :** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` pour désactiver +* `PICOCLAW_HEARTBEAT_INTERVAL=60` pour changer l'intervalle + +### Providers + +> [!NOTE] +> Groq fournit une transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent. + +| Provider | Usage | Obtenir une clé API | +| ------------ | --------------------------------------- | ------------------------------------------------------------ | +| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | +| `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) | +| `openrouter` | LLM (recommandé, accès à tous modèles) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **Transcription vocale** (Whisper)| [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | + +### Configuration des Modèles (model_list) + +> **Nouveauté :** PicoClaw utilise désormais une approche **centrée sur le modèle**. Spécifiez simplement le format `vendor/model` (ex. `zhipu/glm-4.7`) pour ajouter de nouveaux providers — **aucune modification de code requise !** + +#### Tous les Vendors Supportés + +| Vendor | Préfixe `model` | API Base par défaut | Protocole | API Key | +| ----------------------- | --------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obtenir](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obtenir](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obtenir](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obtenir](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Obtenir](https://aistudio.google.com/api-keys) | +| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obtenir](https://console.groq.com) | +| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obtenir](https://dashscope.console.aliyun.com) | +| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (pas de clé) | +| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir](https://openrouter.ai/keys) | +| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement | + +#### Équilibrage de Charge + +Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectuera automatiquement un round-robin : + +```json +{ + "model_list": [ + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] }, + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] } + ] +} +``` + +#### Migration depuis l'ancienne config `providers` + +L'ancienne configuration `providers` est **dépréciée** et a été supprimée dans V2. Les configs V0/V1 existantes sont auto-migrées. Voir [docs/migration/model-list-migration.md](../migration/model-list-migration.md). + +### Architecture des Providers + +PicoClaw route les providers par famille de protocole : + +- **Compatible OpenAI** : OpenRouter, Groq, Zhipu, endpoints vLLM et la plupart des autres. +- **Gemini natif** : Google Gemini via les endpoints natifs `models/*:generateContent` et `models/*:streamGenerateContent`. +- **Anthropic** : Comportement natif de l'API Claude. +- **Codex/OAuth** : Route d'authentification OAuth/token OpenAI. + +Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_keys`). + +### Tâches Planifiées / Rappels + +PicoClaw supporte les tâches planifiées via l'outil `cron`. L'agent peut définir, lister et annuler des rappels ou tâches récurrentes. + +```json +{ + "tools": { + "cron": { + "enabled": true, + "exec_timeout_minutes": 5 + } + } +} +``` + +Les tâches planifiées persistent après redémarrage dans `~/.picoclaw/workspace/cron/`. + +### Sujets Avancés + +| Sujet | Description | +| ----- | ----------- | +| [Système de Hooks](../architecture/hooks/README.md) | Hooks événementiels : observateurs, intercepteurs, hooks d'approbation | +| [Steering](../architecture/steering.md) | Injecter des messages dans une boucle agent en cours d'exécution | +| [SubTurn](../architecture/subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie | +| [Gestion du Contexte](../architecture/agent-refactor/context.md) | Détection des limites de contexte, compression | diff --git a/docs/guides/configuration.ja.md b/docs/guides/configuration.ja.md new file mode 100644 index 000000000..0234edbd7 --- /dev/null +++ b/docs/guides/configuration.ja.md @@ -0,0 +1,403 @@ +# ⚙️ 設定ガイド + +> [README](../project/README.ja.md) に戻る + +## ⚙️ 設定詳細 + +設定ファイルパス: `~/.picoclaw/config.json` + +### 環境変数 + +環境変数を使用してデフォルトパスを上書きできます。ポータブルインストール、コンテナ化デプロイ、または picoclaw をシステムサービスとして実行する場合に便利です。これらの変数は独立しており、異なるパスを制御します。 + +| 変数 | 説明 | デフォルトパス | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PICOCLAW_CONFIG` | 設定ファイルのパスを上書きします。picoclaw がどの `config.json` を読み込むかを直接指定し、他のすべての場所を無視します。 | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | picoclaw データのルートディレクトリを上書きします。`workspace` やその他のデータディレクトリのデフォルト場所を変更します。 | `~/.picoclaw` | + +**例:** + +```bash +# 特定の設定ファイルで picoclaw を実行 +# ワークスペースパスはその設定ファイル内から読み込まれます +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# /opt/picoclaw にすべてのデータを保存して picoclaw を実行 +# 設定はデフォルトの ~/.picoclaw/config.json から読み込まれます +# ワークスペースは /opt/picoclaw/workspace に作成されます +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# 両方を使用して完全にカスタマイズ +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + +### Gateway ログレベル + +`gateway.log_level` は Gateway のログ詳細度を制御します。`config.json` で設定できます: + +```json +{ + "gateway": { + "log_level": "warn" + } +} +``` + +デフォルト値は `warn` です。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。 + +環境変数でも上書き可能です:`PICOCLAW_LOG_LEVEL=info` + +### ワークスペースレイアウト + +PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します: + +``` +~/.picoclaw/workspace/ +├── sessions/ # 会話セッションと履歴 +├── memory/ # 長期記憶 (MEMORY.md) +├── state/ # 永続化状態 (最後のチャネルなど) +├── cron/ # スケジュールジョブデータベース +├── skills/ # カスタムスキル +├── AGENT.md # Agent 動作ガイド +├── HEARTBEAT.md # 定期タスクプロンプト (30 分ごとにチェック) +├── IDENTITY.md # Agent アイデンティティ +├── SOUL.md # Agent ソウル/性格 +└── USER.md # ユーザー設定 +``` + +> **注意:** `AGENT.md`、`SOUL.md`、`USER.md` および `memory/MEMORY.md` への変更は、ファイル更新時刻(mtime)の追跡により実行時に自動検出されます。これらのファイルを編集した後に **gateway を再起動する必要はありません** — Agent は次のリクエスト時に最新の内容を自動的に読み込みます。 + +### スキルソース + +デフォルトでは、スキルは以下の順序で読み込まれます: + +1. `~/.picoclaw/workspace/skills`(ワークスペース) +2. `~/.picoclaw/skills`(グローバル) +3. `<ビルド時埋め込みパス>/skills`(ビルトイン) + +高度な/テスト用セットアップでは、以下の環境変数でビルトインスキルのルートを上書きできます: + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + +### チャットチャネルからスキルとコマンドを使う + +スキルをインストールすると、チャットチャネルから直接確認したり明示的に適用したりできます: + +- `/list skills` は現在の Agent から見えるインストール済みスキル名を表示します。 +- `/use ` は 1 回のリクエストだけそのスキルを強制します。 +- `/use ` は同じチャット内の次のメッセージにそのスキルを予約します。 +- `/use clear` は `/use ` で設定した保留中のスキル上書きを解除します。 +- `/btw ` は現在のセッション履歴を変更せずに即時の横道の質問を送ります。`/btw` はツールなしの直接質問として処理され、通常のツール実行フローには入りません。 + +例: + +```text +/list skills +/use git 直近 3 つのコミットを squash する方法を教えて +/btw さっきのデプロイ方針の結論だけもう一度教えて +/use italiapersonalfinance +dammi le ultime news +``` + +### 統一コマンド実行ポリシー + +- 汎用スラッシュコマンドは `pkg/agent/loop.go` 内の `commands.Executor` を通じて統一的に実行されます。 +- チャネルアダプターはローカルで汎用コマンドを消費しなくなりました。受信テキストを bus/agent パスに転送するだけです。Telegram は起動時に `/start`、`/help`、`/show`、`/list`、`/use`、`/btw` などのサポート済みコマンドを自動登録します。 +- 未登録のスラッシュコマンド(例: `/foo`)は通常の LLM 処理にパススルーされます。 +- 登録済みだが現在のチャネルでサポートされていないコマンド(例: WhatsApp での `/show`)は、明示的なユーザー向けエラーを返し、以降の処理を停止します。 + +### 🔒 セキュリティサンドボックス + +PicoClaw はデフォルトでサンドボックス環境で実行されます。Agent は設定されたワークスペース内のファイルアクセスとコマンド実行のみが可能です。 + +#### デフォルト設定 + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| オプション | デフォルト値 | 説明 | +| ----------------------- | ----------------------- | ------------------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Agent の作業ディレクトリ | +| `restrict_to_workspace` | `true` | ファイル/コマンドアクセスをワークスペース内に制限 | + +#### 保護されたツール + +`restrict_to_workspace: true` の場合、以下のツールがサンドボックス化されます: + +| ツール | 機能 | 制限 | +| ------------- | ---------------- | ---------------------------------- | +| `read_file` | ファイル読み取り | ワークスペース内のファイルのみ | +| `write_file` | ファイル書き込み | ワークスペース内のファイルのみ | +| `list_dir` | ディレクトリ一覧 | ワークスペース内のディレクトリのみ | +| `edit_file` | ファイル編集 | ワークスペース内のファイルのみ | +| `append_file` | ファイル追記 | ワークスペース内のファイルのみ | +| `exec` | コマンド実行 | コマンドパスはワークスペース内必須 | + +#### 追加の Exec 保護 + +`restrict_to_workspace: false` の場合でも、`exec` ツールは以下の危険なコマンドをブロックします: + +* `rm -rf`、`del /f`、`rmdir /s` — 一括削除 +* `format`、`mkfs`、`diskpart` — ディスクフォーマット +* `dd if=` — ディスクイメージング +* `/dev/sd[a-z]` への書き込み — 直接ディスク書き込み +* `shutdown`、`reboot`、`poweroff` — システムシャットダウン +* Fork bomb `:(){ :|:& };:` + +### ファイルアクセス制御 + +| 設定キー | 型 | デフォルト値 | 説明 | +|----------|------|-------------|------| +| `tools.allow_read_paths` | string[] | `[]` | ワークスペース外で読み取りを許可する追加パス | +| `tools.allow_write_paths` | string[] | `[]` | ワークスペース外で書き込みを許可する追加パス | + +### Exec セキュリティ設定 + +| 設定キー | 型 | デフォルト値 | 説明 | +|----------|------|-------------|------| +| `tools.exec.allow_remote` | bool | `false` | リモートチャネル(Telegram/Discord など)からの exec ツール実行を許可 | +| `tools.exec.enable_deny_patterns` | bool | `true` | 危険なコマンドのインターセプトを有効化 | +| `tools.exec.custom_deny_patterns` | string[] | `[]` | カスタムブロック正規表現パターン | +| `tools.exec.custom_allow_patterns` | string[] | `[]` | カスタム許可正規表現パターン | + +> **セキュリティ注意:** Symlink 保護はデフォルトで有効です。すべてのファイルパスはホワイトリストマッチング前に `filepath.EvalSymlinks` で解決され、シンボリックリンクエスケープ攻撃を防止します。 + +#### 既知の制限:ビルドツールの子プロセス + +exec セキュリティガードは PicoClaw が直接起動するコマンドラインのみを検査します。`make`、`go run`、`cargo`、`npm run`、またはカスタムビルドスクリプトなどの開発ツールが生成する子プロセスは再帰的に検査しません。 + +つまり、トップレベルのコマンドが初期ガードチェックを通過した後、他のバイナリをコンパイルまたは起動できます。実際には、ビルドスクリプト、Makefile、パッケージスクリプト、生成されたバイナリを、直接のシェルコマンドと同等レベルの実行可能コードとしてレビューする必要があります。 + +高リスク環境の場合: + +* 実行前にビルドスクリプトをレビューしてください。 +* コンパイル・実行ワークフローには承認/手動レビューを優先してください。 +* ビルトインガードより強力な分離が必要な場合は、コンテナまたは VM 内で PicoClaw を実行してください。 + +#### エラー例 + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### 制限の無効化(セキュリティリスク) + +Agent がワークスペース外のパスにアクセスする必要がある場合: + +**方法 1: 設定ファイル** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**方法 2: 環境変数** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **警告**: この制限を無効にすると、Agent がシステム上の任意のパスにアクセスできるようになります。管理された環境でのみ慎重に使用してください。 + +#### セキュリティ境界の一貫性 + +`restrict_to_workspace` 設定はすべての実行パスで一貫して適用されます: + +| 実行パス | セキュリティ境界 | +| ---------------- | ---------------------------- | +| メイン Agent | `restrict_to_workspace` ✅ | +| サブ Agent / Spawn | 同じ制限を継承 ✅ | +| ハートビートタスク | 同じ制限を継承 ✅ | + +すべてのパスは同じワークスペース制限を共有しており、サブ Agent やスケジュールタスクを通じてセキュリティ境界を回避することはできません。 + +### ハートビート(定期タスク) + +PicoClaw は定期タスクを自動実行できます。ワークスペースに `HEARTBEAT.md` ファイルを作成してください: + +```markdown +# Periodic Tasks + +- Check my email for important messages +- Review my calendar for upcoming events +- Check the weather forecast +``` + +Agent は 30 分ごと(設定可能)にこのファイルを読み取り、利用可能なツールを使用してタスクを実行します。 + +#### Spawn を使用した非同期タスク + +長時間実行タスク(Web 検索、API 呼び出し)には、`spawn` ツールを使用して**サブ Agent (subagent)** を作成します: + +```markdown +# Periodic Tasks + +## Quick Tasks (respond directly) + +- Report current time + +## Long Tasks (use spawn for async) + +- Search the web for AI news and summarize +- Check email and report important messages +``` + +**主な動作:** + +| 特性 | 説明 | +| ---------------- | -------------------------------------------- | +| **spawn** | 非同期サブ Agent を作成、メインハートビートをブロックしない | +| **独立コンテキスト** | サブ Agent は独自のコンテキストを持ち、セッション履歴なし | +| **message tool** | サブ Agent は message ツールでユーザーと直接通信 | +| **ノンブロッキング** | spawn 後、ハートビートは次のタスクに進む | + +**設定:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| オプション | デフォルト値 | 説明 | +| ---------- | ------------ | ------------------------------ | +| `enabled` | `true` | ハートビートの有効/無効 | +| `interval` | `30` | チェック間隔(分単位、最小: 5)| + +**環境変数:** + +- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化 +- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔を変更 + +#### サブ Agent の通信フロー + +``` +ハートビート起動 + ↓ +Agent が HEARTBEAT.md を読む + ↓ +長時間タスク:spawn サブ Agent + ↓ ↓ +次のタスクへ継続 サブ Agent が独立して動作 + ↓ ↓ +全タスク完了 サブ Agent が "message" ツールを使用 + ↓ ↓ +HEARTBEAT_OK を返信 ユーザーが直接結果を受信 +``` + +### Providers + +> [!NOTE] +> Groq は Whisper による無料音声文字起こしを提供します。設定すると、任意のチャンネルの音声メッセージが Agent レベルで自動的に文字起こしされます。 + +| Provider | 用途 | API キー取得 | +| ------------ | --------------------------------------- | ------------------------------------------------------------ | +| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM(Zhipu 直接) | [bigmodel.cn](https://bigmodel.cn) | +| `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) | +| `openrouter` | LLM(推奨、全モデルにアクセス可能) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM(Claude 直接) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM(GPT 直接) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM(DeepSeek 直接) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM(Qwen 直接) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **音声文字起こし**(Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM(Cerebras 直接) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM(Vivgrid 直接) | [vivgrid.com](https://vivgrid.com) | + +### モデル設定 (model_list) + +> **新機能:** PicoClaw は**モデル中心**の設定アプローチを採用しました。`vendor/model` 形式(例:`zhipu/glm-4.7`)を指定するだけで新しい Provider を追加できます — **コード変更不要!** + +#### サポートされている全 Vendor + +| Vendor | `model` プレフィックス | デフォルト API Base | プロトコル | API Key | +| ----------------------- | ---------------------- | --------------------------------------------------- | ---------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [取得](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [取得](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [取得](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [取得](https://aistudio.google.com/api-keys) | +| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [取得](https://console.groq.com) | +| **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [取得](https://dashscope.console.aliyun.com) | +| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) | +| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [取得](https://openrouter.ai/keys) | +| **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) | +| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth のみ | + +#### ロードバランシング + +同じモデル名に複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンします: + +```json +{ + "model_list": [ + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] }, + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] } + ] +} +``` + +#### 旧 `providers` 設定からの移行 + +旧 `providers` 設定は**非推奨**となり、V2 で削除されました。既存の V0/V1 設定は自動的に移行されます。[docs/migration/model-list-migration.md](../migration/model-list-migration.md) を参照してください。 + +### Provider アーキテクチャ + +PicoClaw はプロトコルファミリーで Provider をルーティングします: + +- **OpenAI 互換**:OpenRouter、Groq、Zhipu、vLLM スタイルのエンドポイントなど。 +- **Gemini ネイティブ**:Google Gemini のネイティブ `models/*:generateContent` / `models/*:streamGenerateContent` エンドポイント。 +- **Anthropic**:Claude ネイティブ API の動作。 +- **Codex/OAuth**:OpenAI OAuth/トークン認証ルート。 + +これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_keys`)のみで実現します。 + +### スケジュールタスク / リマインダー + +PicoClaw は `cron` ツールを通じて cron スタイルのスケジュールタスクをサポートします。 + +```json +{ + "tools": { + "cron": { + "enabled": true, + "exec_timeout_minutes": 5 + } + } +} +``` + +スケジュールタスクは再起動後も `~/.picoclaw/workspace/cron/` に保存されます。 + +### 高度なトピック + +| トピック | 説明 | +| -------- | ---- | +| [Hook システム](../architecture/hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook | +| [Steering](../architecture/steering.md) | 実行中の Agent ループにメッセージを注入 | +| [SubTurn](../architecture/subturn.md) | サブ Agent の調整、並行制御、ライフサイクル | +| [コンテキスト管理](../architecture/agent-refactor/context.md) | コンテキスト境界検出、圧縮戦略 | diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md new file mode 100644 index 000000000..28fc7b775 --- /dev/null +++ b/docs/guides/configuration.md @@ -0,0 +1,955 @@ +# ⚙️ Configuration Guide + +> Back to [README](../README.md) + +## ⚙️ Configuration + +Config file: `~/.picoclaw/config.json` + +> **Security Configuration:** For storing API keys, tokens, and other sensitive data, see the [Security Configuration Guide](../security/security_configuration.md). + +### Environment Variables + +You 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. + +| Variable | Description | Default Path | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `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` | +| `PICOCLAW_HOME` | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories. | `~/.picoclaw` | + +**Examples:** + +```bash +# Run picoclaw using a specific config file +# The workspace path will be read from within that config file +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# Run picoclaw with all its data stored in /opt/picoclaw +# Config will be loaded from the default ~/.picoclaw/config.json +# Workspace will be created at /opt/picoclaw/workspace +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# Use both for a fully customized setup +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + +### Gateway Log Level + +`gateway.log_level` controls Gateway log verbosity and is configurable in `config.json`. + +```json +{ + "gateway": { + "log_level": "warn" + } +} +``` + +When omitted, the default is `warn`. Supported values: `debug`, `info`, `warn`, `error`, `fatal`. + +You can also override this with the environment variable `PICOCLAW_LOG_LEVEL`. + +### Workspace Layout + +PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # Conversation sessions and history +├── memory/ # Long-term memory (MEMORY.md) +├── state/ # Persistent state (last channel, etc.) +├── cron/ # Scheduled jobs database +├── skills/ # Custom skills +├── AGENT.md # Agent behavior guide +├── HEARTBEAT.md # Periodic task prompts (checked every 30 min) +├── IDENTITY.md # Agent identity +├── SOUL.md # Agent soul +└── USER.md # User preferences +``` + +> **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. + +### Web launcher dashboard + +**picoclaw-launcher** serves a browser UI that requires password sign-in first. On first run, open `/launcher-setup` to create the dashboard password. Later manual sign-ins use `/launcher-login`. + +- **Config file**: Same directory as `config.json` (or the file pointed to by `PICOCLAW_CONFIG`). The launcher-specific file is `launcher-config.json`. +- **Password storage**: On supported platforms, the password is stored as a bcrypt hash in `launcher-auth.db`. On platforms where the SQLite password store is unavailable, the bcrypt hash is stored in `launcher-config.json`. +- **Legacy migration**: Older `launcher_token` values are migrated once into password login and removed from saved launcher config. +- **Local auto-login**: When the launcher auto-opens a local browser after startup, it uses a one-shot loopback-only bootstrap endpoint to set the session cookie automatically. +- **Unsupported auth paths**: URL token login (`?token=...`), `PICOCLAW_LAUNCHER_TOKEN`, and `Authorization: Bearer` dashboard auth are no longer supported. +- **Sign-out**: Use **`POST /api/auth/logout`** with **`Content-Type: application/json`** (body may be `{}`). Do not rely on a GET URL for logout (CSRF-safe pattern). +- **Brute-force**: **`POST /api/auth/login`** is **rate-limited per client IP per minute** (HTTP 429 when exceeded). +- **Session lifetime**: The HttpOnly session cookie lasts about **31 days** by default, but sessions are invalidated when the launcher process restarts. + +### Skill Sources + +By default, skills are loaded from: + +1. `~/.picoclaw/workspace/skills` (workspace) +2. `~/.picoclaw/skills` (global) +3. `/skills` (builtin, set at build time) + +For advanced/test setups, you can override the builtin skills root with: + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + +### Using Skills From Chat Channels + +Once skills are installed, and MCP servers are configured, you can inspect and force them directly from a chat channel: + +- `/list skills` shows the installed skill names available to the current agent. +- `/list mcp` shows configured MCP servers with enabled/deferred/connected status. +- `/show mcp ` shows the active tools exposed by a connected MCP server. +- `/use ` forces a specific skill for a single request. +- `/use ` arms that skill for your next message in the same chat session. +- `/use clear` cancels a pending skill override created by `/use `. +- `/btw ` asks an immediate side question without changing the current session history. `/btw` is handled as a no-tool query and does not enter the normal tool-execution flow. + +Examples: + +```text +/list skills +/list mcp +/show mcp github +/use git explain how to squash the last 3 commits +/btw remind me what we already decided about the deploy plan +/use italiapersonalfinance +dammi le ultime news +``` + +### Unified Command Execution Policy + +- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`. +- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands such as `/start`, `/help`, `/show`, `/list`, `/use`, and `/btw` at startup. +- Unknown slash command (for example `/foo`) passes through to normal LLM processing. +- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing. + +### Session Isolation + +Session scope controls how much memory is shared between chats, users, threads, and spaces. + +- Use `session.dimensions` for the global default. +- Use `session_dimensions` on a dispatch rule for one routed exception. + +For step-by-step recipes and isolation patterns, see the [Session Guide](session-guide.md). + +### Routing + +Routing is configured through `agents.dispatch.rules`. + +Each rule matches against the normalized inbound context produced by channels. +Rules are evaluated from top to bottom. The first matching rule wins. If no +rule matches, PicoClaw falls back to the configured default agent. + +Supported match fields: + +* `channel` +* `account` +* `space` +* `chat` +* `topic` +* `sender` +* `mentioned` + +Match values use the same scope vocabulary as the session system: + +* `space`: `workspace:t001`, `guild:123456` +* `chat`: `direct:user123`, `group:-100123`, `channel:c123` +* `topic`: `topic:42` +* `sender`: a normalized sender identifier for the platform + +Rules may optionally override the global `session.dimensions` value through +`session_dimensions`. This allows routing and session allocation to stay aligned +without reintroducing the old `bindings` or `dm_scope` formats. + +Example: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" }, + { "id": "sales" } + ], + "dispatch": { + "rules": [ + { + "name": "vip in support group", + "agent": "sales", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890", + "sender": "12345" + }, + "session_dimensions": ["chat", "sender"] + }, + { + "name": "telegram support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +In the example above, the VIP rule must appear before the broader group rule. +Because routing is strictly ordered, more specific rules should be placed +earlier and broader fallback rules later. + +For more complete routing and model-tier examples, see the [Routing Guide](routing-guide.md). + +### 🔒 Security Sandbox + +PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. + +#### Default Configuration + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Option | Default | Description | +| ----------------------- | ----------------------- | ----------------------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent | +| `restrict_to_workspace` | `true` | Restrict file/command access to workspace | + +#### Protected Tools + +When `restrict_to_workspace: true`, the following tools are sandboxed: + +| Tool | Function | Restriction | +| ------------- | ---------------- | -------------------------------------- | +| `read_file` | Read files | Only files within workspace | +| `write_file` | Write files | Only files within workspace | +| `list_dir` | List directories | Only directories within workspace | +| `edit_file` | Edit files | Only files within workspace | +| `append_file` | Append to files | Only files within workspace | +| `exec` | Execute commands | Command paths must be within workspace | + +#### Additional Exec Protection + +Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands: + +* `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion +* `format`, `mkfs`, `diskpart` — Disk formatting +* `dd if=` — Disk imaging +* Writing to `/dev/sd[a-z]` — Direct disk writes +* `shutdown`, `reboot`, `poweroff` — System shutdown +* Fork bomb `:(){ :|:& };:` + +### File Access Control + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace | +| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace | + +### Read File Mode + +`read_file` has two mutually exclusive implementations selected by config. PicoClaw registers exactly one of them at startup: + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.read_file.enabled` | bool | `true` | Enables the `read_file` tool | +| `tools.read_file.mode` | string | `bytes` | Selects the `read_file` implementation: `bytes` or `lines` | +| `tools.read_file.max_read_file_size` | int | `65536` | Maximum bytes returned by `read_file` | + +#### Mode: `bytes` + +Optimized for arbitrary files and binary-safe pagination. + +Parameters: + +* `path` (required): File path +* `offset` (optional): Starting byte offset, default `0` +* `length` (optional): Maximum number of bytes to read, default `max_read_file_size` + +Use `bytes` when: + +* You may read binary files +* You want deterministic byte-range pagination + +#### Mode: `lines` + +Text-oriented behavior, optimized for source files, markdown, logs, and configs. The tool reads sequentially by line and stops when the configured byte budget is reached. + +Parameters: + +* `path` (required): File path +* `start_line` (optional): Starting line number, 1-indexed and inclusive, default `1` +* `max_lines` (optional): Maximum number of lines to read, default = all remaining lines until EOF or byte budget + +Behavior notes: + +* Binary-looking files are rejected with guidance to switch `read_file` to `mode = bytes` +* Extremely long single lines are truncated rather than skipped + +Use `mode = lines` when: + +* The agent mostly reads text files +* You want line-based pagination in prompts and tool calls +* You want cleaner chunks for code review, logs, and documentation + +#### Example + +```json +{ + "tools": { + "read_file": { + "enabled": true, + "mode": "lines", + "max_read_file_size": 65536 + } + } +} +``` + +### Exec Security + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.exec.allow_remote` | bool | `false` | Allow exec tool from remote channels (Telegram/Discord etc.) | +| `tools.exec.enable_deny_patterns` | bool | `true` | Enable dangerous command interception | +| `tools.exec.custom_deny_patterns` | string[] | `[]` | Custom regex patterns to block | +| `tools.exec.custom_allow_patterns` | string[] | `[]` | Custom regex patterns to allow | + +> **Security Note:** Symlink protection is enabled by default — all file paths are resolved through `filepath.EvalSymlinks` before whitelist matching, preventing symlink escape attacks. + +#### Known Limitation: Child Processes From Build Tools + +The exec safety guard only inspects the command line PicoClaw launches directly. It does not recursively inspect child +processes spawned by allowed developer tools such as `make`, `go run`, `cargo`, `npm run`, or custom build scripts. + +That means a top-level command can still compile or launch other binaries after it passes the initial guard check. In +practice, treat build scripts, Makefiles, package scripts, and generated binaries as executable code that needs the same +level of review as a direct shell command. + +For higher-risk environments: + +* Review build scripts before execution. +* Prefer approval/manual review for compile-and-run workflows. +* Run PicoClaw inside a container or VM if you need stronger isolation than the built-in guard provides. + +#### Error Examples + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Disabling Restrictions (Security Risk) + +If you need the agent to access paths outside the workspace: + +**Method 1: Config file** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Method 2: Environment variable** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Warning**: Disabling this restriction allows the agent to access any path on your system. Use with caution in controlled environments only. + +#### Security Boundary Consistency + +The `restrict_to_workspace` setting applies consistently across all execution paths: + +| Execution Path | Security Boundary | +| ---------------- | ---------------------------- | +| Main Agent | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Inherits same restriction ✅ | +| Heartbeat tasks | Inherits same restriction ✅ | + +All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks. + +### Heartbeat (Periodic Tasks) + +PicoClaw can perform periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace: + +```markdown +# Periodic Tasks + +- Check my email for important messages +- Review my calendar for upcoming events +- Check the weather forecast +``` + +The agent will read this file every 30 minutes (configurable) and execute any tasks using available tools. + +#### Async Tasks with Spawn + +For long-running tasks (web search, API calls), use the `spawn` tool to create a **subagent**: + +```markdown +# Periodic Tasks + +## Quick Tasks (respond directly) + +- Report current time + +## Long Tasks (use spawn for async) + +- Search the web for AI news and summarize +- Check email and report important messages +``` + +**Key behaviors:** + +| Feature | Description | +| ----------------------- | --------------------------------------------------------- | +| **spawn** | Creates async subagent, doesn't block heartbeat | +| **Independent context** | Subagent has its own context, no session history | +| **message tool** | Subagent communicates with user directly via message tool | +| **Non-blocking** | After spawning, heartbeat continues to next task | + +#### How Subagent Communication Works + +``` +Heartbeat triggers + ↓ +Agent reads HEARTBEAT.md + ↓ +For long task: spawn subagent + ↓ ↓ +Continue to next task Subagent works independently + ↓ ↓ +All tasks done Subagent uses "message" tool + ↓ ↓ +Respond HEARTBEAT_OK User receives result directly +``` + +The subagent has access to tools (message, web_search, etc.) and can communicate with the user independently without going through the main agent. + +**Configuration:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Option | Default | Description | +| ---------- | ------- | ---------------------------------- | +| `enabled` | `true` | Enable/disable heartbeat | +| `interval` | `30` | Check interval in minutes (min: 5) | + +**Environment variables:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable +* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval + +### Providers + +> [!NOTE] +> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level. + +| Provider | Purpose | Get API Key | +| ------------ | --------------------------------------- | ------------------------------------------------------------ | +| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | +| `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) | +| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | + +### Model Configuration (model_list) + +> **What's New?** PicoClaw now prefers explicit `provider` + native `model` configuration (for example `"provider": "zhipu", "model": "glm-4.7"`). The legacy single-field `provider/model` form remains supported for compatibility when `provider` is omitted. + +This design also enables **multi-agent support** with flexible provider selection: + +- **Different agents, different providers**: Each agent can use its own LLM provider +- **Model fallbacks**: Configure primary and fallback models for resilience +- **Load balancing**: Distribute requests across multiple endpoints or keys +- **Centralized configuration**: Manage all providers in one place +- **Model enable/disable**: Use the `enabled` field to temporarily disable a model without removing its configuration + +#### 🔒 Security Configuration (Recommended) + +PicoClaw supports separating sensitive data (API keys, tokens, secrets) from your main configuration by storing them in a `.security.yml` file. + +**Key Benefits:** +- **Security**: Sensitive data is never in your main config file +- **Easy sharing**: Share config.json without exposing API keys +- **Version control**: Add `.security.yml` to `.gitignore` +- **Flexible deployment**: Different environments can use different security files + +**Quick Setup:** + +1. Create `~/.picoclaw/.security.yml` with your API keys: +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-proj-your-actual-openai-key" + claude-sonnet-4.6: + api_keys: + - "sk-ant-your-actual-anthropic-key" +channels: + telegram: + token: "your-telegram-bot-token" +web: + brave: + api_keys: + - "BSAyour-brave-api-key" + glm_search: + api_key: "your-glm-search-api-key" +``` + +2. Set proper permissions: +```bash +chmod 600 ~/.picoclaw/.security.yml +``` + +3. Remove sensitive fields from `config.json` (recommended): +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4" + // api_key loaded from .security.yml + } + ], + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + // token loaded from .security.yml + } + } +} +``` + +**How it works:** +- Values from `.security.yml` are automatically mapped to config fields +- No special syntax needed — just omit sensitive fields from config.json +- If a field exists in both files, `.security.yml` value takes precedence +- You can mix direct values in config.json with security values + +For complete documentation, see [`../security/security_configuration.md`](../security/security_configuration.md). + +#### All Supported Vendors + +| Vendor | `provider` Value | Default API Base | Protocol | API Key | +| ----------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | +| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | +| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | +| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | +| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | +| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) | +| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | +| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | +| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | Local | +| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | +| **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) | +| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — | +| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) | +| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | +| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | +| **ModelScope (魔搭)** | `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | +| **Antigravity** | `antigravity` | Google Cloud | Custom | OAuth only | +| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | — | + +#### Basic Configuration + +```json +{ + "model_list": [ + { + "model_name": "ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-your-api-key"] + }, + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-your-openai-key"] + }, + { + "model_name": "claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] + }, + { + "model_name": "glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-zhipu-key"] + } + ], + "agents": { + "defaults": { + "model": "gpt-5.4" + } + } +} +``` + +> **Security Note**: You can remove `api_keys` fields from your config and store them in `.security.yml` instead. See [Security Configuration](#-security-configuration-recommended) above for details. +> +> **Note**: The `enabled` field can be set to `false` to disable a model entry without removing it. When omitted, it defaults to `true` during migration for models that have API keys. + +Resolution rules: + +- Prefer explicit `"provider": "openai", "model": "gpt-5.4"`. +- If `provider` is set, PicoClaw sends `model` unchanged. +- If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID. +- This means `"model": "openrouter/openai/gpt-5.4"` still works as a compatibility form and sends `openai/gpt-5.4` to OpenRouter. + +#### Vendor-Specific Examples + +> **Tip**: You can omit `api_key` fields and store them in `.security.yml` for better security. See [Security Configuration](#-security-configuration-recommended). + +
+OpenAI + +```json +{ + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4" + // api_key: set in .security.yml +} +``` + +
+ +
+VolcEngine (Doubao) + +```json +{ + "model_name": "ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest" + // api_key: set in .security.yml +} +``` + +
+ +
+智谱 AI (GLM) + +```json +{ + "model_name": "glm-4.7", + "provider": "zhipu", + "model": "glm-4.7" + // api_key: set in .security.yml +} +``` + +
+ +
+DeepSeek + +```json +{ + "model_name": "deepseek-chat", + "provider": "deepseek", + "model": "deepseek-chat" + // api_key: set in .security.yml +} +``` + +
+ +
+Anthropic + +```json +{ + "model_name": "claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6" + // api_key: set in .security.yml +} +``` + +> Run `picoclaw auth login --provider anthropic` to paste your API token. + +For direct Anthropic API access or custom endpoints that only support Anthropic's native message format: + +```json +{ + "model_name": "claude-opus-4-6", + "provider": "anthropic-messages", + "model": "claude-opus-4-6", + "api_keys": ["sk-ant-your-key"], + "api_base": "https://api.anthropic.com" +} +``` + +> Use `anthropic-messages` when the endpoint requires Anthropic's native `/v1/messages` format instead of OpenAI-compatible `/v1/chat/completions`. + +
+ +
+Ollama (local) + +```json +{ + "model_name": "llama3", + "provider": "ollama", + "model": "llama3" +} +``` + +
+ +
+LM Studio (local) + +```json +{ + "model_name": "lmstudio-local", + "provider": "lmstudio", + "model": "openai/gpt-oss-20b" +} +``` + +`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.
+With explicit `provider`, PicoClaw sends `openai/gpt-oss-20b` unchanged to LM Studio. The legacy compatibility form `"model": "lmstudio/openai/gpt-oss-20b"` still resolves to the same upstream model ID when `provider` is omitted. + +
+ +
+Custom Proxy / LiteLLM + +```json +{ + "model_name": "my-custom-model", + "provider": "openai", + "model": "custom-model", + "api_base": "https://my-proxy.com/v1" + // api_key: set in .security.yml +} +``` + +With explicit `provider`, PicoClaw sends `model` unchanged. That means `"provider": "litellm", "model": "lite-gpt4"` sends `lite-gpt4`, while `"provider": "litellm", "model": "openai/gpt-4o"` sends `openai/gpt-4o`. The legacy compatibility forms `litellm/lite-gpt4` and `litellm/openai/gpt-4o` still resolve the same way when `provider` is omitted. + +
+ +#### Load Balancing + +Configure multiple endpoints for the same model name — PicoClaw will automatically round-robin between them: + +**Option 1: Multiple API Keys in .security.yml (Recommended)** + +```yaml +# .security.yml +model_list: + gpt-5.4: + api_keys: + - "sk-proj-key-1" + - "sk-proj-key-2" +``` + +```json +// config.json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_base": "https://api.openai.com/v1" + // api_keys loaded from .security.yml + } + ] +} +``` + +**Option 2: Multiple Model Entries** + +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_base": "https://api1.example.com/v1", + "api_keys": ["sk-key1"] + }, + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_base": "https://api2.example.com/v1", + "api_keys": ["sk-key2"] + } + ] +} +``` + +#### Migration from Legacy `providers` Config + +The old `providers` configuration is **deprecated** and has been removed in V2. Existing V0/V1 configs are auto-migrated. See [docs/migration/model-list-migration.md](../migration/model-list-migration.md) for the full guide. + +### Provider Architecture + +PicoClaw routes providers by protocol family: + +- **OpenAI-compatible**: OpenRouter, Groq, Zhipu, vLLM-style endpoints, and most others. +- **Gemini native**: Google Gemini via the native `models/*:generateContent` and `models/*:streamGenerateContent` endpoints. +- **Anthropic**: Claude-native API behavior. +- **Codex/OAuth**: OpenAI OAuth/token authentication route. + +This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_keys`). + +
+Zhipu (legacy providers format) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20, + "max_parallel_turns": 1 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +> **Note**: The `providers` format is deprecated. Use the new `model_list` format with `.security.yml` for better security. +> +> **`max_parallel_turns`**: Controls concurrent processing of messages from different sessions. `1` (default) = sequential; `>1` = parallel. Messages from the same session are always serialized. See [Steering docs](../architecture/steering.md) for details. + +
+ +
+Full config example + +```json +{ + "agents": { + "defaults": { + "model_name": "claude-opus-4-5" + } + }, + "session": { + "dm_scope": "per-channel-peer", + "backlog_limit": 20 + }, + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + // token: set in .security.yml + "allow_from": ["123456789"] + } + }, + "tools": { + "web": { + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +> **Note**: Sensitive fields (`api_key`, `token`, etc.) can be omitted and stored in `.security.yml` for better security. + +
+ +### Scheduled Tasks / Reminders + +PicoClaw supports cron-style scheduled tasks via the `cron` tool. The agent can set, list, and cancel reminders or recurring jobs that trigger at specified times. + +```json +{ + "tools": { + "cron": { + "enabled": true, + "exec_timeout_minutes": 5 + } + } +} +``` + +Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace/cron/`. + +### Advanced Topics + +| Topic | Description | +| ----- | ----------- | +| [Security Configuration](../security/security_configuration.md) | Store API keys and secrets in separate `.security.yml` file | +| [Sensitive Data Filtering](../security/sensitive_data_filtering.md) | Filter API keys and tokens from tool results before sending to LLM | +| [Hook System](../architecture/hooks/README.md) | Event-driven hooks: observers, interceptors, approval hooks | +| [Steering](../architecture/steering.md) | Inject messages into a running agent loop between tool calls | +| [SubTurn](../architecture/subturn.md) | Subagent coordination, concurrency control, lifecycle | +| [Context Management](../architecture/agent-refactor/context.md) | Context boundary detection, proactive budget check, compression | diff --git a/docs/guides/configuration.ms.md b/docs/guides/configuration.ms.md new file mode 100644 index 000000000..bcd17afa8 --- /dev/null +++ b/docs/guides/configuration.ms.md @@ -0,0 +1,236 @@ +# ⚙️ Panduan Konfigurasi + +> Kembali ke [README](../project/README.ms.md) + +## ⚙️ Konfigurasi + +Fail konfigurasi: `~/.picoclaw/config.json` + +### Pemboleh Ubah Persekitaran + +Anda boleh menggantikan laluan lalai menggunakan pemboleh ubah persekitaran. Ini berguna untuk pemasangan mudah alih, deployment dalam container, atau menjalankan picoclaw sebagai system service. Pemboleh ubah ini saling bebas dan mengawal laluan yang berbeza. + +| Pemboleh Ubah | Penerangan | Laluan Lalai | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| `PICOCLAW_CONFIG` | Menindih laluan ke fail konfigurasi. Ini memberitahu picoclaw secara terus fail `config.json` yang perlu dimuatkan, dengan mengabaikan lokasi lain. | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | Menindih direktori root untuk data picoclaw. Ini mengubah lokasi lalai bagi `workspace` dan direktori data lain. | `~/.picoclaw` | + +**Contoh:** + +```bash +# Jalankan picoclaw menggunakan fail config tertentu +# Laluan workspace akan dibaca daripada fail config tersebut +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# Jalankan picoclaw dengan semua data disimpan di /opt/picoclaw +# Config akan dimuatkan dari lalai ~/.picoclaw/config.json +# Workspace akan dicipta di /opt/picoclaw/workspace +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# Gunakan kedua-duanya untuk setup yang disesuaikan sepenuhnya +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + +### Susun Atur Workspace + +PicoClaw menyimpan data dalam workspace yang dikonfigurasikan (lalai: `~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # Sesi perbualan dan sejarah +├── memory/ # Memori jangka panjang (MEMORY.md) +├── state/ # Keadaan persisten (saluran terakhir, dll.) +├── cron/ # Pangkalan data job berjadual +├── skills/ # Skill tersuai +├── AGENTS.md # Panduan tingkah laku agen +├── HEARTBEAT.md # Prompt tugasan berkala (disemak setiap 30 minit) +├── IDENTITY.md # Identiti agen +├── SOUL.md # Jiwa agen +└── USER.md # Keutamaan pengguna +``` + +### Sumber Skill + +Secara lalai, skill dimuatkan daripada: + +1. `~/.picoclaw/workspace/skills` (workspace) +2. `~/.picoclaw/skills` (global) +3. `/skills` (builtin) + +Untuk setup lanjutan/ujian, anda boleh menindih root builtin skills dengan: + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + +### Menggunakan Skill dan Arahan Dari Saluran Chat + +Selepas skill dipasang, anda boleh menyemak dan memaksanya terus dari saluran chat: + +- `/list skills` memaparkan nama skill dipasang yang kelihatan kepada agen semasa. +- `/use ` memaksa satu skill untuk satu permintaan sahaja. +- `/use ` menyediakan skill itu untuk mesej anda yang seterusnya dalam chat yang sama. +- `/use clear` membatalkan skill override tertunda yang dibuat melalui `/use `. +- `/btw ` bertanya soalan sampingan segera tanpa mengubah sejarah sesi semasa. `/btw` dikendalikan sebagai pertanyaan langsung tanpa tool dan tidak memasuki aliran pelaksanaan tool biasa. + +Contoh: + +```text +/list skills +/use git terangkan cara squash 3 commit terakhir +/btw ingatkan saya semula apa keputusan tadi untuk pelan deploy +/use italiapersonalfinance +dammi le ultime news +``` + +### Polisi Pelaksanaan Arahan Bersepadu + +- Generic slash command dilaksanakan melalui satu laluan dalam `pkg/agent/loop.go` melalui `commands.Executor`. +- Adapter saluran tidak lagi menggunakan generic command secara setempat; ia memajukan teks masuk ke laluan bus/agent. Telegram masih auto-register arahan yang disokong semasa startup seperti `/start`, `/help`, `/show`, `/list`, `/use`, dan `/btw`. +- Slash command yang tidak dikenali (contohnya `/foo`) akan diteruskan ke pemprosesan LLM biasa. +- Arahan yang didaftarkan tetapi tidak disokong pada saluran semasa (contohnya `/show` di WhatsApp) akan memulangkan ralat yang jelas kepada pengguna dan menghentikan pemprosesan lanjut. + +### 🔒 Security Sandbox + +PicoClaw berjalan dalam persekitaran bersandbox secara lalai. Agen hanya boleh mengakses fail dan melaksanakan arahan dalam workspace yang dikonfigurasikan. + +#### Konfigurasi Lalai + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Option | Default | Description | +| ----------------------- | ----------------------- | ----------------------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Direktori kerja untuk agen | +| `restrict_to_workspace` | `true` | Hadkan akses fail/arahan kepada workspace | + +#### Tools yang Dilindungi + +Apabila `restrict_to_workspace: true`, tools berikut disandboxkan: + +| Tool | Fungsi | Sekatan | +| ------------- | ----------------- | ----------------------------------- | +| `read_file` | Baca fail | Hanya fail dalam workspace | +| `write_file` | Tulis fail | Hanya fail dalam workspace | +| `list_dir` | Senarai direktori | Hanya direktori dalam workspace | +| `edit_file` | Edit fail | Hanya fail dalam workspace | +| `append_file` | Tambah ke fail | Hanya fail dalam workspace | +| `exec` | Jalankan arahan | Laluan arahan mesti dalam workspace | + +#### Perlindungan Exec Tambahan + +Walaupun dengan `restrict_to_workspace: false`, tool `exec` menyekat arahan berbahaya berikut: + +* `rm -rf`, `del /f`, `rmdir /s` — Pemadaman pukal +* `format`, `mkfs`, `diskpart` — Pemformatan cakera +* `dd if=` — Pengimejan cakera +* Menulis ke `/dev/sd[a-z]` — Tulis terus ke cakera +* `shutdown`, `reboot`, `poweroff` — Penutupan sistem +* Fork bomb `:(){ :|:& };:` + +### Kawalan Akses Fail + +| Kunci Config | Jenis | Lalai | Penerangan | +| ------------------------- | -------- | ----- | --------------------------------------------------------------- | +| `tools.allow_read_paths` | string[] | `[]` | Laluan tambahan yang dibenarkan untuk dibaca di luar workspace | +| `tools.allow_write_paths` | string[] | `[]` | Laluan tambahan yang dibenarkan untuk ditulis di luar workspace | + +### Keselamatan Exec + +| Kunci Config | Jenis | Lalai | Penerangan | +| ---------------------------------- | -------- | ------- | ------------------------------------------------------------ | +| `tools.exec.allow_remote` | bool | `false` | Benarkan tool exec dari saluran jauh (Telegram/Discord dll.) | +| `tools.exec.enable_deny_patterns` | bool | `true` | Aktifkan pemintasan arahan berbahaya | +| `tools.exec.custom_deny_patterns` | string[] | `[]` | Corak regex tersuai untuk disekat | +| `tools.exec.custom_allow_patterns` | string[] | `[]` | Corak regex tersuai untuk dibenarkan | + +> **Nota Keselamatan:** Perlindungan symlink diaktifkan secara lalai — semua laluan fail akan diselesaikan melalui `filepath.EvalSymlinks` sebelum dipadankan dengan whitelist, bagi mengelakkan serangan melarikan diri melalui symlink. + +#### Had yang Diketahui: Proses Anak Daripada Build Tools + +Pengawal keselamatan exec hanya memeriksa baris arahan yang PicoClaw lancarkan secara terus. Ia tidak memeriksa secara rekursif proses anak yang dilancarkan oleh tools pembangun yang dibenarkan seperti `make`, `go run`, `cargo`, `npm run`, atau skrip build tersuai. + +Ini bermakna arahan peringkat atas masih boleh mengkompil atau melancarkan binari lain selepas ia melepasi semakan awal pengawal. Dalam amalan, anggap build script, Makefile, package script, dan binari terjana sebagai kod boleh laksana yang memerlukan tahap semakan yang sama seperti arahan shell terus. + +Untuk persekitaran yang lebih berisiko: + +* Semak build script sebelum pelaksanaan. +* Utamakan kelulusan/semakan manual untuk aliran kerja compile-and-run. +* Jalankan PicoClaw dalam container atau VM jika anda memerlukan pengasingan yang lebih kuat daripada pengawal terbina dalam. + +#### Contoh Ralat + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Menyahaktifkan Sekatan (Risiko Keselamatan) + +Jika anda perlu membenarkan agen mengakses laluan di luar workspace: + +**Kaedah 1: Fail config** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Kaedah 2: Pemboleh ubah persekitaran** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Amaran**: Menyahaktifkan sekatan ini membenarkan agen mengakses mana-mana laluan pada sistem anda. Gunakan dengan berhati-hati hanya dalam persekitaran terkawal. + +#### Ketekalan Sempadan Keselamatan + +Tetapan `restrict_to_workspace` digunakan secara konsisten merentas semua laluan pelaksanaan: + +| Execution Path | Security Boundary | +| ---------------- | --------------------------- | +| Main Agent | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Inherits same restriction ✅ | +| Heartbeat tasks | Inherits same restriction ✅ | + +Semua laluan berkongsi sekatan workspace yang sama — tiada cara untuk memintas sempadan keselamatan melalui subagent atau tugasan berjadual. + +### Heartbeat (Tugasan Berkala) + +PicoClaw boleh melaksanakan tugasan berkala secara automatik. Cipta fail `HEARTBEAT.md` dalam workspace anda: + +```markdown +# Periodic Tasks + +- Check my email for important messages +- Review my calendar for upcoming events +- Check the weather forecast +``` + +Agen akan membaca fail ini setiap 30 minit (boleh dikonfigurasi) dan melaksanakan sebarang tugasan menggunakan tools yang tersedia. + +#### Tugasan Async dengan Spawn + +Untuk tugasan yang berjalan lama (carian web, panggilan API), gunakan tool `spawn` untuk mencipta **subagent**: + +```markdown +# Periodic Tasks diff --git a/docs/guides/configuration.pt-br.md b/docs/guides/configuration.pt-br.md new file mode 100644 index 000000000..e5d904e29 --- /dev/null +++ b/docs/guides/configuration.pt-br.md @@ -0,0 +1,403 @@ +# ⚙️ Guia de Configuração + +> Voltar ao [README](../project/README.pt-br.md) + +## ⚙️ Configuração + +Arquivo de configuração: `~/.picoclaw/config.json` + +### Variáveis de Ambiente + +Você 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. + +| Variável | Descrição | Caminho Padrão | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `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` | +| `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` | + +**Exemplos:** + +```bash +# Executar picoclaw usando um arquivo de configuração específico +# O caminho do workspace será lido de dentro desse arquivo de configuração +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# Executar picoclaw com todos os dados armazenados em /opt/picoclaw +# A configuração será carregada do padrão ~/.picoclaw/config.json +# O workspace será criado em /opt/picoclaw/workspace +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# Usar ambos para uma configuração totalmente personalizada +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + +### Nível de Log do Gateway + +`gateway.log_level` controla a verbosidade dos logs do Gateway, configurável em `config.json`: + +```json +{ + "gateway": { + "log_level": "warn" + } +} +``` + +O valor padrão é `warn`. Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. + +Também pode ser substituído pela variável de ambiente: `PICOCLAW_LOG_LEVEL=info` + +### Layout do Workspace + +O PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # Sessões de conversa e histórico +├── memory/ # Memória de longo prazo (MEMORY.md) +├── state/ # Estado persistente (último canal, etc.) +├── cron/ # Banco de dados de tarefas agendadas +├── skills/ # Skills personalizadas +├── AGENT.md # Guia de comportamento do agente +├── HEARTBEAT.md # Prompts de tarefas periódicas (verificados a cada 30 min) +├── IDENTITY.md # Identidade do agente +├── SOUL.md # Alma do agente +└── USER.md # Preferências do usuário +``` + +> **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. + +### Fontes de Skills + +Por padrão, as skills são carregadas de: + +1. `~/.picoclaw/workspace/skills` (workspace) +2. `~/.picoclaw/skills` (global) +3. `/skills` (embutido) + +Para configurações avançadas/de teste, você pode substituir o diretório raiz de skills builtin com: + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + +### Usando Skills e Comandos em Canais de Chat + +Depois que as skills estiverem instaladas, voce pode inspeciona-las e aplica-las diretamente de um canal de chat: + +- `/list skills` mostra os nomes das skills instaladas visiveis para o agente atual. +- `/use ` força uma skill para uma unica requisicao. +- `/use ` prepara essa skill para a sua proxima mensagem no mesmo chat. +- `/use clear` cancela uma substituicao pendente criada por `/use `. +- `/btw ` faz uma pergunta lateral imediata sem alterar o historico atual da sessao. `/btw` e tratado como uma consulta direta sem ferramentas e nao entra no fluxo normal de execucao de ferramentas. + +Exemplos: + +```text +/list skills +/use git explique como fazer squash dos ultimos 3 commits +/btw me relembre o que ja decidimos sobre o plano de deploy +/use italiapersonalfinance +dammi le ultime news +``` + +### Política Unificada de Execução de Comandos + +- Comandos slash genéricos são executados através de um único caminho em `pkg/agent/loop.go` via `commands.Executor`. +- 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 na inicialização comandos suportados como `/start`, `/help`, `/show`, `/list`, `/use` e `/btw`. +- Comando slash desconhecido (por exemplo `/foo`) passa para o processamento normal do LLM. +- 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. + +### 🔒 Sandbox de Segurança + +O PicoClaw é executado em um ambiente sandbox por padrão. O agente só pode acessar arquivos e executar comandos dentro do workspace configurado. + +#### Configuração Padrão + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Opção | Padrão | Descrição | +| ----------------------- | ----------------------- | ----------------------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Diretório de trabalho do agente | +| `restrict_to_workspace` | `true` | Restringir acesso a arquivos/comandos ao workspace | + +#### Ferramentas Protegidas + +Quando `restrict_to_workspace: true`, as seguintes ferramentas são isoladas: + +| Ferramenta | Função | Restrição | +| ------------- | ---------------- | -------------------------------------- | +| `read_file` | Ler arquivos | Apenas arquivos dentro do workspace | +| `write_file` | Escrever arquivos| Apenas arquivos dentro do workspace | +| `list_dir` | Listar diretórios| Apenas diretórios dentro do workspace | +| `edit_file` | Editar arquivos | Apenas arquivos dentro do workspace | +| `append_file` | Anexar a arquivos| Apenas arquivos dentro do workspace | +| `exec` | Executar comandos| Caminhos de comando devem estar dentro do workspace | + +#### Proteção Adicional do Exec + +Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos: + +* `rm -rf`, `del /f`, `rmdir /s` — Exclusão em massa +* `format`, `mkfs`, `diskpart` — Formatação de disco +* `dd if=` — Imagem de disco +* Escrita em `/dev/sd[a-z]` — Escritas diretas em disco +* `shutdown`, `reboot`, `poweroff` — Desligamento do sistema +* Fork bomb `:(){ :|:& };:` + +### Controle de Acesso a Arquivos + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace | +| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace | + +### Segurança do Exec + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.exec.allow_remote` | bool | `false` | Allow exec tool from remote channels (Telegram/Discord etc.) | +| `tools.exec.enable_deny_patterns` | bool | `true` | Enable dangerous command interception | +| `tools.exec.custom_deny_patterns` | string[] | `[]` | Custom regex patterns to block | +| `tools.exec.custom_allow_patterns` | string[] | `[]` | Custom regex patterns to allow | + +> **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. + +#### Limitação Conhecida: Processos Filhos de Ferramentas de Build + +O 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. + +Isso 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. + +Para ambientes de maior risco: + +* Revise scripts de build antes da execução. +* Prefira aprovação/revisão manual para fluxos de trabalho de compilação e execução. +* Execute o PicoClaw dentro de um contêiner ou VM se precisar de isolamento mais forte do que o guard integrado oferece. + +#### Exemplos de Erro + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Desabilitando Restrições (Risco de Segurança) + +Se você precisar que o agente acesse caminhos fora do workspace: + +**Método 1: Arquivo de configuração** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Método 2: Variável de ambiente** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Aviso**: Desabilitar esta restrição permite que o agente acesse qualquer caminho no seu sistema. Use com cautela apenas em ambientes controlados. + +#### Consistência do Limite de Segurança + +A configuração `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execução: + +| Caminho de Execução | Limite de Segurança | +| -------------------- | ---------------------------- | +| Main Agent | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Herda a mesma restrição ✅ | +| Heartbeat tasks | Herda a mesma restrição ✅ | + +Todos 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. + +### Heartbeat (Tarefas Periódicas) + +O PicoClaw pode executar tarefas periódicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace: + +```markdown +# Tarefas Periódicas + +- Verificar meu e-mail para mensagens importantes +- Revisar meu calendário para eventos próximos +- Verificar a previsão do tempo +``` + +O agente lerá este arquivo a cada 30 minutos (configurável) e executará quaisquer tarefas usando as ferramentas disponíveis. + +#### Tarefas Assíncronas com Spawn + +Para tarefas de longa duração (busca na web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**: + +```markdown +# Tarefas Periódicas + +## Tarefas Rápidas (responder diretamente) + +- Informar a hora atual + +## Tarefas Longas (usar spawn para assíncrono) + +- Pesquisar notícias de IA na web e resumir +- Verificar e-mails e reportar mensagens importantes +``` + +**Comportamentos principais:** + +| Funcionalidade | Descrição | +| ---------------- | ------------------------------------------------------------------ | +| **spawn** | Cria subagente assíncrono, não bloqueia o heartbeat | +| **Contexto independente** | Subagente tem seu próprio contexto, sem histórico de sessão | +| **message tool** | Subagente comunica diretamente com o usuário via message tool | +| **Não-bloqueante** | Após o spawn, o heartbeat continua para a próxima tarefa | + +#### Fluxo de Comunicação do Subagente + +``` +Heartbeat disparado + ↓ +Agent lê HEARTBEAT.md + ↓ +Tarefa longa: spawn subagente + ↓ ↓ +Continua próxima tarefa Subagente trabalha independentemente + ↓ ↓ +Todas tarefas concluídas Subagente usa ferramenta "message" + ↓ ↓ +Responde HEARTBEAT_OK Usuário recebe resultado diretamente +``` + +**Configuração:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Opção | Padrão | Descrição | +| ---------- | ------ | -------------------------------------- | +| `enabled` | `true` | Ativar/desativar heartbeat | +| `interval` | `30` | Intervalo em minutos (mínimo: 5) | + +**Variáveis de ambiente:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` para desativar +* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo + +### Providers + +> [!NOTE] +> 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. + +| Provider | Finalidade | Obter API Key | +| ------------ | --------------------------------------- | ------------------------------------------------------------ | +| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](https://bigmodel.cn) | +| `volcengine` | LLM (Volcengine direto) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| `openrouter` | LLM (recomendado, acesso a todos modelos) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Qwen direto) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **Transcrição de voz** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras direto) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid direto) | [vivgrid.com](https://vivgrid.com) | + +### Configuração de Modelos (model_list) + +> **Novidade:** PicoClaw agora usa uma abordagem **centrada no modelo**. Basta especificar o formato `vendor/model` (ex.: `zhipu/glm-4.7`) para adicionar novos providers — **sem alterações de código!** + +#### Todos os Vendors Suportados + +| Vendor | Prefixo `model` | API Base padrão | Protocolo | API Key | +| ----------------------- | --------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obter](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obter](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obter](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obter](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Obter](https://aistudio.google.com/api-keys) | +| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obter](https://console.groq.com) | +| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obter](https://dashscope.console.aliyun.com) | +| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (sem chave) | +| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter](https://openrouter.ai/keys) | +| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| **Antigravity** | `antigravity/` | Google Cloud | Custom | Somente OAuth | + +#### Balanceamento de Carga + +Configure múltiplos endpoints para o mesmo nome de modelo — PicoClaw fará round-robin automaticamente: + +```json +{ + "model_list": [ + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] }, + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] } + ] +} +``` + +#### Migração da Configuração Legada `providers` + +A configuração antiga `providers` está **depreciada** e foi removida no V2. Configs V0/V1 existentes são auto-migradas. Veja [docs/migration/model-list-migration.md](../migration/model-list-migration.md). + +### Arquitetura de Providers + +PicoClaw roteia providers por família de protocolo: + +- **Compatível com OpenAI**: OpenRouter, Groq, Zhipu, endpoints vLLM e a maioria dos outros. +- **Gemini nativo**: Google Gemini via endpoints nativos `models/*:generateContent` e `models/*:streamGenerateContent`. +- **Anthropic**: Comportamento nativo da API Claude. +- **Codex/OAuth**: Rota de autenticação OAuth/token OpenAI. + +Isso mantém o runtime leve enquanto torna novos backends compatíveis com OpenAI basicamente uma operação de configuração (`api_base` + `api_keys`). + +### Tarefas Agendadas / Lembretes + +PicoClaw suporta tarefas agendadas via ferramenta `cron`. + +```json +{ + "tools": { + "cron": { + "enabled": true, + "exec_timeout_minutes": 5 + } + } +} +``` + +As tarefas agendadas persistem após reinicializações em `~/.picoclaw/workspace/cron/`. + +### Tópicos Avançados + +| Tópico | Descrição | +| ------ | --------- | +| [Sistema de Hooks](../architecture/hooks/README.md) | Hooks orientados a eventos: observadores, interceptores, hooks de aprovação | +| [Steering](../architecture/steering.md) | Injetar mensagens em um loop de agente em execução | +| [SubTurn](../architecture/subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida | +| [Gerenciamento de Contexto](../architecture/agent-refactor/context.md) | Detecção de limites de contexto, compressão | diff --git a/docs/guides/configuration.vi.md b/docs/guides/configuration.vi.md new file mode 100644 index 000000000..d905b6d2b --- /dev/null +++ b/docs/guides/configuration.vi.md @@ -0,0 +1,403 @@ +# ⚙️ Hướng Dẫn Cấu Hình + +> Quay lại [README](../project/README.vi.md) + +## ⚙️ Cấu Hình + +File cấu hình: `~/.picoclaw/config.json` + +### Biến Môi Trường + +Bạ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. + +| Biến | Mô tả | Đường Dẫn Mặc Định | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `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` | +| `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` | + +**Ví dụ:** + +```bash +# Chạy picoclaw với file cấu hình cụ thể +# Đường dẫn workspace sẽ được đọc từ trong file cấu hình đó +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# Chạy picoclaw với tất cả dữ liệu lưu tại /opt/picoclaw +# Cấu hình sẽ được tải từ mặc định ~/.picoclaw/config.json +# Workspace sẽ được tạo tại /opt/picoclaw/workspace +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# Sử dụng cả hai cho thiết lập tùy chỉnh hoàn toàn +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + +### Mức Log của Gateway + +`gateway.log_level` kiểm soát mức độ chi tiết của log Gateway, có thể cấu hình trong `config.json`: + +```json +{ + "gateway": { + "log_level": "warn" + } +} +``` + +Giá trị mặc định là `warn`. Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. + +Cũng có thể ghi đè bằng biến môi trường: `PICOCLAW_LOG_LEVEL=info` + +### Bố Cục Workspace + +PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # Phiên hội thoại và lịch sử +├── memory/ # Bộ nhớ dài hạn (MEMORY.md) +├── state/ # Trạng thái bền vững (kênh cuối, v.v.) +├── cron/ # Cơ sở dữ liệu tác vụ lên lịch +├── skills/ # Skill tùy chỉnh +├── AGENT.md # Hướng dẫn hành vi agent +├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút) +├── IDENTITY.md # Danh tính agent +├── SOUL.md # Linh hồn agent +└── USER.md # Tùy chọn người dùng +``` + +> **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. + +### Nguồn Skill + +Mặc định, skill được tải từ: + +1. `~/.picoclaw/workspace/skills` (workspace) +2. `~/.picoclaw/skills` (global) +3. `<đường-dẫn-nhúng-khi-build>/skills` (tích hợp) + +Cho thiết lập nâng cao/test, bạn có thể ghi đè thư mục gốc skill builtin với: + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + +### Dung Skill va Lenh Tu Kenh Chat + +Sau khi cai dat skill, ban co the xem va ep dung truc tiep tu kenh chat: + +- `/list skills` hien ten cac skill da cai dat ma agent hien tai co the dung. +- `/use ` ep dung mot skill cho duy nhat mot yeu cau. +- `/use ` dat san skill do cho tin nhan tiep theo trong cung cuoc tro chuyen. +- `/use clear` huy skill override dang cho duoc tao boi `/use `. +- `/btw ` dat cau hoi phu ngay lap tuc ma khong thay doi lich su phien hien tai. `/btw` duoc xu ly nhu mot truy van truc tiep khong dung cong cu va khong di vao luong thuc thi cong cu thong thuong. + +Vi du: + +```text +/list skills +/use git giai thich cach squash 3 commit cuoi +/btw nhac lai giup toi chung ta da chot gi cho ke hoach deploy +/use italiapersonalfinance +dammi le ultime news +``` + +### Chính Sách Thực Thi Lệnh Thống Nhất + +- 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`. +- 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ý khi khởi động các lệnh được hỗ trợ như `/start`, `/help`, `/show`, `/list`, `/use`, va `/btw`. +- Lệnh slash không xác định (ví dụ `/foo`) được chuyển sang xử lý LLM bình thường. +- 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. + +### 🔒 Sandbox Bảo Mật + +PicoClaw 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. + +#### Cấu Hình Mặc Định + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Tùy chọn | Mặc định | Mô tả | +| ----------------------- | ----------------------- | ----------------------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Thư mục làm việc của agent | +| `restrict_to_workspace` | `true` | Giới hạn truy cập file/lệnh trong workspace | + +#### Công Cụ Được Bảo Vệ + +Khi `restrict_to_workspace: true`, các công cụ sau được sandbox: + +| Công cụ | Chức năng | Giới hạn | +| ------------- | ---------------- | -------------------------------------- | +| `read_file` | Đọc file | Chỉ file trong workspace | +| `write_file` | Ghi file | Chỉ file trong workspace | +| `list_dir` | Liệt kê thư mục | Chỉ thư mục trong workspace | +| `edit_file` | Sửa file | Chỉ file trong workspace | +| `append_file` | Nối vào file | Chỉ file trong workspace | +| `exec` | Thực thi lệnh | Đường dẫn lệnh phải trong workspace | + +#### Bảo Vệ Exec Bổ Sung + +Ngay cả khi `restrict_to_workspace: false`, công cụ `exec` chặn các lệnh nguy hiểm sau: + +* `rm -rf`, `del /f`, `rmdir /s` — Xóa hàng loạt +* `format`, `mkfs`, `diskpart` — Định dạng đĩa +* `dd if=` — Tạo ảnh đĩa +* Ghi vào `/dev/sd[a-z]` — Ghi trực tiếp đĩa +* `shutdown`, `reboot`, `poweroff` — Tắt hệ thống +* Fork bomb `:(){ :|:& };:` + +### Kiểm Soát Truy Cập File + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace | +| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace | + +### Bảo Mật Exec + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.exec.allow_remote` | bool | `false` | Allow exec tool from remote channels (Telegram/Discord etc.) | +| `tools.exec.enable_deny_patterns` | bool | `true` | Enable dangerous command interception | +| `tools.exec.custom_deny_patterns` | string[] | `[]` | Custom regex patterns to block | +| `tools.exec.custom_allow_patterns` | string[] | `[]` | Custom regex patterns to allow | + +> **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. + +#### Hạn Chế Đã Biết: Tiến Trình Con Từ Công Cụ Build + +Guard 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. + +Đ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. + +Cho môi trường rủi ro cao hơn: + +* Review script build trước khi thực thi. +* Ưu tiên phê duyệt/review thủ công cho quy trình biên dịch và chạy. +* 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. + +#### Ví Dụ Lỗi + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Tắt Giới Hạn (Rủi Ro Bảo Mật) + +Nếu bạn cần agent truy cập đường dẫn ngoài workspace: + +**Phương pháp 1: File cấu hình** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Phương pháp 2: Biến môi trường** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **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. + +#### Tính Nhất Quán Ranh Giới Bảo Mật + +Cài đặt `restrict_to_workspace` áp dụng nhất quán trên tất cả đường dẫn thực thi: + +| Đường Dẫn Thực Thi | Ranh Giới Bảo Mật | +| -------------------- | ---------------------------- | +| Main Agent | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Kế thừa cùng giới hạn ✅ | +| Heartbeat tasks | Kế thừa cùng giới hạn ✅ | + +Tấ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. + +### Heartbeat (Tác Vụ Định Kỳ) + +PicoClaw có thể thực hiện tác vụ định kỳ tự động. Tạo file `HEARTBEAT.md` trong workspace: + +```markdown +# Tác Vụ Định Kỳ + +- Kiểm tra email cho tin nhắn quan trọng +- Xem lịch cho sự kiện sắp tới +- Kiểm tra dự báo thời tiết +``` + +Agent 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. + +#### Tác Vụ Bất Đồng Bộ Với Spawn + +Cho 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**: + +```markdown +# Tác Vụ Định Kỳ + +## Tác Vụ Nhanh (trả lời trực tiếp) + +- Báo giờ hiện tại + +## Tác Vụ Dài (dùng spawn cho bất đồng bộ) + +- Tìm kiếm tin tức AI trên web và tóm tắt +- Kiểm tra email và báo cáo tin nhắn quan trọng +``` + +**Hành vi chính:** + +| Tính năng | Mô tả | +| ---------------- | ------------------------------------------------------------------ | +| **spawn** | Tạo subagent bất đồng bộ, không chặn heartbeat | +| **Ngữ cảnh độc lập** | Subagent có ngữ cảnh riêng, không có lịch sử phiên | +| **message tool** | Subagent giao tiếp trực tiếp với người dùng qua message tool | +| **Không chặn** | Sau khi spawn, heartbeat tiếp tục tác vụ tiếp theo | + +#### Luồng Giao Tiếp Của Subagent + +``` +Heartbeat kích hoạt + ↓ +Agent đọc HEARTBEAT.md + ↓ +Tác vụ dài: spawn subagent + ↓ ↓ +Tiếp tục tác vụ tiếp theo Subagent hoạt động độc lập + ↓ ↓ +Hoàn thành tất cả tác vụ Subagent dùng công cụ "message" + ↓ ↓ +Trả lời HEARTBEAT_OK Người dùng nhận kết quả trực tiếp +``` + +**Cấu hình:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Tùy chọn | Mặc định | Mô tả | +| ---------- | -------- | -------------------------------------- | +| `enabled` | `true` | Bật/tắt heartbeat | +| `interval` | `30` | Khoảng thời gian kiểm tra tính bằng phút (tối thiểu: 5) | + +**Biến môi trường:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` để tắt +* `PICOCLAW_HEARTBEAT_INTERVAL=60` để thay đổi khoảng thời gian + +### Providers + +> [!NOTE] +> Groq cung cấp chuyển đổi giọng nói thành văn bản 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. + +| Provider | Mục đích | Lấy API Key | +| ------------ | --------------------------------------- | ------------------------------------------------------------ | +| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](https://bigmodel.cn) | +| `volcengine` | LLM (Volcengine trực tiếp) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| `openrouter` | LLM (khuyến nghị, truy cập tất cả mô hình) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek trực tiếp) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Qwen trực tiếp) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **Chuyển đổi giọng nói** (Whisper)| [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras trực tiếp) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid trực tiếp) | [vivgrid.com](https://vivgrid.com) | + +### Cấu Hình Mô Hình (model_list) + +> **Tính năng mới:** PicoClaw hiện sử dụng cách tiếp cận **lấy mô hình làm trung tâm**. 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!** + +#### Tất Cả Vendor Được Hỗ Trợ + +| Vendor | Tiền tố `model` | API Base mặc định | Giao thức | API Key | +| ----------------------- | --------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Lấy](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Lấy](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Lấy](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Lấy](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Lấy](https://aistudio.google.com/api-keys) | +| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Lấy](https://console.groq.com) | +| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Lấy](https://dashscope.console.aliyun.com) | +| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Cục bộ (không cần key) | +| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy](https://openrouter.ai/keys) | +| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| **Antigravity** | `antigravity/` | Google Cloud | Custom | Chỉ OAuth | + +#### Cân Bằng Tải + +Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự động round-robin: + +```json +{ + "model_list": [ + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] }, + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] } + ] +} +``` + +#### Di Chuyển Từ Cấu Hình `providers` Cũ + +Cấu hình `providers` cũ đã **bị deprecated** và đã được loại bỏ trong V2. Các cấu hình V0/V1 hiện có sẽ được tự động migrate. Xem [docs/migration/model-list-migration.md](../migration/model-list-migration.md). + +### Kiến Trúc Provider + +PicoClaw định tuyến provider theo họ giao thức: + +- **Tương thích OpenAI**: OpenRouter, Groq, Zhipu, endpoint kiểu vLLM và hầu hết các provider khác. +- **Gemini native**: Google Gemini qua các endpoint native `models/*:generateContent` và `models/*:streamGenerateContent`. +- **Anthropic**: Hành vi API Claude gốc. +- **Codex/OAuth**: Tuyến xác thực OAuth/token OpenAI. + +Điều này giữ runtime nhẹ trong khi khiến backend OpenAI-compatible mới chủ yếu chỉ là thao tác cấu hình (`api_base` + `api_keys`). + +### Tác Vụ Đã Lên Lịch / Nhắc Nhở + +PicoClaw hỗ trợ tác vụ theo lịch qua công cụ `cron`. + +```json +{ + "tools": { + "cron": { + "enabled": true, + "exec_timeout_minutes": 5 + } + } +} +``` + +Tác vụ đã lên lịch được lưu trữ bền vững sau khi khởi động lại tại `~/.picoclaw/workspace/cron/`. + +### Chủ Đề Nâng Cao + +| Chủ đề | Mô tả | +| ------ | ----- | +| [Hệ Thống Hook](../architecture/hooks/README.md) | Hook hướng sự kiện: observer, interceptor, approval hook | +| [Steering](../architecture/steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy | +| [SubTurn](../architecture/subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời | +| [Quản Lý Ngữ Cảnh](../architecture/agent-refactor/context.md) | Phát hiện ranh giới ngữ cảnh, nén | diff --git a/docs/guides/configuration.zh.md b/docs/guides/configuration.zh.md new file mode 100644 index 000000000..c41c3dae0 --- /dev/null +++ b/docs/guides/configuration.zh.md @@ -0,0 +1,818 @@ +# ⚙️ 配置指南 + +> 返回 [README](../project/README.zh.md) + +## ⚙️ 配置详解 + +配置文件路径: `~/.picoclaw/config.json` + +### 环境变量 + +你可以使用环境变量覆盖默认路径。这对于便携安装、容器化部署或将 picoclaw 作为系统服务运行非常有用。这些变量是独立的,控制不同的路径。 + +| 变量 | 描述 | 默认路径 | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PICOCLAW_CONFIG` | 覆盖配置文件的路径。这直接告诉 picoclaw 加载哪个 `config.json`,忽略所有其他位置。 | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | 覆盖 picoclaw 数据根目录。这会更改 `workspace` 和其他数据目录的默认位置。 | `~/.picoclaw` | + +**示例:** + +```bash +# 使用特定的配置文件运行 picoclaw +# 工作区路径将从该配置文件中读取 +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# 在 /opt/picoclaw 中存储所有数据运行 picoclaw +# 配置将从默认的 ~/.picoclaw/config.json 加载 +# 工作区将在 /opt/picoclaw/workspace 创建 +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# 同时使用两者进行完全自定义设置 +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + +### Gateway 日志等级 + +`gateway.log_level` 控制 Gateway 的日志详细程度,可在 `config.json` 中配置: + +```json +{ + "gateway": { + "log_level": "warn" + } +} +``` + +默认值为 `warn`。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。 + +也可通过环境变量覆盖:`PICOCLAW_LOG_LEVEL=info` + +### 工作区布局 (Workspace Layout) + +PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # 对话会话和历史 +├── memory/ # 长期记忆 (MEMORY.md) +├── state/ # 持久化状态 (最后一次频道等) +├── cron/ # 定时任务数据库 +├── skills/ # 自定义技能 +├── AGENT.md # Agent 行为指南 +├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次) +├── IDENTITY.md # Agent 身份设定 +├── SOUL.md # Agent 灵魂/性格 +└── USER.md # 用户偏好 +``` + +> **提示:** 对 `AGENT.md`、`SOUL.md`、`USER.md` 和 `memory/MEMORY.md` 的修改会通过文件修改时间(mtime)在运行时自动检测。**无需重启 gateway**,Agent 将在下一次请求时自动加载最新内容。 + +### Web 启动器控制台 + +用 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`。 + +- **配置文件**:与 `config.json` 同一目录(若设置了 `PICOCLAW_CONFIG`,则与它所指的文件同目录)。启动器专用文件名为 `launcher-config.json`。 +- **密码存储**:支持的平台会把 bcrypt 后的密码哈希存入 `launcher-auth.db`。如果当前平台不支持 SQLite 密码存储,则把 bcrypt 哈希存入 `launcher-config.json`。 +- **旧配置迁移**:旧版 `launcher_token` 会一次性迁移为密码登录,并从保存后的 launcher 配置中移除。 +- **本地自动登录**:launcher 启动后自动打开本地浏览器时,会使用仅允许 loopback 访问的一次性引导入口自动设置会话 Cookie。 +- **不再支持的鉴权方式**:不再支持 URL token 登录(`?token=...`)、`PICOCLAW_LAUNCHER_TOKEN` 和 `Authorization: Bearer` dashboard 鉴权。 +- **退出登录**:应使用 **`POST /api/auth/logout`**,且请求头为 **`Content-Type: application/json`**(请求体可为 `{}`),勿使用可被第三方页面触发的 GET 链接登出。 +- **暴力尝试**:`POST /api/auth/login` 对同一远程地址有 **每分钟尝试次数上限**(超限返回 HTTP 429)。 +- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **31 天**有效,但 launcher 进程重启后已有会话会失效。 + +### 技能来源 (Skill Sources) + +默认情况下,技能会按以下顺序加载: + +1. `~/.picoclaw/workspace/skills`(工作区) +2. `~/.picoclaw/skills`(全局) +3. `<构建时嵌入路径>/skills`(内置) + +在高级/测试场景下,可通过以下环境变量覆盖内置技能目录: + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + +### 在聊天频道中使用技能 + +技能安装完成后,可以直接在聊天频道里查看并显式启用它们: + +- `/list skills`:显示当前 Agent 可用的已安装技能名称。 +- `/use `:只对当前这一条请求强制使用指定技能。 +- `/use `:为同一会话中的下一条消息预先启用该技能。 +- `/use clear`:取消通过 `/use ` 设置的待应用技能。 +- `/btw `:发起一个即时的旁支提问,且不改动当前会话历史。`/btw` 会按一次无工具的直接问答处理,不会进入常规的工具执行流程。 + +示例: + +```text +/list skills +/use git explain how to squash the last 3 commits +/btw 帮我回顾一下刚才关于发布方案的结论 +/use italiapersonalfinance +dammi le ultime news +``` + +### 统一命令执行策略 + +- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。 +- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单,例如 `/start`、`/help`、`/show`、`/list`、`/use` 和 `/btw`。 +- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。 +- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。 + +### Session 隔离 + +Session scope 决定了聊天、用户、线程和 space 之间共享多少上下文。 + +- 全局默认值使用 `session.dimensions` +- 如果只想让某条路由例外,使用 dispatch rule 上的 `session_dimensions` + +如果你想看完整的隔离方案和配置配方,请看 [Session 使用指南](session-guide.zh.md)。 + +### Routing + +Routing 通过 `agents.dispatch.rules` 配置。 + +每条规则都针对 channel 归一化后的 inbound context 做匹配。 +规则按从上到下顺序检查,第一条命中的规则立即生效。若没有规则命中,PicoClaw 会回退到默认 agent。 + +支持的匹配字段: + +* `channel` +* `account` +* `space` +* `chat` +* `topic` +* `sender` +* `mentioned` + +这些值使用和 session system 一致的归一化词汇: + +* `space`: `workspace:t001`、`guild:123456` +* `chat`: `direct:user123`、`group:-100123`、`channel:c123` +* `topic`: `topic:42` +* `sender`: 平台归一化后的 sender 标识 + +规则也可以通过 `session_dimensions` 覆盖全局 `session.dimensions`,这样路由和会话隔离就能保持一致,而不必回到旧的 `bindings` 或 `dm_scope` 配置。 + +示例: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" }, + { "id": "sales" } + ], + "dispatch": { + "rules": [ + { + "name": "vip in support group", + "agent": "sales", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890", + "sender": "12345" + }, + "session_dimensions": ["chat", "sender"] + }, + { + "name": "telegram support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +在这个例子里,VIP 规则必须放在更宽泛的群规则前面。 +因为 routing 是严格按顺序执行的,所以更具体的规则要放前面,兜底规则放后面。 + +如果你想看更完整的 agent 路由和模型分层示例,请看 [路由使用指南](routing-guide.zh.md)。 + +### 🔒 安全沙箱 (Security Sandbox) + +PicoClaw 默认在沙箱环境中运行。Agent 只能访问配置的工作区内的文件和执行命令。 + +#### 默认配置 + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| 选项 | 默认值 | 描述 | +| ----------------------- | ----------------------- | ----------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Agent 的工作目录 | +| `restrict_to_workspace` | `true` | 限制文件/命令访问在工作区内 | + +#### 受保护的工具 + +当 `restrict_to_workspace: true` 时,以下工具会被沙箱化: + +| 工具 | 功能 | 限制 | +| ------------- | ------------ | ------------------------------ | +| `read_file` | 读取文件 | 仅限工作区内的文件 | +| `write_file` | 写入文件 | 仅限工作区内的文件 | +| `list_dir` | 列出目录 | 仅限工作区内的目录 | +| `edit_file` | 编辑文件 | 仅限工作区内的文件 | +| `append_file` | 追加文件 | 仅限工作区内的文件 | +| `exec` | 执行命令 | 命令路径必须在工作区内 | + +#### 额外的 Exec 保护 + +即使 `restrict_to_workspace: false`,`exec` 工具也会阻止以下危险命令: + +* `rm -rf`、`del /f`、`rmdir /s` — 批量删除 +* `format`、`mkfs`、`diskpart` — 磁盘格式化 +* `dd if=` — 磁盘镜像 +* 写入 `/dev/sd[a-z]` — 直接磁盘写入 +* `shutdown`、`reboot`、`poweroff` — 系统关机 +* Fork bomb `:(){ :|:& };:` + +### 文件访问控制 + +| 配置键 | 类型 | 默认值 | 描述 | +|--------|------|--------|------| +| `tools.allow_read_paths` | string[] | `[]` | 允许在工作区外读取的额外路径 | +| `tools.allow_write_paths` | string[] | `[]` | 允许在工作区外写入的额外路径 | + +### Exec 安全配置 + +| 配置键 | 类型 | 默认值 | 描述 | +|--------|------|--------|------| +| `tools.exec.allow_remote` | bool | `false` | 允许从远程渠道(Telegram/Discord 等)执行 exec 工具 | +| `tools.exec.enable_deny_patterns` | bool | `true` | 启用危险命令拦截 | +| `tools.exec.custom_deny_patterns` | string[] | `[]` | 自定义阻止的正则表达式模式 | +| `tools.exec.custom_allow_patterns` | string[] | `[]` | 自定义允许的正则表达式模式 | + +> **安全提示:** Symlink 保护默认启用——所有文件路径在白名单匹配前都会通过 `filepath.EvalSymlinks` 解析,防止符号链接逃逸攻击。 + +#### 已知限制:构建工具的子进程 + +exec 安全守卫仅检查 PicoClaw 直接启动的命令行。它不会递归检查由 `make`、`go run`、`cargo`、`npm run` 或自定义构建脚本等开发工具产生的子进程。 + +这意味着顶层命令通过初始守卫检查后,仍可以编译或启动其他二进制文件。实际上,应将构建脚本、Makefile、包脚本和生成的二进制文件视为与直接 shell 命令同等级别的可执行代码进行审查。 + +对于高风险环境: + +* 执行前审查构建脚本。 +* 对编译并运行的工作流优先使用审批/手动审查。 +* 如果需要比内置守卫更强的隔离,请在容器或虚拟机中运行 PicoClaw。 + +#### 错误示例 + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### 禁用限制(安全风险) + +如果需要 Agent 访问工作区外的路径: + +**方法 1: 配置文件** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**方法 2: 环境变量** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **警告**: 禁用此限制将允许 Agent 访问系统上的任何路径。仅在受控环境中谨慎使用。 + +#### 安全边界一致性 + +`restrict_to_workspace` 设置在所有执行路径中一致应用: + +| 执行路径 | 安全边界 | +| ---------------- | ---------------------------- | +| 主 Agent | `restrict_to_workspace` ✅ | +| 子 Agent / Spawn | 继承相同限制 ✅ | +| 心跳任务 | 继承相同限制 ✅ | + +所有路径共享相同的工作区限制——无法通过子 Agent 或定时任务绕过安全边界。 + +### 心跳 / 周期性任务 (Heartbeat) + +PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件: + +```markdown +# Periodic Tasks + +- Check my email for important messages +- Review my calendar for upcoming events +- Check the weather forecast +``` + +Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。 + +#### 使用 Spawn 的异步任务 + +对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**: + +```markdown +# Periodic Tasks + +## Quick Tasks (respond directly) + +- Report current time + +## Long Tasks (use spawn for async) + +- Search the web for AI news and summarize +- Check email and report important messages +``` + +**关键行为:** + +| 特性 | 描述 | +| ---------------- | ---------------------------------------- | +| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 | +| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 | +| **message tool** | 子 Agent 通过 message 工具直接与用户通信 | +| **非阻塞** | spawn 后,心跳继续处理下一个任务 | + +**配置:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| 选项 | 默认值 | 描述 | +| ---------- | ------ | ---------------------------- | +| `enabled` | `true` | 启用/禁用心跳 | +| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) | + +**环境变量:** + +- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 +- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔 + +#### 子 Agent 通信流程 + +``` +心跳触发 + ↓ +Agent 读取 HEARTBEAT.md + ↓ +遇到耗时任务:spawn 子 Agent + ↓ ↓ +继续处理下一个任务 子 Agent 独立运行 + ↓ ↓ +所有任务完成 子 Agent 使用 "message" 工具 + ↓ ↓ +回复 HEARTBEAT_OK 用户直接收到结果 +``` + +子 Agent 拥有工具访问权限(message、web_search 等),可以独立与用户通信,无需经过主 Agent。 + +### Providers(模型提供商) + +> [!NOTE] +> Groq 通过 Whisper 提供免费语音转录。配置后,任意渠道的语音消息都会在 Agent 层自动转录为文字。 + +| 提供商 | 用途 | 获取 API Key | +| ------------ | --------------------------------------- | ------------------------------------------------------------ | +| `gemini` | LLM(Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM(智谱直连) | [bigmodel.cn](https://bigmodel.cn) | +| `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) | +| `openrouter` | LLM(推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM(Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM(GPT 直连) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM(DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM(通义千问直连) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **语音转录**(Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM(Cerebras 直连) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM(Vivgrid 直连) | [vivgrid.com](https://vivgrid.com) | + +### 模型配置 (model_list) + +> **新特性:** PicoClaw 现在优先推荐显式 `provider` + 原生 `model` 的配置方式,例如 `"provider": "zhipu", "model": "glm-4.7"`。如果未设置 `provider`,旧的单字段 `provider/model` 写法仍然兼容。 + +这一设计同时支持**多 Agent**场景,灵活选择提供商: + +- **不同 Agent 使用不同提供商**:每个 Agent 可以使用独立的 LLM 提供商 +- **模型降级**:配置主模型和备用模型,提升可用性 +- **负载均衡**:将请求分发到多个端点 +- **集中管理**:在一处管理所有提供商配置 + +#### 所有支持的厂商 + +| 厂商 | `provider` 值 | 默认 API Base | 协议 | API Key | +| ----------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [获取](https://platform.openai.com) | +| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [获取](https://platform.deepseek.com) | +| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取](https://aistudio.google.com/api-keys) | +| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [获取](https://console.groq.com) | +| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [获取](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) | +| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key) | +| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) | +| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) | +| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key | +| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | 本地 | +| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [获取](https://cerebras.ai) | +| **火山引擎 (豆包)** | `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) | +| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — | +| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取](https://www.byteplus.com) | +| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [获取](https://vivgrid.com) | +| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [获取](https://longcat.chat/platform) | +| **ModelScope (魔搭)** | `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取](https://modelscope.cn/my/tokens) | +| **Antigravity** | `antigravity` | Google Cloud | Custom | 仅 OAuth | +| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | — | + +#### 基础配置 + +```json +{ + "model_list": [ + { + "model_name": "ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-your-api-key"] + }, + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-your-openai-key"] + }, + { + "model_name": "claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] + }, + { + "model_name": "glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-zhipu-key"] + } + ], + "agents": { + "defaults": { + "model": "gpt-5.4" + } + } +} +``` + +解析规则: + +- 推荐显式写成 `"provider": "openai", "model": "gpt-5.4"`。 +- 如果设置了 `provider`,PicoClaw 会将 `model` 原样发送。 +- 如果未设置 `provider`,PicoClaw 会把 `model` 第一个 `/` 之前的字段当作 provider,并把第一个 `/` 之后的全部内容当作最终模型 ID。 +- 这意味着 `"model": "openrouter/openai/gpt-5.4"` 这样的兼容写法仍然可用,并会把 `openai/gpt-5.4` 发送给 OpenRouter。 + +#### 各厂商配置示例 + +
+OpenAI + +```json +{ + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-..."] +} +``` + +
+ +
+火山引擎(豆包) + +```json +{ + "model_name": "ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-..."] +} +``` + +
+ +
+智谱 AI (GLM) + +```json +{ + "model_name": "glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-key"] +} +``` + +
+ +
+DeepSeek + +```json +{ + "model_name": "deepseek-chat", + "provider": "deepseek", + "model": "deepseek-chat", + "api_keys": ["sk-..."] +} +``` + +
+ +
+Anthropic + +```json +{ + "model_name": "claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] +} +``` + +> 运行 `picoclaw auth login --provider anthropic` 粘贴 API Token。 + +如需直连 Anthropic 原生接口(不兼容 OpenAI 格式的端点): + +```json +{ + "model_name": "claude-opus-4-6", + "provider": "anthropic-messages", + "model": "claude-opus-4-6", + "api_keys": ["sk-ant-your-key"], + "api_base": "https://api.anthropic.com" +} +``` + +> 当端点不支持 OpenAI 兼容格式(`/v1/chat/completions`),需要 Anthropic 原生 `/v1/messages` 时使用 `anthropic-messages`。 + +
+ +
+Ollama(本地) + +```json +{ + "model_name": "llama3", + "provider": "ollama", + "model": "llama3" +} +``` + +
+ +
+LM Studio(本地) + +```json +{ + "model_name": "lmstudio-local", + "provider": "lmstudio", + "model": "openai/gpt-oss-20b" +} +``` + +`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。 +显式设置 `provider` 后,PicoClaw 会把 `openai/gpt-oss-20b` 原样发送给 LM Studio。旧的兼容写法 `"model": "lmstudio/openai/gpt-oss-20b"` 在未设置 `provider` 时也会解析成相同的上游模型 ID。 + +
+ +
+自定义代理 / LiteLLM + +```json +{ + "model_name": "my-custom-model", + "provider": "openai", + "model": "custom-model", + "api_base": "https://my-proxy.com/v1", + "api_keys": ["sk-..."] +} +``` + +显式设置 `provider` 后,PicoClaw 会将 `model` 原样发送。因此 `"provider": "litellm", "model": "lite-gpt4"` 会发送 `lite-gpt4`,而 `"provider": "litellm", "model": "openai/gpt-4o"` 会发送 `openai/gpt-4o`。旧的兼容写法 `litellm/lite-gpt4` 和 `litellm/openai/gpt-4o` 在未设置 `provider` 时也会得到相同结果。 + +
+ +#### 负载均衡 + +为同一模型名称配置多个端点,PicoClaw 会自动轮询: + +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_base": "https://api1.example.com/v1", + "api_keys": ["sk-key1"] + }, + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_base": "https://api2.example.com/v1", + "api_keys": ["sk-key2"] + } + ] +} +``` + +#### 从旧版 `providers` 配置迁移 + +旧版 `providers` 配置**已废弃**,V2 中已移除。现有 V0/V1 配置会自动迁移。完整迁移指南见 [docs/migration/model-list-migration.md](../migration/model-list-migration.md)。 + +### Provider 架构 + +PicoClaw 按协议族路由提供商: + +- **OpenAI 兼容**:OpenRouter、Groq、智谱、vLLM 风格端点及大多数其他提供商。 +- **Gemini 原生**:Google Gemini 通过原生 `models/*:generateContent` 和 `models/*:streamGenerateContent` 端点接入。 +- **Anthropic**:Claude 原生 API 行为。 +- **Codex/OAuth**:OpenAI OAuth/Token 认证路由。 + +这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_keys`。 + +
+智谱(旧版 providers 格式) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +
+ +
+完整配置示例 + +```json +{ + "agents": { + "defaults": { + "model_name": "claude-opus-4-5" + } + }, + "session": { + "dm_scope": "per-channel-peer", + "backlog_limit": 20 + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "123456:ABC...", + "allow_from": ["123456789"] + } + }, + "tools": { + "web": { + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +### 事件日志 + +PicoClaw 的 runtime events 会覆盖 agent、channel、gateway、message bus 和 MCP 等运行时组件。默认只打印 `agent.*` 事件,其他事件仍会发布到 runtime event bus,但不会进入日志。 + +```json +{ + "events": { + "logging": { + "enabled": true, + "include": ["agent.*"], + "exclude": [], + "min_severity": "info", + "include_payload": false + } + } +} +``` + +常用配置: + +```json +{ + "events": { + "logging": { + "include": ["*"], + "exclude": ["agent.llm.delta"], + "min_severity": "warn" + } + } +} +``` + +`include` / `exclude` 支持精确事件名和 `gateway.*`、`channel.lifecycle.*` 这类模式。`include_payload` 默认关闭,避免把完整用户消息或工具参数写入日志;agent 事件会默认输出长度、计数、状态等摘要字段。 + +更多字段说明和示例见 [Runtime Events 与事件日志](../architecture/runtime-events.zh.md)。 + +### 定时任务 / 提醒 + +PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设置、列出和取消在指定时间触发的提醒或周期性任务。 + +```json +{ + "tools": { + "cron": { + "enabled": true, + "exec_timeout_minutes": 5 + } + } +} +``` + +定时任务在重启后持久保存,存储于 `~/.picoclaw/workspace/cron/`。 + +### 进阶主题 + +| 主题 | 说明 | +| ---- | ---- | +| [敏感数据过滤](../security/sensitive_data_filtering.zh.md) | 在发送给 LLM 前,从工具结果中过滤 API 密钥和令牌 | +| [Runtime Events 与事件日志](../architecture/runtime-events.zh.md) | 统一运行时事件、日志过滤和调试配置 | +| [Hook 系统](../architecture/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook | +| [Steering](../architecture/steering.md) | 在工具调用间向运行中的 Agent 注入消息 | +| [SubTurn](../architecture/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 | +| [上下文管理](../architecture/agent-refactor/context.md) | 上下文边界检测、主动预算检查、压缩策略 | diff --git a/docs/guides/docker.fr.md b/docs/guides/docker.fr.md new file mode 100644 index 000000000..e174298ac --- /dev/null +++ b/docs/guides/docker.fr.md @@ -0,0 +1,167 @@ +# 🐳 Docker et Démarrage Rapide + +> Retour au [README](../project/README.fr.md) + +## 🐳 Docker Compose + +Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installer localement. + +```bash +# 1. Cloner ce dépôt +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Premier lancement — génère automatiquement docker/data/config.json puis s'arrête +# (se déclenche uniquement quand config.json et workspace/ sont tous deux absents) +docker compose -f docker/docker-compose.yml --profile gateway up +# Le conteneur affiche "First-run setup complete." et s'arrête. + +# 3. Configurer vos clés API +vim docker/data/config.json # Set provider API keys, bot tokens, etc. + +# 4. Démarrer +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **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`. + +```bash +# 5. Vérifier les logs +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. Arrêter +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Mode Launcher (Console Web) + +L'image `launcher` inclut les deux binaires (`picoclaw`, `picoclaw-launcher`) et démarre la console web par défaut, qui fournit une interface navigateur pour la configuration et le chat. + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +Ouvrez http://localhost:18800 dans votre navigateur. Le launcher gère automatiquement le processus gateway. + +> [!WARNING] +> La console web est protégée par un mot de passe de connexion au dashboard. Ne l'exposez pas à des réseaux non fiables ni à Internet public. + +### Mode Agent (One-shot) + +```bash +# Poser une question +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" + +# Mode interactif +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### Mise à jour + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +### 🚀 Démarrage Rapide + +> [!TIP] +> 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). + +**1. Initialiser** + +```bash +picoclaw onboard +``` + +**2. Configurer** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_keys": ["sk-your-api-key"], + "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_keys": ["your-api-key"], + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_keys": ["your-anthropic-key"] + } + ], + "tools": { + "web": { + "enabled": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **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. +> `request_timeout` est optionnel et utilise les secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le timeout par défaut (120s). + +**3. Obtenir des clés API** + +* **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) +* **Recherche Web** (optionnel) : + * [Brave Search](https://brave.com/search/api) - Payant ($5/1000 requêtes, ~$5-6/mois) + * [Perplexity](https://www.perplexity.ai) - Recherche alimentée par l'IA avec interface de chat + * [SearXNG](https://github.com/searxng/searxng) - Métamoteur auto-hébergé (gratuit, pas de clé API nécessaire) + * [Tavily](https://tavily.com) - Optimisé pour les agents IA (1000 requêtes/mois) + * DuckDuckGo - Solution de repli intégrée (pas de clé API requise) + +> **Note** : Voir `config.example.json` pour un modèle de configuration complet. + +**4. Discuter** + +```bash +picoclaw agent -m "What is 2+2?" +``` + +C'est tout ! Vous avez un assistant IA fonctionnel en 2 minutes. + +--- diff --git a/docs/guides/docker.ja.md b/docs/guides/docker.ja.md new file mode 100644 index 000000000..19199aaac --- /dev/null +++ b/docs/guides/docker.ja.md @@ -0,0 +1,169 @@ +# 🐳 Docker とクイックスタート + +> [README](../project/README.ja.md) に戻る + +## 🐳 Docker Compose + +Docker Compose を使用して PicoClaw を実行できます。ローカルに何もインストールする必要はありません。 + +```bash +# 1. リポジトリをクローン +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. 初回実行 — docker/data/config.json を自動生成して終了 +# (config.json と workspace/ の両方が存在しない場合のみ実行) +docker compose -f docker/docker-compose.yml --profile gateway up +# コンテナが "First-run setup complete." と表示して停止します + +# 3. API Key を設定 +vim docker/data/config.json # provider API key、Bot Token などを設定 + +# 4. 起動 +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **Docker ユーザー**: デフォルトでは Gateway は `127.0.0.1` でリッスンしており、コンテナ外からはアクセスできません。ヘルスチェックエンドポイントへのアクセスやポート公開が必要な場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。 + +```bash +# 5. ログを確認 +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. 停止 +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Launcher モード (Web コンソール) + +`launcher` イメージには 2 つのバイナリ(`picoclaw`、`picoclaw-launcher`)が含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。 + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +ブラウザで http://localhost:18800 を開いてください。Launcher が Gateway プロセスを自動管理します。 + +> [!WARNING] +> Web コンソールは dashboard ログインパスワードで保護されます。信頼できないネットワークや公開インターネットには公開しないでください。 + +### Agent モード (ワンショット) + +```bash +# 質問する +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2は?" + +# インタラクティブモード +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### イメージの更新 + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +--- + +## 🚀 クイックスタート + +> [!TIP] +> `~/.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 回無料) を取得できます。 + +**1. 初期化** + +```bash +picoclaw onboard +``` + +**2. 設定** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_keys": ["sk-your-api-key"], + "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_keys": ["your-api-key"], + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_keys": ["your-anthropic-key"] + } + ], + "tools": { + "web": { + "enabled": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **新機能**: `model_list` 設定形式により、コード変更なしで provider を追加できます。詳細は[モデル設定](providers.ja.md#モデル設定-model_list)を参照してください。 +> `request_timeout` はオプションで、単位は秒です。省略または `<= 0` に設定した場合、PicoClaw はデフォルトのタイムアウト(120 秒)を使用します。 + +**3. API Key の取得** + +* **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) +* **Web 検索** (オプション): + * [Brave Search](https://brave.com/search/api) - 有料 ($5/1000 queries, ~$5-6/month) + * [Perplexity](https://www.perplexity.ai) - AI 搭載の検索・チャットインターフェース + * [SearXNG](https://github.com/searxng/searxng) - セルフホスト型メタ検索エンジン(無料、API Key 不要) + * [Tavily](https://tavily.com) - AI Agent 向けに最適化 (1000 requests/month) + * DuckDuckGo - 組み込みフォールバック(API Key 不要) + +> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。 + +**4. チャット** + +```bash +picoclaw agent -m "2+2は?" +``` + +以上です!2 分で動作する AI アシスタントが手に入ります。 + +--- diff --git a/docs/guides/docker.md b/docs/guides/docker.md new file mode 100644 index 000000000..e2e472fbf --- /dev/null +++ b/docs/guides/docker.md @@ -0,0 +1,173 @@ +# 🐳 Docker & Quick Start Guide + +> Back to [README](../README.md) + +## 🐳 Docker Compose + +You can also run PicoClaw using Docker Compose without installing anything locally. + +```bash +# 1. Clone this repo +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. First run — auto-generates docker/data/config.json then exits +# (only triggers when both config.json and workspace/ are missing) +docker compose -f docker/docker-compose.yml --profile gateway up +# The container prints "First-run setup complete." and stops. + +# 3. Set your API keys +vim docker/data/config.json # Set provider API keys, bot tokens, etc. + +# 4. Start +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **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`. + +> [!NOTE] +> The `gateway` profile only serves the webhook handlers (including Pico when enabled) and health endpoints on the gateway port, so it does not expose generic REST chat endpoints such as `/chat` or `/a2a`. Launcher mode adds the browser UI plus `/api/pico/info` and an authenticated `/pico/ws` proxy on the launcher port, but `/pico/ws` is also available directly on the gateway whenever the Pico channel is enabled. + +```bash +# 5. Check logs +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. Stop +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Launcher Mode (Web Console) + +The `launcher` image includes both binaries (`picoclaw`, `picoclaw-launcher`) and starts the web console by default, which provides a browser-based UI for configuration and chat. + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically. + +> [!WARNING] +> The web console is protected by dashboard password login. **Do not** expose the launcher to untrusted networks or the public internet. See [Web launcher dashboard](configuration.md#web-launcher-dashboard) in the Configuration Guide. + +### Agent Mode (One-shot) + +```bash +# Ask a question +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" + +# Interactive mode +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### Update + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +### 🚀 Quick Start + +> [!TIP] +> 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). + +**1. Initialize** + +```bash +picoclaw onboard +``` + +**2. Configure** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-your-api-key"], + "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" + }, + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["your-api-key"], + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", + "api_keys": ["your-anthropic-key"] + } + ], + "tools": { + "web": { + "enabled": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details. +> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s). + +**3. Get API Keys** + +* **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) +* **Web Search** (optional): + * [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month) + * [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface + * [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed) + * [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) + * DuckDuckGo - Built-in fallback (no API key required) + +> **Note**: See `config.example.json` for a complete configuration template. + +**4. Chat** + +```bash +picoclaw agent -m "What is 2+2?" +``` + +That's it! You have a working AI assistant in 2 minutes. + +--- diff --git a/docs/guides/docker.ms.md b/docs/guides/docker.ms.md new file mode 100644 index 000000000..5a426cb99 --- /dev/null +++ b/docs/guides/docker.ms.md @@ -0,0 +1,166 @@ +# 🐳 Panduan Docker & Quick Start + +> Kembali ke [README](../project/README.ms.md) + +## 🐳 Docker Compose + +Anda juga boleh menjalankan PicoClaw menggunakan Docker Compose tanpa memasang apa-apa secara setempat. + +```bash +# 1. Clone repo ini +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Larian pertama — jana docker/data/config.json secara automatik kemudian keluar +docker compose -f docker/docker-compose.yml --profile gateway up +# Container akan memaparkan "First-run setup complete." dan berhenti. + +# 3. Tetapkan kunci API anda +vim docker/data/config.json # Tetapkan API key penyedia, token bot, dan sebagainya. + +# 4. Mula +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **Pengguna Docker**: Secara lalai, Gateway mendengar pada `127.0.0.1` yang tidak boleh diakses dari host. Jika anda perlu mengakses health endpoint atau mendedahkan port, tetapkan `PICOCLAW_GATEWAY_HOST=0.0.0.0` dalam persekitaran anda atau kemas kini `config.json`. + +```bash +# 5. Semak log +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. Hentikan +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Mod Launcher (Konsol Web) + +Imej `launcher` merangkumi kedua-dua binari (`picoclaw`, `picoclaw-launcher`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang. + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +Buka http://localhost:18800 dalam pelayar anda. Launcher mengurus proses gateway secara automatik. + +> [!WARNING] +> Konsol web dilindungi oleh kata laluan log masuk dashboard. Jangan dedahkannya kepada rangkaian tidak dipercayai atau internet awam. + +### Mod Agent (One-shot) + +```bash +# Tanyakan soalan +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" + +# Mod interaktif +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### Kemas kini + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +### 🚀 Quick Start + +> [!TIP] +> Tetapkan API Key anda dalam `~/.picoclaw/config.json`. Dapatkan 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). Carian web adalah pilihan — dapatkan [Tavily API](https://tavily.com) percuma (1000 pertanyaan percuma/bulan) atau [Brave Search API](https://brave.com/search/api) (2000 pertanyaan percuma/bulan). + +**1. Inisialisasi** + +```bash +picoclaw onboard +``` + +**2. Konfigurasi** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_keys": ["sk-your-api-key"], + "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_keys": ["your-api-key"], + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_keys": ["your-anthropic-key"] + } + ], + "tools": { + "web": { + "enabled": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **Baharu**: Format konfigurasi `model_list` membolehkan penambahan penyedia tanpa perubahan kod. Lihat [Konfigurasi Model](#konfigurasi-model-model_list) untuk butiran. +> `request_timeout` adalah pilihan dan menggunakan saat. Jika diabaikan atau ditetapkan kepada `<= 0`, PicoClaw menggunakan timeout lalai (120s). + +**3. Dapatkan API Key** + +* **Penyedia 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) +* **Carian Web** (pilihan): + * [Brave Search](https://brave.com/search/api) - Berbayar ($5/1000 pertanyaan, ~$5-6/bulan) + * [Perplexity](https://www.perplexity.ai) - Carian berkuasa AI dengan antara muka sembang + * [SearXNG](https://github.com/searxng/searxng) - Enjin meta-carian hos kendiri (percuma, tidak perlu API key) + * [Tavily](https://tavily.com) - Dioptimumkan untuk AI Agents (1000 permintaan/bulan) + * DuckDuckGo - Fallback terbina dalam (tidak memerlukan API key) + +> **Nota**: Lihat `config.example.json` untuk templat konfigurasi penuh. + +**4. Sembang** + +```bash +picoclaw agent -m "What is 2+2?" +``` + +Itu sahaja! Anda kini mempunyai pembantu AI yang berfungsi dalam masa 2 minit. + +--- diff --git a/docs/guides/docker.pt-br.md b/docs/guides/docker.pt-br.md new file mode 100644 index 000000000..ab71af8e6 --- /dev/null +++ b/docs/guides/docker.pt-br.md @@ -0,0 +1,167 @@ +# 🐳 Docker e Início Rápido + +> Voltar ao [README](../project/README.pt-br.md) + +## 🐳 Docker Compose + +Você também pode executar o PicoClaw usando Docker Compose sem instalar nada localmente. + +```bash +# 1. Clone este repositório +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Primeira execução — gera automaticamente docker/data/config.json e encerra +# (só é acionado quando config.json e workspace/ estão ambos ausentes) +docker compose -f docker/docker-compose.yml --profile gateway up +# O contêiner exibe "First-run setup complete." e para. + +# 3. Configure suas chaves de API +vim docker/data/config.json # Set provider API keys, bot tokens, etc. + +# 4. Iniciar +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **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`. + +```bash +# 5. Verificar logs +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. Parar +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Modo Launcher (Console Web) + +A imagem `launcher` inclui ambos os binários (`picoclaw`, `picoclaw-launcher`) e inicia o console web por padrão, que fornece uma interface baseada em navegador para configuração e chat. + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +Abra http://localhost:18800 no seu navegador. O launcher gerencia o processo do gateway automaticamente. + +> [!WARNING] +> O console web é protegido por senha de login do dashboard. Não exponha o launcher a redes não confiáveis nem à internet pública. + +### Modo Agent (One-shot) + +```bash +# Fazer uma pergunta +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" + +# Modo interativo +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### Atualização + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +### 🚀 Início Rápido + +> [!TIP] +> 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). + +**1. Inicializar** + +```bash +picoclaw onboard +``` + +**2. Configurar** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_keys": ["sk-your-api-key"], + "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_keys": ["your-api-key"], + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_keys": ["your-anthropic-key"] + } + ], + "tools": { + "web": { + "enabled": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **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. +> `request_timeout` é opcional e usa segundos. Se omitido ou definido como `<= 0`, o PicoClaw usa o timeout padrão (120s). + +**3. Obter chaves de API** + +* **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) +* **Busca na Web** (opcional): + * [Brave Search](https://brave.com/search/api) - Pago ($5/1000 consultas, ~$5-6/mês) + * [Perplexity](https://www.perplexity.ai) - Busca com IA e interface de chat + * [SearXNG](https://github.com/searxng/searxng) - Metabuscador auto-hospedado (gratuito, sem necessidade de chave de API) + * [Tavily](https://tavily.com) - Otimizado para agentes de IA (1000 requisições/mês) + * DuckDuckGo - Fallback integrado (sem necessidade de chave de API) + +> **Nota**: Veja `config.example.json` para um modelo de configuração completo. + +**4. Conversar** + +```bash +picoclaw agent -m "What is 2+2?" +``` + +Pronto! Você tem um assistente de IA funcionando em 2 minutos. + +--- diff --git a/docs/guides/docker.vi.md b/docs/guides/docker.vi.md new file mode 100644 index 000000000..e91450bb0 --- /dev/null +++ b/docs/guides/docker.vi.md @@ -0,0 +1,167 @@ +# 🐳 Docker và Bắt Đầu Nhanh + +> Quay lại [README](../project/README.vi.md) + +## 🐳 Docker Compose + +Bạ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. + +```bash +# 1. Clone repo này +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Lần chạy đầu tiên — tự động tạo docker/data/config.json rồi thoát +# (chỉ kích hoạt khi cả config.json và workspace/ đều không tồn tại) +docker compose -f docker/docker-compose.yml --profile gateway up +# Container hiển thị "First-run setup complete." và dừng lại. + +# 3. Cấu hình API key của bạn +vim docker/data/config.json # Set provider API keys, bot tokens, etc. + +# 4. Khởi động +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **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`. + +```bash +# 5. Kiểm tra log +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. Dừng +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Chế Độ Launcher (Web Console) + +Image `launcher` bao gồm cả hai binary (`picoclaw`, `picoclaw-launcher`) 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. + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +Mở http://localhost:18800 trong trình duyệt. Launcher tự động quản lý tiến trình gateway. + +> [!WARNING] +> Web console được bảo vệ bằng mật khẩu đăng nhập dashboard. Không để lộ launcher ra mạng không tin cậy hoặc internet công cộng. + +### Chế Độ Agent (One-shot) + +```bash +# Đặt câu hỏi +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" + +# Chế độ tương tác +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### Cập Nhật + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +### 🚀 Bắt Đầu Nhanh + +> [!TIP] +> 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). + +**1. Khởi tạo** + +```bash +picoclaw onboard +``` + +**2. Cấu hình** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_keys": ["sk-your-api-key"], + "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_keys": ["your-api-key"], + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_keys": ["your-anthropic-key"] + } + ], + "tools": { + "web": { + "enabled": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **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. +> `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). + +**3. Lấy API Key** + +* **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) +* **Tìm kiếm Web** (tùy chọn): + * [Brave Search](https://brave.com/search/api) - Trả phí ($5/1000 truy vấn, ~$5-6/tháng) + * [Perplexity](https://www.perplexity.ai) - Tìm kiếm bằng AI với giao diện chat + * [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) + * [Tavily](https://tavily.com) - Tối ưu cho AI Agent (1000 yêu cầu/tháng) + * DuckDuckGo - Fallback tích hợp (không cần API key) + +> **Lưu ý**: Xem `config.example.json` để có mẫu cấu hình đầy đủ. + +**4. Chat** + +```bash +picoclaw agent -m "What is 2+2?" +``` + +Vậy là xong! Bạn có một trợ lý AI hoạt động trong 2 phút. + +--- diff --git a/docs/guides/docker.zh.md b/docs/guides/docker.zh.md new file mode 100644 index 000000000..855375d9c --- /dev/null +++ b/docs/guides/docker.zh.md @@ -0,0 +1,172 @@ +# 🐳 Docker 与快速开始 + +> 返回 [README](../project/README.zh.md) + +## 🐳 Docker Compose + +您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境。 + +```bash +# 1. 克隆仓库 +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. 首次运行 — 自动生成 docker/data/config.json 后退出 +# (仅在 config.json 和 workspace/ 都不存在时触发) +docker compose -f docker/docker-compose.yml --profile gateway up +# 容器打印 "First-run setup complete." 后自动停止 + +# 3. 填写 API Key 等配置 +vim docker/data/config.json # 设置 provider API key、Bot Token 等 + +# 4. 正式启动 +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。 + +```bash +# 5. 查看日志 +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. 停止 +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Launcher 模式 (Web 控制台) + +`launcher` 镜像包含两个二进制文件(`picoclaw`、`picoclaw-launcher`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。 + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +在浏览器中打开 。Launcher 会自动管理 Gateway 进程。 + +> [!WARNING] +> Web 控制台通过 dashboard 登录密码保护。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。 + +### Agent 模式 (一次性运行) + +```bash +# 提问 +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?" + +# 交互模式 +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### 更新镜像 + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +--- + +## 🚀 快速开始 + +> [!TIP] +> 在 `~/.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 次免费查询)。 + +**1. 初始化 (Initialize)** + +```bash +picoclaw onboard +``` + +**2. 配置 (Configure)** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "gpt-5.4", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "model_list": [ + { + "model_name": "ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-your-api-key"], + "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" + }, + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["your-api-key"], + "request_timeout": 300 + }, + { + "model_name": "claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", + "api_keys": ["your-anthropic-key"] + } + ], + "tools": { + "web": { + "enabled": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "YOUR_PERPLEXITY_API_KEY", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://your-searxng-instance:8888", + "max_results": 5 + } + } + } +} +``` + +> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](providers.zh.md#模型配置-model_list)章节。 +> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。 + +**3. 获取 API Key** + +* **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) +* **网络搜索** (可选): + * [Brave Search](https://brave.com/search/api) - 付费 ($5/1000 次查询,约 $5-6/月) + * [Perplexity](https://www.perplexity.ai) - AI 驱动的搜索与聊天界面 + * [SearXNG](https://github.com/searxng/searxng) - 自建元搜索引擎(免费,无需 API Key) + * [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) + * DuckDuckGo - 内置回退(无需 API Key) + +> **注意**: 完整的配置模板请参考 `config.example.json`。 + +**4. 对话 (Chat)** + +```bash +picoclaw agent -m "2+2 等于几?" +``` + +就是这样!您在 2 分钟内就拥有了一个可工作的 AI 助手。 + +--- diff --git a/docs/guides/hardware-compatibility.fr.md b/docs/guides/hardware-compatibility.fr.md new file mode 100644 index 000000000..bb2d92d57 --- /dev/null +++ b/docs/guides/hardware-compatibility.fr.md @@ -0,0 +1,152 @@ +> Retour au [README](../project/README.fr.md) + +# 🖥️ PicoClaw Liste de compatibilité matérielle + +PicoClaw fonctionne sur pratiquement n'importe quel appareil Linux. Cette page répertorie les puces, produits et cartes de développement vérifiés. + +**Votre matériel n'est pas listé ?** Soumettez une PR pour l'ajouter ! Les fabricants de matériel sont invités à contribuer et à co-promouvoir. + +--- + +## 1. Support de puces vérifié + +### x86 + +| Fabricant | Puce | Notes | +|-----------|------|-------| +| Intel | Any x86 CPU (i386+) | Tous les processeurs de bureau/serveur/portable | +| AMD | Any x86 CPU | Tous les processeurs de bureau/serveur/portable | + +### ARM + +| Sous-arch | Puces typiques | Notes | +|-----------|----------------|-------| +| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | Monocœur ARM1176JZF-S | +| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | Monocœur Cortex-A7, utilisé dans LicheePi Zero | +| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | Quadricœur Cortex-A53, utilisé dans Orange Pi Zero 3 | +| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | Quadricœur Cortex-A72 | +| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | Quadricœur Cortex-A76 | +| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | Bicœur Cortex-A53 + NPU, utilisé dans NanoKVM-Pro / MaixCAM2 | + +### RISC-V (riscv64) + +| Fabricant | Puce | Cœur | Notes | +|-----------|------|------|-------| +| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 intégré, utilisé dans LicheeRV-Nano / NanoKVM / MaixCAM | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L intégré, 1 TOPS NPU, caméra AI 4K SiP | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | Série de caméras AI RISC-V | +| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | Utilisé dans HaaS506-LD1 RTU industriel | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Utilisé dans Milk-V Jupiter, BananaPi BPI-F3 | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | Conforme RVA23, RVV 1024 bits, inférence AI FP8 | +| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 cœurs, 16MB cache L3, classe bureau | +| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU, utilisé dans CanMV-K230 | + +### MIPS + +| Fabricant | Puce | Notes | +|-----------|------|-------| +| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz, utilisé dans de nombreux routeurs OpenWrt (ex. Xiaomi Router 3G) | + +### LoongArch (loong64) + +| Fabricant | Puce | Notes | +|-----------|------|-------| +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | Quadricœur LA464 @ 2.5GHz, bureau/station de travail | +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | Quadricœur 4C/8T @ 2.5GHz, IPC comparable à Intel 10e génération | +| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | Bicœur @ 1GHz, applications industrielles/IoT | + +--- + +## 2. Produits vérifiés (par date de sortie) + +Produits grand public, routeurs et appareils industriels testés avec PicoClaw. + +| Année | Produit | Arch | SoC | RAM | Catégorie | +|-------|---------|------|-----|-----|-----------| +| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | Smartphone | +| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | Tablette | +| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | Routeur (OpenWrt) | +| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | Boîtier TV / Serveur domestique | +| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | Enceinte connectée | +| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM | +| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | RTU industriel | +| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | IP-KVM Pro | +| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | Caméra AI 4K | + +--- + +## 3. Cartes de développement vérifiées (par date de sortie) + +| Année | Carte | Arch | SoC | RAM | Lien d'achat | +|-------|-------|------|-----|-----|--------------| +| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — | +| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — | +| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — | +| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — | +| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) | +| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) | +| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) | +| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) | +| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) | +| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) | +| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) | + +--- + +## 4. Fonctionne également sur + +### Téléphones Android (via Termux) + +Tout téléphone Android ARM64 (2015+) avec 1 Go+ de RAM. Installez [Termux](https://github.com/termux/termux-app), utilisez `proot` pour exécuter PicoClaw. + +> Voir [README : Exécuter sur d'anciens téléphones Android](../project/README.fr.md#-run-on-old-android-phones) pour les instructions de configuration. + +### Bureau / Serveur / Cloud + +| Plateforme | Notes | +|------------|-------| +| x86_64 Linux | Binaire natif, aucune dépendance | +| x86_64 Windows | Binaire natif | +| macOS (Intel / Apple Silicon) | Binaire natif | +| Docker (any platform) | `docker compose` en une ligne, voir [Guide Docker](docker.md) | +| OpenWrt routers | Builds MIPS/ARM, nécessite >32 Mo de RAM libre | +| FreeBSD / NetBSD | Builds x86_64 et arm64 disponibles | + +--- + +## 5. Configuration minimale requise + +| Ressource | Minimum | Recommandé | +|-----------|---------|------------| +| RAM | 10 Mo libres | 32 Mo+ libres | +| Stockage | 20 Mo (binaire) | 50 Mo+ (avec espace de travail) | +| CPU | N'importe lequel (monocœur 0,6 GHz+) | — | +| OS | Linux (kernel 3.x+) | Linux 5.x+ | +| Réseau | Requis (pour les appels API LLM) | Ethernet ou WiFi | + +--- + +## 6. Comment tester et contribuer + +```bash +# 1. Télécharger pour votre architecture +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz + +# 2. Initialiser +./picoclaw onboard + +# 3. Tester +./picoclaw agent -m "Hello, what board am I running on?" +``` + +Builds disponibles : `linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle` + +### Ajouter votre matériel + +1. Forkez ce dépôt +2. Ajoutez votre puce / produit / carte dans le tableau approprié +3. Incluez : nom, architecture, SoC, RAM, année et un lien si disponible +4. Soumettez une PR + +Fabricants de matériel : vous souhaitez ajouter un support officiel ou co-promouvoir ? Ouvrez une issue ou contactez-nous via [Discord](https://discord.gg/V4sAZ9XWpN). diff --git a/docs/guides/hardware-compatibility.ja.md b/docs/guides/hardware-compatibility.ja.md new file mode 100644 index 000000000..c86684f84 --- /dev/null +++ b/docs/guides/hardware-compatibility.ja.md @@ -0,0 +1,152 @@ +> [README](../project/README.ja.md) に戻る + +# 🖥️ PicoClaw ハードウェア互換性リスト + +PicoClaw はほぼすべての Linux デバイスで動作します。このページでは、検証済みのチップ、製品、開発ボードを記録しています。 + +**お使いのハードウェアがリストにない場合は?** PR を送信して追加してください!ハードウェアベンダーの貢献と共同プロモーションを歓迎します。 + +--- + +## 1. 検証済みチップサポート + +### x86 + +| ベンダー | チップ | 備考 | +|----------|--------|------| +| Intel | Any x86 CPU (i386+) | すべてのデスクトップ/サーバー/ノートPC プロセッサ | +| AMD | Any x86 CPU | すべてのデスクトップ/サーバー/ノートPC プロセッサ | + +### ARM + +| サブアーキテクチャ | 代表的なチップ | 備考 | +|--------------------|----------------|------| +| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | シングルコア ARM1176JZF-S | +| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | シングルコア Cortex-A7、LicheePi Zero で使用 | +| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | クアッドコア Cortex-A53、Orange Pi Zero 3 で使用 | +| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | クアッドコア Cortex-A72 | +| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | クアッドコア Cortex-A76 | +| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | デュアルコア Cortex-A53 + NPU、NanoKVM-Pro / MaixCAM2 で使用 | + +### RISC-V (riscv64) + +| ベンダー | チップ | コア | 備考 | +|----------|--------|------|------| +| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 オンチップ、LicheeRV-Nano / NanoKVM / MaixCAM で使用 | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L オンチップ、1 TOPS NPU、4K AI カメラ SiP | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | RISC-V AI カメラシリーズ | +| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | HaaS506-LD1 産業用 RTU で使用 | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Milk-V Jupiter, BananaPi BPI-F3 で使用 | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | RVA23 準拠、1024 ビット RVV、FP8 AI 推論 | +| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 コア、16MB L3 キャッシュ、デスクトップクラス | +| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU、CanMV-K230 で使用 | + +### MIPS + +| ベンダー | チップ | 備考 | +|----------|--------|------| +| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz、多くの OpenWrt ルーターで使用(例:Xiaomi Router 3G) | + +### LoongArch (loong64) + +| ベンダー | チップ | 備考 | +|----------|--------|------| +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | クアッドコア LA464 @ 2.5GHz、デスクトップ/ワークステーション | +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | クアッドコア 4C/8T @ 2.5GHz、IPC は Intel 第10世代に匹敵 | +| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | デュアルコア @ 1GHz、産業/IoT アプリケーション | + +--- + +## 2. 検証済み製品(発売日順) + +PicoClaw でテスト済みのコンシューマー製品、ルーター、産業用デバイス。 + +| 年 | 製品 | アーキテクチャ | SoC | RAM | カテゴリ | +|----|------|----------------|-----|-----|----------| +| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | スマートフォン | +| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | タブレット | +| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | ルーター (OpenWrt) | +| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | TV ボックス / ホームサーバー | +| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | スマートスピーカー | +| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM | +| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | 産業用 RTU | +| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | プロ IP-KVM | +| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | 4K AI カメラ | + +--- + +## 3. 検証済み開発ボード(発売日順) + +| 年 | ボード | アーキテクチャ | SoC | RAM | 購入リンク | +|----|--------|----------------|-----|-----|------------| +| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — | +| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — | +| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — | +| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — | +| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) | +| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) | +| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) | +| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) | +| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) | +| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) | +| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) | + +--- + +## 4. その他の対応環境 + +### Android スマートフォン(Termux 経由) + +1GB 以上の RAM を搭載した ARM64 Android スマートフォン(2015年以降)。[Termux](https://github.com/termux/termux-app) をインストールし、`proot` を使用して PicoClaw を実行します。 + +> セットアップ手順は [README:古い Android スマートフォンで実行](../project/README.ja.md#-run-on-old-android-phones) を参照してください。 + +### デスクトップ / サーバー / クラウド + +| プラットフォーム | 備考 | +|------------------|------| +| x86_64 Linux | ネイティブバイナリ、依存関係なし | +| x86_64 Windows | ネイティブバイナリ | +| macOS (Intel / Apple Silicon) | ネイティブバイナリ | +| Docker (any platform) | `docker compose` ワンライナー、[Docker ガイド](docker.md) を参照 | +| OpenWrt routers | MIPS/ARM ビルド、32MB 以上の空きメモリが必要 | +| FreeBSD / NetBSD | x86_64 および arm64 ビルドが利用可能 | + +--- + +## 5. 最小要件 + +| リソース | 最小 | 推奨 | +|----------|------|------| +| RAM | 10MB 空き | 32MB 以上空き | +| ストレージ | 20MB(バイナリ) | 50MB 以上(ワークスペース含む) | +| CPU | 任意(シングルコア 0.6GHz 以上) | — | +| OS | Linux (kernel 3.x+) | Linux 5.x+ | +| ネットワーク | 必須(LLM API 呼び出し用) | イーサネットまたは WiFi | + +--- + +## 6. テストと貢献の方法 + +```bash +# 1. お使いのアーキテクチャ向けをダウンロード +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz + +# 2. 初期化 +./picoclaw onboard + +# 3. テスト +./picoclaw agent -m "Hello, what board am I running on?" +``` + +利用可能なビルド:`linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle` + +### ハードウェアを追加する + +1. このリポジトリをフォーク +2. 該当するテーブルにチップ/製品/ボードを追加 +3. 名前、アーキテクチャ、SoC、RAM、年、リンク(あれば)を含める +4. PR を送信 + +ハードウェアベンダーの方へ:公式サポートの追加や共同プロモーションをご希望ですか?Issue を作成するか、[Discord](https://discord.gg/V4sAZ9XWpN) でお問い合わせください。 diff --git a/docs/guides/hardware-compatibility.md b/docs/guides/hardware-compatibility.md new file mode 100644 index 000000000..a07bb5116 --- /dev/null +++ b/docs/guides/hardware-compatibility.md @@ -0,0 +1,150 @@ +# 🖥️ PicoClaw Hardware Compatibility List + +PicoClaw runs on virtually any Linux device. This page tracks verified chips, products, and development boards. + +**Your hardware not listed?** Submit a PR to add it! Hardware vendors are welcome to contribute and co-promote. + +--- + +## 1. Verified Chip Support + +### x86 + +| Vendor | Chip | Notes | +|--------|------|-------| +| Intel | Any x86 CPU (i386+) | All desktop/server/laptop processors | +| AMD | Any x86 CPU | All desktop/server/laptop processors | + +### ARM + +| Sub-arch | Typical Chips | Notes | +|----------|--------------|-------| +| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | Single-core ARM1176JZF-S | +| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | Single-core Cortex-A7, used in LicheePi Zero | +| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | Quad-core Cortex-A53, used in Orange Pi Zero 3 | +| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | Quad-core Cortex-A72 | +| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | Quad-core Cortex-A76 | +| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | Dual-core Cortex-A53 + NPU, used in NanoKVM-Pro / MaixCAM2 | + +### RISC-V (riscv64) + +| Vendor | Chip | Core | Notes | +|--------|------|------|-------| +| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 on-chip, used in LicheeRV-Nano / NanoKVM / MaixCAM | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L on-chip, 1 TOPS NPU, 4K AI camera SiP | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | RISC-V AI camera series | +| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | Used in HaaS506-LD1 industrial RTU | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Used in Milk-V Jupiter, BananaPi BPI-F3 | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | RVA23 compliant, 1024-bit RVV, FP8 AI inference | +| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8-core, 16MB L3 cache, desktop-class | +| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU, used in CanMV-K230 | + +### MIPS + +| Vendor | Chip | Notes | +|--------|------|-------| +| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz, used in many OpenWrt routers (e.g. Xiaomi Router 3G) | + +### LoongArch (loong64) + +| Vendor | Chip | Notes | +|--------|------|-------| +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | Quad-core LA464 @ 2.5GHz, desktop/workstation | +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | Quad-core 4C/8T @ 2.5GHz, IPC comparable to Intel 10th gen | +| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | Dual-core @ 1GHz, industrial/IoT applications | + +--- + +## 2. Verified Products (by release date) + +Consumer products, routers, and industrial devices that have been tested with PicoClaw. + +| Year | Product | Arch | SoC | RAM | Category | +|------|---------|------|-----|-----|----------| +| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | Smartphone | +| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | Tablet | +| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | Router (OpenWrt) | +| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | TV Box / Home Server | +| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | Smart Speaker | +| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM | +| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | Industrial RTU | +| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | Pro IP-KVM | +| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | 4K AI Camera | + +--- + +## 3. Verified Development Boards (by release date) + +| Year | Board | Arch | SoC | RAM | Buy Link | +|------|-------|------|-----|-----|----------| +| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — | +| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — | +| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — | +| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — | +| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) | +| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) | +| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) | +| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) | +| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) | +| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) | +| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) | + +--- + +## 4. Also Works On + +### Android Phones (via Termux) + +Any ARM64 Android phone (2015+) with 1GB+ RAM. Install [Termux](https://github.com/termux/termux-app), use `proot` to run PicoClaw. + +> See [README: Run on old Android Phones](../../README.md#-run-on-old-android-phones) for setup instructions. + +### Desktop / Server / Cloud + +| Platform | Notes | +|----------|-------| +| x86_64 Linux | Native binary, no dependencies | +| x86_64 Windows | Native binary | +| macOS (Intel / Apple Silicon) | Native binary | +| Docker (any platform) | `docker compose` one-liner, see [Docker Guide](docker.md) | +| OpenWrt routers | MIPS/ARM builds, requires >32MB free RAM | +| FreeBSD / NetBSD | x86_64 and arm64 builds available | + +--- + +## 5. Minimum Requirements + +| Resource | Minimum | Recommended | +|----------|---------|-------------| +| RAM | 10MB free | 32MB+ free | +| Storage | 20MB (binary) | 50MB+ (with workspace) | +| CPU | Any (single core 0.6GHz+) | — | +| OS | Linux (kernel 3.x+) | Linux 5.x+ | +| Network | Required (for LLM API calls) | Ethernet or WiFi | + +--- + +## 6. How to Test & Contribute + +```bash +# 1. Download for your architecture +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz + +# 2. Initialize +./picoclaw onboard + +# 3. Test +./picoclaw agent -m "Hello, what board am I running on?" +``` + +Available builds: `linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle` + +### Add Your Hardware + +1. Fork this repo +2. Add your chip / product / board to the appropriate table +3. Include: name, arch, SoC, RAM, year, and a link if available +4. Submit a PR + +Hardware vendors: want to add official support or co-promote? Open an issue or reach out via [Discord](https://discord.gg/V4sAZ9XWpN). diff --git a/docs/guides/hardware-compatibility.pt-br.md b/docs/guides/hardware-compatibility.pt-br.md new file mode 100644 index 000000000..1fc8ee25e --- /dev/null +++ b/docs/guides/hardware-compatibility.pt-br.md @@ -0,0 +1,152 @@ +> Voltar ao [README](../project/README.pt-br.md) + +# 🖥️ PicoClaw Lista de compatibilidade de hardware + +O PicoClaw roda em praticamente qualquer dispositivo Linux. Esta página registra chips, produtos e placas de desenvolvimento verificados. + +**Seu hardware não está na lista?** Envie um PR para adicioná-lo! Fabricantes de hardware são bem-vindos para contribuir e co-promover. + +--- + +## 1. Suporte a chips verificado + +### x86 + +| Fabricante | Chip | Notas | +|------------|------|-------| +| Intel | Any x86 CPU (i386+) | Todos os processadores desktop/servidor/notebook | +| AMD | Any x86 CPU | Todos os processadores desktop/servidor/notebook | + +### ARM + +| Sub-arq | Chips típicos | Notas | +|---------|---------------|-------| +| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | Single-core ARM1176JZF-S | +| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | Single-core Cortex-A7, usado no LicheePi Zero | +| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | Quad-core Cortex-A53, usado no Orange Pi Zero 3 | +| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | Quad-core Cortex-A72 | +| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | Quad-core Cortex-A76 | +| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | Dual-core Cortex-A53 + NPU, usado no NanoKVM-Pro / MaixCAM2 | + +### RISC-V (riscv64) + +| Fabricante | Chip | Núcleo | Notas | +|------------|------|--------|-------| +| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 integrado, usado no LicheeRV-Nano / NanoKVM / MaixCAM | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L integrado, 1 TOPS NPU, câmera AI 4K SiP | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | Série de câmeras AI RISC-V | +| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | Usado no HaaS506-LD1 RTU industrial | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Usado no Milk-V Jupiter, BananaPi BPI-F3 | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | Compatível com RVA23, RVV de 1024 bits, inferência AI FP8 | +| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 núcleos, 16MB cache L3, classe desktop | +| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU, usado no CanMV-K230 | + +### MIPS + +| Fabricante | Chip | Notas | +|------------|------|-------| +| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz, usado em muitos roteadores OpenWrt (ex. Xiaomi Router 3G) | + +### LoongArch (loong64) + +| Fabricante | Chip | Notas | +|------------|------|-------| +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | Quad-core LA464 @ 2.5GHz, desktop/estação de trabalho | +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | Quad-core 4C/8T @ 2.5GHz, IPC comparável ao Intel 10ª geração | +| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | Dual-core @ 1GHz, aplicações industriais/IoT | + +--- + +## 2. Produtos verificados (por data de lançamento) + +Produtos de consumo, roteadores e dispositivos industriais testados com o PicoClaw. + +| Ano | Produto | Arq | SoC | RAM | Categoria | +|-----|---------|-----|-----|-----|-----------| +| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | Smartphone | +| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | Tablet | +| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | Roteador (OpenWrt) | +| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | TV Box / Servidor doméstico | +| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | Alto-falante inteligente | +| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM | +| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | RTU industrial | +| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | IP-KVM Pro | +| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | Câmera AI 4K | + +--- + +## 3. Placas de desenvolvimento verificadas (por data de lançamento) + +| Ano | Placa | Arq | SoC | RAM | Link de compra | +|-----|-------|-----|-----|-----|----------------| +| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — | +| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — | +| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — | +| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — | +| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) | +| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) | +| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) | +| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) | +| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) | +| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) | +| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) | + +--- + +## 4. Também funciona em + +### Celulares Android (via Termux) + +Qualquer celular Android ARM64 (2015+) com 1GB+ de RAM. Instale o [Termux](https://github.com/termux/termux-app), use `proot` para rodar o PicoClaw. + +> Veja [README: Rodar em celulares Android antigos](../project/README.pt-br.md#-run-on-old-android-phones) para instruções de configuração. + +### Desktop / Servidor / Nuvem + +| Plataforma | Notas | +|------------|-------| +| x86_64 Linux | Binário nativo, sem dependências | +| x86_64 Windows | Binário nativo | +| macOS (Intel / Apple Silicon) | Binário nativo | +| Docker (any platform) | `docker compose` em uma linha, veja [Guia Docker](docker.md) | +| OpenWrt routers | Builds MIPS/ARM, requer >32MB de RAM livre | +| FreeBSD / NetBSD | Builds x86_64 e arm64 disponíveis | + +--- + +## 5. Requisitos mínimos + +| Recurso | Mínimo | Recomendado | +|---------|--------|-------------| +| RAM | 10MB livres | 32MB+ livres | +| Armazenamento | 20MB (binário) | 50MB+ (com workspace) | +| CPU | Qualquer (single-core 0,6GHz+) | — | +| OS | Linux (kernel 3.x+) | Linux 5.x+ | +| Rede | Necessária (para chamadas de API LLM) | Ethernet ou WiFi | + +--- + +## 6. Como testar e contribuir + +```bash +# 1. Baixar para sua arquitetura +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz + +# 2. Inicializar +./picoclaw onboard + +# 3. Testar +./picoclaw agent -m "Hello, what board am I running on?" +``` + +Builds disponíveis: `linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle` + +### Adicionar seu hardware + +1. Faça fork deste repositório +2. Adicione seu chip / produto / placa na tabela apropriada +3. Inclua: nome, arquitetura, SoC, RAM, ano e um link se disponível +4. Envie um PR + +Fabricantes de hardware: deseja adicionar suporte oficial ou co-promover? Abra uma issue ou entre em contato via [Discord](https://discord.gg/V4sAZ9XWpN). diff --git a/docs/guides/hardware-compatibility.vi.md b/docs/guides/hardware-compatibility.vi.md new file mode 100644 index 000000000..5566a4248 --- /dev/null +++ b/docs/guides/hardware-compatibility.vi.md @@ -0,0 +1,152 @@ +> Quay lại [README](../project/README.vi.md) + +# 🖥️ PicoClaw Danh sách tương thích phần cứng + +PicoClaw chạy được trên hầu hết mọi thiết bị Linux. Trang này ghi nhận các chip, sản phẩm và bo mạch phát triển đã được xác minh. + +**Phần cứng của bạn chưa có trong danh sách?** Gửi PR để thêm vào! Các nhà sản xuất phần cứng được hoan nghênh đóng góp và đồng quảng bá. + +--- + +## 1. Hỗ trợ chip đã xác minh + +### x86 + +| Nhà sản xuất | Chip | Ghi chú | +|--------------|------|---------| +| Intel | Any x86 CPU (i386+) | Tất cả bộ xử lý desktop/server/laptop | +| AMD | Any x86 CPU | Tất cả bộ xử lý desktop/server/laptop | + +### ARM + +| Kiến trúc phụ | Chip tiêu biểu | Ghi chú | +|----------------|----------------|---------| +| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | Đơn nhân ARM1176JZF-S | +| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | Đơn nhân Cortex-A7, dùng trong LicheePi Zero | +| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | Bốn nhân Cortex-A53, dùng trong Orange Pi Zero 3 | +| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | Bốn nhân Cortex-A72 | +| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | Bốn nhân Cortex-A76 | +| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | Hai nhân Cortex-A53 + NPU, dùng trong NanoKVM-Pro / MaixCAM2 | + +### RISC-V (riscv64) + +| Nhà sản xuất | Chip | Lõi | Ghi chú | +|--------------|------|-----|---------| +| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 tích hợp, dùng trong LicheeRV-Nano / NanoKVM / MaixCAM | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L tích hợp, 1 TOPS NPU, camera AI 4K SiP | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | Dòng camera AI RISC-V | +| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | Dùng trong HaaS506-LD1 RTU công nghiệp | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | Dùng trong Milk-V Jupiter, BananaPi BPI-F3 | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | Tuân thủ RVA23, RVV 1024-bit, suy luận AI FP8 | +| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 lõi, 16MB cache L3, cấp desktop | +| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU, dùng trong CanMV-K230 | + +### MIPS + +| Nhà sản xuất | Chip | Ghi chú | +|--------------|------|---------| +| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz, dùng trong nhiều router OpenWrt (vd. Xiaomi Router 3G) | + +### LoongArch (loong64) + +| Nhà sản xuất | Chip | Ghi chú | +|--------------|------|---------| +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | Bốn nhân LA464 @ 2.5GHz, desktop/máy trạm | +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | Bốn nhân 4C/8T @ 2.5GHz, IPC tương đương Intel thế hệ 10 | +| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | Hai nhân @ 1GHz, ứng dụng công nghiệp/IoT | + +--- + +## 2. Sản phẩm đã xác minh (theo ngày phát hành) + +Sản phẩm tiêu dùng, router và thiết bị công nghiệp đã được kiểm thử với PicoClaw. + +| Năm | Sản phẩm | Kiến trúc | SoC | RAM | Danh mục | +|-----|----------|-----------|-----|-----|----------| +| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | Điện thoại thông minh | +| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | Máy tính bảng | +| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | Router (OpenWrt) | +| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | TV Box / Máy chủ gia đình | +| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | Loa thông minh | +| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM | +| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | RTU công nghiệp | +| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | IP-KVM Pro | +| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | Camera AI 4K | + +--- + +## 3. Bo mạch phát triển đã xác minh (theo ngày phát hành) + +| Năm | Bo mạch | Kiến trúc | SoC | RAM | Liên kết mua | +|-----|---------|-----------|-----|-----|--------------| +| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — | +| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — | +| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — | +| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — | +| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) | +| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) | +| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) | +| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) | +| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) | +| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) | +| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) | + +--- + +## 4. Cũng hoạt động trên + +### Điện thoại Android (qua Termux) + +Bất kỳ điện thoại Android ARM64 nào (2015+) với 1GB+ RAM. Cài đặt [Termux](https://github.com/termux/termux-app), sử dụng `proot` để chạy PicoClaw. + +> Xem [README: Chạy trên điện thoại Android cũ](../project/README.vi.md#-run-on-old-android-phones) để biết hướng dẫn cài đặt. + +### Desktop / Máy chủ / Đám mây + +| Nền tảng | Ghi chú | +|----------|---------| +| x86_64 Linux | Binary gốc, không phụ thuộc | +| x86_64 Windows | Binary gốc | +| macOS (Intel / Apple Silicon) | Binary gốc | +| Docker (any platform) | `docker compose` một dòng lệnh, xem [Hướng dẫn Docker](docker.md) | +| OpenWrt routers | Bản dựng MIPS/ARM, yêu cầu >32MB RAM trống | +| FreeBSD / NetBSD | Có bản dựng x86_64 và arm64 | + +--- + +## 5. Yêu cầu tối thiểu + +| Tài nguyên | Tối thiểu | Khuyến nghị | +|------------|-----------|-------------| +| RAM | 10MB trống | 32MB+ trống | +| Lưu trữ | 20MB (binary) | 50MB+ (với workspace) | +| CPU | Bất kỳ (đơn nhân 0.6GHz+) | — | +| OS | Linux (kernel 3.x+) | Linux 5.x+ | +| Mạng | Bắt buộc (cho các lệnh gọi API LLM) | Ethernet hoặc WiFi | + +--- + +## 6. Cách kiểm thử và đóng góp + +```bash +# 1. Tải xuống cho kiến trúc của bạn +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz + +# 2. Khởi tạo +./picoclaw onboard + +# 3. Kiểm thử +./picoclaw agent -m "Hello, what board am I running on?" +``` + +Các bản dựng có sẵn: `linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle` + +### Thêm phần cứng của bạn + +1. Fork kho lưu trữ này +2. Thêm chip / sản phẩm / bo mạch của bạn vào bảng tương ứng +3. Bao gồm: tên, kiến trúc, SoC, RAM, năm và liên kết nếu có +4. Gửi PR + +Nhà sản xuất phần cứng: muốn thêm hỗ trợ chính thức hoặc đồng quảng bá? Mở issue hoặc liên hệ qua [Discord](https://discord.gg/V4sAZ9XWpN). diff --git a/docs/guides/hardware-compatibility.zh.md b/docs/guides/hardware-compatibility.zh.md new file mode 100644 index 000000000..d563f3ebe --- /dev/null +++ b/docs/guides/hardware-compatibility.zh.md @@ -0,0 +1,152 @@ +> 返回 [README](../project/README.zh.md) + +# 🖥️ PicoClaw 硬件兼容性列表 + +PicoClaw 几乎可以在任何 Linux 设备上运行。本页面记录了已验证的芯片、产品和开发板。 + +**你的硬件不在列表中?** 提交 PR 来添加它!欢迎硬件厂商贡献和联合推广。 + +--- + +## 1. 已验证的芯片支持 + +### x86 + +| 厂商 | 芯片 | 备注 | +|------|------|------| +| Intel | Any x86 CPU (i386+) | 所有桌面/服务器/笔记本处理器 | +| AMD | Any x86 CPU | 所有桌面/服务器/笔记本处理器 | + +### ARM + +| 子架构 | 典型芯片 | 备注 | +|--------|----------|------| +| ARMv6 | [BCM2835](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2835) (Raspberry Pi 1/Zero) | 单核 ARM1176JZF-S | +| ARMv7 | [Allwinner V3s](https://linux-sunxi.org/V3s) | 单核 Cortex-A7,用于 LicheePi Zero | +| ARM64 | [Allwinner H618](https://linux-sunxi.org/H618) | 四核 Cortex-A53,用于 Orange Pi Zero 3 | +| ARM64 | [BCM2711](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711) (Raspberry Pi 4) | 四核 Cortex-A72 | +| ARM64 | [BCM2712](https://www.raspberrypi.com/documentation/computers/processors.html#bcm2712) (Raspberry Pi 5) | 四核 Cortex-A76 | +| ARM64 | [AX630C](https://www.axera-tech.com/) (爱芯元智) | 双核 Cortex-A53 + NPU,用于 NanoKVM-Pro / MaixCAM2 | + +### RISC-V (riscv64) + +| 厂商 | 芯片 | 核心 | 备注 | +|------|------|------|------| +| [SOPHGO (算能)](https://www.sophgo.com/) | SG2002 | C906 @ 1GHz | 256MB DDR3 片上内存,用于 LicheeRV-Nano / NanoKVM / MaixCAM | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V861 | Dual C907 | 128MB DDR3L 片上内存,1 TOPS NPU,4K AI 摄像头 SiP | +| [Allwinner (全志)](https://www.allwinnertech.com/) | V881 | C907 | RISC-V AI 摄像头系列 | +| [Arterytek (匠芯创)](https://www.arterytek.com/) | D213 | RISC-V | 用于 HaaS506-LD1 工业 RTU | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K1 | 8x X60 @ 1.8GHz | 用于 Milk-V Jupiter, BananaPi BPI-F3 | +| [SpacemiT (进迭)](https://www.spacemit.com/) | K3 | 8x X100 @ 2.5GHz | 符合 RVA23 规范,1024 位 RVV,FP8 AI 推理 | +| [Zhihe (知合)](https://www.zhihe-tech.com/) | A210 | High-perf RISC-V | 8 核,16MB L3 缓存,桌面级 | +| [Canaan (嘉楠)](https://www.canaan-creative.com/) | K230 | Dual C908 @ 1.6GHz | 6 TOPS KPU,用于 CanMV-K230 | + +### MIPS + +| 厂商 | 芯片 | 备注 | +|------|------|------| +| MediaTek | [MT7620](https://www.mediatek.com/products/home-networking/mt7620) | MIPS24KEc @ 580MHz,用于许多 OpenWrt 路由器(如小米路由器 3G) | + +### LoongArch (loong64) + +| 厂商 | 芯片 | 备注 | +|------|------|------| +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A5000 | 四核 LA464 @ 2.5GHz,桌面/工作站 | +| [Loongson (龙芯)](https://www.loongson.cn/) | 3A6000 | 四核 4C/8T @ 2.5GHz,IPC 可与 Intel 第十代相媲美 | +| [Loongson (龙芯)](https://www.loongson.cn/) | 2K1000LA | 双核 @ 1GHz,工业/物联网应用 | + +--- + +## 2. 已验证的产品(按发布日期排列) + +已通过 PicoClaw 测试的消费产品、路由器和工业设备。 + +| 年份 | 产品 | 架构 | SoC | 内存 | 类别 | +|------|------|------|-----|------|------| +| 2009 | Nokia N900 | ARM (A8) | OMAP3430 | 256MB | 智能手机 | +| 2012 | Samsung Galaxy Note 10.1 (N8000) | ARM (A9) | Exynos 4412 | 2GB | 平板电脑 | +| 2016 | Xiaomi Router 3G (小米路由器3G) | MIPS | MT7620 | 256MB | 路由器 (OpenWrt) | +| 2018 | Phicomm N1 (斐讯N1) | ARM64 (A53) | S905D | 2GB | 电视盒子 / 家庭服务器 | +| 2019 | Xiaomi AI Speaker (小爱音箱) | ARM64 (A53) | — | 256MB | 智能音箱 | +| 2024 | [NanoKVM](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html) | RISC-V | SG2002 | 256MB | IP-KVM | +| 2025 | HaaS506-LD1 | RISC-V | D213 | 128MB | 工业 RTU | +| 2025 | [NanoKVM-Pro](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM_Pro/introduction.html) | ARM64 (A53) | AX630C | 1GB | 专业 IP-KVM | +| 2026 | [MaixCAM2](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | ARM64 (A53) | AX630C | 1/4GB | 4K AI 摄像头 | + +--- + +## 3. 已验证的开发板(按发布日期排列) + +| 年份 | 开发板 | 架构 | SoC | 内存 | 购买链接 | +|------|--------|------|-----|------|----------| +| 2012 | [Raspberry Pi 1 Model B](https://www.raspberrypi.com/products/) | ARMv6 | BCM2835 | 512MB | — | +| 2015 | [Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) | ARMv7 (A7) | BCM2836 | 1GB | — | +| 2015 | [Raspberry Pi Zero](https://www.raspberrypi.com/products/raspberry-pi-zero/) | ARMv6 | BCM2835 | 512MB | — | +| 2016 | [Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) | ARM64 (A53) | BCM2837 | 1GB | — | +| 2017 | [LicheePi Zero](https://wiki.sipeed.com/hardware/en/lichee/Zero/Zero.html) | ARMv7 (A7) | Allwinner V3s | 64MB | [Sipeed](https://sipeed.com/) | +| 2019 | [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) | ARM64 (A72) | BCM2711 | 1~8GB | [RPi](https://www.raspberrypi.com/) | +| 2023 | [Raspberry Pi 5](https://www.raspberrypi.com/products/raspberry-pi-5/) | ARM64 (A76) | BCM2712 | 2~8GB | [RPi](https://www.raspberrypi.com/) | +| 2024 | [LicheeRV-Nano](https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html) | RISC-V | SG2002 | 256MB | [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) | +| 2024 | [MaixCAM-Pro](https://wiki.sipeed.com/hardware/en/maixcam/index.html) | RISC-V | SG2002 | 256MB | [Sipeed](https://sipeed.com/) | +| 2024 | [Milk-V Duo 64M](https://milkv.io/docs/duo/getting-started/duo) | RISC-V | CV1800B | 64MB | [Milk-V](https://milkv.io/) | +| 2024 | [CanMV-K230](https://developer.canaan-creative.com/k230_canmv/en/main/) | RISC-V | K230 | 512MB | [Canaan](https://www.canaan-creative.com/) | + +--- + +## 4. 同样适用于 + +### Android 手机(通过 Termux) + +任何 ARM64 Android 手机(2015 年以后),1GB 以上内存。安装 [Termux](https://github.com/termux/termux-app),使用 `proot` 运行 PicoClaw。 + +> 参见 [README:在旧 Android 手机上运行](../project/README.zh.md#-run-on-old-android-phones) 获取设置说明。 + +### 桌面 / 服务器 / 云 + +| 平台 | 备注 | +|------|------| +| x86_64 Linux | 原生二进制文件,无依赖 | +| x86_64 Windows | 原生二进制文件 | +| macOS (Intel / Apple Silicon) | 原生二进制文件 | +| Docker (any platform) | `docker compose` 一行命令,参见 [Docker 指南](docker.md) | +| OpenWrt routers | MIPS/ARM 构建,需要 >32MB 可用内存 | +| FreeBSD / NetBSD | 提供 x86_64 和 arm64 构建 | + +--- + +## 5. 最低要求 + +| 资源 | 最低要求 | 推荐配置 | +|------|----------|----------| +| 内存 | 10MB 可用 | 32MB 以上可用 | +| 存储 | 20MB(二进制文件) | 50MB 以上(含工作区) | +| CPU | 任意(单核 0.6GHz 以上) | — | +| 操作系统 | Linux (kernel 3.x+) | Linux 5.x+ | +| 网络 | 必需(用于 LLM API 调用) | 以太网或 WiFi | + +--- + +## 6. 如何测试与贡献 + +```bash +# 1. 下载适合你架构的版本 +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz + +# 2. 初始化 +./picoclaw onboard + +# 3. 测试 +./picoclaw agent -m "Hello, what board am I running on?" +``` + +可用构建版本:`linux-amd64`, `linux-arm64`, `linux-arm`, `linux-riscv64`, `linux-loong64`, `linux-mipsle` + +### 添加你的硬件 + +1. Fork 本仓库 +2. 将你的芯片/产品/开发板添加到相应的表格中 +3. 包含:名称、架构、SoC、内存、年份,以及可用的链接 +4. 提交 PR + +硬件厂商:想要添加官方支持或联合推广?请提交 issue 或通过 [Discord](https://discord.gg/V4sAZ9XWpN) 联系我们。 diff --git a/docs/guides/providers.fr.md b/docs/guides/providers.fr.md new file mode 100644 index 000000000..aff600351 --- /dev/null +++ b/docs/guides/providers.fr.md @@ -0,0 +1,459 @@ +# 🔌 Fournisseurs et Configuration des Modèles + +> Retour au [README](../project/README.fr.md) + +### Fournisseurs + +> [!NOTE] +> 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. + +| Provider | Purpose | Get API Key | +| ------------ | --------------------------------------- | ------------------------------------------------------------ | +| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | +| `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) | +| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | +| `moonshot` | LLM (Kimi/Moonshot direct) | [platform.moonshot.cn](https://platform.moonshot.cn) | +| `minimax` | LLM (Minimax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | +| `avian` | LLM (Avian direct) | [avian.io](https://avian.io) | +| `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) | +| `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) | +| `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) | + +### Configuration des Modèles (model_list) + +> **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 !** + +Cette conception permet également le **support multi-agents** avec une sélection flexible de fournisseurs : + +- **Différents agents, différents fournisseurs** : Chaque agent peut utiliser son propre fournisseur LLM +- **Modèles de repli** : Configurez des modèles principaux et de repli pour la résilience +- **Répartition de charge** : Distribuez les requêtes entre plusieurs endpoints +- **Configuration centralisée** : Gérez tous les fournisseurs en un seul endroit + +#### 📋 Tous les Vendors Supportés + +| Vendor | `model` Prefix | Default API Base | Protocol | API Key | +| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | +| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | +| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | +| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | +| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | +| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | +| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | +| **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) | +| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) | +| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | +| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | +| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | +| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | + +#### Configuration de Base + +```json +{ + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_keys": ["sk-your-api-key"] + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_keys": ["sk-your-openai-key"] + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] + }, + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_keys": ["your-zhipu-key"] + } + ], + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + } +} +``` + +#### Champs d'entrée `model_list` + +| Champ | Type | Requis | Description | +|-------|------|--------|-------------| +| `model_name` | string | Oui | Nom unique pour référencer ce modèle dans la config agent | +| `model` | string | Oui | Identifiant fournisseur/modèle (ex : `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | Oui* | Clé(s) API pour l'authentification. Plusieurs clés permettent la rotation par requête. Non requis pour les fournisseurs locaux (Ollama, LM Studio, VLLM) | +| `api_base` | string | Non | Remplace l'URL de base API par défaut | +| `proxy` | string | Non | URL du proxy HTTP pour cette entrée de modèle | +| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers compatibles OpenAI, Gemini, Anthropic et Azure) | +| `request_timeout` | int | Non | Délai d'expiration de la requête en secondes (la valeur par défaut varie selon le provider) | +| `max_tokens_field` | string | Non | Remplace le nom du champ max tokens dans le corps de la requête (ex : `max_completion_tokens` pour les modèles o1) | +| `thinking_level` | string | Non | Niveau de pensée étendue : `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` | +| `extra_body` | object | Non | Champs supplémentaires à injecter dans chaque corps de requête | +| `rpm` | int | Non | Limite de requêtes par minute | +| `fallbacks` | string[] | Non | Noms des modèles de secours pour le basculement automatique | +| `enabled` | bool | Non | Activer ou désactiver cette entrée de modèle (par défaut : `true`) | + +#### Exemples par Vendor + +**OpenAI** + +```json +{ + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_keys": ["sk-..."] +} +``` + +**VolcEngine (Doubao)** + +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_keys": ["sk-..."] +} +``` + +**智谱 AI (GLM)** + +```json +{ + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_keys": ["your-key"] +} +``` + +**DeepSeek** + +```json +{ + "model_name": "deepseek-chat", + "model": "deepseek/deepseek-chat", + "api_keys": ["sk-..."] +} +``` + +**Anthropic (avec clé API)** + +```json +{ + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] +} +``` + +> Exécutez `picoclaw auth login --provider anthropic` pour coller votre token API. + +**API Anthropic Messages (format natif)** + +Pour 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 : + +```json +{ + "model_name": "claude-opus-4-6", + "model": "anthropic-messages/claude-opus-4-6", + "api_keys": ["sk-ant-your-key"], + "api_base": "https://api.anthropic.com" +} +``` + +> Utilisez le protocole `anthropic-messages` lorsque : +> - 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`) +> - Vous vous connectez à des services comme MiniMax, Synthetic qui nécessitent le format de message natif d'Anthropic +> - Le protocole `anthropic` existant renvoie des erreurs 404 (indiquant que l'endpoint ne prend pas en charge le format compatible OpenAI) +> +> **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. + +**Ollama (local)** + +```json +{ + "model_name": "llama3", + "model": "ollama/llama3" +} +``` + +**Proxy/API Personnalisé** + +```json +{ + "model_name": "my-custom-model", + "model": "openai/custom-model", + "api_base": "https://my-proxy.com/v1", + "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", + "request_timeout": 300 +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "model": "litellm/lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_keys": ["sk-..."] +} +``` + +PicoClaw 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`. + +#### Répartition de Charge + +Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectuera automatiquement un round-robin entre eux : + +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api1.example.com/v1", + "api_keys": ["sk-key1"] + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api2.example.com/v1", + "api_keys": ["sk-key2"] + } + ] +} +``` + +#### Migration depuis l'Ancienne Configuration `providers` + +L'ancienne configuration `providers` est **dépréciée** et a été supprimée dans V2. Les configs V0/V1 existantes sont auto-migrées. + +**Ancienne configuration (dépréciée) :** + +```json +{ + "providers": { + "zhipu": { + "api_key": "your-key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + }, + "agents": { + "defaults": { + "provider": "zhipu", + "model": "glm-4.7" + } + } +} +``` + +**Nouvelle configuration (recommandée) :** + +```json +{ + "version": 3, + "model_list": [ + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_keys": ["your-key"] + } + ], + "agents": { + "defaults": { + "model_name": "glm-4.7" + } + } +} +``` + +Pour un guide de migration détaillé, voir [migration/model-list-migration.md](../migration/model-list-migration.md). + +### Architecture des Fournisseurs + +PicoClaw route les fournisseurs par famille de protocoles : + +- Protocole compatible OpenAI : OpenRouter, passerelles compatibles OpenAI, Groq, Zhipu et endpoints de type vLLM. +- Protocole Gemini natif : Google Gemini via les endpoints natifs `models/*:generateContent` et `models/*:streamGenerateContent`. +- Protocole Anthropic : Comportement natif de l'API Claude. +- Chemin Codex/OAuth : Route d'authentification OAuth/token OpenAI. + +Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_keys`). + +
+Zhipu + +**1. Obtenir la clé API et l'URL de base** + +* Obtenir la [clé API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Configurer** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Lancer** + +```bash +picoclaw agent -m "Hello" +``` + +
+ +
+Exemple de configuration complète + +```json +{ + "agents": { + "defaults": { + "model_name": "anthropic/claude-opus-4-5" + } + }, + "session": { + "dm_scope": "per-channel-peer" + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "type": "discord", + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false, + "type": "whatsapp", + "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", + "allow_from": [] + }, + "feishu": { + "enabled": false, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "type": "qq", + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://localhost:8888", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +--- + +## 📝 Comparaison des Clés API + +| Service | Pricing | Use Case | +| ---------------- | ------------------------ | ------------------------------------- | +| **OpenRouter** | Free: 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | +| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) | +| **Zhipu** | Free: 200K tokens/month | Suitable for Chinese users | +| **Brave Search** | $5/1000 queries | Web search functionality | +| **SearXNG** | Free (self-hosted) | Privacy-focused metasearch (70+ engines) | +| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | +| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | +| **LongCat** | Free: up to 5M tokens/day | Fast inference | +| **ModelScope** | Free: 2000 requests/day | Inference (Qwen, GLM, DeepSeek, etc.) | + +--- + +
+ PicoClaw Meme +
diff --git a/docs/guides/providers.ja.md b/docs/guides/providers.ja.md new file mode 100644 index 000000000..fecc74519 --- /dev/null +++ b/docs/guides/providers.ja.md @@ -0,0 +1,471 @@ +# 🔌 プロバイダーとモデル設定 + +> [README](../project/README.ja.md) に戻る + +### プロバイダー + +> [!NOTE] +> Groq は Whisper による無料の音声文字起こしを提供しています。Groq を設定すると、任意のチャネルからの音声メッセージが Agent レベルで自動的にテキストに変換されます。 + +| プロバイダー | 用途 | API Key の取得 | +| -------------------- | ---------------------------- | -------------------------------------------------------------------- | +| `gemini` | LLM (Gemini 直接接続) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu 直接接続) | [bigmodel.cn](https://bigmodel.cn) | +| `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) | +| `openrouter` | LLM (推奨、全モデルアクセス可) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude 直接接続) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT 直接接続) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek 直接接続) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Qwen 直接接続) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **音声文字起こし** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras 直接接続) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid 直接接続) | [vivgrid.com](https://vivgrid.com) | +| `moonshot` | LLM (Kimi/Moonshot 直接接続) | [platform.moonshot.cn](https://platform.moonshot.cn) | +| `minimax` | LLM (Minimax 直接接続) | [platform.minimaxi.com](https://platform.minimaxi.com) | +| `avian` | LLM (Avian 直接接続) | [avian.io](https://avian.io) | +| `mistral` | LLM (Mistral 直接接続) | [console.mistral.ai](https://console.mistral.ai) | +| `longcat` | LLM (Longcat 直接接続) | [longcat.ai](https://longcat.ai) | +| `modelscope` | LLM (ModelScope 直接接続) | [modelscope.cn](https://modelscope.cn) | + + +### モデル設定 (model_list) + +> **新機能!** PicoClaw は**モデル中心**の設定方式を採用しました。`ベンダー/モデル` 形式(例: `zhipu/glm-4.7`)を指定するだけで新しい provider を追加できます——**コード変更は一切不要です!** + +この設計は**マルチ Agent シナリオ**もサポートし、柔軟な Provider 選択を提供します: + +- **Agent ごとに異なる Provider**: 各 Agent が独自の LLM provider を使用可能 +- **モデルフォールバック**: プライマリモデルとフォールバックモデルを設定し、信頼性を向上 +- **ロードバランシング**: 複数の API エンドポイント間でリクエストを分散 +- **一元管理**: すべての provider を一箇所で管理 + +#### 📋 サポートされている全ベンダー + +| ベンダー | `model` プレフィックス | デフォルト API Base | プロトコル | API Key の取得 | +| ------------------- | --------------------- | --------------------------------------------------- | ---------- | ----------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [キーを取得](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [キーを取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [キーを取得](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [キーを取得](https://aistudio.google.com/api-keys) | +| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [キーを取得](https://console.groq.com) | +| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [キーを取得](https://platform.moonshot.cn) | +| **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [キーを取得](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [キーを取得](https://build.nvidia.com) | +| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) | +| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) | +| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | LiteLLM プロキシキー | +| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル | +| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) | +| **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) | +| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [キーを取得](https://www.byteplus.com) | +| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [キーを取得](https://vivgrid.com) | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) | +| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [トークンを取得](https://modelscope.cn/my/tokens) | +| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuth のみ | +| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | + +#### 基本設定 + +```json +{ + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_keys": ["sk-your-api-key"] + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_keys": ["sk-your-openai-key"] + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] + }, + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_keys": ["your-zhipu-key"] + } + ], + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + } +} +``` + +#### `model_list` エントリフィールド + +| フィールド | 型 | 必須 | 説明 | +|-----------|------|------|------| +| `model_name` | string | はい | agent 設定でこのモデルを参照するための一意の名前 | +| `model` | string | はい | ベンダー/モデル識別子(例:`openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル provider(Ollama、LM Studio、VLLM)には不要 | +| `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き | +| `proxy` | string | いいえ | このモデルエントリの HTTP プロキシ URL | +| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Gemini、Anthropic、Azure provider で対応) | +| `request_timeout` | int | いいえ | リクエストタイムアウト(秒)。デフォルト値は provider により異なる | +| `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書き(例:o1 モデルでは `max_completion_tokens`) | +| `thinking_level` | string | いいえ | 拡張思考レベル:`off`、`low`、`medium`、`high`、`xhigh`、`adaptive` | +| `extra_body` | object | いいえ | 各リクエストボディに注入する追加フィールド | +| `rpm` | int | いいえ | 1 分あたりのリクエストレート制限 | +| `fallbacks` | string[] | いいえ | 自動フェイルオーバーのフォールバックモデル名 | +| `enabled` | bool | いいえ | このモデルエントリを有効にするかどうか(デフォルト:`true`) | + +#### ベンダー別設定例 + +**OpenAI** + +```json +{ + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_keys": ["sk-..."] +} +``` + +**VolcEngine (Doubao)** + +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_keys": ["sk-..."] +} +``` + +**智谱 AI (GLM)** + +```json +{ + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_keys": ["your-key"] +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "model": "litellm/lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_keys": ["sk-..."] +} +``` + +**DeepSeek** + +```json +{ + "model_name": "deepseek-chat", + "model": "deepseek/deepseek-chat", + "api_keys": ["sk-..."] +} +``` + +**Anthropic (API キー使用)** + +```json +{ + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] +} +``` + +> `picoclaw auth login --provider anthropic` を実行して API トークンを設定してください。 + +**Anthropic Messages API(ネイティブ形式)** + +Anthropic API への直接アクセスや、Anthropic のネイティブメッセージ形式のみをサポートするカスタムエンドポイント向け: + +```json +{ + "model_name": "claude-opus-4-6", + "model": "anthropic-messages/claude-opus-4-6", + "api_keys": ["sk-ant-your-key"], + "api_base": "https://api.anthropic.com" +} +``` + +> `anthropic-messages` プロトコルを使用するケース: +> - Anthropic のネイティブ `/v1/messages` エンドポイントのみをサポートするサードパーティプロキシを使用する場合(OpenAI 互換の `/v1/chat/completions` 非対応) +> - MiniMax、Synthetic など Anthropic のネイティブメッセージ形式を必要とするサービスに接続する場合 +> - 既存の `anthropic` プロトコルが 404 エラーを返す場合(エンドポイントが OpenAI 互換形式をサポートしていないことを示す) +> +> **注意:** `anthropic` プロトコルは OpenAI 互換形式(`/v1/chat/completions`)を使用し、`anthropic-messages` は Anthropic のネイティブ形式(`/v1/messages`)を使用します。エンドポイントがサポートする形式に応じて選択してください。 + +**Ollama (ローカル)** + +```json +{ + "model_name": "llama3", + "model": "ollama/llama3" +} +``` + +**カスタムプロキシ/API** + +```json +{ + "model_name": "my-custom-model", + "model": "openai/custom-model", + "api_base": "https://my-proxy.com/v1", + "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", + "request_timeout": 300 +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "model": "litellm/lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_keys": ["sk-..."] +} +``` + +PicoClaw はリクエスト送信前に外側の `litellm/` プレフィックスのみを除去するため、`litellm/lite-gpt4` は `lite-gpt4` を送信し、`litellm/openai/gpt-4o` は `openai/gpt-4o` を送信します。 + +#### ロードバランシング + +同じモデル名に複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します: + +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api1.example.com/v1", + "api_keys": ["sk-key1"] + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api2.example.com/v1", + "api_keys": ["sk-key2"] + } + ] +} +``` + +#### レガシー `providers` 設定からの移行 + +旧 `providers` 設定形式は**非推奨**となり、V2 で削除されました。既存の V0/V1 設定は自動的に移行されます。 + +**旧設定(非推奨):** + +```json +{ + "providers": { + "zhipu": { + "api_key": "your-key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + }, + "agents": { + "defaults": { + "provider": "zhipu", + "model": "glm-4.7" + } + } +} +``` + +**新設定(推奨):** + +```json +{ + "version": 3, + "model_list": [ + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_keys": ["your-key"] + } + ], + "agents": { + "defaults": { + "model_name": "glm-4.7" + } + } +} +``` + +詳細な移行ガイドは [docs/migration/model-list-migration.md](../migration/model-list-migration.md) を参照してください。 + +### Provider アーキテクチャ + +PicoClaw はプロトコルファミリーごとに Provider をルーティングします: + +- OpenAI 互換プロトコル:OpenRouter、OpenAI 互換ゲートウェイ、Groq、Zhipu、vLLM スタイルのエンドポイント。 +- Gemini ネイティブプロトコル:Google Gemini のネイティブ `models/*:generateContent` / `models/*:streamGenerateContent` エンドポイント。 +- Anthropic プロトコル:Claude ネイティブ API 動作。 +- Codex/OAuth パス:OpenAI OAuth/Token 認証ルート。 + +これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_keys`)のみで実現しています。 + +
+Zhipu 設定例 + +**1. API key と base URL を取得** + +- [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) を取得 + +**2. 設定** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. 実行** + +```bash +picoclaw agent -m "こんにちは" +``` + +
+ +
+完全な設定例 + +```json +{ + "agents": { + "defaults": { + "model_name": "anthropic/claude-opus-4-5" + } + }, + "session": { + "dm_scope": "per-channel-peer" + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "type": "discord", + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false, + "type": "whatsapp", + "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", + "allow_from": [] + }, + "feishu": { + "enabled": false, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "type": "qq", + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://localhost:8888", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +--- + +## 📝 API Key 比較表 + +| サービス | Pricing | ユースケース | +| ---------------- | ------------------------ | ------------------------------------- | +| **OpenRouter** | Free: 200K tokens/month | マルチモデル (Claude, GPT-4 など) | +| **Volcengine CodingPlan** | ¥9.9/first month | 中国ユーザー向け、複数の SOTA モデル (Doubao, DeepSeek など) | +| **Zhipu** | Free: 200K tokens/month | 中国ユーザー向け | +| **Brave Search** | $5/1000 queries | Web 検索機能 | +| **SearXNG** | Free (self-hosted) | プライバシー重視のメタ検索 (70+ engines) | +| **Groq** | Free tier available | 高速推論 (Llama, Mixtral) | +| **Cerebras** | Free tier available | 高速推論 (Llama, Qwen など) | +| **LongCat** | Free: up to 5M tokens/day | 高速推論 | +| **ModelScope** | Free: 2000 requests/day | 推論 (Qwen, GLM, DeepSeek など) | + +--- + +
+ PicoClaw Meme +
diff --git a/docs/guides/providers.md b/docs/guides/providers.md new file mode 100644 index 000000000..7b078373d --- /dev/null +++ b/docs/guides/providers.md @@ -0,0 +1,641 @@ +# 🔌 Providers & Model Configuration + +> Back to [README](../README.md) + +### Providers + +> [!NOTE] +> Voice transcription can use a configured multimodal model via `voice.model_name`. Groq Whisper remains available as a fallback when no voice model is configured. + +| Provider | Purpose | Get API Key | +| ------------ | --------------------------------------- | ------------------------------------------------------------ | +| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | +| `zai-coding` | LLM (Z.AI Coding Plan) | [z.ai](https://z.ai/manage-apikey/apikey-list) | +| `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) | +| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `venice` | LLM (Venice AI direct) | [venice.ai](https://venice.ai) | +| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | +| `nvidia` | LLM (NVIDIA NIM) | [build.nvidia.com](https://build.nvidia.com) | +| `moonshot` | LLM (Kimi/Moonshot direct) | [platform.moonshot.cn](https://platform.moonshot.cn) | +| `minimax` | LLM (Minimax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | +| `avian` | LLM (Avian direct) | [avian.io](https://avian.io) | +| `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) | +| `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) | +| `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) | +| `mimo` | LLM (Xiaomi MiMo direct) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | + +### Model Configuration (model_list) + +> **What's New?** PicoClaw now prefers explicit `provider` + native `model` configuration (for example `"provider": "zhipu", "model": "glm-4.7"`). The legacy single-field `provider/model` form remains supported for compatibility when `provider` is omitted. + +For agent dispatch and light-model routing examples, see the [Routing Guide](routing-guide.md). + +This design also enables **multi-agent support** with flexible provider selection: + +- **Different agents, different providers**: Each agent can use its own LLM provider +- **Model fallbacks**: Configure primary and fallback models for resilience +- **Load balancing**: Distribute requests across multiple endpoints +- **Centralized configuration**: Manage all providers in one place + +#### 📋 All Supported Vendors + +| Vendor | `provider` Value | Default API Base | Protocol | API Key | +| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | +| **Venice AI** | `venice` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) | +| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **Z.AI Coding Plan** | `openai` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) | +| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | +| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | +| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | +| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | +| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) | +| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | +| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | +| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | Local | +| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | +| **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) | +| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) | +| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | +| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | +| **ModelScope (魔搭)**| `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | +| **Xiaomi MiMo** | `mimo` | `https://api.xiaomimimo.com/v1` | OpenAI | [Get Key](https://platform.xiaomimimo.com) | +| **Azure OpenAI** | `azure` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) | +| **Antigravity** | `antigravity` | Google Cloud | Custom | OAuth only | +| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | - | + +#### Basic Configuration + +```json +{ + "model_list": [ + { + "model_name": "ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-your-api-key"] + }, + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-your-openai-key"] + }, + { + "model_name": "claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] + }, + { + "model_name": "glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-zhipu-key"] + } + ], + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + } +} +``` + +#### `model_list` Entry Fields + +| Field | Type | Required | Description | +|-------|------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `model_name` | string | Yes | Unique name used to reference this model in agent config | +| `provider` | string | No | Preferred provider identifier. When present, PicoClaw sends `model` unchanged to that provider | +| `model` | string | Yes | Native model ID when `provider` is set. If `provider` is omitted, the legacy `provider/model` form is still supported | +| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) | +| `api_base` | string | No | Override the default API endpoint URL | +| `proxy` | string | No | HTTP proxy URL for this model entry | +| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Gemini, Anthropic, and Azure providers) | +| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) | +| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) | +| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` | +| `tool_schema_transform` | string | No | Optional compatibility transform for tool parameter schemas. Default: disabled. Supported values: `simple`. | +| `extra_body` | object | No | Additional fields to inject into every request body | +| `custom_headers` | object | No | Additional HTTP headers to inject into every request (e.g., `{"X-Source":"coding-plan"}`). If a key matches a built-in header, the custom value overrides the built-in one (e.g., `Authorization`, `User-Agent`, `Content-Type`, `Accept`). | +| `rpm` | int | No | Per-minute request rate limit | +| `fallbacks` | string[] | No | Fallback model names for automatic failover | +| `enabled` | bool | No | Whether this model entry is active (default: `true`) | + +#### Tool Schema Compatibility + +By default, PicoClaw now forwards tool JSON Schemas unchanged. + +Some providers reject advanced JSON Schema features such as `$ref`, `$defs`, `anyOf`, `oneOf`, `allOf`, `pattern`, or numeric/string constraints inside tool declarations. For those models, you can opt into a compatibility transform per model entry with `tool_schema_transform`. + +Use `simple` when the upstream provider expects the conservative style function schema subset: + +```json +{ + "model_name": "gemini-2.5-flash-safe-tools", + "provider": "gemini", + "model": "gemini-2.5-flash", + "api_keys": ["your-gemini-key"], + "tool_schema_transform": "simple" +} +``` + +Notes: + +- Default behavior is disabled. If you omit `tool_schema_transform`, PicoClaw sends the original tool schema. +- The setting is per model entry, so you can enable it only for the providers that need it. + +#### Provider / Model Resolution + +PicoClaw resolves `provider` and the runtime model ID using these rules: + +- If `provider` is set, `model` is used as-is. +- If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID. + +Examples: + +| Config | Resolved Provider | Model Sent Upstream | +| --- | --- | --- | +| `"provider": "openai", "model": "gpt-5.4"` | `openai` | `gpt-5.4` | +| `"model": "openai/gpt-5.4"` | `openai` | `gpt-5.4` | +| `"provider": "openrouter", "model": "openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` | +| `"model": "openrouter/openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` | + +#### Voice Transcription + +You can configure a dedicated model for audio transcription with `voice.model_name`. This lets you reuse existing multimodal providers that support audio input instead of relying only on Groq. + +If `voice.model_name` is not configured, PicoClaw will continue to fall back to Groq transcription when a Groq API key is available. + +```json +{ + "model_list": [ + { + "model_name": "voice-gemini", + "provider": "gemini", + "model": "gemini-2.5-flash", + "api_keys": ["your-gemini-key"] + } + ], + "voice": { + "model_name": "voice-gemini", + "echo_transcription": false + }, + "providers": { + "groq": { + "api_key": "gsk_xxx" + } + } +} +``` + +#### Vendor-Specific Examples + +**OpenAI** + +```json +{ + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-..."] +} +``` + +**VolcEngine (Doubao)** + +```json +{ + "model_name": "ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-..."] +} +``` + +**智谱 AI (GLM)** + +```json +{ + "model_name": "glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-key"] +} +``` + +**Z.AI Coding Plan (GLM)** +> Z.AI and 智谱 AI are two brands of the same provider. For the Z.AI Coding Plan use the `openai` model key and the api base as follows, rather than the zhipu config +```json +{ + "model_name": "glm-4.7", + "provider": "openai", + "model": "glm-4.7", + "api_keys": ["your-z.ai-key"], + "api_base": "https://api.z.ai/api/coding/paas/v4" +} +``` + +**DeepSeek** + +```json +{ + "model_name": "deepseek-chat", + "provider": "deepseek", + "model": "deepseek-chat", + "api_keys": ["sk-..."] +} +``` + +**Anthropic (with API key)** + +```json +{ + "model_name": "claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] +} +``` + +> Run `picoclaw auth login --provider anthropic` to paste your API token. + +**Anthropic Messages API (native format)** + +For direct Anthropic API access or custom endpoints that only support Anthropic's native message format: + +```json +{ + "model_name": "claude-opus-4-6", + "provider": "anthropic-messages", + "model": "claude-opus-4-6", + "api_keys": ["sk-ant-your-key"], + "api_base": "https://api.anthropic.com" +} +``` + +> Use `anthropic-messages` protocol when: +> - Using third-party proxies that only support Anthropic's native `/v1/messages` endpoint (not OpenAI-compatible `/v1/chat/completions`) +> - Connecting to services like MiniMax, Synthetic that require Anthropic's native message format +> - The existing `anthropic` protocol returns 404 errors (indicating the endpoint doesn't support OpenAI-compatible format) +> +> **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. + +**Ollama (local)** + +```json +{ + "model_name": "llama3", + "provider": "ollama", + "model": "llama3" +} +``` + +**LM Studio (local)** + +```json +{ + "model_name": "lmstudio-local", + "provider": "lmstudio", + "model": "openai/gpt-oss-20b" +} +``` + +`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.
+With explicit `provider`, PicoClaw sends `openai/gpt-oss-20b` unchanged to the LM Studio server. The legacy compatibility form `"model": "lmstudio/openai/gpt-oss-20b"` still resolves to the same upstream model ID when `provider` is omitted. + +**Custom Proxy/API** + +```json +{ + "model_name": "my-custom-model", + "provider": "openai", + "model": "custom-model", + "api_base": "https://my-proxy.com/v1", + "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", + "request_timeout": 300 +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "provider": "litellm", + "model": "lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_keys": ["sk-..."] +} +``` + +With explicit `provider`, PicoClaw sends `model` unchanged. That means `"provider": "litellm", "model": "lite-gpt4"` sends `lite-gpt4`, while `"provider": "litellm", "model": "openai/gpt-4o"` sends `openai/gpt-4o`. The legacy compatibility forms `litellm/lite-gpt4` and `litellm/openai/gpt-4o` still resolve the same way when `provider` is omitted. + +**Z.AI Coding Plan** + +If the standard Zhipu endpoint (`https://open.bigmodel.cn/api/paas/v4`) returns 429 (code 1113: insufficient balance), try using the Z.AI Coding Plan endpoint instead: + +```json +{ + "model_name": "glm-4.7", + "provider": "openai", + "model": "glm-4.7", + "api_keys": ["your-zhipu-api-key"], + "api_base": "https://api.z.ai/api/coding/paas/v4" +} +``` + +**Note:** The Z.AI Coding Plan endpoint and standard Zhipu endpoint use the same API key format but have separate billing. If you encounter 429 errors with the standard Zhipu endpoint, the Z.AI Coding Plan endpoint may have available balance. + +#### Load Balancing + +Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them: + +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_base": "https://api1.example.com/v1", + "api_keys": ["sk-key1"] + }, + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_base": "https://api2.example.com/v1", + "api_keys": ["sk-key2"] + } + ] +} +``` + +#### Automatic Model Failover (Cascade) + +PicoClaw already supports automatic failover when you configure `primary` + `fallbacks` in the agent model settings. +The runtime fallback chain retries the next candidate for retriable failures such as HTTP `429`, quota/rate-limit errors, and timeout errors. +It also applies cooldown tracking per candidate to avoid immediately retrying a recently failed target. + +```json +{ + "model_list": [ + { + "model_name": "qwen-main", + "provider": "openai", + "model": "qwen3.5:cloud", + "api_base": "https://api.example.com/v1", + "api_keys": ["sk-main"] + }, + { + "model_name": "deepseek-backup", + "provider": "deepseek", + "model": "deepseek-chat", + "api_keys": ["sk-backup-1"] + }, + { + "model_name": "gemini-backup", + "provider": "gemini", + "model": "gemini-2.5-flash", + "api_keys": ["sk-backup-2"] + } + ], + "agents": { + "defaults": { + "model_name": "qwen-main", + "model_fallbacks": ["deepseek-backup", "gemini-backup"] + } + } +} +``` + +If you use key-level failover for the same model, PicoClaw can chain through additional key-backed candidates before moving to cross-model backups. + +#### Migration from Legacy `providers` Config + +The old `providers` configuration is **deprecated** and has been removed in V2. Existing V0/V1 configs are auto-migrated. + +**Old Config (deprecated):** + +```json +{ + "providers": { + "zhipu": { + "api_key": "your-key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + }, + "agents": { + "defaults": { + "provider": "zhipu", + "model": "glm-4.7" + } + } +} +``` + +**New Config (recommended):** + +```json +{ + "version": 3, + "model_list": [ + { + "model_name": "glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-key"] + } + ], + "agents": { + "defaults": { + "model_name": "glm-4.7" + } + } +} +``` + +For detailed migration guide, see [migration/model-list-migration.md](../migration/model-list-migration.md). + +### Provider Architecture + +PicoClaw routes providers by protocol family: + +- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints. +- Gemini native protocol: Google Gemini via the native `models/*:generateContent` and `models/*:streamGenerateContent` endpoints. +- Anthropic protocol: Claude-native API behavior. +- Codex/OAuth path: OpenAI OAuth/token authentication route. + +This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_keys`). + +
+Zhipu + +**1. Get API key and base URL** + +* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Configure** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Run** + +```bash +picoclaw agent -m "Hello" +``` + +
+ +
+Full config example + +```json +{ + "agents": { + "defaults": { + "model_name": "claude-opus-4-5" + } + }, + "session": { + "dm_scope": "per-channel-peer" + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "voice": { + "model_name": "voice-gemini", + "echo_transcription": false + }, + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "type": "discord", + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false, + "type": "whatsapp", + "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", + "allow_from": [] + }, + "feishu": { + "enabled": false, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "type": "qq", + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://localhost:8888", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +--- + +## 📝 API Key Comparison + +| Service | Pricing | Use Case | +| ---------------- | ------------------------ | ------------------------------------- | +| **OpenRouter** | Free: 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | +| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) | +| **Zhipu** | Free: 200K tokens/month | Suitable for Chinese users | +| **Brave Search** | $5/1000 queries | Web search functionality | +| **SearXNG** | Free (self-hosted) | Privacy-focused metasearch (70+ engines) | +| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | +| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | +| **LongCat** | Free: up to 5M tokens/day | Fast inference | +| **ModelScope** | Free: 2000 requests/day | Inference (Qwen, GLM, DeepSeek, etc.) | + +--- + +
+ PicoClaw Meme +
diff --git a/docs/guides/providers.pt-br.md b/docs/guides/providers.pt-br.md new file mode 100644 index 000000000..0d45dc309 --- /dev/null +++ b/docs/guides/providers.pt-br.md @@ -0,0 +1,459 @@ +# 🔌 Provedores e Configuração de Modelos + +> Voltar ao [README](../project/README.pt-br.md) + +### Provedores + +> [!NOTE] +> 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. + +| Provider | Purpose | Get API Key | +| ------------ | --------------------------------------- | ------------------------------------------------------------ | +| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | +| `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) | +| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | +| `moonshot` | LLM (Kimi/Moonshot direct) | [platform.moonshot.cn](https://platform.moonshot.cn) | +| `minimax` | LLM (Minimax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | +| `avian` | LLM (Avian direct) | [avian.io](https://avian.io) | +| `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) | +| `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) | +| `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) | + +### Configuração de Modelos (model_list) + +> **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!** + +Este design também permite **suporte multi-agente** com seleção flexível de provedores: + +- **Agentes diferentes, provedores diferentes**: Cada agente pode usar seu próprio provedor LLM +- **Fallback de modelos**: Configure modelos primários e de fallback para resiliência +- **Balanceamento de carga**: Distribua requisições entre múltiplos endpoints +- **Configuração centralizada**: Gerencie todos os provedores em um só lugar + +#### 📋 Todos os Vendors Suportados + +| Vendor | `model` Prefix | Default API Base | Protocol | API Key | +| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | +| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | +| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | +| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | +| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | +| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | +| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | +| **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) | +| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) | +| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | +| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | +| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | +| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | + +#### Configuração Básica + +```json +{ + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_keys": ["sk-your-api-key"] + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_keys": ["sk-your-openai-key"] + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] + }, + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_keys": ["your-zhipu-key"] + } + ], + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + } +} +``` + +#### Campos de entrada `model_list` + +| Campo | Tipo | Obrigatório | Descrição | +|-------|------|-------------|-----------| +| `model_name` | string | Sim | Nome único para referenciar este modelo na config do agent | +| `model` | string | Sim | Identificador fornecedor/modelo (ex: `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | Sim* | Chave(s) API para autenticação. Múltiplas chaves permitem rotação por requisição. Não necessário para providers locais (Ollama, LM Studio, VLLM) | +| `api_base` | string | Não | Substitui a URL base da API padrão | +| `proxy` | string | Não | URL do proxy HTTP para esta entrada de modelo | +| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Gemini, Anthropic e Azure) | +| `request_timeout` | int | Não | Timeout de requisição em segundos (o padrão varia por provider) | +| `max_tokens_field` | string | Não | Substitui o nome do campo max tokens no corpo da requisição (ex: `max_completion_tokens` para modelos o1) | +| `thinking_level` | string | Não | Nível de pensamento estendido: `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` | +| `extra_body` | object | Não | Campos adicionais para injetar em cada corpo de requisição | +| `rpm` | int | Não | Limite de requisições por minuto | +| `fallbacks` | string[] | Não | Nomes dos modelos de fallback para failover automático | +| `enabled` | bool | Não | Ativar ou desativar esta entrada de modelo (padrão: `true`) | + +#### Exemplos por Vendor + +**OpenAI** + +```json +{ + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_keys": ["sk-..."] +} +``` + +**VolcEngine (Doubao)** + +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_keys": ["sk-..."] +} +``` + +**智谱 AI (GLM)** + +```json +{ + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_keys": ["your-key"] +} +``` + +**DeepSeek** + +```json +{ + "model_name": "deepseek-chat", + "model": "deepseek/deepseek-chat", + "api_keys": ["sk-..."] +} +``` + +**Anthropic (com chave de API)** + +```json +{ + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] +} +``` + +> Execute `picoclaw auth login --provider anthropic` para colar seu token de API. + +**Anthropic Messages API (formato nativo)** + +Para acesso direto à API Anthropic ou endpoints personalizados que suportam apenas o formato de mensagem nativo da Anthropic: + +```json +{ + "model_name": "claude-opus-4-6", + "model": "anthropic-messages/claude-opus-4-6", + "api_keys": ["sk-ant-your-key"], + "api_base": "https://api.anthropic.com" +} +``` + +> Use o protocolo `anthropic-messages` quando: +> - Usar proxies de terceiros que suportam apenas o endpoint nativo `/v1/messages` da Anthropic (não o compatível com OpenAI `/v1/chat/completions`) +> - Conectar a serviços como MiniMax, Synthetic que requerem o formato de mensagem nativo da Anthropic +> - O protocolo `anthropic` existente retorna erros 404 (indicando que o endpoint não suporta formato compatível com OpenAI) +> +> **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. + +**Ollama (local)** + +```json +{ + "model_name": "llama3", + "model": "ollama/llama3" +} +``` + +**Proxy/API Personalizado** + +```json +{ + "model_name": "my-custom-model", + "model": "openai/custom-model", + "api_base": "https://my-proxy.com/v1", + "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", + "request_timeout": 300 +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "model": "litellm/lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_keys": ["sk-..."] +} +``` + +O 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`. + +#### Balanceamento de Carga + +Configure múltiplos endpoints para o mesmo nome de modelo — o PicoClaw fará automaticamente round-robin entre eles: + +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api1.example.com/v1", + "api_keys": ["sk-key1"] + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api2.example.com/v1", + "api_keys": ["sk-key2"] + } + ] +} +``` + +#### Migração da Configuração Legacy `providers` + +A configuração antiga `providers` está **descontinuada** e foi removida no V2. Configs V0/V1 existentes são auto-migradas. + +**Configuração Antiga (descontinuada):** + +```json +{ + "providers": { + "zhipu": { + "api_key": "your-key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + }, + "agents": { + "defaults": { + "provider": "zhipu", + "model": "glm-4.7" + } + } +} +``` + +**Configuração Nova (recomendada):** + +```json +{ + "version": 3, + "model_list": [ + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_keys": ["your-key"] + } + ], + "agents": { + "defaults": { + "model_name": "glm-4.7" + } + } +} +``` + +Para guia de migração detalhado, veja [migration/model-list-migration.md](../migration/model-list-migration.md). + +### Arquitetura de Provedores + +O PicoClaw roteia provedores por família de protocolo: + +- Protocolo compatível com OpenAI: OpenRouter, gateways compatíveis com OpenAI, Groq, Zhipu e endpoints estilo vLLM. +- Protocolo Gemini nativo: Google Gemini via endpoints nativos `models/*:generateContent` e `models/*:streamGenerateContent`. +- Protocolo Anthropic: Comportamento nativo da API Claude. +- Caminho Codex/OAuth: Rota de autenticação OAuth/token da OpenAI. + +Isso mantém o runtime leve enquanto torna novos backends compatíveis com OpenAI basicamente uma operação de configuração (`api_base` + `api_keys`). + +
+Zhipu + +**1. Obter chave de API e URL base** + +* Obtenha a [chave de API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Configurar** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Executar** + +```bash +picoclaw agent -m "Hello" +``` + +
+ +
+Exemplo de configuração completa + +```json +{ + "agents": { + "defaults": { + "model_name": "anthropic/claude-opus-4-5" + } + }, + "session": { + "dm_scope": "per-channel-peer" + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "type": "discord", + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false, + "type": "whatsapp", + "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", + "allow_from": [] + }, + "feishu": { + "enabled": false, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "type": "qq", + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://localhost:8888", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +--- + +## 📝 Comparação de Chaves de API + +| Service | Pricing | Use Case | +| ---------------- | ------------------------ | ------------------------------------- | +| **OpenRouter** | Free: 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | +| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) | +| **Zhipu** | Free: 200K tokens/month | Suitable for Chinese users | +| **Brave Search** | $5/1000 queries | Web search functionality | +| **SearXNG** | Free (self-hosted) | Privacy-focused metasearch (70+ engines) | +| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | +| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | +| **LongCat** | Free: up to 5M tokens/day | Fast inference | +| **ModelScope** | Free: 2000 requests/day | Inference (Qwen, GLM, DeepSeek, etc.) | + +--- + +
+ PicoClaw Meme +
diff --git a/docs/guides/providers.vi.md b/docs/guides/providers.vi.md new file mode 100644 index 000000000..c354461cf --- /dev/null +++ b/docs/guides/providers.vi.md @@ -0,0 +1,459 @@ +# 🔌 Nhà Cung Cấp và Cấu Hình Mô Hình + +> Quay lại [README](../project/README.vi.md) + +### Nhà Cung Cấp + +> [!NOTE] +> 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. + +| Provider | Purpose | Get API Key | +| ------------ | --------------------------------------- | ------------------------------------------------------------ | +| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | +| `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) | +| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | +| `moonshot` | LLM (Kimi/Moonshot direct) | [platform.moonshot.cn](https://platform.moonshot.cn) | +| `minimax` | LLM (Minimax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | +| `avian` | LLM (Avian direct) | [avian.io](https://avian.io) | +| `mistral` | LLM (Mistral direct) | [console.mistral.ai](https://console.mistral.ai) | +| `longcat` | LLM (Longcat direct) | [longcat.ai](https://longcat.ai) | +| `modelscope` | LLM (ModelScope direct) | [modelscope.cn](https://modelscope.cn) | + +### Cấu Hình Mô Hình (model_list) + +> **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!** + +Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn provider linh hoạt: + +- **Agent khác nhau, provider khác nhau**: Mỗi agent có thể sử dụng provider LLM riêng +- **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 +- **Cân bằng tải**: Phân phối yêu cầu qua nhiều endpoint +- **Cấu hình tập trung**: Quản lý tất cả provider tại một nơi + +#### 📋 Tất Cả Vendor Được Hỗ Trợ + +| Vendor | `model` Prefix | Default API Base | Protocol | API Key | +| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | +| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | +| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | +| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | +| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | +| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | +| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | +| **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) | +| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) | +| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | +| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | +| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | +| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | + +#### Cấu Hình Cơ Bản + +```json +{ + "model_list": [ + { + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_keys": ["sk-your-api-key"] + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_keys": ["sk-your-openai-key"] + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] + }, + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_keys": ["your-zhipu-key"] + } + ], + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + } +} +``` + +#### Các trường entry `model_list` + +| Trường | Kiểu | Bắt buộc | Mô tả | +|--------|------|----------|------| +| `model_name` | string | Có | Tên duy nhất để tham chiếu model này trong cấu hình agent | +| `model` | string | Có | Định danh nhà cung cấp/model (ví dụ: `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | Có* | Khóa API xác thực. Nhiều khóa cho phép xoay vòng theo yêu cầu. Không cần thiết cho provider nội bộ (Ollama, LM Studio, VLLM) | +| `api_base` | string | Không | Ghi đè URL endpoint API mặc định | +| `proxy` | string | Không | URL proxy HTTP cho entry model này | +| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Gemini, Anthropic và Azure) | +| `request_timeout` | int | Không | Timeout yêu cầu tính bằng giây (mặc định khác nhau tùy provider) | +| `max_tokens_field` | string | Không | Ghi đè tên trường max tokens trong request body (ví dụ: `max_completion_tokens` cho model o1) | +| `thinking_level` | string | Không | Mức độ tư duy mở rộng: `off`, `low`, `medium`, `high`, `xhigh` hoặc `adaptive` | +| `extra_body` | object | Không | Các trường bổ sung để chèn vào mỗi request body | +| `rpm` | int | Không | Giới hạn tốc độ yêu cầu mỗi phút | +| `fallbacks` | string[] | Không | Tên model dự phòng cho failover tự động | +| `enabled` | bool | Không | Kích hoạt hay vô hiệu hóa entry model này (mặc định: `true`) | + +#### Ví Dụ Theo Vendor + +**OpenAI** + +```json +{ + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_keys": ["sk-..."] +} +``` + +**VolcEngine (Doubao)** + +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_keys": ["sk-..."] +} +``` + +**智谱 AI (GLM)** + +```json +{ + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_keys": ["your-key"] +} +``` + +**DeepSeek** + +```json +{ + "model_name": "deepseek-chat", + "model": "deepseek/deepseek-chat", + "api_keys": ["sk-..."] +} +``` + +**Anthropic (với API key)** + +```json +{ + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] +} +``` + +> Chạy `picoclaw auth login --provider anthropic` để dán API token. + +**Anthropic Messages API (định dạng native)** + +Để 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: + +```json +{ + "model_name": "claude-opus-4-6", + "model": "anthropic-messages/claude-opus-4-6", + "api_keys": ["sk-ant-your-key"], + "api_base": "https://api.anthropic.com" +} +``` + +> Sử dụng giao thức `anthropic-messages` khi: +> - 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`) +> - Kết nối đến dịch vụ như MiniMax, Synthetic yêu cầu định dạng message native của Anthropic +> - 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) +> +> **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ợ. + +**Ollama (local)** + +```json +{ + "model_name": "llama3", + "model": "ollama/llama3" +} +``` + +**Proxy/API Tùy Chỉnh** + +```json +{ + "model_name": "my-custom-model", + "model": "openai/custom-model", + "api_base": "https://my-proxy.com/v1", + "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", + "request_timeout": 300 +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "model": "litellm/lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_keys": ["sk-..."] +} +``` + +PicoClaw 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`. + +#### Cân Bằng Tải + +Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự động round-robin giữa chúng: + +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api1.example.com/v1", + "api_keys": ["sk-key1"] + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api2.example.com/v1", + "api_keys": ["sk-key2"] + } + ] +} +``` + +#### Di Chuyển Từ Cấu Hình Legacy `providers` + +Cấu hình `providers` cũ đã **bị deprecated** và đã được loại bỏ trong V2. Các cấu hình V0/V1 hiện có sẽ được tự động migrate. + +**Cấu hình cũ (ngừng hỗ trợ):** + +```json +{ + "providers": { + "zhipu": { + "api_key": "your-key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + }, + "agents": { + "defaults": { + "provider": "zhipu", + "model": "glm-4.7" + } + } +} +``` + +**Cấu hình mới (khuyến nghị):** + +```json +{ + "version": 3, + "model_list": [ + { + "model_name": "glm-4.7", + "model": "zhipu/glm-4.7", + "api_keys": ["your-key"] + } + ], + "agents": { + "defaults": { + "model_name": "glm-4.7" + } + } +} +``` + +Để xem hướng dẫn di chuyển chi tiết, xem [migration/model-list-migration.md](../migration/model-list-migration.md). + +### Kiến Trúc Provider + +PicoClaw định tuyến provider theo họ giao thức: + +- Giao thức tương thích OpenAI: OpenRouter, gateway tương thích OpenAI, Groq, Zhipu, và endpoint kiểu vLLM. +- Giao thức Gemini native: Google Gemini qua các endpoint native `models/*:generateContent` và `models/*:streamGenerateContent`. +- Giao thức Anthropic: Hành vi API native của Claude. +- Đường dẫn Codex/OAuth: Tuyến xác thực OAuth/token của OpenAI. + +Đ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_keys`). + +
+Zhipu + +**1. Lấy API key và URL base** + +* Lấy [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Cấu hình** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Chạy** + +```bash +picoclaw agent -m "Hello" +``` + +
+ +
+Ví dụ cấu hình đầy đủ + +```json +{ + "agents": { + "defaults": { + "model_name": "anthropic/claude-opus-4-5" + } + }, + "session": { + "dm_scope": "per-channel-peer" + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "type": "discord", + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false, + "type": "whatsapp", + "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", + "allow_from": [] + }, + "feishu": { + "enabled": false, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "type": "qq", + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://localhost:8888", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +--- + +## 📝 So Sánh API Key + +| Service | Pricing | Use Case | +| ---------------- | ------------------------ | ------------------------------------- | +| **OpenRouter** | Free: 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | +| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) | +| **Zhipu** | Free: 200K tokens/month | Suitable for Chinese users | +| **Brave Search** | $5/1000 queries | Web search functionality | +| **SearXNG** | Free (self-hosted) | Privacy-focused metasearch (70+ engines) | +| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | +| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | +| **LongCat** | Free: up to 5M tokens/day | Fast inference | +| **ModelScope** | Free: 2000 requests/day | Inference (Qwen, GLM, DeepSeek, etc.) | + +--- + +
+ PicoClaw Meme +
diff --git a/docs/guides/providers.zh.md b/docs/guides/providers.zh.md new file mode 100644 index 000000000..4bab65f6b --- /dev/null +++ b/docs/guides/providers.zh.md @@ -0,0 +1,580 @@ +# 🔌 提供商与模型配置 + +> 返回 [README](../project/README.zh.md) + +### 提供商 (Providers) + +> [!NOTE] +> 语音转录现在可以通过 `voice.model_name` 指定的多模态模型完成;如果未配置语音模型,Groq Whisper 仍可作为回退方案。 + +| 提供商 | 用途 | 获取 API Key | +| -------------------- | ---------------------------- | -------------------------------------------------------------------- | +| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (智谱直连) | [bigmodel.cn](https://bigmodel.cn) | +| `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) | +| `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) | +| `venice` | LLM (Venice AI 直连) | [venice.ai](https://venice.ai) | +| `deepseek` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) | +| `vivgrid` | LLM (Vivgrid 直连) | [vivgrid.com](https://vivgrid.com) | +| `moonshot` | LLM (Kimi/Moonshot 直连) | [platform.moonshot.cn](https://platform.moonshot.cn) | +| `minimax` | LLM (Minimax 直连) | [platform.minimaxi.com](https://platform.minimaxi.com) | +| `avian` | LLM (Avian 直连) | [avian.io](https://avian.io) | +| `mistral` | LLM (Mistral 直连) | [console.mistral.ai](https://console.mistral.ai) | +| `longcat` | LLM (Longcat 直连) | [longcat.ai](https://longcat.ai) | +| `modelscope` | LLM (ModelScope 直连) | [modelscope.cn](https://modelscope.cn) | +| `mimo` | LLM (小米 MiMo 直连) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | + + +### 模型配置 (model_list) + +> **新功能!** PicoClaw 现在优先推荐显式 `provider` + 原生 `model` 的配置方式,例如 `"provider": "zhipu", "model": "glm-4.7"`。如果未设置 `provider`,旧的单字段 `provider/model` 写法仍然兼容。 + +如果你想看 agent 分发和轻量模型路由的完整示例,请看 [路由使用指南](routing-guide.zh.md)。 + +该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择: + +- **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider +- **模型回退(Fallback)**:配置主模型和备用模型,提高可靠性 +- **负载均衡**:在多个 API 端点之间分配请求 +- **集中化配置**:在一个地方管理所有 provider + +#### 📋 所有支持的厂商 + +| 厂商 | `provider` 值 | 默认 API Base | 协议 | 获取 API Key | +| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- | +| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) | +| **Venice AI** | `venice` | `https://api.venice.ai/api/v1` | OpenAI | [获取密钥](https://venice.ai) | +| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) | +| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取密钥](https://aistudio.google.com/api-keys) | +| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) | +| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) | +| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | +| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) | +| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | +| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 | +| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | 本地 | +| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | +| **火山引擎(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) | +| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) | +| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [获取密钥](https://vivgrid.com) | +| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) | +| **ModelScope (魔搭)**| `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) | +| **小米 MiMo** | `mimo` | `https://api.xiaomimimo.com/v1` | OpenAI | [获取密钥](https://platform.xiaomimimo.com) | +| **Antigravity** | `antigravity` | Google Cloud | 自定义 | 仅 OAuth | +| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | - | + +#### 基础配置示例 + +```json +{ + "model_list": [ + { + "model_name": "ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-your-api-key"] + }, + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-your-openai-key"] + }, + { + "model_name": "claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] + }, + { + "model_name": "glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-zhipu-key"] + } + ], + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + } +} +``` + +#### `model_list` 条目字段 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `model_name` | string | 是 | 在 agent 配置中引用此模型的唯一名称 | +| `provider` | string | 否 | 推荐的 provider 标识。设置后,PicoClaw 会将 `model` 原样发送给该 provider | +| `model` | string | 是 | 当设置 `provider` 时,这里填写 provider 原生模型 ID。若未设置 `provider`,仍兼容旧的 `provider/model` 写法 | +| `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、LM Studio、VLLM)不需要 | +| `api_base` | string | 否 | 覆盖默认的 API 端点 URL | +| `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL | +| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Gemini、Anthropic 和 Azure provider) | +| `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 | +| `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens`) | +| `thinking_level` | string | 否 | 扩展思考级别:`off`、`low`、`medium`、`high`、`xhigh` 或 `adaptive` | +| `extra_body` | object | 否 | 注入到每个请求体中的额外字段 | +| `custom_headers` | object | 否 | 注入到每个请求中的额外 HTTP 请求头(例如 `{"X-Source":"coding-plan"}`)。若键名与内置请求头同名,会覆盖内置值(如 `Authorization`、`User-Agent`、`Content-Type`、`Accept`)。 | +| `rpm` | int | 否 | 每分钟请求速率限制 | +| `fallbacks` | string[] | 否 | 自动故障转移的备用模型名称 | +| `enabled` | bool | 否 | 是否启用此模型条目(默认:`true`) | + +#### `provider` / `model` 解析规则 + +PicoClaw 按下面的规则解析 `provider` 和最终发给上游的模型 ID: + +- 如果设置了 `provider`,则直接使用 `model`。 +- 如果未设置 `provider`,则把 `model` 中第一个 `/` 之前的字段当作 provider,第一个 `/` 之后的全部内容当作最终模型 ID。 + +示例: + +| 配置 | 解析后的 Provider | 实际发送的模型 ID | +| --- | --- | --- | +| `"provider": "openai", "model": "gpt-5.4"` | `openai` | `gpt-5.4` | +| `"model": "openai/gpt-5.4"` | `openai` | `gpt-5.4` | +| `"provider": "openrouter", "model": "openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` | +| `"model": "openrouter/openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` | + +#### 语音转录 + +你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider,而不必只依赖 Groq。 + +如果没有配置 `voice.model_name`,且存在 Groq API Key,PicoClaw 会继续回退到 Groq 转录。 + +```json +{ + "model_list": [ + { + "model_name": "voice-gemini", + "provider": "gemini", + "model": "gemini-2.5-flash", + "api_keys": ["your-gemini-key"] + } + ], + "voice": { + "model_name": "voice-gemini", + "echo_transcription": false + }, + "providers": { + "groq": { + "api_key": "gsk_xxx" + } + } +} +``` + +#### 各厂商配置示例 + +**OpenAI** + +```json +{ + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-..."] +} +``` + +**火山引擎(Doubao)** + +```json +{ + "model_name": "ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", + "api_keys": ["sk-..."] +} +``` + +**智谱 AI (GLM)** + +```json +{ + "model_name": "glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-key"] +} +``` + +**DeepSeek** + +```json +{ + "model_name": "deepseek-chat", + "provider": "deepseek", + "model": "deepseek-chat", + "api_keys": ["sk-..."] +} +``` + +**Anthropic (使用 OAuth)** + +```json +{ + "model_name": "claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", + "auth_method": "oauth" +} +``` + +> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。 + +**Anthropic Messages API(原生格式)** + +用于直接访问 Anthropic API 或仅支持 Anthropic 原生消息格式的自定义端点: + +```json +{ + "model_name": "claude-opus-4-6", + "provider": "anthropic-messages", + "model": "claude-opus-4-6", + "api_keys": ["sk-ant-your-key"], + "api_base": "https://api.anthropic.com" +} +``` + +> 使用 `anthropic-messages` 协议的场景: +> - 使用仅支持 Anthropic 原生 `/v1/messages` 端点的第三方代理(不支持 OpenAI 兼容的 `/v1/chat/completions`) +> - 连接到 MiniMax、Synthetic 等需要 Anthropic 原生消息格式的服务 +> - 现有的 `anthropic` 协议返回 404 错误(说明端点不支持 OpenAI 兼容格式) +> +> **注意:** `anthropic` 协议使用 OpenAI 兼容格式(`/v1/chat/completions`),而 `anthropic-messages` 使用 Anthropic 原生格式(`/v1/messages`)。请根据端点支持的格式选择。 + +**Ollama (本地)** + +```json +{ + "model_name": "llama3", + "provider": "ollama", + "model": "llama3" +} +``` + +**LM Studio(本地)** + +```json +{ + "model_name": "lmstudio-local", + "provider": "lmstudio", + "model": "openai/gpt-oss-20b" +} +``` + +`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。 +显式设置 `provider` 后,PicoClaw 会把 `openai/gpt-oss-20b` 原样发送给 LM Studio。旧的兼容写法 `"model": "lmstudio/openai/gpt-oss-20b"` 在未设置 `provider` 时也会解析成相同的上游模型 ID。 + +**自定义代理/API** + +```json +{ + "model_name": "my-custom-model", + "provider": "openai", + "model": "custom-model", + "api_base": "https://my-proxy.com/v1", + "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", + "request_timeout": 300 +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "provider": "litellm", + "model": "lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_keys": ["sk-..."] +} +``` + +显式设置 `provider` 后,PicoClaw 会将 `model` 原样发送。因此 `"provider": "litellm", "model": "lite-gpt4"` 会发送 `lite-gpt4`,而 `"provider": "litellm", "model": "openai/gpt-4o"` 会发送 `openai/gpt-4o`。旧的兼容写法 `litellm/lite-gpt4` 和 `litellm/openai/gpt-4o` 在未设置 `provider` 时也会得到相同结果。 + +#### 负载均衡 + +为同一个模型名称配置多个端点——PicoClaw 会自动在它们之间轮询: + +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_base": "https://api1.example.com/v1", + "api_keys": ["sk-key1"] + }, + { + "model_name": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", + "api_base": "https://api2.example.com/v1", + "api_keys": ["sk-key2"] + } + ] +} +``` + +#### 自动模型失败切换(Cascade) + +当你在 Agent 的模型设置里配置 `primary` + `fallbacks` 时,PicoClaw 已经支持自动失败切换。 +运行时 fallback 链会在可重试错误时切到下一个候选(例如 HTTP `429`、配额/限流错误、超时错误)。 +同时会对每个候选应用 cooldown,避免对刚失败的目标立即重试。 + +```json +{ + "model_list": [ + { + "model_name": "qwen-main", + "provider": "openai", + "model": "qwen3.5:cloud", + "api_base": "https://api.example.com/v1", + "api_keys": ["sk-main"] + }, + { + "model_name": "deepseek-backup", + "provider": "deepseek", + "model": "deepseek-chat", + "api_keys": ["sk-backup-1"] + }, + { + "model_name": "gemini-backup", + "provider": "gemini", + "model": "gemini-2.5-flash", + "api_keys": ["sk-backup-2"] + } + ], + "agents": { + "defaults": { + "model_name": "qwen-main", + "model_fallbacks": ["deepseek-backup", "gemini-backup"] + } + } +} +``` + +如果你在同一模型上启用了 key 级失败切换,PicoClaw 会先在该模型的多 key 候选间切换,再继续切到跨模型备选。 + +#### 从旧的 `providers` 配置迁移 + +旧的 `providers` 配置格式**已弃用**,V2 中已移除。现有 V0/V1 配置会自动迁移。 + +**旧配置(已弃用):** + +```json +{ + "providers": { + "zhipu": { + "api_key": "your-key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + }, + "agents": { + "defaults": { + "provider": "zhipu", + "model": "glm-4.7" + } + } +} +``` + +**新配置(推荐):** + +```json +{ + "version": 3, + "model_list": [ + { + "model_name": "glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", + "api_keys": ["your-key"] + } + ], + "agents": { + "defaults": { + "model_name": "glm-4.7" + } + } +} +``` + +详细的迁移指南请参考 [docs/migration/model-list-migration.md](../migration/model-list-migration.md)。 + +### Provider 架构 + +PicoClaw 按协议族路由 Provider: + +- OpenAI 兼容协议:OpenRouter、OpenAI 兼容网关、Groq、智谱、vLLM 风格端点。 +- Gemini 原生协议:Google Gemini 通过原生 `models/*:generateContent` 和 `models/*:streamGenerateContent` 端点接入。 +- Anthropic 协议:Claude 原生 API 行为。 +- Codex/OAuth 路径:OpenAI OAuth/Token 认证路由。 + +这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_keys`)。 + +
+智谱 (Zhipu) 配置示例 + +**1. 获取 API key 和 base URL** + +- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. 配置** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. 运行** + +```bash +picoclaw agent -m "你好" +``` + +
+ +
+完整配置示例 + +```json +{ + "agents": { + "defaults": { + "model_name": "claude-opus-4-5" + } + }, + "session": { + "dm_scope": "per-channel-peer" + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "voice": { + "model_name": "voice-gemini", + "echo_transcription": false + }, + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "type": "discord", + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false, + "type": "whatsapp", + "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", + "allow_from": [] + }, + "feishu": { + "enabled": false, + "type": "feishu", + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "type": "qq", + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "", + "max_results": 5 + }, + "searxng": { + "enabled": false, + "base_url": "http://localhost:8888", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +--- + +## 📝 API Key 对比 + +| 服务 | 价格 | 适用场景 | +| --- | --- | --- | +| **OpenRouter** | 免费: 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) | +| **火山引擎 CodingPlan** | ¥9.9/首月 | 最适合国内用户,多种 SOTA 模型(豆包、DeepSeek 等) | +| **智谱 (Zhipu)** | 免费: 200K tokens/月 | 适合中国用户 | +| **Brave Search** | $5/1000 次查询 | 网络搜索功能 | +| **SearXNG** | 免费(自建) | 隐私优先的元搜索引擎(70+ 搜索引擎) | +| **Groq** | 免费额度可用 | 极速推理 (Llama, Mixtral) | +| **Cerebras** | 免费额度可用 | 极速推理 (Llama, Qwen 等) | +| **LongCat** | 免费: 最多 5M tokens/天 | 极速推理 | +| **ModelScope (魔搭)** | 免费: 2000 次请求/天 | 推理 (Qwen, GLM, DeepSeek 等) | diff --git a/docs/guides/routing-guide.md b/docs/guides/routing-guide.md new file mode 100644 index 000000000..a47984324 --- /dev/null +++ b/docs/guides/routing-guide.md @@ -0,0 +1,333 @@ +# Routing Guide + +> Back to [README](../README.md) + +In PicoClaw, routing has two user-facing parts: + +- **agent routing**: choose which agent should handle a message +- **model routing**: choose whether a turn should use the primary model or the configured light model + +This guide explains how to configure both for real deployments. + +## Quick Start + +### Route one Telegram group to a support agent + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "telegram support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + } + } + ] + } + } +} +``` + +### Route only Slack mentions in one workspace + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "slack mentions", + "agent": "support", + "when": { + "channel": "slack", + "space": "workspace:t001", + "mentioned": true + } + } + ] + } + } +} +``` + +### Use a light model for simple turns + +```json +{ + "model_list": [ + { + "model_name": "gpt-main", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-main"] + }, + { + "model_name": "flash-light", + "provider": "gemini", + "model": "gemini-2.0-flash-exp", + "api_keys": ["sk-light"] + } + ], + "agents": { + "defaults": { + "model_name": "gpt-main", + "routing": { + "enabled": true, + "light_model": "flash-light", + "threshold": 0.35 + } + } + } +} +``` + +## Agent Routing + +Agent routing is configured with: + +```text +agents.dispatch.rules +``` + +Rules are evaluated from top to bottom. +The **first matching rule wins**. +If no rule matches, PicoClaw falls back to the default agent. + +## Supported Match Fields + +| Field | Meaning | Example | +| --- | --- | --- | +| `channel` | Channel name | `telegram`, `slack`, `discord` | +| `account` | Normalized account ID | `default`, `bot2` | +| `space` | Workspace, guild, or similar container | `workspace:t001`, `guild:123456` | +| `chat` | Direct chat, group, or channel | `direct:user123`, `group:-100123`, `channel:c123` | +| `topic` | Thread or topic | `topic:42` | +| `sender` | Normalized sender identity | `12345`, `john` | +| `mentioned` | Whether the bot was explicitly mentioned | `true` | + +Values must match the normalized runtime shape, not the raw incoming payload. + +## Rule Ordering + +Put more specific rules before broader rules. + +Good: + +1. VIP sender inside one group +2. all traffic for that group +3. channel-wide fallback + +Bad: + +1. all traffic for that group +2. VIP sender inside the same group + +In the bad ordering, the broad rule wins first and the VIP rule never runs. + +## Session Interaction + +Routing and sessions are related but different. + +- routing decides which agent handles the message +- session settings decide which messages share memory + +You can override the global `session.dimensions` value for one matched rule with `session_dimensions`. + +Example: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" }, + { "id": "sales" } + ], + "dispatch": { + "rules": [ + { + "name": "vip in support group", + "agent": "sales", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890", + "sender": "12345" + }, + "session_dimensions": ["chat", "sender"] + }, + { + "name": "support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +In this configuration: + +- the VIP gets routed to `sales` +- everyone else in the group goes to `support` +- the VIP route also gets per-user session isolation + +## Identity Links + +`session.identity_links` also affects routing when you match on `sender`. +Use it when the same real user may appear under multiple raw sender IDs. + +Example: + +```json +{ + "session": { + "identity_links": { + "john": ["slack:u123", "legacy-user-42"] + } + }, + "agents": { + "dispatch": { + "rules": [ + { + "name": "john goes to sales", + "agent": "sales", + "when": { + "sender": "john" + } + } + ] + } + } +} +``` + +## Model Routing + +Model routing is configured under: + +```text +agents.defaults.routing +``` + +Current fields: + +| Field | Meaning | +| --- | --- | +| `enabled` | Turn model routing on or off | +| `light_model` | `model_name` from `model_list` used for simple turns | +| `threshold` | Complexity cutoff in `[0, 1]` | + +Important behavior: + +- the light model must exist in `model_list` +- PicoClaw resolves the light model at startup; if it is invalid, routing is disabled +- one turn stays on one model tier, even if it later calls tools + +## What Affects The Complexity Score + +The current model router looks at structural signals such as: + +- message length +- fenced code blocks +- recent tool calls in the same session +- conversation depth +- media or attachments + +This means a "simple" turn may still go to the primary model if it includes: + +- code +- images or audio +- a very long prompt +- a tool-heavy ongoing workflow + +## Choosing A Threshold + +Recommended starting point: + +```json +{ + "agents": { + "defaults": { + "routing": { + "enabled": true, + "light_model": "flash-light", + "threshold": 0.35 + } + } + } +} +``` + +General rule: + +- lower threshold: use the primary model more often +- higher threshold: use the light model more aggressively + +Practical suggestions: + +- `0.25` if you want safer routing with fewer light-model turns +- `0.35` as the default starting point +- `0.50+` only if your light model is already strong enough for most chat traffic + +## Troubleshooting + +### A rule is not matching + +Check: + +- rule order +- normalized value shape such as `group:-100123` instead of just `-100123` +- whether the channel actually provides `space`, `topic`, or `mentioned` + +### The wrong agent handles a message + +The most common cause is ordering. +Remember: first match wins. + +### The light model is never used + +Check: + +- `agents.defaults.routing.enabled` is `true` +- `light_model` exists in `model_list` +- the light model can actually initialize +- your threshold is not too low + +### The primary model is still chosen for short messages + +That can still happen when the turn includes: + +- a code block +- media or attachments +- recent tool-heavy history + +### Routing works, but the conversation memory is still too shared + +Adjust `session.dimensions` globally or `session_dimensions` on the specific route. +Routing chooses the agent, but sessions decide context sharing. + +## Related Guides + +- [Session Guide](session-guide.md) +- [Configuration Guide](configuration.md) +- [Providers & Model Configuration](providers.md) diff --git a/docs/guides/routing-guide.zh.md b/docs/guides/routing-guide.zh.md new file mode 100644 index 000000000..713cbeb04 --- /dev/null +++ b/docs/guides/routing-guide.zh.md @@ -0,0 +1,333 @@ +# 路由使用指南 + +> 返回 [README](../project/README.zh.md) + +PicoClaw 里用户能直接感知到的“路由”主要有两部分: + +- **agent 路由**:决定哪一个 agent 处理一条消息 +- **模型路由**:决定这一轮是走主模型,还是走轻量模型 + +这份文档面向真实部署中的配置使用场景。 + +## 快速开始 + +### 把一个 Telegram 群路由给 support agent + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "telegram support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + } + } + ] + } + } +} +``` + +### 只处理某个 Slack workspace 里的 @提及 + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "slack mentions", + "agent": "support", + "when": { + "channel": "slack", + "space": "workspace:t001", + "mentioned": true + } + } + ] + } + } +} +``` + +### 给简单请求启用轻量模型 + +```json +{ + "model_list": [ + { + "model_name": "gpt-main", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-main"] + }, + { + "model_name": "flash-light", + "provider": "gemini", + "model": "gemini-2.0-flash-exp", + "api_keys": ["sk-light"] + } + ], + "agents": { + "defaults": { + "model_name": "gpt-main", + "routing": { + "enabled": true, + "light_model": "flash-light", + "threshold": 0.35 + } + } + } +} +``` + +## Agent 路由 + +Agent 路由通过下面这个配置项定义: + +```text +agents.dispatch.rules +``` + +规则从上到下依次检查。 +**第一条匹配的规则直接生效**。 +如果没有规则命中,PicoClaw 会回退到默认 agent。 + +## 支持的匹配字段 + +| 字段 | 含义 | 示例 | +| --- | --- | --- | +| `channel` | Channel 名称 | `telegram`、`slack`、`discord` | +| `account` | 归一化后的 account ID | `default`、`bot2` | +| `space` | workspace、guild 等上层容器 | `workspace:t001`、`guild:123456` | +| `chat` | 私聊、群或频道 | `direct:user123`、`group:-100123`、`channel:c123` | +| `topic` | 线程或话题 | `topic:42` | +| `sender` | 归一化后的发送者身份 | `12345`、`john` | +| `mentioned` | 是否显式 @ 了 bot | `true` | + +注意,配置里要写的是运行时归一化后的值,不是原始 webhook / SDK payload。 + +## 规则顺序 + +把更具体的规则放前面,把更宽泛的规则放后面。 + +正确顺序: + +1. 某个群里的 VIP 用户 +2. 这个群的全部消息 +3. 某个 channel 的更宽泛兜底 + +错误顺序: + +1. 这个群的全部消息 +2. 同一个群里的 VIP 用户 + +在错误顺序下,宽泛规则会先命中,VIP 规则永远不会生效。 + +## 和 Session 的关系 + +路由和 Session 是相关但不同的两件事: + +- 路由决定由哪个 agent 处理 +- Session 决定这些消息是否共享同一段记忆 + +如果你想让某条命中的路由使用不同的会话策略,可以用 `session_dimensions` 覆盖全局 `session.dimensions`。 + +示例: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" }, + { "id": "sales" } + ], + "dispatch": { + "rules": [ + { + "name": "vip in support group", + "agent": "sales", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890", + "sender": "12345" + }, + "session_dimensions": ["chat", "sender"] + }, + { + "name": "support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +在这个配置里: + +- VIP 用户会被路由到 `sales` +- 其他群成员会进入 `support` +- VIP 路由还会额外按 `chat + sender` 做每用户隔离 + +## Identity Links + +当你用 `sender` 做匹配时,`session.identity_links` 也会影响路由结果。 +适合这种场景:同一个真实用户可能出现为多个原始 sender ID。 + +示例: + +```json +{ + "session": { + "identity_links": { + "john": ["slack:u123", "legacy-user-42"] + } + }, + "agents": { + "dispatch": { + "rules": [ + { + "name": "john goes to sales", + "agent": "sales", + "when": { + "sender": "john" + } + } + ] + } + } +} +``` + +## 模型路由 + +模型路由配置在: + +```text +agents.defaults.routing +``` + +当前支持字段: + +| 字段 | 含义 | +| --- | --- | +| `enabled` | 开启或关闭模型路由 | +| `light_model` | `model_list` 中用于简单请求的 `model_name` | +| `threshold` | `[0, 1]` 范围内的复杂度阈值 | + +关键行为: + +- `light_model` 必须存在于 `model_list` +- PicoClaw 会在启动时解析轻量模型;如果模型无效,路由会被禁用 +- 同一轮 turn 只会使用同一档模型,不会中途切档 + +## 什么会影响复杂度分数 + +当前模型路由会看一些结构化信号,例如: + +- 消息长度 +- fenced code block +- 同一 session 最近是否频繁调用工具 +- 会话深度 +- 是否带有媒体或附件 + +因此,看起来“很简单”的消息,在以下情况下仍可能走主模型: + +- 带代码 +- 带图片或音频 +- prompt 很长 +- 当前是一个工具调用很多的工作流 + +## 阈值怎么选 + +推荐起点: + +```json +{ + "agents": { + "defaults": { + "routing": { + "enabled": true, + "light_model": "flash-light", + "threshold": 0.35 + } + } + } +} +``` + +通用规律: + +- 阈值越低,越容易回到主模型 +- 阈值越高,越积极地使用轻量模型 + +实用建议: + +- `0.25`:更保守,更少轻量模型 turn +- `0.35`:默认推荐起点 +- `0.50+`:只有当你的轻量模型已经能覆盖大多数聊天任务时再考虑 + +## 常见问题 + +### 某条规则没有命中 + +优先检查: + +- 规则顺序 +- 值的形状是否写成了归一化格式,例如 `group:-100123` 而不是裸 `-100123` +- 当前 channel 是否真的提供了 `space`、`topic` 或 `mentioned` + +### 消息被错误的 agent 处理了 + +最常见原因还是顺序。 +记住:第一条匹配的规则直接生效。 + +### 轻量模型从来没有被用到 + +检查: + +- `agents.defaults.routing.enabled` 是否为 `true` +- `light_model` 是否存在于 `model_list` +- 轻量模型能否成功初始化 +- 阈值是不是设得太低 + +### 明明是短消息,还是走了主模型 + +这通常是因为当前 turn 同时满足了其他“复杂”信号,例如: + +- 带代码块 +- 带媒体或附件 +- 最近的 session 历史里工具调用很多 + +### 路由没问题,但上下文还是共享得太多 + +去调整 `session.dimensions` 或某条 route 上的 `session_dimensions`。 +路由只决定“谁来处理”,session 才决定“记忆怎么共享”。 + +## 相关文档 + +- [Session 使用指南](session-guide.zh.md) +- [配置指南](configuration.zh.md) +- [Provider 与模型配置](providers.zh.md) diff --git a/docs/guides/session-guide.md b/docs/guides/session-guide.md new file mode 100644 index 000000000..3f3759260 --- /dev/null +++ b/docs/guides/session-guide.md @@ -0,0 +1,273 @@ +# Session Guide + +> Back to [README](../README.md) + +PicoClaw sessions decide which messages share the same conversation history. +If your bot "remembers too much" or "forgets too much", the first thing to check is the session configuration. + +This guide is for users configuring session behavior in `config.json`. +For implementation details, see the architecture docs instead. + +## What Sessions Control + +A session controls: + +- which previous messages are visible to the agent +- when summarization starts for that conversation +- whether two users in the same group share context +- whether different chats, threads, or spaces stay isolated + +Session data is stored under your workspace, typically: + +```text +~/.picoclaw/workspace/sessions/ +``` + +## Quick Start + +### Default: one context per chat + +This is the default and is the right choice for most bots. + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +Use this when: + +- each group/channel should have its own shared memory +- each direct message should have its own separate memory + +### Separate each user inside a group + +If users in the same group should not share memory, add `sender`: + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +Use this when: + +- one shared assistant sits in a busy group +- each user should keep a private thread of context even inside the same room + +### Share one context across multiple rooms in the same workspace or guild + +If your channel exposes a `space` value, you can route by workspace or guild instead of by room: + +```json +{ + "session": { + "dimensions": ["space"] + } +} +``` + +Use this when: + +- a Slack workspace assistant should share context across channels +- a Discord guild assistant should share context across channels + +### Split by thread or forum topic + +If your channel exposes `topic`, you can isolate per thread: + +```json +{ + "session": { + "dimensions": ["chat", "topic"] + } +} +``` + +Use this when: + +- each forum topic should keep its own history +- each threaded discussion should stay separate + +## Available Dimensions + +| Dimension | What it means | Good for | +| --- | --- | --- | +| `space` | Workspace, guild, or similar top-level container | One shared assistant across many rooms | +| `chat` | Direct chat, group, or channel | Default per-room isolation | +| `topic` | Thread, topic, or forum sub-channel | Keep threaded discussions separate | +| `sender` | The message sender after normalization | Per-user context inside shared rooms | + +Not every channel provides every field. +If a channel does not supply `space` or `topic`, those dimensions simply have no effect for that message. + +## Important Behavior + +### Sessions are always separated by agent + +Even if two agents receive messages from the same chat, they do not share one session. + +### Sessions are still separated by channel and account + +`session.dimensions` adds finer-grained isolation, but PicoClaw still keeps a baseline separation by: + +- agent +- channel +- account + +That means an empty or very small `dimensions` list does **not** create one global memory across every platform. + +### Telegram forum topics already stay isolated in the default `chat` mode + +Telegram forum messages keep topic isolation by default even when `dimensions` only contains `chat`. +You usually do not need a special workaround for Telegram forums. + +### Summaries happen per session + +`summarize_message_threshold` and `summarize_token_percent` apply inside each session independently. +If you create smaller sessions, summarization also happens on smaller per-session histories. + +## Common Recipes + +### One shared assistant per group or direct chat + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +### One context per user inside each chat + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +### One context per sender across one workspace or guild + +```json +{ + "session": { + "dimensions": ["space", "sender"] + } +} +``` + +This is useful for workspace-wide assistants where each user should keep their own memory while moving across rooms in the same workspace. + +### Use a different session policy for one routed agent only + +You can keep the global default and override it for one dispatch rule: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat", "sender"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +In this example: + +- most traffic uses one shared context per chat +- the support group uses one context per user inside that chat + +## Identity Links + +`session.identity_links` helps when the same user may appear under multiple raw sender IDs and you want PicoClaw to treat them as one sender identity. + +Example: + +```json +{ + "session": { + "dimensions": ["chat", "sender"], + "identity_links": { + "john": ["slack:u123", "u123", "legacy-user-42"] + } + } +} +``` + +This is mainly useful for: + +- migrated sender IDs +- platform-specific ID aliases +- cleanup after changing channel adapters or account naming + +Current limitation: + +- `identity_links` does not make one user share memory across different channels automatically +- channel and account remain part of the baseline session scope + +## Troubleshooting + +### Users in one group are sharing memory + +Your current session is probably keyed only by `chat`. +Switch to: + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +### The same user does not share memory across Slack and Telegram + +That is expected. +PicoClaw still separates sessions by channel even if you use `sender`. + +### Threads are mixing together + +Add `topic` when the channel provides one: + +```json +{ + "session": { + "dimensions": ["chat", "topic"] + } +} +``` + +### Old sessions seem to use legacy keys + +That is normal during migration. +PicoClaw keeps compatibility with older `agent:...` session keys while moving runtime storage to opaque canonical keys. + +## Related Guides + +- [Configuration Guide](configuration.md) +- [Routing Guide](routing-guide.md) +- [Providers & Model Configuration](providers.md) diff --git a/docs/guides/session-guide.zh.md b/docs/guides/session-guide.zh.md new file mode 100644 index 000000000..679a7f68d --- /dev/null +++ b/docs/guides/session-guide.zh.md @@ -0,0 +1,273 @@ +# Session 使用指南 + +> 返回 [README](../project/README.zh.md) + +PicoClaw 的 Session 决定了哪些消息会共享同一段对话历史。 +如果你的 bot 表现为“记得太多”或“忘得太快”,首先就该检查 session 配置。 + +这份文档面向编辑 `config.json` 的普通用户。 +如果你想看内部实现细节,请看 architecture 文档,而不是这里。 + +## Session 控制什么 + +一个 session 会影响: + +- Agent 能看到哪些历史消息 +- 这段对话何时开始触发摘要 +- 同一个群里的不同用户是否共享上下文 +- 不同聊天、不同线程、不同空间是否保持隔离 + +Session 数据保存在工作区目录下,通常是: + +```text +~/.picoclaw/workspace/sessions/ +``` + +## 快速开始 + +### 默认:每个 chat 一段上下文 + +这是默认值,也是大多数 bot 的正确起点。 + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +适用场景: + +- 每个群 / 频道都有自己的共享记忆 +- 每个私聊都有各自独立的记忆 + +### 在同一个群里按用户分开 + +如果同一个群里的不同用户不应该共享上下文,增加 `sender`: + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +适用场景: + +- 一个群里挂着一个共享 assistant,但不希望用户之间串上下文 +- 希望每个用户在同一个房间里保留自己的独立记忆 + +### 在同一个 workspace / guild 下跨多个房间共享上下文 + +如果你的 channel 会提供 `space`,可以按 workspace 或 guild 共享,而不是按单个房间共享: + +```json +{ + "session": { + "dimensions": ["space"] + } +} +``` + +适用场景: + +- Slack workspace 里的 assistant 想跨多个 channel 共享上下文 +- Discord guild 里的 assistant 想跨多个 channel 共享上下文 + +### 按线程或论坛 topic 隔离 + +如果 channel 会提供 `topic`,可以显式按线程隔离: + +```json +{ + "session": { + "dimensions": ["chat", "topic"] + } +} +``` + +适用场景: + +- 每个论坛 topic 都要保留独立历史 +- 每个 threaded discussion 都不能串上下文 + +## 可用维度 + +| 维度 | 含义 | 适合什么场景 | +| --- | --- | --- | +| `space` | workspace、guild 或类似的上层容器 | 一个 assistant 跨多个房间共享上下文 | +| `chat` | 私聊、群聊或频道 | 默认按房间隔离 | +| `topic` | 线程、topic 或 forum 子通道 | 让 threaded discussion 保持隔离 | +| `sender` | 归一化后的消息发送者 | 在共享房间内按用户隔离 | + +并不是每个 channel 都会提供全部字段。 +如果某个 channel 没有 `space` 或 `topic`,对应维度对那条消息就不会生效。 + +## 关键行为 + +### Session 总是按 agent 分开 + +即使两个 agent 处理同一个 chat,它们也不会共享同一段 session。 + +### Session 仍然会按 channel 和 account 分开 + +`session.dimensions` 只是添加更细的隔离维度,PicoClaw 仍然保留一层基础隔离: + +- agent +- channel +- account + +这意味着即使 `dimensions` 为空,系统也**不会**把所有平台的消息都混成一个全局记忆。 + +### Telegram forum topic 在默认 `chat` 模式下也会保持隔离 + +Telegram forum 消息在默认 `chat` 模式下就会保留 topic 隔离。 +通常不需要额外为 Telegram forum 单独写 workaround。 + +### 摘要是按 session 触发的 + +`summarize_message_threshold` 和 `summarize_token_percent` 都是针对单个 session 生效。 +如果你把 session 切得更小,摘要也会按更小的历史范围触发。 + +## 常见配置方案 + +### 每个群 / 私聊共享一段上下文 + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +### 每个 chat 内再按用户拆分 + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +### 在同一个 workspace / guild 内按用户保留上下文 + +```json +{ + "session": { + "dimensions": ["space", "sender"] + } +} +``` + +这适合做 workspace 级 assistant:用户在同一个 workspace 里跨多个房间移动,但仍保留自己的上下文。 + +### 只给某个路由出来的 agent 覆盖 session 策略 + +你可以保留全局默认值,再在某条 dispatch rule 上单独覆盖: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat", "sender"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +在这个例子里: + +- 大部分流量仍然按 `chat` 共享上下文 +- 只有 support 群按 `chat + sender` 拆成每人一段上下文 + +## Identity Links + +`session.identity_links` 适合处理这种场景:同一个人可能会以多个原始 sender ID 出现,但你希望 PicoClaw 把它们视为同一个发送者身份。 + +示例: + +```json +{ + "session": { + "dimensions": ["chat", "sender"], + "identity_links": { + "john": ["slack:u123", "u123", "legacy-user-42"] + } + } +} +``` + +这主要适用于: + +- sender ID 迁移 +- 同一平台下的多个 ID 别名 +- 调整 channel adapter 或 account 命名后的兼容清理 + +当前限制: + +- `identity_links` 不会自动让同一个用户跨不同 channel 共享记忆 +- channel 和 account 仍然属于基础 session scope 的一部分 + +## 常见问题 + +### 同一个群里的用户在共享记忆 + +大概率是当前 session 只按 `chat` 建。 +改成: + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +### 同一个用户在 Slack 和 Telegram 之间没有共享记忆 + +这是当前实现下的预期行为。 +即使使用了 `sender`,PicoClaw 仍然会按 channel 做基础隔离。 + +### 不同线程混在一起了 + +如果这个 channel 提供 `topic`,加上它: + +```json +{ + "session": { + "dimensions": ["chat", "topic"] + } +} +``` + +### 升级后看到旧的 session key + +这属于正常兼容行为。 +PicoClaw 在迁移到新的 opaque canonical key 时,仍会兼容旧的 `agent:...` session key。 + +## 相关文档 + +- [配置指南](configuration.zh.md) +- [路由指南](routing-guide.zh.md) +- [Provider 与模型配置](providers.zh.md) diff --git a/docs/guides/spawn-tasks.fr.md b/docs/guides/spawn-tasks.fr.md new file mode 100644 index 000000000..40a7a3ded --- /dev/null +++ b/docs/guides/spawn-tasks.fr.md @@ -0,0 +1,61 @@ +# 🔄 Tâches Asynchrones et Spawn + +> Retour au [README](../project/README.fr.md) + +## Tâches Rapides (réponse directe) + +- Rapporter l'heure actuelle + +## Tâches Longues (utiliser spawn pour l'asynchrone) + +- Rechercher sur le web des actualités IA et résumer +- Vérifier les emails et rapporter les messages importants +``` + +**Comportements clés :** + +| Fonctionnalité | Description | +| ----------------------- | --------------------------------------------------------------- | +| **spawn** | Crée un subagent asynchrone, ne bloque pas le heartbeat | +| **Independent context** | Le subagent a son propre contexte, pas d'historique de session | +| **message tool** | Le subagent communique directement avec l'utilisateur via l'outil message | +| **Non-blocking** | Après le spawn, le heartbeat continue à la tâche suivante | + +#### Fonctionnement de la Communication du Subagent + +``` +Heartbeat se déclenche + ↓ +L'agent lit HEARTBEAT.md + ↓ +Pour une tâche longue : spawn subagent + ↓ ↓ +Continue à la tâche suivante Le subagent travaille indépendamment + ↓ ↓ +Toutes les tâches terminées Le subagent utilise l'outil "message" + ↓ ↓ +Répond HEARTBEAT_OK L'utilisateur reçoit le résultat directement +``` + +Le subagent a accès aux outils (message, web_search, etc.) et peut communiquer avec l'utilisateur indépendamment sans passer par l'agent principal. + +**Configuration :** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Option | Par défaut | Description | +| ---------- | ---------- | ---------------------------------------------- | +| `enabled` | `true` | Activer/désactiver le heartbeat | +| `interval` | `30` | Intervalle de vérification en minutes (min: 5) | + +**Variables d'environnement :** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` pour désactiver +* `PICOCLAW_HEARTBEAT_INTERVAL=60` pour changer l'intervalle diff --git a/docs/guides/spawn-tasks.ja.md b/docs/guides/spawn-tasks.ja.md new file mode 100644 index 000000000..598654242 --- /dev/null +++ b/docs/guides/spawn-tasks.ja.md @@ -0,0 +1,68 @@ +# 🔄 非同期タスクと Spawn + +> [README](../project/README.ja.md) に戻る + +### Spawn を使用した非同期タスク + +長時間実行タスク(Web 検索、API 呼び出し)には、`spawn` ツールを使用して**サブ Agent (subagent)** を作成します: + +```markdown +# Periodic Tasks + +## Quick Tasks (respond directly) + +- Report current time + +## Long Tasks (use spawn for async) + +- Search the web for AI news and summarize +- Check email and report important messages +``` + +**主な動作:** + +| 特性 | 説明 | +| ---------------- | ------------------------------------------------ | +| **spawn** | 非同期サブ Agent を作成、メインハートビートをブロックしない | +| **独立コンテキスト** | サブ Agent は独自のコンテキストを持ち、セッション履歴なし | +| **message tool** | サブ Agent は message ツールでユーザーと直接通信 | +| **ノンブロッキング** | spawn 後、ハートビートは次のタスクに進む | + +#### サブ Agent の通信の仕組み + +``` +ハートビートトリガー (Heartbeat triggers) + ↓ +Agent が HEARTBEAT.md を読み取り + ↓ +長時間タスクの場合: サブ Agent を spawn + ↓ ↓ +次のタスクに進む サブ Agent が独立して作業 + ↓ ↓ +すべてのタスク完了 サブ Agent が "message" ツールを使用 + ↓ ↓ +HEARTBEAT_OK を応答 ユーザーが直接結果を受信 +``` + +サブ Agent はツール(message、web_search など)にアクセスでき、メイン Agent を経由せずにユーザーと独立して通信できます。 + +**設定:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| オプション | デフォルト値 | 説明 | +| ---------- | ------------ | ------------------------------ | +| `enabled` | `true` | ハートビートの有効/無効 | +| `interval` | `30` | チェック間隔(分単位、最小: 5)| + +**環境変数:** + +- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化 +- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔を変更 diff --git a/docs/guides/spawn-tasks.md b/docs/guides/spawn-tasks.md new file mode 100644 index 000000000..05a5215d2 --- /dev/null +++ b/docs/guides/spawn-tasks.md @@ -0,0 +1,70 @@ +# 🔄 Spawn & Async Tasks + +> Back to [README](../README.md) + +PicoClaw supports **asynchronous task execution** via the `spawn` tool. This is primarily used by the **Heartbeat** system to run long-running tasks without blocking the main agent loop. + +## Heartbeat + +The heartbeat system periodically checks `workspace/HEARTBEAT.md` for scheduled tasks. On first run, a default template is auto-generated. You can customize it to define quick tasks (handled inline) and long tasks (delegated via `spawn`). + +**Example `HEARTBEAT.md`:** + +```markdown +## Quick Tasks (respond directly) + +- Report current time + +## Long Tasks (use spawn for async) + +- Search the web for AI news and summarize +- Check email and report important messages +``` + +**Key behaviors:** + +| Feature | Description | +| ----------------------- | --------------------------------------------------------- | +| **spawn** | Creates async subagent, doesn't block heartbeat | +| **Independent context** | Subagent has its own context, no session history | +| **message tool** | Subagent communicates with user directly via message tool | +| **Non-blocking** | After spawning, heartbeat continues to next task | + +#### How Subagent Communication Works + +``` +Heartbeat triggers + ↓ +Agent reads HEARTBEAT.md + ↓ +For long task: spawn subagent + ↓ ↓ +Continue to next task Subagent works independently + ↓ ↓ +All tasks done Subagent uses "message" tool + ↓ ↓ +Respond HEARTBEAT_OK User receives result directly +``` + +The subagent has access to tools (message, web_search, etc.) and can communicate with the user independently without going through the main agent. + +**Configuration:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Option | Default | Description | +| ---------- | ------- | ---------------------------------- | +| `enabled` | `true` | Enable/disable heartbeat | +| `interval` | `30` | Check interval in minutes (min: 5) | + +**Environment variables:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable +* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval diff --git a/docs/guides/spawn-tasks.ms.md b/docs/guides/spawn-tasks.ms.md new file mode 100644 index 000000000..055ebf20d --- /dev/null +++ b/docs/guides/spawn-tasks.ms.md @@ -0,0 +1,61 @@ +# 🔄 Spawn & Tugasan Async + +> Kembali ke [README](../project/README.ms.md) + +## Tugasan Cepat (balas terus) + +- Laporkan masa semasa + +## Tugasan Panjang (guna spawn untuk async) + +- Cari berita AI di web dan ringkaskan +- Semak e-mel dan laporkan mesej penting +``` + +**Tingkah laku utama:** + +| Feature | Description | +| ----------------------- | --------------------------------------------------------- | +| **spawn** | Mencipta sub-agen async, tidak menyekat heartbeat | +| **Independent context** | Sub-agen mempunyai konteks sendiri, tiada sejarah sesi | +| **message tool** | Sub-agen berkomunikasi terus dengan pengguna melalui message tool | +| **Non-blocking** | Selepas spawn, heartbeat terus ke tugasan seterusnya | + +#### Cara Komunikasi Sub-agen Berfungsi + +``` +Heartbeat dicetuskan + ↓ +Agen membaca HEARTBEAT.md + ↓ +Untuk tugasan panjang: spawn sub-agen + ↓ ↓ +Terus ke tugasan seterusnya Sub-agen bekerja secara bebas + ↓ ↓ +Semua tugasan selesai Sub-agen menggunakan tool "message" + ↓ ↓ +Balas HEARTBEAT_OK Pengguna menerima hasil secara terus +``` + +Sub-agen mempunyai akses kepada tools (message, web_search, dan sebagainya) dan boleh berkomunikasi dengan pengguna secara bebas tanpa melalui agen utama. + +**Konfigurasi:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Option | Default | Description | +| ---------- | ------- | ---------------------------------------- | +| `enabled` | `true` | Hidupkan/matikan heartbeat | +| `interval` | `30` | Selang semakan dalam minit (minimum: 5) | + +**Pemboleh ubah persekitaran:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` untuk nyahaktifkan +* `PICOCLAW_HEARTBEAT_INTERVAL=60` untuk menukar selang diff --git a/docs/guides/spawn-tasks.pt-br.md b/docs/guides/spawn-tasks.pt-br.md new file mode 100644 index 000000000..0de929821 --- /dev/null +++ b/docs/guides/spawn-tasks.pt-br.md @@ -0,0 +1,61 @@ +# 🔄 Tarefas Assíncronas e Spawn + +> Voltar ao [README](../project/README.pt-br.md) + +## Tarefas Rápidas (resposta direta) + +- Informar a hora atual + +## Tarefas Longas (usar spawn para assíncrono) + +- Pesquisar na web notícias sobre IA e resumir +- Verificar e-mail e relatar mensagens importantes +``` + +**Comportamentos principais:** + +| Feature | Description | +| ----------------------- | --------------------------------------------------------- | +| **spawn** | Creates async subagent, doesn't block heartbeat | +| **Independent context** | Subagent has its own context, no session history | +| **message tool** | Subagent communicates with user directly via message tool | +| **Non-blocking** | After spawning, heartbeat continues to next task | + +#### Como Funciona a Comunicação do Subagente + +``` +Heartbeat é acionado + ↓ +Agente lê HEARTBEAT.md + ↓ +Para tarefa longa: spawn subagente + ↓ ↓ +Continua para próxima tarefa Subagente trabalha independentemente + ↓ ↓ +Todas as tarefas concluídas Subagente usa ferramenta "message" + ↓ ↓ +Responde HEARTBEAT_OK Usuário recebe resultado diretamente +``` + +O subagente tem acesso a ferramentas (message, web_search, etc.) e pode se comunicar com o usuário independentemente sem passar pelo agente principal. + +**Configuração:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Option | Default | Description | +| ---------- | ------- | ---------------------------------- | +| `enabled` | `true` | Enable/disable heartbeat | +| `interval` | `30` | Check interval in minutes (min: 5) | + +**Variáveis de ambiente:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar +* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo diff --git a/docs/guides/spawn-tasks.vi.md b/docs/guides/spawn-tasks.vi.md new file mode 100644 index 000000000..e8533750b --- /dev/null +++ b/docs/guides/spawn-tasks.vi.md @@ -0,0 +1,61 @@ +# 🔄 Tác Vụ Bất Đồng Bộ và Spawn + +> Quay lại [README](../project/README.vi.md) + +## Tác Vụ Nhanh (phản hồi trực tiếp) + +- Báo cáo thời gian hiện tại + +## Tác Vụ Dài (sử dụng spawn cho bất đồng bộ) + +- Tìm kiếm web tin tức AI và tóm tắt +- Kiểm tra email và báo cáo tin nhắn quan trọng +``` + +**Hành vi chính:** + +| Feature | Description | +| ----------------------- | --------------------------------------------------------- | +| **spawn** | Creates async subagent, doesn't block heartbeat | +| **Independent context** | Subagent has its own context, no session history | +| **message tool** | Subagent communicates with user directly via message tool | +| **Non-blocking** | After spawning, heartbeat continues to next task | + +#### Cách Giao Tiếp Subagent Hoạt Động + +``` +Heartbeat được kích hoạt + ↓ +Agent đọc HEARTBEAT.md + ↓ +Cho tác vụ dài: spawn subagent + ↓ ↓ +Tiếp tục tác vụ tiếp theo Subagent làm việc độc lập + ↓ ↓ +Tất cả tác vụ hoàn thành Subagent sử dụng công cụ "message" + ↓ ↓ +Phản hồi HEARTBEAT_OK Người dùng nhận kết quả trực tiếp +``` + +Subagent 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. + +**Cấu hình:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Option | Default | Description | +| ---------- | ------- | ---------------------------------- | +| `enabled` | `true` | Enable/disable heartbeat | +| `interval` | `30` | Check interval in minutes (min: 5) | + +**Biến môi trường:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` để tắt +* `PICOCLAW_HEARTBEAT_INTERVAL=60` để thay đổi khoảng thời gian diff --git a/docs/guides/spawn-tasks.zh.md b/docs/guides/spawn-tasks.zh.md new file mode 100644 index 000000000..ee5f1580e --- /dev/null +++ b/docs/guides/spawn-tasks.zh.md @@ -0,0 +1,70 @@ +# 🔄 异步任务与 Spawn + +> 返回 [README](../project/README.zh.md) + +PicoClaw 通过 `spawn` 工具支持**异步任务执行**。主要由 **Heartbeat(心跳)** 系统使用,在不阻塞主 Agent 循环的情况下运行耗时任务。 + +## Heartbeat + +心跳系统会定期检查 `workspace/HEARTBEAT.md` 中的计划任务。首次运行时会自动生成默认模板,你可以自定义它来定义快速任务(内联处理)和长任务(通过 `spawn` 委派)。 + +**`HEARTBEAT.md` 示例:** + +```markdown +## Quick Tasks (respond directly) + +- Report current time + +## Long Tasks (use spawn for async) + +- Search the web for AI news and summarize +- Check email and report important messages +``` + +**关键行为:** + +| 特性 | 描述 | +| ---------------- | ---------------------------------------- | +| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 | +| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 | +| **message tool** | 子 Agent 通过 message 工具直接与用户通信 | +| **非阻塞** | spawn 后,心跳继续处理下一个任务 | + +#### 子 Agent 通信原理 + +``` +心跳触发 (Heartbeat triggers) + ↓ +Agent 读取 HEARTBEAT.md + ↓ +对于长任务: spawn 子 Agent + ↓ ↓ +继续下一个任务 子 Agent 独立工作 + ↓ ↓ +所有任务完成 子 Agent 使用 "message" 工具 + ↓ ↓ +响应 HEARTBEAT_OK 用户直接收到结果 +``` + +子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。 + +**配置:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| 选项 | 默认值 | 描述 | +| ---------- | ------ | ---------------------------- | +| `enabled` | `true` | 启用/禁用心跳 | +| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) | + +**环境变量:** + +- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 +- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔 diff --git a/docs/migration/README.md b/docs/migration/README.md new file mode 100644 index 000000000..eb37eec20 --- /dev/null +++ b/docs/migration/README.md @@ -0,0 +1,5 @@ +# Migration + +Migration notes for major configuration and behavior changes across PicoClaw versions. + +- [Migration Guide: From `providers` to `model_list`](model-list-migration.md): update legacy provider config to the current `model_list` format. diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md index 0d4af719c..4fb37c580 100644 --- a/docs/migration/model-list-migration.md +++ b/docs/migration/model-list-migration.md @@ -8,7 +8,7 @@ The new `model_list` configuration offers several advantages: - **Zero-code provider addition**: Add OpenAI-compatible providers with configuration only - **Load balancing**: Configure multiple endpoints for the same model -- **Protocol-based routing**: Use prefixes like `openai/`, `anthropic/`, etc. +- **Explicit provider resolution**: Prefer `provider` + native `model`, with legacy `provider/model` compatibility when needed - **Cleaner configuration**: Model-centric instead of vendor-centric ## Timeline @@ -40,7 +40,7 @@ The new `model_list` configuration offers several advantages: "agents": { "defaults": { "provider": "openai", - "model": "gpt-5.2" + "model": "gpt-5.4" } } } @@ -50,68 +50,81 @@ The new `model_list` configuration offers several advantages: ```json { + "version": 3, "model_list": [ { "model_name": "gpt4", - "model": "openai/gpt-5.2", - "api_key": "sk-your-openai-key", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-your-openai-key"], "api_base": "https://api.openai.com/v1" }, { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "provider": "anthropic", + "model": "claude-sonnet-4.6", + "api_keys": ["sk-ant-your-key"] }, { "model_name": "deepseek", - "model": "deepseek/deepseek-chat", - "api_key": "sk-your-deepseek-key" + "provider": "deepseek", + "model": "deepseek-chat", + "api_keys": ["sk-your-deepseek-key"] } ], "agents": { "defaults": { - "model": "gpt4" + "model_name": "gpt4" } } } ``` -## Protocol Prefixes +> **Note**: The `enabled` field can be omitted — during V1→V2 migration it is auto-inferred (models with API keys or the `local-model` name are enabled by default). For new configs, you can explicitly set `"enabled": false` to disable a model entry without removing it. -The `model` field uses a protocol prefix format: `[protocol/]model-identifier` +## Provider / Model Resolution -| Prefix | Description | Example | -|--------|-------------|---------| -| `openai/` | OpenAI API (default) | `openai/gpt-5.2` | -| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` | -| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` | -| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` | -| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4.6` | -| `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` | -| `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` | -| `openrouter/` | OpenRouter | `openrouter/anthropic/claude-sonnet-4.6` | -| `groq/` | Groq API | `groq/llama-3.1-70b` | -| `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` | -| `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` | -| `qwen/` | Alibaba Qwen | `qwen/qwen-max` | -| `zhipu/` | Zhipu AI | `zhipu/glm-4` | -| `nvidia/` | NVIDIA NIM | `nvidia/llama-3.1-nemotron-70b` | -| `ollama/` | Ollama (local) | `ollama/llama3` | -| `vllm/` | vLLM (local) | `vllm/my-model` | -| `moonshot/` | Moonshot AI | `moonshot/moonshot-v1-8k` | -| `shengsuanyun/` | ShengSuanYun | `shengsuanyun/deepseek-v3` | -| `volcengine/` | Volcengine | `volcengine/doubao-pro-32k` | +Preferred format: -**Note**: If no prefix is specified, `openai/` is used as the default. +```json +{ + "provider": "openai", + "model": "gpt-5.4" +} +``` + +Legacy compatibility format: + +```json +{ + "model": "openai/gpt-5.4" +} +``` + +Resolution rules: + +1. If `provider` is set, PicoClaw sends `model` unchanged. +2. If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID. + +Examples: + +| Config | Resolved Provider | Model Sent Upstream | +|--------|-------------------|---------------------| +| `"provider": "openai", "model": "gpt-5.4"` | `openai` | `gpt-5.4` | +| `"model": "openai/gpt-5.4"` | `openai` | `gpt-5.4` | +| `"provider": "openrouter", "model": "google/gemini-2.0-flash-exp:free"` | `openrouter` | `google/gemini-2.0-flash-exp:free` | +| `"model": "openrouter/google/gemini-2.0-flash-exp:free"` | `openrouter` | `google/gemini-2.0-flash-exp:free` | ## ModelConfig Fields | Field | Required | Description | |-------|----------|-------------| | `model_name` | Yes | User-facing alias for the model | -| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.2`) | +| `provider` | No | Preferred provider identifier. When set, `model` is sent unchanged | +| `model` | Yes | Native model ID when `provider` is set, or legacy `provider/model` when `provider` is omitted | | `api_base` | No | API endpoint URL | -| `api_key` | No* | API authentication key | +| `api_keys` | No | API authentication keys (array; supports multiple keys for load balancing) | +| `enabled` | No | Whether this model entry is active. Defaults to `true` during migration for models with API keys or named `local-model`. Set to `false` to disable. | | `proxy` | No | HTTP proxy URL | | `auth_method` | No | Authentication method: `oauth`, `token` | | `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` | @@ -119,31 +132,63 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier` | `max_tokens_field` | No | Field name for max tokens | | `request_timeout` | No | HTTP request timeout in seconds; `<=0` uses default `120s` | -*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server. +> **Note**: `api_key` (singular) has been **removed** in V2 configs. Only `api_keys` (array) is supported. During migration from V0/V1, both `api_key` and `api_keys` are automatically merged into the new `api_keys` array. ## Load Balancing -Configure multiple endpoints for the same model to distribute load: +There are two ways to configure load balancing: + +### Option 1: Multiple API Keys in `api_keys` (Recommended) ```json { "model_list": [ { "model_name": "gpt4", - "model": "openai/gpt-5.2", - "api_key": "sk-key1", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-key1", "sk-key2", "sk-key3"], + "api_base": "https://api.openai.com/v1" + } + ] +} +``` + +Or via `.security.yml`: + +```yaml +model_list: + gpt4: + api_keys: + - "sk-key1" + - "sk-key2" + - "sk-key3" +``` + +### Option 2: Multiple Model Entries + +```json +{ + "model_list": [ + { + "model_name": "gpt4", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-key1"], "api_base": "https://api1.example.com/v1" }, { "model_name": "gpt4", - "model": "openai/gpt-5.2", - "api_key": "sk-key2", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-key2"], "api_base": "https://api2.example.com/v1" }, { "model_name": "gpt4", - "model": "openai/gpt-5.2", - "api_key": "sk-key3", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-key3"], "api_base": "https://api3.example.com/v1" } ] @@ -161,30 +206,32 @@ With `model_list`, adding a new provider requires zero code changes: "model_list": [ { "model_name": "my-custom-llm", - "model": "openai/my-model-v1", - "api_key": "your-api-key", + "provider": "openai", + "model": "my-model-v1", + "api_keys": ["your-api-key"], "api_base": "https://api.your-provider.com/v1" } ] } ``` -Just specify `openai/` as the protocol (or omit it for the default), and provide your provider's API base URL. +Just set `provider` to `openai` (or another supported provider), and provide your provider's API base URL. ## Backward Compatibility -During the migration period, your existing `providers` configuration will continue to work: +During the migration period, your existing V0/V1 config will be auto-migrated to V2: 1. If `model_list` is empty and `providers` has data, the system auto-converts internally -2. A deprecation warning is logged: `"providers config is deprecated, please migrate to model_list"` -3. All existing functionality remains unchanged +2. Both `api_key` (singular) and `api_keys` (array) in V0/V1 configs are merged into the new `api_keys` array +3. A deprecation warning is logged: `"providers config is deprecated, please migrate to model_list"` +4. All existing functionality remains unchanged ## Migration Checklist - [ ] Identify all providers you're currently using - [ ] Create `model_list` entries for each provider -- [ ] Use appropriate protocol prefixes -- [ ] Update `agents.defaults.model` to reference the new `model_name` +- [ ] Prefer explicit `provider` values and native model IDs +- [ ] Update `agents.defaults.model_name` to reference the new `model_name` - [ ] Test that all models work correctly - [ ] Remove or comment out the old `providers` section @@ -196,15 +243,15 @@ During the migration period, your existing `providers` configuration will contin model "xxx" not found in model_list or providers ``` -**Solution**: Ensure the `model_name` in `model_list` matches the value in `agents.defaults.model`. +**Solution**: Ensure the `model_name` in `model_list` matches the value in `agents.defaults.model_name`. ### Unknown protocol error ``` -unknown protocol "xxx" in model "xxx/model-name" +unknown provider "xxx" in model "xxx/model-name" ``` -**Solution**: Use a supported protocol prefix. See the [Protocol Prefixes](#protocol-prefixes) table above. +**Solution**: Use a supported `provider` value, or use the legacy `provider/model` compatibility form correctly. See [Provider / Model Resolution](#provider--model-resolution). ### Missing API key error @@ -212,7 +259,7 @@ unknown protocol "xxx" in model "xxx/model-name" api_key or api_base is required for HTTP-based protocol "xxx" ``` -**Solution**: Provide `api_key` and/or `api_base` for HTTP-based providers. +**Solution**: Provide `api_keys` and/or `api_base` for HTTP-based providers. ## Need Help? diff --git a/docs/operations/README.md b/docs/operations/README.md new file mode 100644 index 000000000..b775ca3d9 --- /dev/null +++ b/docs/operations/README.md @@ -0,0 +1,6 @@ +# Operations + +Operational docs for debugging, diagnosis, and production troubleshooting. + +- [Troubleshooting](troubleshooting.md): common failures, symptoms, and recovery steps. +- [Debugging PicoClaw](debug.md): logs, runtime visibility, and debugging workflow. diff --git a/docs/operations/debug.fr.md b/docs/operations/debug.fr.md new file mode 100644 index 000000000..331f7c4ba --- /dev/null +++ b/docs/operations/debug.fr.md @@ -0,0 +1,36 @@ +# Débogage de PicoClaw + +> Retour au [README](../project/README.fr.md) + +PicoClaw effectue de multiples interactions complexes en arrière-plan pour chaque requête qu'il reçoit — du routage des messages et de l'évaluation de la complexité, à l'exécution des outils et à l'adaptation aux défaillances de modèle. Pouvoir voir exactement ce qui se passe est crucial, non seulement pour résoudre les problèmes potentiels, mais aussi pour véritablement comprendre le fonctionnement de l'agent. + +## Démarrer PicoClaw en mode débogage + +Pour obtenir des informations détaillées sur ce que fait l'agent (requêtes LLM, appels d'outils, routage des messages), vous pouvez démarrer la passerelle PicoClaw avec le drapeau de débogage : + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +Dans ce mode, le système formate les logs de manière détaillée et affiche des aperçus des prompts système et des résultats d'exécution des outils. + +## Désactiver la troncature des logs (logs complets) + +Par défaut, PicoClaw tronque les chaînes très longues (comme le *Prompt Système* ou les résultats JSON volumineux) dans les logs de débogage afin de garder la console lisible. + +Si vous avez besoin d'inspecter la sortie complète d'une commande ou le payload exact envoyé au modèle LLM, vous pouvez utiliser le drapeau `--no-truncate`. + +**Remarque :** Ce drapeau fonctionne *uniquement* en combinaison avec le mode `--debug`. + +```bash +picoclaw gateway --debug --no-truncate + +``` + +Lorsque ce drapeau est actif, la fonction de troncature globale est désactivée. Cela est extrêmement utile pour : + +* Vérifier la syntaxe exacte des messages envoyés au fournisseur. +* Lire la sortie complète d'outils comme `exec`, `web_fetch` ou `read_file`. +* Déboguer l'historique de session sauvegardé en mémoire. diff --git a/docs/operations/debug.ja.md b/docs/operations/debug.ja.md new file mode 100644 index 000000000..5b3365bf8 --- /dev/null +++ b/docs/operations/debug.ja.md @@ -0,0 +1,36 @@ +# PicoClaw のデバッグ + +> [README](../project/README.ja.md) に戻る + +PicoClaw は、受信するすべてのリクエストに対して、メッセージのルーティングや複雑度の評価、ツールの実行、モデル障害への適応など、多くの複雑な処理をバックグラウンドで実行しています。何が起きているかを正確に把握できることは、潜在的な問題のトラブルシューティングだけでなく、エージェントの動作を真に理解するためにも非常に重要です。 + +## デバッグモードで PicoClaw を起動する + +エージェントの動作に関する詳細情報(LLM リクエスト、ツール呼び出し、メッセージルーティング)を取得するには、デバッグフラグを付けて PicoClaw ゲートウェイを起動します: + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +このモードでは、システムがログを詳細にフォーマットし、システムプロンプトやツール実行結果のプレビューを表示します。 + +## ログの切り詰めを無効にする(完全なログ) + +デフォルトでは、PicoClaw はコンソールの可読性を保つために、デバッグログ内の非常に長い文字列(*システムプロンプト*や大きな JSON 出力結果など)を切り詰めます。 + +コマンドの完全な出力や、LLM モデルに送信された正確なペイロードを確認する必要がある場合は、`--no-truncate` フラグを使用できます。 + +**注意:** このフラグは `--debug` モードと組み合わせた場合に*のみ*機能します。 + +```bash +picoclaw gateway --debug --no-truncate + +``` + +このフラグが有効な場合、グローバルな切り詰め機能が無効になります。これは以下の場合に非常に便利です: + +* プロバイダーに送信されるメッセージの正確な構文を確認する。 +* `exec`、`web_fetch`、`read_file` などのツールの完全な出力を読む。 +* メモリに保存されたセッション履歴をデバッグする。 diff --git a/docs/operations/debug.md b/docs/operations/debug.md new file mode 100644 index 000000000..eacd72380 --- /dev/null +++ b/docs/operations/debug.md @@ -0,0 +1,101 @@ +# Debugging PicoClaw + +PicoClaw 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. +## Starting PicoClaw in Debug Mode + +To 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: + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +In this mode, the system will format the logs extensively and display previews of system prompts and tool execution results. + +## Disabling Log Truncation (Full Logs) + +By 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. + +If 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. + +**Note:** This flag *only* works when combined with the `--debug` mode. + +```bash +picoclaw gateway --debug --no-truncate + +``` + +When this flag is active, the global truncation function is disabled. This is extremely useful for: + +* Verifying the exact syntax of the messages sent to the provider. +* Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`. +* Debugging the session history saved in memory. + +## Tool Call Visibility in Debug Logs + +When 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: + +| Log message | Level | Key fields | Description | +|---|---|---|---| +| `LLM requested tool calls` | INFO | `tools`, `count`, `iteration` | List of tool names the model decided to call | +| `Tool call: ()` | INFO | `tool`, `iteration` | The tool name and a preview of its arguments (truncated to 200 chars) | +| `Sent tool result to user` | DEBUG | `tool`, `content_len` | Fired when a tool result is forwarded to the chat channel | +| `TTL tick after tool execution` | DEBUG | `agent_id`, `iteration` | MCP tool-discovery TTL decrement after each tool round | +| `Async tool completed, publishing result` | INFO | `tool`, `content_len`, `channel` | Only for tools that run asynchronously in the background | + +### Reading a tool call log entry + +A typical synchronous tool call produces two consecutive lines in the console: + +``` +[...] [INFO] agent: LLM requested tool calls {tools=[web_search], count=1, iteration=1} +[...] [INFO] agent: Tool call: web_search({"query":"picoclaw release notes"}) {tool=web_search, iteration=1} +``` + +The 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. + +## Real-Time Tool Feedback in Chat (tool_feedback) + +Debug 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`: + +```json +{ + "agents": { + "defaults": { + "tool_feedback": { + "enabled": true, + "max_args_length": 300, + "separate_messages": true + } + } + } +} +``` + +When `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: + +```bash +🔧 `web_search` +{"query": "picoclaw release notes"} +``` + + +### Options + +| Field | Type | Default | Description | +|---|---|---|---| +| `enabled` | bool | `false` | Send a chat notification for each tool call | +| `separate_messages` | bool | `false` | Keep every tool feedback update as a separate chat message instead of reusing a single placeholder/progress message | +| `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification | + +### Environment variables + +Both fields can also be set via environment variables: + +```bash +PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED=true +PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH=300 +``` + +> **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. diff --git a/docs/operations/debug.ms.md b/docs/operations/debug.ms.md new file mode 100644 index 000000000..6ab28365e --- /dev/null +++ b/docs/operations/debug.ms.md @@ -0,0 +1,33 @@ +# Penyahpepijatan PicoClaw + +PicoClaw melakukan pelbagai interaksi kompleks di sebalik tabir untuk setiap permintaan yang diterimanya, daripada menghala mesej dan menilai kerumitan, hinggalah melaksanakan tools dan menyesuaikan diri dengan kegagalan model. Keupayaan melihat dengan tepat apa yang sedang berlaku sangat penting, bukan sahaja untuk menyelesaikan masalah, malah untuk benar-benar memahami cara agen ini beroperasi. +## Memulakan PicoClaw dalam Mod Debug + +Untuk mendapatkan maklumat terperinci tentang apa yang sedang dilakukan oleh agen (permintaan LLM, panggilan tool, penghalaan mesej), anda boleh memulakan gateway PicoClaw dengan flag debug: + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +Dalam mod ini, sistem akan memformat log dengan lebih terperinci dan memaparkan pratonton system prompt serta hasil pelaksanaan tool. + +## Menyahaktifkan Pemotongan Log (Log Penuh) + +Secara lalai, PicoClaw memotong rentetan yang sangat panjang (seperti *System Prompt* atau hasil output JSON yang besar) dalam log debug supaya konsol kekal mudah dibaca. + +Jika anda perlu memeriksa output penuh sesuatu arahan atau payload tepat yang dihantar kepada model LLM, anda boleh menggunakan flag `--no-truncate`. + +**Nota:** Flag ini *hanya* berfungsi apabila digabungkan dengan mod `--debug`. + +```bash +picoclaw gateway --debug --no-truncate + +``` + +Apabila flag ini aktif, fungsi pemotongan global dinyahaktifkan. Ini sangat berguna untuk: + +* Mengesahkan sintaks tepat mesej yang dihantar kepada penyedia. +* Membaca output lengkap daripada tools seperti `exec`, `web_fetch`, atau `read_file`. +* Menyahpepijat sejarah sesi yang disimpan dalam memori. diff --git a/docs/operations/debug.pt-br.md b/docs/operations/debug.pt-br.md new file mode 100644 index 000000000..655385840 --- /dev/null +++ b/docs/operations/debug.pt-br.md @@ -0,0 +1,36 @@ +# Depuração do PicoClaw + +> Voltar ao [README](../project/README.pt-br.md) + +O PicoClaw realiza múltiplas interações complexas nos bastidores para cada requisição que recebe — desde o roteamento de mensagens e avaliação de complexidade, até a execução de ferramentas e adaptação a falhas de modelo. Poder ver exatamente o que está acontecendo é crucial, não apenas para solucionar problemas potenciais, mas também para realmente entender como o agente opera. + +## Iniciando o PicoClaw em modo de depuração + +Para obter informações detalhadas sobre o que o agente está fazendo (requisições LLM, chamadas de ferramentas, roteamento de mensagens), você pode iniciar o gateway do PicoClaw com a flag de depuração: + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +Neste modo, o sistema formata os logs de forma detalhada e exibe prévias dos prompts do sistema e dos resultados de execução das ferramentas. + +## Desabilitando a truncagem de logs (logs completos) + +Por padrão, o PicoClaw trunca strings muito longas (como o *Prompt do Sistema* ou resultados JSON grandes) nos logs de depuração para manter o console legível. + +Se você precisar inspecionar a saída completa de um comando ou o payload exato enviado ao modelo LLM, pode usar a flag `--no-truncate`. + +**Nota:** Esta flag *só* funciona quando combinada com o modo `--debug`. + +```bash +picoclaw gateway --debug --no-truncate + +``` + +Quando esta flag está ativa, a função de truncagem global é desabilitada. Isso é extremamente útil para: + +* Verificar a sintaxe exata das mensagens enviadas ao provedor. +* Ler a saída completa de ferramentas como `exec`, `web_fetch` ou `read_file`. +* Depurar o histórico de sessão salvo na memória. diff --git a/docs/operations/debug.vi.md b/docs/operations/debug.vi.md new file mode 100644 index 000000000..76d555648 --- /dev/null +++ b/docs/operations/debug.vi.md @@ -0,0 +1,36 @@ +# Gỡ lỗi PicoClaw + +> Quay lại [README](../project/README.vi.md) + +PicoClaw thực hiện nhiều tương tác phức tạp ở hậu trường cho mỗi yêu cầu nhận được — từ định tuyến tin nhắn và đánh giá độ phức tạp, đến thực thi công cụ và thích ứng với lỗi mô hình. Khả năng xem chính xác những gì đang xảy ra là rất quan trọng, không chỉ để khắc phục các sự cố tiềm ẩn, mà còn để thực sự hiểu cách agent hoạt động. + +## Khởi động PicoClaw ở chế độ gỡ lỗi + +Để nhận thông tin chi tiết về những gì agent đang thực hiện (yêu cầu LLM, lệnh gọi công cụ, định tuyến tin nhắn), bạn có thể khởi động gateway PicoClaw với cờ gỡ lỗi: + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +Ở chế độ này, hệ thống sẽ định dạng log chi tiết và hiển thị bản xem trước của prompt hệ thống và kết quả thực thi công cụ. + +## Tắt cắt ngắn log (log đầy đủ) + +Theo mặc định, PicoClaw cắt ngắn các chuỗi rất dài (như *Prompt Hệ thống* hoặc kết quả JSON lớn) trong log gỡ lỗi để giữ cho console dễ đọc. + +Nếu bạn cần kiểm tra đầu ra đầy đủ của một lệnh hoặc payload chính xác được gửi đến mô hình LLM, bạn có thể sử dụng cờ `--no-truncate`. + +**Lưu ý:** Cờ này *chỉ* hoạt động khi kết hợp với chế độ `--debug`. + +```bash +picoclaw gateway --debug --no-truncate + +``` + +Khi cờ này được kích hoạt, chức năng cắt ngắn toàn cục sẽ bị vô hiệu hóa. Điều này cực kỳ hữu ích để: + +* Xác minh cú pháp chính xác của các tin nhắn được gửi đến nhà cung cấp. +* Đọc đầu ra đầy đủ của các công cụ như `exec`, `web_fetch` hoặc `read_file`. +* Gỡ lỗi lịch sử phiên được lưu trong bộ nhớ. diff --git a/docs/operations/debug.zh.md b/docs/operations/debug.zh.md new file mode 100644 index 000000000..8e544c03b --- /dev/null +++ b/docs/operations/debug.zh.md @@ -0,0 +1,36 @@ +# 调试 PicoClaw + +> 返回 [README](../project/README.zh.md) + +PicoClaw 在处理每一个请求时,都会在后台执行多个复杂的交互操作——从消息路由和复杂度评估,到工具执行和模型故障适配。能够准确地看到正在发生什么至关重要,这不仅有助于排查潜在问题,也有助于真正理解代理的运作方式。 + +## 以调试模式启动 PicoClaw + +要获取代理运行的详细信息(LLM 请求、工具调用、消息路由),可以使用调试标志启动 PicoClaw 网关: + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +在此模式下,系统会对日志进行详细格式化,并显示系统提示词和工具执行结果的预览。 + +## 禁用日志截断(完整日志) + +默认情况下,PicoClaw 会在调试日志中截断过长的字符串(例如*系统提示词*或大型 JSON 输出结果),以保持控制台的可读性。 + +如果你需要检查某个命令的完整输出,或发送给 LLM 模型的确切载荷,可以使用 `--no-truncate` 标志。 + +**注意:** 此标志*仅*在与 `--debug` 模式组合使用时有效。 + +```bash +picoclaw gateway --debug --no-truncate + +``` + +当此标志激活时,全局截断功能将被禁用。这在以下场景中非常有用: + +* 验证发送给提供商的消息的确切语法。 +* 读取 `exec`、`web_fetch` 或 `read_file` 等工具的完整输出。 +* 调试保存在内存中的会话历史。 diff --git a/docs/operations/troubleshooting.fr.md b/docs/operations/troubleshooting.fr.md new file mode 100644 index 000000000..630f69627 --- /dev/null +++ b/docs/operations/troubleshooting.fr.md @@ -0,0 +1,45 @@ +# 🐛 Dépannage + +> Retour au [README](../project/README.fr.md) + +## "model ... not found in model_list" ou OpenRouter "free is not a valid model ID" + +**Symptôme :** Vous voyez l'une des erreurs suivantes : + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter retourne 400 : `"free is not a valid model ID"` + +**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. + +- **Incorrect :** `"model": "free"` → OpenRouter reçoit `free` et le rejette. +- **Correct :** `"model": "openrouter/free"` → OpenRouter reçoit `openrouter/free` (routage automatique du niveau gratuit). + +**Correction :** Dans `~/.picoclaw/config.json` (ou votre chemin de configuration) : + +1. **agents.defaults.model_name** doit correspondre à un `model_name` dans `model_list` (par ex. `"openrouter-free"`). +2. Le **model** de cette entrée doit être un identifiant de modèle OpenRouter valide, par exemple : + - `"openrouter/free"` – niveau gratuit automatique + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +Exemple : + +```json +{ + "agents": { + "defaults": { + "model_name": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "model": "openrouter/free", + "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +Obtenez votre clé sur [OpenRouter Keys](https://openrouter.ai/keys). diff --git a/docs/operations/troubleshooting.ja.md b/docs/operations/troubleshooting.ja.md new file mode 100644 index 000000000..f1d244c92 --- /dev/null +++ b/docs/operations/troubleshooting.ja.md @@ -0,0 +1,45 @@ +# 🐛 トラブルシューティング + +> [README](../project/README.ja.md) に戻る + +## "model ... not found in model_list" または OpenRouter "free is not a valid model ID" + +**症状:** 以下のいずれかのエラーが表示されます: + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter が 400 を返す:`"free is not a valid model ID"` + +**原因:** `model_list` エントリの `model` フィールドは API に送信される値です。OpenRouter では省略形ではなく、**完全な**モデル ID を使用する必要があります。 + +- **誤り:** `"model": "free"` → OpenRouter は `free` を受け取り、拒否します。 +- **正しい:** `"model": "openrouter/free"` → OpenRouter は `openrouter/free` を受け取ります(自動無料枠ルーティング)。 + +**修正方法:** `~/.picoclaw/config.json`(またはお使いの設定パス)で: + +1. **agents.defaults.model_name** は `model_list` 内の `model_name` と一致する必要があります(例:`"openrouter-free"`)。 +2. そのエントリの **model** は有効な OpenRouter モデル ID である必要があります。例: + - `"openrouter/free"` – 自動無料枠 + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +設定例: + +```json +{ + "agents": { + "defaults": { + "model_name": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "model": "openrouter/free", + "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +キーは [OpenRouter Keys](https://openrouter.ai/keys) で取得できます。 diff --git a/docs/operations/troubleshooting.md b/docs/operations/troubleshooting.md new file mode 100644 index 000000000..16229f369 --- /dev/null +++ b/docs/operations/troubleshooting.md @@ -0,0 +1,50 @@ +# Troubleshooting + +## "model ... not found in model_list" or OpenRouter "free is not a valid model ID" + +**Symptom:** You see either: + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter returns 400: `"free is not a valid model ID"` + +**Cause:** PicoClaw now resolves provider/model in two steps: + +- If `provider` is set, the `model` field is sent to that provider unchanged. +- If `provider` is omitted, PicoClaw infers the provider from the first `/` segment and sends everything after that first `/` as the runtime model ID. + +For OpenRouter free-tier routing, the preferred config is explicit `provider`. + +- **Wrong:** `"model": "free"` → no OpenRouter provider is selected, so `free` is not a valid OpenRouter model route. +- **Right:** `"provider": "openrouter", "model": "free"` → OpenRouter receives `free`. +- **Also supported:** `"model": "openrouter/free"` → provider resolves to `openrouter`, runtime model ID resolves to `free`. + +**Fix:** In `~/.picoclaw/config.json` (or your config path): + +1. **agents.defaults.model_name** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`). +2. That entry should preferably set **provider** to `openrouter`, and **model** should be a valid OpenRouter model ID, for example: + - `"free"` – auto free-tier + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +Example snippet: + +```json +{ + "agents": { + "defaults": { + "model_name": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "provider": "openrouter", + "model": "free", + "api_keys": ["sk-or-v1-YOUR_OPENROUTER_KEY"], + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +Get your key at [OpenRouter Keys](https://openrouter.ai/keys). diff --git a/docs/operations/troubleshooting.ms.md b/docs/operations/troubleshooting.ms.md new file mode 100644 index 000000000..c9d987ab4 --- /dev/null +++ b/docs/operations/troubleshooting.ms.md @@ -0,0 +1,43 @@ +# Penyelesaian Masalah + +## "model ... not found in model_list" atau OpenRouter "free is not a valid model ID" + +**Gejala:** Anda akan melihat salah satu daripada mesej berikut: + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter memulangkan 400: `"free is not a valid model ID"` + +**Punca:** Medan `model` dalam entri `model_list` anda ialah nilai yang dihantar ke API. Untuk OpenRouter, anda mesti menggunakan ID model **penuh**, bukan bentuk singkatan. + +- **Salah:** `"model": "free"` → OpenRouter menerima `free` dan menolaknya. +- **Betul:** `"model": "openrouter/free"` → OpenRouter menerima `openrouter/free` (routing auto free-tier). + +**Penyelesaian:** Dalam `~/.picoclaw/config.json` (atau laluan config anda): + +1. **agents.defaults.model** mesti sepadan dengan `model_name` dalam `model_list` (contohnya `"openrouter-free"`). +2. Medan **model** bagi entri tersebut mesti merupakan ID model OpenRouter yang sah, contohnya: + - `"openrouter/free"` – auto free-tier + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +Example snippet: + +```json +{ + "agents": { + "defaults": { + "model": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "model": "openrouter/free", + "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +Dapatkan kunci anda di [OpenRouter Keys](https://openrouter.ai/keys). diff --git a/docs/operations/troubleshooting.pt-br.md b/docs/operations/troubleshooting.pt-br.md new file mode 100644 index 000000000..eec64d9d8 --- /dev/null +++ b/docs/operations/troubleshooting.pt-br.md @@ -0,0 +1,45 @@ +# 🐛 Solução de Problemas + +> Voltar ao [README](../project/README.pt-br.md) + +## "model ... not found in model_list" ou OpenRouter "free is not a valid model ID" + +**Sintoma:** Você vê um dos seguintes erros: + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter retorna 400: `"free is not a valid model ID"` + +**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. + +- **Errado:** `"model": "free"` → OpenRouter recebe `free` e rejeita. +- **Correto:** `"model": "openrouter/free"` → OpenRouter recebe `openrouter/free` (roteamento automático do nível gratuito). + +**Correção:** Em `~/.picoclaw/config.json` (ou seu caminho de configuração): + +1. **agents.defaults.model_name** deve corresponder a um `model_name` em `model_list` (ex.: `"openrouter-free"`). +2. O **model** dessa entrada deve ser um ID de modelo OpenRouter válido, por exemplo: + - `"openrouter/free"` – nível gratuito automático + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +Exemplo: + +```json +{ + "agents": { + "defaults": { + "model_name": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "model": "openrouter/free", + "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +Obtenha sua chave em [OpenRouter Keys](https://openrouter.ai/keys). diff --git a/docs/operations/troubleshooting.vi.md b/docs/operations/troubleshooting.vi.md new file mode 100644 index 000000000..8aa5e2ae4 --- /dev/null +++ b/docs/operations/troubleshooting.vi.md @@ -0,0 +1,45 @@ +# 🐛 Khắc Phục Sự Cố + +> Quay lại [README](../project/README.vi.md) + +## "model ... not found in model_list" hoặc OpenRouter "free is not a valid model ID" + +**Triệu chứng:** Bạn thấy một trong các lỗi sau: + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter trả về 400: `"free is not a valid model ID"` + +**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. + +- **Sai:** `"model": "free"` → OpenRouter nhận được `free` và từ chối. +- **Đúng:** `"model": "openrouter/free"` → OpenRouter nhận được `openrouter/free` (định tuyến tự động tầng miễn phí). + +**Cách sửa:** Trong `~/.picoclaw/config.json` (hoặc đường dẫn cấu hình của bạn): + +1. **agents.defaults.model_name** phải khớp với một `model_name` trong `model_list` (ví dụ: `"openrouter-free"`). +2. **model** của mục đó phải là ID mô hình OpenRouter hợp lệ, ví dụ: + - `"openrouter/free"` – tầng miễn phí tự động + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +Ví dụ: + +```json +{ + "agents": { + "defaults": { + "model_name": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "model": "openrouter/free", + "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +Lấy khóa của bạn tại [OpenRouter Keys](https://openrouter.ai/keys). diff --git a/docs/operations/troubleshooting.zh.md b/docs/operations/troubleshooting.zh.md new file mode 100644 index 000000000..1569e3385 --- /dev/null +++ b/docs/operations/troubleshooting.zh.md @@ -0,0 +1,52 @@ +# 🐛 疑难解答 + +> 返回 [README](../project/README.zh.md) + +## "model ... not found in model_list" 或 OpenRouter "free is not a valid model ID" + +**症状:** 你看到以下任一错误: + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter 返回 400:`"free is not a valid model ID"` + +**原因:** PicoClaw 现在按两步解析 provider 和 model: + +- 如果设置了 `provider`,则会把 `model` 原样发送给该 provider。 +- 如果未设置 `provider`,则会把 `model` 第一个 `/` 之前的字段当作 provider,并把第一个 `/` 之后的全部内容当作最终发送的模型 ID。 + +对于 OpenRouter 免费层路由,推荐显式设置 `provider`。 + +- **错误:** `"model": "free"` → 不会选中 OpenRouter,`free` 也不是可直接路由的 OpenRouter 模型配置。 +- **正确:** `"provider": "openrouter", "model": "free"` → OpenRouter 收到 `free`。 +- **也兼容:** `"model": "openrouter/free"` → provider 解析为 `openrouter`,最终模型 ID 解析为 `free`。 + +**修复方法:** 在 `~/.picoclaw/config.json`(或你的配置路径)中: + +1. **agents.defaults.model_name** 必须匹配 `model_list` 中的某个 `model_name`(例如 `"openrouter-free"`)。 +2. 该条目推荐显式设置 **provider** 为 `openrouter`,并在 **model** 中填写有效的 OpenRouter 模型 ID,例如: + - `"free"` – 自动免费层 + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +示例片段: + +```json +{ + "agents": { + "defaults": { + "model_name": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "provider": "openrouter", + "model": "free", + "api_keys": ["sk-or-v1-YOUR_OPENROUTER_KEY"], + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +在 [OpenRouter Keys](https://openrouter.ai/keys) 获取你的密钥。 diff --git a/CONTRIBUTING.zh.md b/docs/project/CONTRIBUTING.zh.md similarity index 99% rename from CONTRIBUTING.zh.md rename to docs/project/CONTRIBUTING.zh.md index 196aecc65..ca6c66b3d 100644 --- a/CONTRIBUTING.zh.md +++ b/docs/project/CONTRIBUTING.zh.md @@ -108,7 +108,7 @@ git checkout -b 你的功能分支名 - 有关联 Issue 时请引用:`Fix session leak (#123)`。 - 保持 commit 专注,每个 commit 只做一件事。 - 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。 -- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写 +- 按照 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/v1.0.0/) 规范来撰写 ### 保持与上游同步 diff --git a/docs/project/README.fr.md b/docs/project/README.fr.md new file mode 100644 index 000000000..b02067d2a --- /dev/null +++ b/docs/project/README.fr.md @@ -0,0 +1,612 @@ +
+ PicoClaw + +

PicoClaw : Assistant IA Ultra-Efficace en Go

+ +

Matériel à $10 · 10 Mo de RAM · Démarrage en ms · Let's Go, PicoClaw!

+

+ Go + Hardware + License +
+ Website + Docs + Wiki +
+ Twitter + + Discord +

+ +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.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) | [Malay](README.ms.md) | [English](../../README.md) + +
+ +--- + +> **PicoClaw** est un projet open-source indépendant initié par [Sipeed](https://sipeed.com), entièrement écrit en **Go** à partir de zéro — ce n'est pas un fork d'OpenClaw, de NanoBot ou de tout autre projet. + +**PicoClaw** est un assistant personnel IA ultra-léger inspiré de [NanoBot](https://github.com/HKUDS/nanobot). Il a été entièrement reconstruit en **Go** via un processus d'auto-amorçage (self-bootstrapping) — l'Agent IA lui-même a piloté la migration architecturale et l'optimisation du code. + +**Fonctionne sur du matériel à $10 avec <10 Mo de RAM** — c'est 99% de mémoire en moins qu'OpenClaw et 98% moins cher qu'un Mac mini ! + + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **Avis de sécurité** +> +> * **PAS DE CRYPTO :** PicoClaw n'a **pas** émis de tokens officiels ni de cryptomonnaie. Toute affirmation sur `pump.fun` ou d'autres plateformes de trading est une **arnaque**. +> * **DOMAINE OFFICIEL :** Le **SEUL** site officiel est **[picoclaw.io](https://picoclaw.io)**, et le site de l'entreprise est **[sipeed.com](https://sipeed.com)** +> * **ATTENTION :** De nombreux domaines `.ai/.org/.com/.net/...` ont été enregistrés par des tiers. Ne leur faites pas confiance. +> * **NOTE :** PicoClaw est en développement rapide précoce. Des problèmes de sécurité non résolus peuvent exister. Ne pas déployer en production avant la v1.0. +> * **NOTE :** PicoClaw a récemment fusionné de nombreuses PRs. Les builds récents peuvent utiliser 10-20 Mo de RAM. L'optimisation des ressources est prévue après la stabilisation des fonctionnalités. + +## 📢 Actualités + +2026-03-31 📱 **Support Android !** PicoClaw fonctionne maintenant sur Android ! Téléchargez l'APK sur [picoclaw.io](https://picoclaw.io/download) + +2026-03-25 🚀 **v0.2.4 publiée !** Refonte de l'architecture Agent (SubTurn, Hooks, Steering, EventBus), intégration WeChat/WeCom, renforcement de la sécurité (.security.yml, filtrage des données sensibles), nouveaux providers (AWS Bedrock, Azure, Xiaomi MiMo), et 35 corrections de bugs. PicoClaw a atteint **26K Stars** ! + +2026-03-17 🚀 **v0.2.3 publiée !** Interface system tray (Windows & Linux), requête de statut des sous-agents (`spawn_status`), rechargement à chaud expérimental du Gateway, sécurisation Cron, et 2 correctifs de sécurité. PicoClaw a atteint **25K Stars** ! + +2026-03-09 🎉 **v0.2.1 — Plus grande mise à jour à ce jour !** Support du protocole MCP, 4 nouveaux channels (Matrix/IRC/WeCom/Discord Proxy), 3 nouveaux providers (Kimi/Minimax/Avian), pipeline vision, stockage mémoire JSONL, routage de modèles. + +2026-02-28 📦 **v0.2.0** publiée avec support Docker Compose et Web UI Launcher. + +
+Actualités précédentes... + +2026-02-26 🎉 PicoClaw atteint **20K Stars** en seulement 17 jours ! L'orchestration automatique des channels et les interfaces de capacités sont disponibles. + +2026-02-16 🎉 PicoClaw dépasse 12K Stars en une semaine ! Rôles de mainteneurs communautaires et [Roadmap](../../ROADMAP.md) officiellement lancés. + +2026-02-13 🎉 PicoClaw dépasse 5000 Stars en 4 jours ! Roadmap du projet et groupes de développeurs en cours. + +2026-02-09 🎉 **PicoClaw publié !** Construit en 1 jour pour apporter les Agents IA sur du matériel à $10 avec <10 Mo de RAM. Let's Go, PicoClaw ! + +
+ + +## ✨ Fonctionnalités + +🪶 **Ultra-léger** : Empreinte mémoire du cœur <10 Mo — 99% plus petit qu'OpenClaw.* + +💰 **Coût minimal** : Suffisamment efficace pour fonctionner sur du matériel à $10 — 98% moins cher qu'un Mac mini. + +⚡️ **Démarrage ultra-rapide** : 400x plus rapide au démarrage. Démarre en <1s même sur un processeur monocœur à 0,6 GHz. + +🌍 **Vraiment portable** : Binaire unique pour les architectures RISC-V, ARM, MIPS et x86. Un seul binaire, fonctionne partout ! + +🤖 **Auto-amorcé par IA** : Implémentation native pure Go — 95% du code principal a été généré par un Agent et affiné via une révision humaine en boucle. + +🔌 **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. + +👁️ **Pipeline vision** : Envoyez des images et des fichiers directement à l'Agent — encodage base64 automatique pour les LLMs multimodaux. + +🧠 **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. + +_*Les builds récents peuvent utiliser 10-20 Mo en raison des fusions rapides de PRs. L'optimisation des ressources est prévue. Comparaison de vitesse de démarrage basée sur des benchmarks monocœur à 0,8 GHz (voir tableau ci-dessous)._ + +
+ +| | OpenClaw | NanoBot | **PicoClaw** | +| ------------------------------ | ------------- | ------------------------ | -------------------------------------- | +| **Langage** | TypeScript | Python | **Go** | +| **RAM** | >1 Go | >100 Mo | **< 10 Mo*** | +| **Temps de démarrage**
(cœur 0,8 GHz) | >500s | >30s | **<1s** | +| **Coût** | Mac Mini $599 | La plupart des cartes Linux ~$50 | **N'importe quelle carte Linux**
**à partir de $10** | + +PicoClaw + +
+ +> **[Liste de compatibilité matérielle](../guides/hardware-compatibility.fr.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR ! + +

+PicoClaw Hardware Compatibility +

+ +## 🦾 Démonstration + +### 🛠️ Flux de travail standard de l'assistant + + + + + + + + + + + + + + + + + +

Mode Ingénieur Full-Stack

Journalisation & Planification

Recherche Web & Apprentissage

Développer · Déployer · Mettre à l'échellePlanifier · Automatiser · MémoriserDécouvrir · Analyser · Tendances
+ +### 🐜 Déploiement innovant à faible empreinte + +PicoClaw peut être déployé sur pratiquement n'importe quel appareil Linux ! + +- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) édition E(Ethernet) ou W(WiFi6), pour un assistant domestique minimal +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), pour des opérations serveur automatisées +- $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 + + + +🌟 D'autres cas de déploiement vous attendent ! + + +## 📦 Installation + +### Télécharger depuis picoclaw.io (Recommandé) + +Visitez **[picoclaw.io](https://picoclaw.io)** — le site officiel détecte automatiquement votre plateforme et fournit un téléchargement en un clic. Pas besoin de choisir manuellement une architecture. + +### Télécharger le binaire précompilé + +Vous pouvez aussi télécharger le binaire pour votre plateforme depuis la page [GitHub Releases](https://github.com/sipeed/picoclaw/releases). + +### Compiler depuis les sources (pour le développement) + +Prérequis : + +- Go 1.25+ +- Node.js 22+ et pnpm 10.33.0+ pour les builds Web UI / launcher + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Installer les dépendances frontend +(cd web/frontend && pnpm install --frozen-lockfile) + +# Compiler le binaire principal +make build + +# Compiler le Web UI Launcher (requis pour le mode WebUI) +make build-launcher + +# Compiler les binaires core pour toutes les plateformes gérées par le Makefile +make build-all + +# Compiler pour Raspberry Pi Zero 2 W (32 bits : make build-linux-arm ; 64 bits : make build-linux-arm64) +make build-pi-zero + +# Compiler et installer +make install +``` + +**Raspberry Pi Zero 2 W :** Utilisez le binaire correspondant à votre OS : Raspberry Pi OS 32 bits -> `make build-linux-arm` ; 64 bits -> `make build-linux-arm64`. Ou exécutez `make build-pi-zero` pour compiler les deux. + +## 🚀 Guide de démarrage rapide + +### 🌐 WebUI Launcher (Recommandé pour le bureau) + +Le WebUI Launcher fournit une interface basée sur navigateur pour la configuration et le chat. C'est la façon la plus simple de démarrer — aucune connaissance de la ligne de commande requise. + +**Option 1 : Double-clic (Bureau)** + +Après téléchargement depuis [picoclaw.io](https://picoclaw.io), double-cliquez sur `picoclaw-launcher` (ou `picoclaw-launcher.exe` sous Windows). Votre navigateur s'ouvrira automatiquement sur `http://localhost:18800`. + +**Option 2 : Ligne de commande** + +```bash +picoclaw-launcher +# Ouvrez http://localhost:18800 dans votre navigateur +``` + +> [!TIP] +> **Accès distant / Docker / VM :** Ajoutez le flag `-public` pour écouter sur toutes les interfaces : +> ```bash +> picoclaw-launcher -public +> ``` + +

+WebUI Launcher +

+ +**Pour commencer :** + +Ouvrez le WebUI, puis : **1)** Configurez un Provider (ajoutez votre clé API LLM) -> **2)** Configurez un Channel (ex. Telegram) -> **3)** Démarrez le Gateway -> **4)** Chattez ! + +Pour la documentation détaillée du WebUI, voir [docs.picoclaw.io](https://docs.picoclaw.io). + +
+Docker (alternative) + +```bash +# 1. Cloner ce dépôt +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Premier lancement — génère automatiquement docker/data/config.json puis s'arrête +# (se déclenche uniquement quand config.json et workspace/ sont tous deux absents) +docker compose -f docker/docker-compose.yml --profile launcher up +# Le conteneur affiche "First-run setup complete." et s'arrête. + +# 3. Définir vos clés API +vim docker/data/config.json + +# 4. Démarrer +docker compose -f docker/docker-compose.yml --profile launcher up -d +# Ouvrez http://localhost:18800 +``` + +> **Utilisateurs Docker / VM :** Le Gateway écoute sur `127.0.0.1` par défaut. Définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` ou utilisez le flag `-public` pour le rendre accessible depuis l'hôte. + +```bash +# Vérifier les logs +docker compose -f docker/docker-compose.yml logs -f + +# Arrêter +docker compose -f docker/docker-compose.yml --profile launcher down + +# Mettre à jour +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +
+ +
+macOS — Avertissement de sécurité au premier lancement + +macOS peut bloquer `picoclaw-launcher` au premier lancement car il est téléchargé depuis Internet et n'est pas notarisé via le Mac App Store. + +**Étape 1 :** Double-cliquez sur `picoclaw-launcher`. Un avertissement de sécurité s'affiche : + +

+Avertissement macOS Gatekeeper +

+ +> *"picoclaw-launcher" n'a pas pu être ouvert — Apple n'a pas pu vérifier que "picoclaw-launcher" ne contient pas de logiciel malveillant susceptible de nuire à votre Mac ou de compromettre votre confidentialité.* + +**Étape 2 :** Ouvrez **Réglages Système** → **Confidentialité et sécurité** → faites défiler jusqu'à la section **Sécurité** → cliquez sur **Ouvrir quand même** → confirmez en cliquant sur **Ouvrir quand même** dans la boîte de dialogue. + +

+macOS Confidentialité et sécurité — Ouvrir quand même +

+ +Après cette étape unique, `picoclaw-launcher` s'ouvrira normalement lors des lancements suivants. + +
+ + +### 📱 Android + +Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. + +**Option 1 : Installation APK** + +Aperçu : + + + + + + + + +
+ +Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux ! + +**Option 2 : Termux** + +
+Terminal Launcher (pour les environnements à ressources limitées) + +1. Installez [Termux](https://github.com/termux/termux-app) (téléchargez depuis [GitHub Releases](https://github.com/termux/termux-app/releases), ou cherchez dans F-Droid / Google Play) +2. Exécutez les commandes suivantes : + +```bash +# Télécharger la dernière version +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz +pkg install proot +termux-chroot ./picoclaw onboard # chroot fournit une arborescence Linux standard +``` + +Suivez ensuite la section Terminal Launcher ci-dessous pour terminer la configuration. + +PicoClaw on Termux + +Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON. + +**1. Initialiser** + +```bash +picoclaw onboard +``` + +Cela crée `~/.picoclaw/config.json` et le répertoire workspace. + +**2. Configurer** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-your-api-key" + } + ] +} +``` + +> Voir `config/config.example.json` dans le dépôt pour un modèle de configuration complet avec toutes les options disponibles. + +**3. Chatter** + +```bash +# Question ponctuelle +picoclaw agent -m "What is 2+2?" + +# Mode interactif +picoclaw agent + +# Démarrer le gateway pour l'intégration d'applications de chat +picoclaw gateway +``` + +
+ + +## 🔌 Providers (LLM) + +PicoClaw supporte plus de 30 providers LLM via la configuration `model_list`. Utilisez le format `protocole/modèle` : + +| Provider | Protocole | Clé API | Notes | +|----------|-----------|---------|-------| +| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Requise | GPT-5.4, GPT-4o, o3, etc. | +| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Requise | Claude Opus 4.6, Sonnet 4.6, etc. | +| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Requise | Gemini 3 Flash, 2.5 Pro, etc. | +| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Requise | 200+ modèles, API unifiée | +| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Requise | GLM-4.7, GLM-5, etc. | +| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Requise | DeepSeek-V3, DeepSeek-R1 | +| [Volcengine](https://console.volcengine.com) | `volcengine/` | Requise | Modèles Doubao, Ark | +| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Requise | Qwen3, Qwen-Max, etc. | +| [Groq](https://console.groq.com/keys) | `groq/` | Requise | Inférence rapide (Llama, Mixtral) | +| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Requise | Modèles Kimi | +| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Requise | Modèles MiniMax | +| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Requise | Mistral Large, Codestral | +| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Requise | Modèles hébergés NVIDIA | +| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Requise | Inférence rapide | +| [Novita AI](https://novita.ai/) | `novita/` | Requise | Divers modèles open | +| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Requise | Modèles MiMo | +| [Ollama](https://ollama.com/) | `ollama/` | Non requise | Modèles locaux, auto-hébergé | +| [vLLM](https://docs.vllm.ai/) | `vllm/` | Non requise | Déploiement local, compatible OpenAI | +| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Variable | Proxy pour 100+ providers | +| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Requise | Déploiement Azure entreprise | +| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Connexion par code appareil | +| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI | + +
+Déploiement local (Ollama, vLLM, etc.) + +**Ollama :** +```json +{ + "model_list": [ + { + "model_name": "local-llama", + "model": "ollama/llama3.1:8b", + "api_base": "http://localhost:11434/v1" + } + ] +} +``` + +**vLLM :** +```json +{ + "model_list": [ + { + "model_name": "local-vllm", + "model": "vllm/your-model", + "api_base": "http://localhost:8000/v1" + } + ] +} +``` + +Pour les détails complets de configuration des providers, voir [Providers & Models](../guides/providers.fr.md). + +
+ +## 💬 Channels (Applications de chat) + +Parlez à votre PicoClaw via plus de 17 plateformes de messagerie : + +| Channel | Configuration | Protocole | Docs | +|---------|---------------|-----------|------| +| **Telegram** | Facile (token bot) | Long polling | [Guide](../channels/telegram/README.fr.md) | +| **Discord** | Facile (token bot + intents) | WebSocket | [Guide](../channels/discord/README.fr.md) | +| **WhatsApp** | Facile (scan QR ou URL bridge) | Natif / Bridge | [Guide](../guides/chat-apps.fr.md#whatsapp) | +| **Weixin** | Facile (scan QR natif) | iLink API | [Guide](../guides/chat-apps.fr.md#weixin) | +| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guide](../channels/qq/README.fr.md) | +| **Slack** | Facile (token bot + app) | Socket Mode | [Guide](../channels/slack/README.fr.md) | +| **Matrix** | Moyen (homeserver + token) | Sync API | [Guide](../channels/matrix/README.fr.md) | +| **DingTalk** | Moyen (identifiants client) | Stream | [Guide](../channels/dingtalk/README.fr.md) | +| **Feishu / Lark** | Moyen (App ID + Secret) | WebSocket/SDK | [Guide](../channels/feishu/README.fr.md) | +| **LINE** | Moyen (identifiants + webhook) | Webhook | [Guide](../channels/line/README.fr.md) | +| **WeCom** | Facile (QR login ou manuel) | WebSocket | [Guide](../channels/wecom/README.fr.md) | +| **IRC** | Moyen (serveur + pseudo) | Protocole IRC | [Guide](../guides/chat-apps.fr.md#irc) | +| **OneBot** | Moyen (URL WebSocket) | OneBot v11 | [Guide](../channels/onebot/README.fr.md) | +| **MaixCam** | Facile (activer) | Socket TCP | [Guide](../channels/maixcam/README.fr.md) | +| **Pico** | Facile (activer) | Protocole natif | Intégré | +| **Pico Client** | Facile (URL WebSocket) | WebSocket | Intégré | + +> Tous les channels basés sur webhook partagent un seul serveur HTTP Gateway (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP partagé. + +> La verbosité des logs est contrôlée par `gateway.log_level` (par défaut : `warn`). Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. Peut aussi être défini via `PICOCLAW_LOG_LEVEL`. Voir [Configuration](../guides/configuration.fr.md#niveau-de-log-du-gateway) pour plus de détails. + +Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](../guides/chat-apps.fr.md). + +## 🔧 Outils + +### 🔍 Recherche Web + +PicoClaw peut effectuer des recherches sur le web pour fournir des informations à jour. Configurez dans `tools.web` : + +| Moteur de recherche | Clé API | Niveau gratuit | Lien | +|--------------------|---------|----------------|------| +| DuckDuckGo | Non requise | Illimité | Fallback intégré | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Requise | 1000 requêtes/jour | IA, optimisé pour le chinois | +| [Tavily](https://tavily.com) | Requise | 1000 requêtes/mois | Optimisé pour les Agents IA | +| [Brave Search](https://brave.com/search/api) | Requise | 2000 requêtes/mois | Rapide et privé | +| [Perplexity](https://www.perplexity.ai) | Requise | Payant | Recherche propulsée par IA | +| [SearXNG](https://github.com/searxng/searxng) | Non requise | Auto-hébergé | Métamoteur de recherche gratuit | +| [GLM Search](https://open.bigmodel.cn/) | Requise | Variable | Recherche web Zhipu | + +### ⚙️ Autres outils + +PicoClaw inclut des outils intégrés pour les opérations sur fichiers, l'exécution de code, la planification et plus encore. Voir [Configuration des outils](../reference/tools_configuration.fr.md) pour les détails. + +## 🎯 Skills + +Les Skills sont des capacités modulaires qui étendent votre Agent. Elles sont chargées depuis les fichiers `SKILL.md` dans votre workspace. + +**Installer des Skills depuis ClawHub :** + +```bash +picoclaw skills search "web scraping" +picoclaw skills install +``` + +**Configurer le token ClawHub** (optionnel, pour des limites de débit plus élevées) : + +Ajoutez à votre `config.json` : +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "auth_token": "your-clawhub-token" + } + } + } + } +} +``` + +Pour plus de détails, voir [Configuration des outils - Skills](../reference/tools_configuration.fr.md#skills-tool). + +## 🔗 MCP (Model Context Protocol) + +PicoClaw supporte nativement [MCP](https://modelcontextprotocol.io/) — connectez n'importe quel serveur MCP pour étendre les capacités de votre Agent avec des outils et sources de données externes. + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } + } + } +} +``` + +Pour la configuration MCP complète (transports stdio, SSE, HTTP, Tool Discovery), voir [Configuration des outils - MCP](../reference/tools_configuration.fr.md#mcp-tool). + +## ClawdChat Rejoignez le réseau social des Agents + +Connectez PicoClaw au réseau social des Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée. + +**Lisez `https://clawdchat.ai/skill.md` et suivez les instructions pour rejoindre [ClawdChat.ai](https://clawdchat.ai)** + +## 🖥️ Référence CLI + +| Commande | Description | +| ------------------------- | ---------------------------------------- | +| `picoclaw onboard` | Initialiser la config & le workspace | +| `picoclaw auth weixin` | Connecter un compte WeChat via QR | +| `picoclaw agent -m "..."` | Chatter avec l'agent | +| `picoclaw agent` | Mode chat interactif | +| `picoclaw gateway` | Démarrer le gateway | +| `picoclaw status` | Afficher le statut | +| `picoclaw version` | Afficher les informations de version | +| `picoclaw model` | Voir ou changer le modèle par défaut | +| `picoclaw cron list` | Lister toutes les tâches planifiées | +| `picoclaw cron add ...` | Ajouter une tâche planifiée | +| `picoclaw cron disable` | Désactiver une tâche planifiée | +| `picoclaw cron remove` | Supprimer une tâche planifiée | +| `picoclaw skills list` | Lister les Skills installées | +| `picoclaw skills install` | Installer une Skill | +| `picoclaw migrate` | Migrer les données depuis d'anciennes versions | +| `picoclaw auth login` | S'authentifier auprès des providers | + +### ⏰ Tâches planifiées / Rappels + +PicoClaw supporte les rappels planifiés et les tâches récurrentes via l'outil `cron` : + +* **Rappels ponctuels** : "Rappelle-moi dans 10 minutes" -> se déclenche une fois après 10 min +* **Tâches récurrentes** : "Rappelle-moi toutes les 2 heures" -> se déclenche toutes les 2 heures +* **Expressions cron** : "Rappelle-moi à 9h chaque jour" -> utilise une expression cron + +## 📚 Documentation + +Pour des guides détaillés au-delà de ce README : + +| Sujet | Description | +|-------|-------------| +| [Docker & Démarrage rapide](../guides/docker.fr.md) | Configuration Docker Compose, modes Launcher/Agent | +| [Applications de chat](../guides/chat-apps.fr.md) | Guides de configuration pour les 17+ channels | +| [Configuration](../guides/configuration.fr.md) | Variables d'environnement, structure du workspace, sandbox de sécurité | +| [Providers & Modèles](../guides/providers.fr.md) | 30+ providers LLM, routage de modèles, configuration model_list | +| [Spawn & Tâches asynchrones](../guides/spawn-tasks.fr.md) | Tâches rapides, tâches longues avec spawn, orchestration de sous-agents asynchrones | +| [Hooks](../architecture/hooks/README.md) | Système de hooks événementiels : observateurs, intercepteurs, hooks d'approbation | +| [Steering](../architecture/steering.md) | Injecter des messages dans une boucle agent en cours d'exécution | +| [SubTurn](../architecture/subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie | +| [Dépannage](../operations/troubleshooting.fr.md) | Problèmes courants et solutions | +| [Configuration des outils](../reference/tools_configuration.fr.md) | Activation/désactivation par outil, politiques d'exécution, MCP, Skills | +| [Compatibilité matérielle](../guides/hardware-compatibility.fr.md) | Cartes testées, exigences minimales | + +## 🤝 Contribuer & Roadmap + +Les PRs sont les bienvenues ! Le code source est intentionnellement petit et lisible. + +Consultez notre [Roadmap communautaire](https://github.com/sipeed/picoclaw/issues/988) et [CONTRIBUTING.md](../../CONTRIBUTING.md) pour les directives. + +Groupe de développeurs en construction, rejoignez-le après votre première PR fusionnée ! + +Groupes d'utilisateurs : + +Discord : + +WeChat : +WeChat group QR code diff --git a/docs/project/README.id.md b/docs/project/README.id.md new file mode 100644 index 000000000..49c64e74c --- /dev/null +++ b/docs/project/README.id.md @@ -0,0 +1,607 @@ +
+PicoClaw + +

PicoClaw: Asisten AI Super Ringan berbasis Go

+ +

Perangkat Keras $10 · RAM 10MB · Boot ms · Let's Go, PicoClaw!

+

+ Go + Hardware + License +
+ Website + Docs + Wiki +
+ Twitter + + Discord +

+ +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.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** | [Malay](README.ms.md) | [English](../../README.md) + +
+ +--- + +> **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. + +**PicoClaw** adalah asisten AI pribadi yang super ringan, terinspirasi dari [NanoBot](https://github.com/HKUDS/nanobot). Dibangun ulang dari awal dalam **Go** melalui proses "self-bootstrapping" — AI Agent itu sendiri yang memandu migrasi arsitektur dan optimasi kode. + +**Berjalan di perangkat keras $10 dengan RAM <10MB** — hemat 99% memori dibanding OpenClaw dan 98% lebih murah dari Mac mini! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **Peringatan Keamanan** +> +> * **TANPA KRIPTO:** PicoClaw **tidak** menerbitkan token atau cryptocurrency resmi apa pun. Semua klaim di `pump.fun` atau platform trading lainnya adalah **penipuan**. +> * **DOMAIN RESMI:** Satu-satunya website resmi adalah **[picoclaw.io](https://picoclaw.io)**, dan website perusahaan adalah **[sipeed.com](https://sipeed.com)** +> * **WASPADA:** Banyak domain `.ai/.org/.com/.net/...` telah didaftarkan oleh pihak ketiga. Jangan percaya mereka. +> * **CATATAN:** PicoClaw masih dalam tahap pengembangan awal yang cepat. Mungkin ada masalah keamanan yang belum terselesaikan. Jangan deploy ke produksi sebelum v1.0. +> * **CATATAN:** PicoClaw baru-baru ini menggabungkan banyak PR. Build terbaru mungkin menggunakan RAM 10-20MB. Optimasi sumber daya direncanakan setelah fitur stabil. + +## 📢 Berita + +2026-03-31 📱 **Dukungan Android!** PicoClaw sekarang berjalan di Android! Unduh APK di [picoclaw.io](https://picoclaw.io/download) + +2026-03-25 🚀 **v0.2.4 Dirilis!** Perombakan arsitektur Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keamanan (.security.yml, penyaringan data sensitif), provider baru (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 perbaikan bug. PicoClaw telah mencapai **26K Stars**! + +2026-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 telah mencapai **25K Stars**! + +2026-03-09 🎉 **v0.2.1 — Pembaruan terbesar sejauh ini!** Dukungan protokol MCP, 4 channel baru (Matrix/IRC/WeCom/Discord Proxy), 3 provider baru (Kimi/Minimax/Avian), pipeline visi, penyimpanan memori JSONL, perutean model. + +2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan Web UI Launcher. + +
+Berita sebelumnya... + +2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas kini aktif. + +2026-02-16 🎉 PicoClaw menembus 12K Stars dalam satu minggu! Peran maintainer komunitas dan [Roadmap](../../ROADMAP.md) resmi diluncurkan. + +2026-02-13 🎉 PicoClaw menembus 5000 Stars dalam 4 hari! Roadmap proyek dan grup pengembang sedang dalam proses. + +2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. Let's Go, PicoClaw! + +
+ +## ✨ Fitur + +🪶 **Super Ringan**: Penggunaan memori inti <10MB — 99% lebih kecil dari OpenClaw.* + +💰 **Biaya Minimal**: Cukup efisien untuk berjalan di perangkat keras $10 — 98% lebih murah dari Mac mini. + +⚡️ **Boot Secepat Kilat**: Startup 400x lebih cepat. Boot dalam <1 detik bahkan di prosesor single-core 0,6GHz. + +🌍 **Portabilitas Sejati**: Satu binary untuk RISC-V, ARM, MIPS, dan x86. Satu binary, jalan di mana saja! + +🤖 **AI-Bootstrapped**: Implementasi Go native murni — 95% kode inti dihasilkan oleh Agent dengan penyempurnaan human-in-the-loop. + +🔌 **Dukungan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) native — hubungkan server MCP mana pun untuk memperluas kapabilitas Agent. + +👁️ **Pipeline Vision**: Kirim gambar dan file langsung ke Agent — encoding base64 otomatis untuk LLM multimodal. + +🧠 **Routing Cerdas**: Routing model berbasis aturan — kueri sederhana diarahkan ke model ringan, menghemat biaya API. + +_*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. Optimasi sumber daya direncanakan. Perbandingan kecepatan boot berdasarkan benchmark single-core 0,8GHz (lihat tabel di bawah)._ + +
+ +| | OpenClaw | NanoBot | **PicoClaw** | +| ------------------------------ | ------------- | ------------------------ | -------------------------------------- | +| **Bahasa** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB*** | +| **Waktu Boot**
(core 0,8GHz) | >500d | >30d | **<1d** | +| **Biaya** | Mac Mini $599 | Kebanyakan board Linux ~$50 | **Board Linux mana pun**
**mulai $10** | + +PicoClaw + +
+ +> **[Daftar Kompatibilitas Hardware](../guides/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR! + +

+PicoClaw Hardware Compatibility +

+ +## 🦾 Demonstrasi + +### 🛠️ Alur Kerja Asisten Standar + + + + + + + + + + + + + + + + + +

Mode Full-Stack Engineer

Pencatatan & Perencanaan

Pencarian Web & Pembelajaran

Develop · Deploy · ScaleJadwal · Otomasi · IngatTemukan · Wawasan · Tren
+ +### 🐜 Deploy Inovatif dengan Footprint Rendah + +PicoClaw dapat di-deploy di hampir semua perangkat Linux! + +- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versi E(Ethernet) atau W(WiFi6), untuk home assistant minimal +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), atau $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), untuk operasi server otomatis +- $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 pengawasan cerdas + + + +🌟 Lebih Banyak Kasus Deploy Menanti! + +## 📦 Instalasi + +### Unduh dari picoclaw.io (Direkomendasikan) + +Kunjungi **[picoclaw.io](https://picoclaw.io)** — website resmi mendeteksi platform Anda secara otomatis dan menyediakan unduhan satu klik. Tidak perlu memilih arsitektur secara manual. + +### Unduh binary yang sudah dikompilasi + +Atau, unduh binary untuk platform Anda dari halaman [GitHub Releases](https://github.com/sipeed/picoclaw/releases). + +### Build dari source (untuk pengembangan) + +Prasyarat: + +- Go 1.25+ +- Node.js 22+ dan pnpm 10.33.0+ untuk build Web UI / launcher + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Instal dependensi frontend +(cd web/frontend && pnpm install --frozen-lockfile) + +# Build binary inti +make build + +# Build Web UI Launcher (diperlukan untuk mode WebUI) +make build-launcher + +# Build binary inti untuk semua platform yang dikelola Makefile +make build-all + +# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) +make build-pi-zero + +# Build dan instal +make install +``` + +**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. + +## 🚀 Panduan Memulai Cepat + +### 🌐 WebUI Launcher (Direkomendasikan untuk Desktop) + +WebUI Launcher menyediakan antarmuka berbasis browser untuk konfigurasi dan chat. Ini adalah cara termudah untuk memulai — tidak perlu pengetahuan command-line. + +**Opsi 1: Klik dua kali (Desktop)** + +Setelah mengunduh dari [picoclaw.io](https://picoclaw.io), klik dua kali `picoclaw-launcher` (atau `picoclaw-launcher.exe` di Windows). Browser Anda akan terbuka otomatis di `http://localhost:18800`. + +**Opsi 2: Command line** + +```bash +picoclaw-launcher +# Buka http://localhost:18800 di browser Anda +``` + +> [!TIP] +> **Akses jarak jauh / Docker / VM:** Tambahkan flag `-public` untuk mendengarkan di semua antarmuka: +> ```bash +> picoclaw-launcher -public +> ``` + +

+WebUI Launcher +

+ +**Memulai:** + +Buka WebUI, lalu: **1)** Konfigurasi Provider (tambahkan API key LLM Anda) -> **2)** Konfigurasi Channel (mis. Telegram) -> **3)** Mulai Gateway -> **4)** Chat! + +Untuk dokumentasi WebUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io). + +
+Docker (alternatif) + +```bash +# 1. Clone repo ini +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Jalankan pertama kali — otomatis membuat docker/data/config.json lalu keluar +# (hanya terpicu ketika config.json dan workspace/ keduanya tidak ada) +docker compose -f docker/docker-compose.yml --profile launcher up +# Container mencetak "First-run setup complete." dan berhenti. + +# 3. Atur API key Anda +vim docker/data/config.json + +# 4. Mulai +docker compose -f docker/docker-compose.yml --profile launcher up -d +# Buka http://localhost:18800 +``` + +> **Pengguna Docker / VM:** Gateway mendengarkan di `127.0.0.1` secara default. Atur `PICOCLAW_GATEWAY_HOST=0.0.0.0` atau gunakan flag `-public` agar dapat diakses dari host. + +```bash +# Cek log +docker compose -f docker/docker-compose.yml logs -f + +# Hentikan +docker compose -f docker/docker-compose.yml --profile launcher down + +# Update +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +
+ +
+macOS — Peringatan Keamanan saat Pertama Kali Diluncurkan + +macOS mungkin memblokir `picoclaw-launcher` saat pertama kali diluncurkan karena diunduh dari internet dan tidak dinotarisasi melalui Mac App Store. + +**Langkah 1:** Klik dua kali `picoclaw-launcher`. Anda akan melihat peringatan keamanan: + +

+Peringatan macOS Gatekeeper +

+ +> *"picoclaw-launcher" Tidak Dapat Dibuka — Apple tidak dapat memverifikasi bahwa "picoclaw-launcher" bebas dari malware yang dapat membahayakan Mac Anda atau mengancam privasi Anda.* + +**Langkah 2:** Buka **Pengaturan Sistem** → **Privasi & Keamanan** → gulir ke bawah ke bagian **Keamanan** → klik **Tetap Buka** → konfirmasi dengan mengklik **Tetap Buka** pada dialog. + +

+macOS Privasi & Keamanan — Tetap Buka +

+ +Setelah langkah satu kali ini, `picoclaw-launcher` akan terbuka secara normal pada peluncuran berikutnya. + +
+ +### 📱 Android + +Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw. + +**Opsi 1: Instal APK** + +Pratinjau: + + + + + + + + +
+ +Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux! + +**Opsi 2: Termux** + +
+Terminal Launcher (untuk lingkungan dengan sumber daya terbatas) + +1. 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) +2. Jalankan perintah berikut: + +```bash +# Unduh rilis terbaru +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz +pkg install proot +termux-chroot ./picoclaw onboard # chroot menyediakan tata letak filesystem Linux standar +``` + +Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi. + +PicoClaw on Termux + +Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON. + +**1. Inisialisasi** + +```bash +picoclaw onboard +``` + +Ini membuat `~/.picoclaw/config.json` dan direktori workspace. + +**2. Konfigurasi** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-your-api-key" + } + ] +} +``` + +> Lihat `config/config.example.json` di repo untuk template konfigurasi lengkap dengan semua opsi yang tersedia. + +**3. Chat** + +```bash +# Pertanyaan satu kali +picoclaw agent -m "What is 2+2?" + +# Mode interaktif +picoclaw agent + +# Mulai gateway untuk integrasi aplikasi chat +picoclaw gateway +``` + +
+ +## 🔌 Providers (LLM) + +PicoClaw mendukung 30+ provider LLM melalui konfigurasi `model_list`. Gunakan format `protocol/model`: + +| Provider | Protocol | API Key | Catatan | +|----------|----------|---------|---------| +| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Diperlukan | GPT-5.4, GPT-4o, o3, dll. | +| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Diperlukan | Claude Opus 4.6, Sonnet 4.6, dll. | +| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Diperlukan | Gemini 3 Flash, 2.5 Pro, dll. | +| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Diperlukan | 200+ model, API terpadu | +| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Diperlukan | GLM-4.7, GLM-5, dll. | +| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Diperlukan | DeepSeek-V3, DeepSeek-R1 | +| [Volcengine](https://console.volcengine.com) | `volcengine/` | Diperlukan | Doubao, model Ark | +| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Diperlukan | Qwen3, Qwen-Max, dll. | +| [Groq](https://console.groq.com/keys) | `groq/` | Diperlukan | Inferensi cepat (Llama, Mixtral) | +| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Diperlukan | Model Kimi | +| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Diperlukan | Model MiniMax | +| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Diperlukan | Mistral Large, Codestral | +| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Diperlukan | Model yang di-host NVIDIA | +| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Diperlukan | Inferensi cepat | +| [Novita AI](https://novita.ai/) | `novita/` | Diperlukan | Berbagai model open | +| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Diperlukan | Model MiMo | +| [Ollama](https://ollama.com/) | `ollama/` | Tidak perlu | Model lokal, self-hosted | +| [vLLM](https://docs.vllm.ai/) | `vllm/` | Tidak perlu | Deploy lokal, kompatibel OpenAI | +| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Bervariasi | Proxy untuk 100+ provider | +| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Diperlukan | Deploy Azure enterprise | +| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login dengan device code | +| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI | + +
+Deploy lokal (Ollama, vLLM, dll.) + +**Ollama:** +```json +{ + "model_list": [ + { + "model_name": "local-llama", + "model": "ollama/llama3.1:8b", + "api_base": "http://localhost:11434/v1" + } + ] +} +``` + +**vLLM:** +```json +{ + "model_list": [ + { + "model_name": "local-vllm", + "model": "vllm/your-model", + "api_base": "http://localhost:8000/v1" + } + ] +} +``` + +Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](../guides/providers.md). + +
+ +## 💬 Channels (Aplikasi Chat) + +Bicara dengan PicoClaw Anda melalui 17+ platform pesan: + +| Channel | Pengaturan | Protocol | Dokumentasi | +|---------|------------|----------|-------------| +| **Telegram** | Mudah (bot token) | Long polling | [Panduan](../channels/telegram/README.md) | +| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](../channels/discord/README.md) | +| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](../guides/chat-apps.md#whatsapp) | +| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](../guides/chat-apps.md#weixin) | +| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](../channels/qq/README.md) | +| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](../channels/slack/README.md) | +| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](../channels/matrix/README.md) | +| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](../channels/dingtalk/README.md) | +| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](../channels/feishu/README.md) | +| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](../channels/line/README.md) | +| **WeCom** | Mudah (login QR atau manual) | WebSocket | [Panduan](../channels/wecom/README.md) | +| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](../guides/chat-apps.md#irc) | +| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](../channels/onebot/README.md) | +| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](../channels/maixcam/README.md) | +| **Pico** | Mudah (aktifkan) | Native protocol | Bawaan | +| **Pico Client** | Mudah (WebSocket URL) | WebSocket | Bawaan | + +> Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama. + +> Verbositas log dikontrol oleh `gateway.log_level` (default: `warn`). Nilai yang didukung: `debug`, `info`, `warn`, `error`, `fatal`. Juga dapat diatur melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](../guides/configuration.md#gateway-log-level) untuk detail. + +Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](../guides/chat-apps.md). + +## 🔧 Tools + +### 🔍 Pencarian Web + +PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `tools.web`: + +| Mesin Pencari | API Key | Tier Gratis | Tautan | +|--------------|---------|-------------|--------| +| DuckDuckGo | Tidak perlu | Tidak terbatas | Fallback bawaan | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 kueri/hari | Bertenaga AI, dioptimalkan untuk bahasa Mandarin | +| [Tavily](https://tavily.com) | Diperlukan | 1000 kueri/bulan | Dioptimalkan untuk AI Agent | +| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 kueri/bulan | Cepat dan privat | +| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Pencarian bertenaga AI | +| [SearXNG](https://github.com/searxng/searxng) | Tidak perlu | Self-hosted | Mesin metasearch gratis | +| [GLM Search](https://open.bigmodel.cn/) | Diperlukan | Bervariasi | Pencarian web Zhipu | + +### ⚙️ Tools Lainnya + +PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](../reference/tools_configuration.md) untuk detail. + +## 🎯 Skills + +Skills adalah kapabilitas modular yang memperluas Agent Anda. Dimuat dari file `SKILL.md` di workspace Anda. + +**Instal skills dari ClawHub:** + +```bash +picoclaw skills search "web scraping" +picoclaw skills install +``` + +**Konfigurasi token ClawHub** (opsional, untuk rate limit lebih tinggi): + +Tambahkan ke `config.json` Anda: +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "auth_token": "your-clawhub-token" + } + } + } + } +} +``` + +Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](../reference/tools_configuration.md#skills-tool). + +## 🔗 MCP (Model Context Protocol) + +PicoClaw mendukung [MCP](https://modelcontextprotocol.io/) secara native — hubungkan server MCP mana pun untuk memperluas kapabilitas Agent Anda dengan tools dan sumber data eksternal. + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } + } + } +} +``` + +Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](../reference/tools_configuration.md#mcp-tool). + +## ClawdChat Bergabung dengan Jaringan Sosial Agent + +Hubungkan PicoClaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi mana pun. + +**Baca `https://clawdchat.ai/skill.md` dan ikuti instruksi untuk bergabung dengan [ClawdChat.ai](https://clawdchat.ai)** + +## 🖥️ Referensi CLI + +| Perintah | Deskripsi | +| -------------------------- | -------------------------------- | +| `picoclaw onboard` | Inisialisasi konfigurasi & workspace | +| `picoclaw auth weixin` | Hubungkan akun WeChat via QR | +| `picoclaw agent -m "..."` | Chat dengan agent | +| `picoclaw agent` | Mode chat interaktif | +| `picoclaw gateway` | Mulai gateway | +| `picoclaw status` | Tampilkan status | +| `picoclaw version` | Tampilkan info versi | +| `picoclaw model` | Lihat atau ganti model default | +| `picoclaw cron list` | Daftar semua tugas terjadwal | +| `picoclaw cron add ...` | Tambah tugas terjadwal | +| `picoclaw cron disable` | Nonaktifkan tugas terjadwal | +| `picoclaw cron remove` | Hapus tugas terjadwal | +| `picoclaw skills list` | Daftar skill yang terinstal | +| `picoclaw skills install` | Instal skill | +| `picoclaw migrate` | Migrasi data dari versi lama | +| `picoclaw auth login` | Autentikasi dengan provider | + +### ⏰ Tugas Terjadwal / Pengingat + +PicoClaw mendukung pengingat terjadwal dan tugas berulang melalui tool `cron`: + +* **Pengingat satu kali**: "Ingatkan saya dalam 10 menit" -> terpicu sekali setelah 10 menit +* **Tugas berulang**: "Ingatkan saya setiap 2 jam" -> terpicu setiap 2 jam +* **Ekspresi cron**: "Ingatkan saya jam 9 pagi setiap hari" -> menggunakan ekspresi cron + +## 📚 Dokumentasi + +Untuk panduan lengkap di luar README ini: + +| Topik | Deskripsi | +|-------|-----------| +| [Docker & Panduan Cepat](../guides/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent | +| [Aplikasi Chat](../guides/chat-apps.md) | Semua 17+ panduan pengaturan channel | +| [Konfigurasi](../guides/configuration.md) | Variabel environment, tata letak workspace, sandbox keamanan | +| [Providers & Models](../guides/providers.md) | 30+ provider LLM, routing model, konfigurasi model_list | +| [Spawn & Tugas Async](../guides/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async | +| [Hooks](../architecture/hooks/README.md) | Sistem hook berbasis event: observer, interceptor, approval hook | +| [Steering](../architecture/steering.md) | Menyuntikkan pesan ke dalam loop agent yang sedang berjalan | +| [SubTurn](../architecture/subturn.md) | Koordinasi subagent, kontrol konkurensi, siklus hidup | +| [Pemecahan Masalah](../operations/troubleshooting.md) | Masalah umum dan solusinya | +| [Konfigurasi Tools](../reference/tools_configuration.md) | Aktifkan/nonaktifkan per-tool, kebijakan exec, MCP, Skills | +| [Kompatibilitas Hardware](../guides/hardware-compatibility.md) | Board yang telah diuji, persyaratan minimum | + +## 🤝 Kontribusi & Roadmap + +PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca. + +Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](../../CONTRIBUTING.md) untuk panduan. + +Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge! + +Grup Pengguna: + +Discord: + +WeChat: +Kode QR grup WeChat diff --git a/docs/project/README.it.md b/docs/project/README.it.md new file mode 100644 index 000000000..0cf6cf8db --- /dev/null +++ b/docs/project/README.it.md @@ -0,0 +1,626 @@ +
+PicoClaw + +

PicoClaw: Assistente IA Ultra-Efficiente in Go

+ +

Hardware da $10 · 10MB di RAM · Avvio in ms · Let's Go, PicoClaw!

+

+ Go + Hardware + License +
+ Website + Docs + Wiki +
+ Twitter + + Discord +

+ +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.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) | [Malay](README.ms.md) | [English](../../README.md) + +
+ +--- + +> **PicoClaw** è un progetto open-source indipendente avviato da [Sipeed](https://sipeed.com), scritto interamente in **Go** da zero — non è un fork di OpenClaw, NanoBot o di qualsiasi altro progetto. + +**PicoClaw** è un assistente IA personale ultra-leggero ispirato a [NanoBot](https://github.com/HKUDS/nanobot). È stato riscritto da zero in **Go** attraverso un processo di "auto-bootstrapping" — l'Agent IA stesso ha guidato la migrazione architetturale e l'ottimizzazione del codice. + +**Funziona su hardware da $10 con <10MB di RAM** — il 99% di memoria in meno rispetto a OpenClaw e il 98% più economico di un Mac mini! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **Avviso di Sicurezza** +> +> * **NESSUNA CRYPTO:** PicoClaw **non** ha emesso token o criptovalute ufficiali. Qualsiasi annuncio su `pump.fun` o altre piattaforme di trading è una **truffa**. +> * **DOMINIO UFFICIALE:** L'**UNICO** sito ufficiale è **[picoclaw.io](https://picoclaw.io)**, e il sito aziendale è **[sipeed.com](https://sipeed.com)** +> * **ATTENZIONE:** Molti domini `.ai/.org/.com/.net/...` sono stati registrati da terze parti. Non fidarti di essi. +> * **NOTA:** PicoClaw è in fase di sviluppo iniziale rapido. Potrebbero esserci problemi di sicurezza non risolti. Non distribuire in produzione prima della v1.0. +> * **NOTA:** PicoClaw ha recentemente unito molte PR. Le build recenti potrebbero usare 10-20MB di RAM. L'ottimizzazione delle risorse è pianificata dopo la stabilizzazione delle funzionalità. + +## 📢 Novità + +2026-03-31 📱 **Supporto Android!** PicoClaw ora funziona su Android! Scarica l'APK su [picoclaw.io](https://picoclaw.io/download) + +2026-03-25 🚀 **v0.2.4 rilasciata!** Revisione dell'architettura Agent (SubTurn, Hooks, Steering, EventBus), integrazione WeChat/WeCom, rafforzamento della sicurezza (.security.yml, filtraggio dati sensibili), nuovi provider (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correzioni di bug. PicoClaw raggiunge **26K Stars**! + +2026-03-17 🚀 **v0.2.3 rilasciata!** Interfaccia system tray (Windows & Linux), query sullo stato dei sub-agent (`spawn_status`), hot-reload sperimentale del Gateway, gate di sicurezza per Cron e 2 correzioni di sicurezza. PicoClaw raggiunge **25K Stars**! + +2026-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 visiva, archivio memoria JSONL, routing dei modelli. + +2026-02-28 📦 **v0.2.0** rilasciata con supporto Docker Compose e Web UI Launcher. + +
+Notizie precedenti... + +2026-02-26 🎉 PicoClaw raggiunge **20K stelle** in soli 17 giorni! Orchestrazione automatica dei canali e interfacce di capacità sono attive. + +2026-02-16 🎉 PicoClaw supera 12K stelle in una settimana! Ruoli di maintainer della community e [Roadmap](../../ROADMAP.md) pubblicati ufficialmente. + +2026-02-13 🎉 PicoClaw supera 5000 stelle in 4 giorni! Roadmap del progetto e gruppi sviluppatori in fase di avvio. + +2026-02-09 🎉 **PicoClaw lanciato!** Costruito in 1 giorno per portare gli AI Agent su hardware da $10 con <10MB di RAM. Let's Go, PicoClaw! + +
+ +## ✨ Caratteristiche + +🪶 **Ultra-Leggero**: Impronta di memoria <10MB — il 99% più piccolo rispetto a OpenClaw.* + +💰 **Costo Minimo**: Abbastanza efficiente da girare su hardware da $10 — il 98% più economico di un Mac mini. + +⚡️ **Avvio Fulmineo**: Avvio 400 volte più veloce. Boot in meno di 1 secondo anche su un singolo core a 0,6 GHz. + +🌍 **Vera Portabilità**: Singolo binario per RISC-V, ARM, MIPS e x86. Un binario, funziona ovunque! + +🤖 **Auto-Costruito dall'IA**: Implementazione nativa in Go — il 95% del codice core è stato generato da un Agent e perfezionato tramite revisione umana nel ciclo. + +🔌 **Supporto MCP**: Integrazione nativa del [Model Context Protocol](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità dell'Agent. + +👁️ **Pipeline di Visione**: Invia immagini e file direttamente all'Agent — codifica base64 automatica per LLM multimodali. + +🧠 **Routing Intelligente**: Routing dei modelli basato su regole — le query semplici vanno verso modelli leggeri, risparmiando sui costi API. + +_*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR. L'ottimizzazione delle risorse è pianificata. Il confronto dell'avvio è basato su benchmark con singolo core a 0,8 GHz (vedi tabella sotto)._ + +
+ +| | OpenClaw | NanoBot | **PicoClaw** | +| ------------------------------ | ------------- | ------------------------ | -------------------------------------- | +| **Linguaggio** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB*** | +| **Avvio**
(core 0,8 GHz) | >500s | >30s | **<1s** | +| **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux ~$50 | **Qualsiasi scheda Linux**
**a partire da $10** | + +PicoClaw + +
+ +> **[Lista di Compatibilità Hardware](../guides/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR! + +

+PicoClaw Hardware Compatibility +

+ +## 🦾 Dimostrazione + +### 🛠️ Flussi di Lavoro Standard dell'Assistente + + + + + + + + + + + + + + + + + +

Modalità Ingegnere Full-Stack

Log & Pianificazione

Ricerca Web & Apprendimento

Sviluppa · Distribuisci · ScalaPianifica · Automatizza · MemorizzaScopri · Analizza · Tendenze
+ +### 🐜 Deploy Innovativo a Bassa Impronta + +PicoClaw può essere distribuito su quasi qualsiasi dispositivo Linux! + +- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versione E (Ethernet) o W (WiFi6), per un assistente domotico minimale +- $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 +- $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 la sorveglianza intelligente + + + +🌟 Molti altri scenari di deploy ti aspettano! + +## 📦 Installazione + +### Scarica da picoclaw.io (Consigliato) + +Visita **[picoclaw.io](https://picoclaw.io)** — il sito ufficiale rileva automaticamente la tua piattaforma e fornisce il download con un clic. Non è necessario scegliere manualmente l'architettura. + +### Scarica il binario precompilato + +In alternativa, scarica il binario per la tua piattaforma dalla pagina delle [GitHub Releases](https://github.com/sipeed/picoclaw/releases). + +### Compila dai sorgenti (per lo sviluppo) + +Prerequisiti: + +- Go 1.25+ +- Node.js 22+ e pnpm 10.33.0+ per le build Web UI / launcher + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Installa le dipendenze frontend +(cd web/frontend && pnpm install --frozen-lockfile) + +# Compila il binario core +make build + +# Compila il Web UI Launcher (necessario per la modalità WebUI) +make build-launcher + +# Compila i binari core per tutte le piattaforme gestite dal Makefile +make build-all + +# Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) +make build-pi-zero + +# Compila e installa +make install +``` + +**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. + +## 🚀 Guida Rapida + +### 🌐 WebUI Launcher (Consigliato per Desktop) + +Il WebUI Launcher fornisce un'interfaccia basata su browser per la configurazione e la chat. È il modo più semplice per iniziare — non è richiesta alcuna conoscenza della riga di comando. + +**Opzione 1: Doppio clic (Desktop)** + +Dopo aver scaricato da [picoclaw.io](https://picoclaw.io), fai doppio clic su `picoclaw-launcher` (o `picoclaw-launcher.exe` su Windows). Il browser si aprirà automaticamente su `http://localhost:18800`. + +**Opzione 2: Riga di comando** + +```bash +picoclaw-launcher +# Apri http://localhost:18800 nel browser +``` + +> [!TIP] +> **Accesso remoto / Docker / VM:** Aggiungi il flag `-public` per ascoltare su tutte le interfacce: +> ```bash +> picoclaw-launcher -public +> ``` + +

+WebUI Launcher +

+ +**Per iniziare:** + +Apri il WebUI, poi: **1)** Configura un Provider (aggiungi la tua API key LLM) -> **2)** Configura un Channel (es. Telegram) -> **3)** Avvia il Gateway -> **4)** Chatta! + +Per la documentazione dettagliata del WebUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io). + +
+Docker (alternativa) + +```bash +# 1. Clona questo repo +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Prima esecuzione — genera automaticamente docker/data/config.json poi si ferma +# (si attiva solo quando sia config.json che workspace/ sono assenti) +docker compose -f docker/docker-compose.yml --profile launcher up +# Il container stampa "First-run setup complete." e si ferma. + +# 3. Imposta le tue API key +vim docker/data/config.json + +# 4. Avvia +docker compose -f docker/docker-compose.yml --profile launcher up -d +# Apri http://localhost:18800 +``` + +> **Utenti Docker / VM:** Il Gateway ascolta su `127.0.0.1` per impostazione predefinita. Imposta `PICOCLAW_GATEWAY_HOST=0.0.0.0` o usa il flag `-public` per renderlo accessibile dall'host. + +```bash +# Controlla i log +docker compose -f docker/docker-compose.yml logs -f + +# Ferma +docker compose -f docker/docker-compose.yml --profile launcher down + +# Aggiorna +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +
+ +
+macOS — Avviso di sicurezza al primo avvio + +macOS potrebbe bloccare `picoclaw-launcher` al primo avvio perché è stato scaricato da internet e non è notarizzato tramite il Mac App Store. + +**Passo 1:** Fai doppio clic su `picoclaw-launcher`. Verrà visualizzato un avviso di sicurezza: + +

+Avviso macOS Gatekeeper +

+ +> *"picoclaw-launcher" Non Aperto — Apple non è riuscita a verificare che "picoclaw-launcher" sia privo di malware che potrebbe danneggiare il Mac o compromettere la privacy.* + +**Passo 2:** Apri **Impostazioni di Sistema** → **Privacy e sicurezza** → scorri fino alla sezione **Sicurezza** → clicca su **Apri comunque** → conferma cliccando su **Apri comunque** nella finestra di dialogo. + +

+macOS Privacy e sicurezza — Apri comunque +

+ +Dopo questo passaggio una tantum, `picoclaw-launcher` si aprirà normalmente ai lanci successivi. + +
+ +### 📱 Android + +Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw. + +**Opzione 1: Installazione APK** + +Anteprima: + + + + + + + + +
+ +Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux! + +**Opzione 2: Termux** + +
+Terminal Launcher (per ambienti con risorse limitate) + +1. 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) +2. Esegui i seguenti comandi: + +```bash +# Scarica l'ultima release +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz +pkg install proot +termux-chroot ./picoclaw onboard # chroot fornisce un layout standard del filesystem Linux +``` + +Poi segui la sezione Terminal Launcher qui sotto per completare la configurazione. + +PicoClaw on Termux + +Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON. + +**1. Inizializza** + +```bash +picoclaw onboard +``` + +Questo crea `~/.picoclaw/config.json` e la directory workspace. + +**2. Configura** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-your-api-key" + } + ] +} +``` + +> Vedi `config/config.example.json` nel repo per un template di configurazione completo con tutte le opzioni disponibili. + +**3. Chatta** + +```bash +# Domanda singola +picoclaw agent -m "Quanto fa 2+2?" + +# Modalità interattiva +picoclaw agent + +# Avvia il gateway per l'integrazione con app di chat +picoclaw gateway +``` + +
+ +## 🔌 Provider (LLM) + +PicoClaw supporta 30+ provider LLM tramite la configurazione `model_list`. Usa il formato `protocollo/modello`: + +| Provider | Protocollo | API Key | Note | +|----------|------------|---------|------| +| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Richiesta | GPT-5.4, GPT-4o, o3, ecc. | +| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Richiesta | Claude Opus 4.6, Sonnet 4.6, ecc. | +| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Richiesta | Gemini 3 Flash, 2.5 Pro, ecc. | +| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Richiesta | 200+ modelli, API unificata | +| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Richiesta | GLM-4.7, GLM-5, ecc. | +| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Richiesta | DeepSeek-V3, DeepSeek-R1 | +| [Volcengine](https://console.volcengine.com) | `volcengine/` | Richiesta | Doubao, modelli Ark | +| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Richiesta | Qwen3, Qwen-Max, ecc. | +| [Groq](https://console.groq.com/keys) | `groq/` | Richiesta | Inferenza veloce (Llama, Mixtral) | +| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Richiesta | Modelli Kimi | +| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Richiesta | Modelli MiniMax | +| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Richiesta | Mistral Large, Codestral | +| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Richiesta | Modelli ospitati NVIDIA | +| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Richiesta | Inferenza veloce | +| [Novita AI](https://novita.ai/) | `novita/` | Richiesta | Vari modelli open | +| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Richiesta | Modelli MiMo | +| [Ollama](https://ollama.com/) | `ollama/` | Non necessaria | Modelli locali, self-hosted | +| [vLLM](https://docs.vllm.ai/) | `vllm/` | Non necessaria | Deploy locale, compatibile OpenAI | +| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Variabile | Proxy per 100+ provider | +| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Richiesta | Deploy Azure enterprise | +| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login con device code | +| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI | + +
+Deploy locale (Ollama, vLLM, ecc.) + +**Ollama:** +```json +{ + "model_list": [ + { + "model_name": "local-llama", + "model": "ollama/llama3.1:8b", + "api_base": "http://localhost:11434/v1" + } + ] +} +``` + +**vLLM:** +```json +{ + "model_list": [ + { + "model_name": "local-vllm", + "model": "vllm/your-model", + "api_base": "http://localhost:8000/v1" + } + ] +} +``` + +Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](../guides/providers.md). + +
+ +## 💬 Channel (App di Chat) + +Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica: + +| Channel | Configurazione | Protocollo | Docs | +|---------|----------------|------------|------| +| **Telegram** | Facile (bot token) | Long polling | [Guida](../channels/telegram/README.md) | +| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](../channels/discord/README.md) | +| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](../guides/chat-apps.md#whatsapp) | +| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](../guides/chat-apps.md#weixin) | +| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](../channels/qq/README.md) | +| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](../channels/slack/README.md) | +| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](../channels/matrix/README.md) | +| **DingTalk** | Medio (credenziali client) | Stream | [Guida](../channels/dingtalk/README.md) | +| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](../channels/feishu/README.md) | +| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](../channels/line/README.md) | +| **WeCom** | Facile (login QR o manuale) | WebSocket | [Guida](../channels/wecom/README.md) | +| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](../guides/chat-apps.md#irc) | +| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](../channels/onebot/README.md) | +| **MaixCam** | Facile (abilita) | TCP socket | [Guida](../channels/maixcam/README.md) | +| **Pico** | Facile (abilita) | Protocollo nativo | Integrato | +| **Pico Client** | Facile (WebSocket URL) | WebSocket | Integrato | + +> Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso. + +> La verbosità dei log è controllata da `gateway.log_level` (default: `warn`). Valori supportati: `debug`, `info`, `warn`, `error`, `fatal`. Può essere impostato anche tramite `PICOCLAW_LOG_LEVEL`. Vedi [Configurazione](../guides/configuration.md#gateway-log-level) per i dettagli. + +Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](../guides/chat-apps.md). + +## 🔧 Strumenti + +### 🔍 Ricerca Web + +PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in `tools.web`: + +| Motore di Ricerca | API Key | Piano Gratuito | Link | +|-------------------|---------|----------------|------| +| DuckDuckGo | Non necessaria | Illimitato | Fallback integrato | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1000 query/giorno | IA, ottimizzato per il cinese | +| [Tavily](https://tavily.com) | Richiesta | 1000 query/mese | Ottimizzato per AI Agent | +| [Brave Search](https://brave.com/search/api) | Richiesta | 2000 query/mese | Veloce e privato | +| [Perplexity](https://www.perplexity.ai) | Richiesta | A pagamento | Ricerca potenziata dall'IA | +| [SearXNG](https://github.com/searxng/searxng) | Non necessaria | Self-hosted | Metasearch engine gratuito | +| [GLM Search](https://open.bigmodel.cn/) | Richiesta | Variabile | Ricerca web Zhipu | + +### ⚙️ Altri Strumenti + +PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](../reference/tools_configuration.md) per i dettagli. + +## 🎯 Skill + +Le Skill sono capacità modulari che estendono il tuo Agent. Vengono caricate dai file `SKILL.md` nel tuo workspace. + +**Installa skill da ClawHub:** + +```bash +picoclaw skills search "web scraping" +picoclaw skills install +``` + +**Configura il token ClawHub** (opzionale, per limiti di frequenza più alti): + +Aggiungi al tuo `config.json`: +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "auth_token": "your-clawhub-token" + } + } + } + } +} +``` + +Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](../reference/tools_configuration.md#skills-tool). + +## 🔗 MCP (Model Context Protocol) + +PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità del tuo Agent con strumenti e sorgenti di dati esterni. + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } + } + } +} +``` + +Puoi gestire i casi MCP più comuni direttamente dalla CLI senza modificare a mano il JSON: + +```bash +picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp +picoclaw mcp list +picoclaw mcp test filesystem +``` + +`picoclaw mcp` agisce come configuration manager: aggiorna `config.json` sotto `tools.mcp.servers`, ma non mantiene in esecuzione il processo del server. + +Usa `picoclaw mcp edit` quando ti servono campi avanzati che non sono coperti da `picoclaw mcp add`. +Per esempio, `picoclaw mcp add` supporta `--deferred` e `--env-file`, mentre `picoclaw mcp edit` resta utile per modifiche JSON dirette e opzioni MCP meno comuni. + +Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](../reference/tools_configuration.md#mcp-tool). Per la reference della CLI, vedi [MCP Server CLI](../reference/mcp-cli.md). + +## ClawdChat Unisciti al Social Network degli Agent + +Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singolo messaggio tramite CLI o qualsiasi app di chat integrata. + +**Leggi `https://clawdchat.ai/skill.md` e segui le istruzioni per unirti a [ClawdChat.ai](https://clawdchat.ai)** + +## 🖥️ Riferimento CLI + +| Comando | Descrizione | +| ------------------------- | ---------------------------------- | +| `picoclaw onboard` | Inizializza config & workspace | +| `picoclaw auth weixin` | Connetti account WeChat tramite QR | +| `picoclaw agent -m "..."` | Chatta con l'agent | +| `picoclaw agent` | Modalità chat interattiva | +| `picoclaw gateway` | Avvia il gateway | +| `picoclaw status` | Mostra lo stato | +| `picoclaw version` | Mostra le info sulla versione | +| `picoclaw model` | Visualizza o cambia il modello predefinito | +| `picoclaw mcp list` | Elenca i server MCP configurati | +| `picoclaw mcp add ...` | Aggiunge o aggiorna un server MCP | +| `picoclaw mcp test` | Verifica la raggiungibilità di un server MCP | +| `picoclaw mcp edit` | Apre la config per modifiche MCP avanzate | +| `picoclaw mcp remove` | Rimuove un server MCP dalla config | +| `picoclaw cron list` | Elenca tutti i job pianificati | +| `picoclaw cron add ...` | Aggiunge un job pianificato | +| `picoclaw cron disable` | Disabilita un job pianificato | +| `picoclaw cron remove` | Rimuove un job pianificato | +| `picoclaw skills list` | Elenca le skill installate | +| `picoclaw skills install` | Installa una skill | +| `picoclaw migrate` | Migra i dati dalle versioni precedenti | +| `picoclaw auth login` | Autenticazione con i provider | + +### ⏰ Task Pianificati / Promemoria + +PicoClaw supporta promemoria pianificati e task ricorrenti tramite lo strumento `cron`: + +* **Promemoria una tantum**: "Ricordami tra 10 minuti" -> si attiva una volta dopo 10 min +* **Task ricorrenti**: "Ricordami ogni 2 ore" -> si attiva ogni 2 ore +* **Espressioni cron**: "Ricordami alle 9 ogni giorno" -> usa un'espressione cron + +## 📚 Documentazione + +Per guide dettagliate oltre questo README: + +| Argomento | Descrizione | +|-----------|-------------| +| [Docker & Avvio Rapido](../guides/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent | +| [App di Chat](../guides/chat-apps.md) | Tutte le guide di configurazione per 17+ channel | +| [Configurazione](../guides/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza | +| [MCP Server CLI](../reference/mcp-cli.md) | Aggiunta, elenco, test, modifica e rimozione dei server MCP da CLI | +| [Provider & Modelli](../guides/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list | +| [Spawn & Task Asincroni](../guides/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent | +| [Hooks](../architecture/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook | +| [Steering](../architecture/steering.md) | Iniettare messaggi in un loop agent in esecuzione | +| [SubTurn](../architecture/subturn.md) | Coordinamento subagent, controllo concorrenza, ciclo di vita | +| [Risoluzione Problemi](../operations/troubleshooting.md) | Problemi comuni e soluzioni | +| [Configurazione degli Strumenti](../reference/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec, MCP, Skill | +| [Compatibilità Hardware](../guides/hardware-compatibility.md) | Schede testate, requisiti minimi | + +## 🤝 Contribuisci & Roadmap + +Le PR sono benvenute! Il codice è volutamente piccolo e leggibile. + +Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](../../CONTRIBUTING.md) per le linee guida. + +Gruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata! + +Gruppi utenti: + +Discord: + +WeChat: +WeChat group QR code diff --git a/docs/project/README.ja.md b/docs/project/README.ja.md new file mode 100644 index 000000000..6e3060688 --- /dev/null +++ b/docs/project/README.ja.md @@ -0,0 +1,608 @@ +
+ PicoClaw + +

PicoClaw: Go で書かれた超効率 AI アシスタント

+ +

$10 ハードウェア · 10MB RAM · ms 起動 · Let's Go, PicoClaw!

+

+ Go + Hardware + License +
+ Website + Docs + Wiki +
+ Twitter + + Discord +

+ +[中文](README.zh.md) | **日本語** | [한국어](README.ko.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) | [Malay](README.ms.md) | [English](../../README.md) + +
+ +--- + +> **PicoClaw** は [Sipeed](https://sipeed.com) が立ち上げた独立したオープンソースプロジェクトです。完全に **Go 言語**で一から書かれており、OpenClaw、NanoBot、その他のプロジェクトのフォークではありません。 + +**PicoClaw** は [NanoBot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。**Go** でゼロからリビルドされ、「セルフブートストラッピング」プロセスで構築されました — AI Agent 自身がアーキテクチャの移行とコード最適化を推進しました。 + +**$10 のハードウェアで 10MB 未満の RAM で動作** — OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **セキュリティに関する注意** +> +> * **暗号通貨なし:** PicoClaw には公式トークン/コインは**一切ありません**。`pump.fun` やその他の取引プラットフォームでの主張はすべて**詐欺**です。 +> * **公式ドメイン:** **唯一**の公式サイトは **[picoclaw.io](https://picoclaw.io)**、企業サイトは **[sipeed.com](https://sipeed.com)** です。 +> * **注意:** 多くの `.ai/.org/.com/.net/...` ドメインは第三者によって登録されています。信頼しないでください。 +> * **注記:** PicoClaw は初期開発段階にあり、未解決のネットワークセキュリティ問題がある可能性があります。v1.0 リリース前に本番環境へのデプロイは避けてください。 +> * **注記:** PicoClaw は最近多くの PR をマージしており、最新バージョンではメモリフットプリントが大きくなる場合があります(10〜20MB)。機能セットが安定次第、リソース最適化を優先する予定です。 + +## 📢 ニュース + +2026-03-31 📱 **Android サポート!** PicoClawがAndroidで動作!APKは[picoclaw.io](https://picoclaw.io/download)からダウンロード + +2026-03-25 🚀 **v0.2.4 リリース!** Agent アーキテクチャ全面刷新(SubTurn、Hooks、Steering、EventBus)、WeChat/WeCom 統合、セキュリティ強化(.security.yml、機密データフィルタリング)、新プロバイダー(AWS Bedrock、Azure、Xiaomi MiMo)、35 件のバグ修正。PicoClaw **26K ⭐** 達成! + +2026-03-17 🚀 **v0.2.3 リリース!** システムトレイ UI(Windows & Linux)、サブエージェントステータス追跡(`spawn_status`)、実験的 Gateway ホットリロード、cron セキュリティゲート、セキュリティ修正 2 件。PicoClaw **25K ⭐** 達成! + +2026-03-09 🎉 **v0.2.1 — 最大のアップデート!** MCP プロトコルサポート、4 つの新チャンネル (Matrix/IRC/WeCom/Discord Proxy)、3 つの新プロバイダー (Kimi/Minimax/Avian)、ビジョンパイプライン、JSONL メモリストア、モデルルーティング。 + +2026-02-28 📦 **v0.2.0** リリース — Docker Compose と Web UI Launcher サポート。 + +
+過去のニュース... + +2026-02-26 🎉 PicoClaw がわずか 17 日で **20K スター** 達成!Channel 自動オーケストレーションとケイパビリティインターフェースが実装されました。 + +2026-02-16 🎉 PicoClaw が 1 週間で 12K スター達成!コミュニティメンテナーの役割と[ロードマップ](../../ROADMAP.md)が正式に公開されました。 + +2026-02-13 🎉 PicoClaw が 4 日間で 5000 スター達成!プロジェクトロードマップと開発者グループの準備が進行中。 + +2026-02-09 🎉 **PicoClaw リリース!** $10 ハードウェアで 10MB 未満の RAM で動く AI Agent を 1 日で構築。Let's Go, PicoClaw! + +
+ +## ✨ 特徴 + +🪶 **超軽量**: コアメモリフットプリント 10MB 未満 — OpenClaw より 99% 小さい。* + +💰 **最小コスト**: $10 ハードウェアで動作 — Mac mini より 98% 安い。 + +⚡️ **超高速起動**: 起動時間 400 倍高速。0.6GHz シングルコアでも 1 秒未満で起動。 + +🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。どこでも動く! + +🤖 **AI ブートストラップ**: 純粋な Go ネイティブ実装 — コアコードの 95% が Agent によって生成され、人間によるレビューで調整。 + +🔌 **MCP 対応**: ネイティブ [Model Context Protocol](https://modelcontextprotocol.io/) 統合 — 任意の MCP サーバーに接続して Agent 機能を拡張。 + +👁️ **ビジョンパイプライン**: 画像やファイルを Agent に直接送信 — マルチモーダル LLM 向けの自動 base64 エンコーディング。 + +🧠 **スマートルーティング**: ルールベースのモデルルーティング — 簡単なクエリは軽量モデルへ、API コストを節約。 + +_*最近のバージョンでは急速な PR マージにより 10〜20MB になる場合があります。リソース最適化は計画中です。起動時間の比較は 0.8GHz シングルコアベンチマークに基づいています(下表参照)。_ + +
+ +| | OpenClaw | NanoBot | **PicoClaw** | +| ------------------------------ | ------------- | ------------------------ | -------------------------------------- | +| **言語** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB*** | +| **起動時間**
(0.8GHz コア) | >500秒 | >30秒 | **<1秒** | +| **コスト** | Mac Mini $599 | 大半の Linux ボード ~$50 | **あらゆる Linux ボード**
**最安 $10** | + +PicoClaw + +
+ +> **[ハードウェア互換性リスト](../guides/hardware-compatibility.ja.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください! + +

+PicoClaw Hardware Compatibility +

+ +## 🦾 デモンストレーション + +### 🛠️ スタンダードアシスタントワークフロー + + + + + + + + + + + + + + + + + +

フルスタックエンジニアモード

ログ&計画管理

Web 検索&学習

開発 · デプロイ · スケールスケジュール · 自動化 · メモリ発見 · インサイト · トレンド
+ +### 🐜 革新的な省フットプリントデプロイ + +PicoClaw はほぼすべての Linux デバイスにデプロイできます! + +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) または W(WiFi6) バージョン、最小ホームアシスタントに +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html) または $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) サーバー自動メンテナンスに +- $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) スマート監視に + + + +🌟 もっと多くのデプロイ事例が待っています! + +## 📦 インストール + +### picoclaw.io からダウンロード(推奨) + +**[picoclaw.io](https://picoclaw.io)** にアクセス — 公式サイトがプラットフォームを自動検出し、ワンクリックでダウンロードできます。アーキテクチャを手動で選ぶ必要はありません。 + +### プリコンパイル済みバイナリをダウンロード + +または、[GitHub Releases](https://github.com/sipeed/picoclaw/releases) ページからプラットフォームに合ったバイナリをダウンロードしてください。 + +### ソースからビルド(開発用) + +前提条件: + +- Go 1.25+ +- Web UI / launcher のビルドには Node.js 22+ と pnpm 10.33.0+ が必要 + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# フロントエンド依存関係をインストール +(cd web/frontend && pnpm install --frozen-lockfile) + +# コアバイナリをビルド +make build + +# Web UI Launcher をビルド(WebUI モードに必要) +make build-launcher + +# Makefile が管理するすべてのプラットフォーム向けにコアバイナリをビルド +make build-all + +# Raspberry Pi Zero 2 W 向けビルド(32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) +make build-pi-zero + +# ビルドとインストール +make install +``` + +**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` で両方をビルド。 + +## 🚀 クイックスタートガイド + +### 🌐 WebUI Launcher(デスクトップ向け推奨) + +WebUI Launcher はブラウザベースの設定・チャットインターフェースを提供します。コマンドラインの知識不要で、最も簡単に始められる方法です。 + +**オプション 1: ダブルクリック(デスクトップ)** + +[picoclaw.io](https://picoclaw.io) からダウンロード後、`picoclaw-launcher`(Windows では `picoclaw-launcher.exe`)をダブルクリックしてください。ブラウザが自動的に `http://localhost:18800` を開きます。 + +**オプション 2: コマンドライン** + +```bash +picoclaw-launcher +# ブラウザで http://localhost:18800 を開く +``` + +> [!TIP] +> **リモートアクセス / Docker / VM:** すべてのインターフェースでリッスンするには `-public` フラグを追加してください: +> ```bash +> picoclaw-launcher -public +> ``` + +

+WebUI Launcher +

+ +**始め方:** + +WebUI を開いたら:**1)** Provider を設定(LLM API キーを追加)→ **2)** Channel を設定(例:Telegram)→ **3)** Gateway を起動 → **4)** チャット! + +WebUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.io) を参照してください。 + +
+Docker(代替手段) + +```bash +# 1. このリポジトリをクローン +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. 初回実行 — docker/data/config.json を自動生成して終了 +# (config.json と workspace/ の両方が存在しない場合のみ実行) +docker compose -f docker/docker-compose.yml --profile launcher up +# コンテナが "First-run setup complete." を出力して停止します。 + +# 3. API キーを設定 +vim docker/data/config.json + +# 4. 起動 +docker compose -f docker/docker-compose.yml --profile launcher up -d +# http://localhost:18800 を開く +``` + +> **Docker / VM ユーザー:** Gateway はデフォルトで `127.0.0.1` でリッスンします。ホストからアクセスできるようにするには `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`-public` フラグを使用してください。 + +```bash +# ログを確認 +docker compose -f docker/docker-compose.yml logs -f + +# 停止 +docker compose -f docker/docker-compose.yml --profile launcher down + +# 更新 +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +
+ +
+macOS — 初回起動時のセキュリティ警告 + +`picoclaw-launcher` はインターネットからダウンロードされ、Mac App Store を通じて公証されていないため、macOS が初回起動時にブロックする場合があります。 + +**ステップ 1:** `picoclaw-launcher` をダブルクリックすると、セキュリティ警告が表示されます: + +

+macOS Gatekeeper 警告 +

+ +> *"picoclaw-launcher" は開けません — "picoclaw-launcher" がMacに害を与えたりプライバシーを侵害するマルウェアを含まないことをAppleは確認できません。* + +**ステップ 2:** **システム設定** → **プライバシーとセキュリティ** を開き、**セキュリティ** セクションまでスクロールして **このまま開く** をクリック → ダイアログで再度 **開く** をクリックします。 + +

+macOS プライバシーとセキュリティ — このまま開く +

+ +この操作を一度行うと、以降の起動では警告が表示されなくなります。 + +
+ + +### 📱 Android + +10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。 + +**オプション 1: APK インストール** + +プレビュー: + + + + + + + + +
+ +[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要! + +**オプション 2: Termux** + +
+Terminal Launcher(リソース制約環境向け) + +1. [Termux](https://github.com/termux/termux-app) をインストール([GitHub Releases](https://github.com/termux/termux-app/releases) からダウンロード、または F-Droid / Google Play で検索) +2. 以下のコマンドを実行: + +```bash +# 最新リリースをダウンロード +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz +pkg install proot +termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイルシステムレイアウトを提供 +``` + +その後、下記の Terminal Launcher セクションの手順に従って設定を完了してください。 + +PicoClaw on Termux + +`picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。 + +**1. 初期化** + +```bash +picoclaw onboard +``` + +`~/.picoclaw/config.json` とワークスペースディレクトリが作成されます。 + +**2. 設定** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-your-api-key" + } + ] +} +``` + +> 利用可能なすべてのオプションを含む完全な設定テンプレートは、リポジトリの `config/config.example.json` を参照してください。 + +**3. チャット** + +```bash +# ワンショット質問 +picoclaw agent -m "What is 2+2?" + +# インタラクティブモード +picoclaw agent + +# チャットアプリ統合用 Gateway を起動 +picoclaw gateway +``` + +
+ +## 🔌 Provider(LLM) + +PicoClaw は `model_list` 設定を通じて 30 以上の LLM Provider をサポートしています。`protocol/model` 形式を使用してください: + +| Provider | Protocol | API キー | 備考 | +|----------|----------|---------|------| +| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | 必須 | GPT-5.4、GPT-4o、o3 など | +| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | 必須 | Claude Opus 4.6、Sonnet 4.6 など | +| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | 必須 | Gemini 3 Flash、2.5 Pro など | +| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | 必須 | 200 以上のモデル、統合 API | +| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | 必須 | GLM-4.7、GLM-5 など | +| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | 必須 | DeepSeek-V3、DeepSeek-R1 | +| [Volcengine](https://console.volcengine.com) | `volcengine/` | 必須 | Doubao、Ark モデル | +| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | 必須 | Qwen3、Qwen-Max など | +| [Groq](https://console.groq.com/keys) | `groq/` | 必須 | 高速推論(Llama、Mixtral) | +| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | 必須 | Kimi モデル | +| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | 必須 | MiniMax モデル | +| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | 必須 | Mistral Large、Codestral | +| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | 必須 | NVIDIA ホスティングモデル | +| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | 必須 | 高速推論 | +| [Novita AI](https://novita.ai/) | `novita/` | 必須 | 各種オープンモデル | +| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | 必須 | MiMo モデル | +| [Ollama](https://ollama.com/) | `ollama/` | 不要 | ローカルモデル、セルフホスト | +| [vLLM](https://docs.vllm.ai/) | `vllm/` | 不要 | ローカルデプロイ、OpenAI 互換 | +| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | 場合による | 100 以上の Provider のプロキシ | +| [Azure OpenAI](https://portal.azure.com/) | `azure/` | 必須 | エンタープライズ Azure デプロイ | +| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | デバイスコードログイン | +| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI | + +
+ローカルデプロイ(Ollama、vLLM など) + +**Ollama:** +```json +{ + "model_list": [ + { + "model_name": "local-llama", + "model": "ollama/llama3.1:8b", + "api_base": "http://localhost:11434/v1" + } + ] +} +``` + +**vLLM:** +```json +{ + "model_list": [ + { + "model_name": "local-vllm", + "model": "vllm/your-model", + "api_base": "http://localhost:8000/v1" + } + ] +} +``` + +Provider の完全な設定詳細は [Provider とモデル](../guides/providers.ja.md) を参照してください。 + +
+ +## 💬 Channel(チャットアプリ) + +17 以上のメッセージングプラットフォームで PicoClaw と会話できます: + +| Channel | セットアップ | Protocol | ドキュメント | +|---------|------------|----------|------------| +| **Telegram** | 簡単(bot トークン) | Long polling | [ガイド](../channels/telegram/README.ja.md) | +| **Discord** | 簡単(bot トークン + intents) | WebSocket | [ガイド](../channels/discord/README.ja.md) | +| **WhatsApp** | 簡単(QR スキャンまたは bridge URL) | Native / Bridge | [ガイド](../guides/chat-apps.ja.md#whatsapp) | +| **微信 (Weixin)** | 簡単(QR スキャン) | iLink API | [ガイド](../guides/chat-apps.ja.md#weixin) | +| **QQ** | 簡単(AppID + AppSecret) | WebSocket | [ガイド](../channels/qq/README.ja.md) | +| **Slack** | 簡単(bot + app トークン) | Socket Mode | [ガイド](../channels/slack/README.ja.md) | +| **Matrix** | 中級(homeserver + トークン) | Sync API | [ガイド](../channels/matrix/README.ja.md) | +| **DingTalk** | 中級(クライアント認証情報) | Stream | [ガイド](../channels/dingtalk/README.ja.md) | +| **Feishu / Lark** | 中級(App ID + Secret) | WebSocket/SDK | [ガイド](../channels/feishu/README.ja.md) | +| **LINE** | 中級(認証情報 + webhook) | Webhook | [ガイド](../channels/line/README.ja.md) | +| **WeCom** | 簡単(QR ログインまたは手動) | WebSocket | [ガイド](../channels/wecom/README.ja.md) | +| **IRC** | 中級(サーバー + nick) | IRC protocol | [ガイド](../guides/chat-apps.ja.md#irc) | +| **OneBot** | 中級(WebSocket URL) | OneBot v11 | [ガイド](../channels/onebot/README.ja.md) | +| **MaixCam** | 簡単(有効化) | TCP socket | [ガイド](../channels/maixcam/README.ja.md) | +| **Pico** | 簡単(有効化) | Native protocol | 内蔵 | +| **Pico Client** | 簡単(WebSocket URL) | WebSocket | 内蔵 | + +> webhook ベースのすべての Channel は単一の Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)を共有します。Feishu は WebSocket/SDK モードを使用し、共有 HTTP サーバーを使用しません。 + +> ログの詳細度は `gateway.log_level` で制御します(デフォルト:`warn`)。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。`PICOCLAW_LOG_LEVEL` 環境変数でも設定可能です。詳細は[設定ガイド](../guides/configuration.ja.md#gateway-ログレベル)を参照してください。 + +Channel の詳細なセットアップ手順は [チャットアプリ設定](../guides/chat-apps.ja.md) を参照してください。 + +## 🔧 ツール + +### 🔍 Web 検索 + +PicoClaw は最新情報を提供するために Web を検索できます。`tools.web` で設定してください: + +| 検索エンジン | API キー | 無料枠 | リンク | +|------------|---------|--------|-------| +| DuckDuckGo | 不要 | 無制限 | 内蔵フォールバック | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必須 | 1000 クエリ/日 | AI 搭載、中国語に最適化 | +| [Tavily](https://tavily.com) | 必須 | 1000 クエリ/月 | AI Agent 向けに最適化 | +| [Brave Search](https://brave.com/search/api) | 必須 | 2000 クエリ/月 | 高速でプライベート | +| [Perplexity](https://www.perplexity.ai) | 必須 | 有料 | AI 搭載検索 | +| [SearXNG](https://github.com/searxng/searxng) | 不要 | セルフホスト | 無料メタ検索エンジン | +| [GLM Search](https://open.bigmodel.cn/) | 必須 | 場合による | Zhipu Web 検索 | + +### ⚙️ その他のツール + +PicoClaw にはファイル操作、コード実行、スケジューリングなどの組み込みツールが含まれています。詳細は [ツール設定](../reference/tools_configuration.ja.md) を参照してください。 + +## 🎯 Skill + +Skill は Agent を拡張するモジュール型の機能です。ワークスペース内の `SKILL.md` ファイルから読み込まれます。 + +**ClawHub から Skill をインストール:** + +```bash +picoclaw skills search "web scraping" +picoclaw skills install +``` + +**ClawHub トークンを設定**(オプション、レート制限を上げるため): + +`config.json` に追加: +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "auth_token": "your-clawhub-token" + } + } + } + } +} +``` + +詳細は [ツール設定 - Skill](../reference/tools_configuration.ja.md#skills-tool) を参照してください。 + +## 🔗 MCP(Model Context Protocol) + +PicoClaw は [MCP](https://modelcontextprotocol.io/) をネイティブサポートしています — 任意の MCP サーバーに接続して、外部ツールやデータソースで Agent の機能を拡張できます。 + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } + } + } +} +``` + +MCP の完全な設定(stdio、SSE、HTTP トランスポート、Tool Discovery)は [ツール設定 - MCP](../reference/tools_configuration.ja.md#mcp-tool) を参照してください。 + +## ClawdChat エージェントソーシャルネットワークに参加 + +CLI または統合チャットアプリからメッセージを 1 つ送るだけで、PicoClaw をエージェントソーシャルネットワークに接続できます。 + +**`https://clawdchat.ai/skill.md` を読み、指示に従って [ClawdChat.ai](https://clawdchat.ai) に参加してください** + +## 🖥️ CLI リファレンス + +| コマンド | 説明 | +| ------------------------- | ------------------------------ | +| `picoclaw onboard` | 設定&ワークスペースの初期化 | +| `picoclaw auth weixin` | WeChat アカウントを QR で接続 | +| `picoclaw agent -m "..."` | Agent とチャット | +| `picoclaw agent` | インタラクティブチャットモード | +| `picoclaw gateway` | Gateway を起動 | +| `picoclaw status` | ステータスを表示 | +| `picoclaw version` | バージョン情報を表示 | +| `picoclaw model` | デフォルトモデルの表示・切替 | +| `picoclaw cron list` | スケジュールジョブ一覧 | +| `picoclaw cron add ...` | スケジュールジョブを追加 | +| `picoclaw cron disable` | スケジュールジョブを無効化 | +| `picoclaw cron remove` | スケジュールジョブを削除 | +| `picoclaw skills list` | インストール済み Skill 一覧 | +| `picoclaw skills install` | Skill をインストール | +| `picoclaw migrate` | 旧バージョンからデータを移行 | +| `picoclaw auth login` | Provider への認証 | + +### ⏰ スケジュールタスク / リマインダー + +PicoClaw は `cron` ツールによるスケジュールリマインダーと定期タスクをサポートしています: + +* **ワンタイムリマインダー**: 「10分後にリマインド」→ 10分後に1回トリガー +* **定期タスク**: 「2時間ごとにリマインド」→ 2時間ごとにトリガー +* **Cron 式**: 「毎日9時にリマインド」→ cron 式を使用 + +## 📚 ドキュメント + +この README を超えた詳細なガイドについては: + +| トピック | 説明 | +|---------|------| +| [Docker & クイックスタート](../guides/docker.ja.md) | Docker Compose セットアップ、Launcher/Agent モード | +| [チャットアプリ](../guides/chat-apps.ja.md) | 17 以上の Channel セットアップガイド | +| [設定](../guides/configuration.ja.md) | 環境変数、ワークスペース構成、セキュリティサンドボックス | +| [Provider とモデル](../guides/providers.ja.md) | 30 以上の LLM Provider、モデルルーティング、model_list 設定 | +| [Spawn & 非同期タスク](../guides/spawn-tasks.ja.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション | +| [Hook システム](../architecture/hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook | +| [Steering](../architecture/steering.md) | 実行中の Agent ループにメッセージを注入 | +| [SubTurn](../architecture/subturn.md) | サブ Agent の調整、並行制御、ライフサイクル | +| [トラブルシューティング](../operations/troubleshooting.ja.md) | よくある問題と解決策 | +| [ツール設定](../reference/tools_configuration.ja.md) | ツールごとの有効/無効、exec ポリシー、MCP、Skill | +| [ハードウェア互換性](../guides/hardware-compatibility.ja.md) | テスト済みボード、最小要件 | + +## 🤝 コントリビュート&ロードマップ + +PR 歓迎!コードベースは意図的に小さく読みやすくしています。 + +[コミュニティロードマップ](https://github.com/sipeed/picoclaw/issues/988)と[CONTRIBUTING.md](../../CONTRIBUTING.md)をご覧ください。 + +開発者グループ構築中、最初の PR がマージされたら参加できます! + +ユーザーグループ: + +Discord: + +WeChat: +WeChat group QR code diff --git a/docs/project/README.ko.md b/docs/project/README.ko.md new file mode 100644 index 000000000..dfefa67fe --- /dev/null +++ b/docs/project/README.ko.md @@ -0,0 +1,616 @@ +
+PicoClaw + +

PicoClaw: Go로 작성된 초고효율 AI 어시스턴트

+ +

$10 하드웨어 · 10MB RAM · ms 부팅 · Let's Go, PicoClaw!

+

+ Go + Hardware + License +
+ Website + Docs + Wiki +
+ Twitter + + Discord +

+ +[中文](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) | [Malay](README.ms.md) | [English](../../README.md) + +
+ +--- + +> **PicoClaw**는 [Sipeed](https://sipeed.com)가 시작한 독립적인 오픈소스 프로젝트입니다. 처음부터 끝까지 **Go**로 새로 작성되었으며, OpenClaw, NanoBot, 혹은 다른 어떤 프로젝트의 포크도 아닙니다. + +**PicoClaw**는 [NanoBot](https://github.com/HKUDS/nanobot)에서 영감을 받은 초경량 개인용 AI 어시스턴트입니다. **Go**로 처음부터 다시 구현되었고, "셀프 부트스트래핑" 방식으로 만들어졌습니다. 즉, AI 에이전트 자체가 아키텍처 전환과 코드 최적화를 주도했습니다. + +**$10 하드웨어에서 10MB 미만 RAM으로 동작**합니다. OpenClaw보다 메모리를 99% 적게 쓰고, Mac mini보다 98% 저렴합니다! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **보안 안내** +> +> * **암호화폐 없음:** PicoClaw는 공식 토큰이나 암호화폐를 **발행한 적이 없습니다**. `pump.fun` 또는 기타 거래 플랫폼에서의 모든 주장은 **사기**입니다. +> * **공식 도메인:** **유일한** 공식 웹사이트는 **[picoclaw.io](https://picoclaw.io)** 이며, 회사 웹사이트는 **[sipeed.com](https://sipeed.com)** 입니다. +> * **주의:** 많은 `.ai/.org/.com/.net/...` 도메인이 제3자에 의해 등록되어 있습니다. 신뢰하지 마세요. +> * **참고:** PicoClaw는 빠르게 초기 개발이 진행 중입니다. 아직 해결되지 않은 보안 문제가 있을 수 있습니다. v1.0 이전에는 프로덕션 배포를 권장하지 않습니다. +> * **참고:** PicoClaw는 최근 많은 PR을 병합했습니다. 최근 빌드는 10~20MB RAM을 사용할 수 있습니다. 기능이 안정화된 뒤 리소스 최적화를 진행할 예정입니다. + +## 📢 뉴스 + +2026-03-31 📱 **Android 지원!** PicoClaw가 이제 Android에서 실행됩니다! APK는 [picoclaw.io](https://picoclaw.io/download)에서 다운로드하세요. + +2026-03-25 🚀 **v0.2.4 출시!** 에이전트 아키텍처 전면 개편(SubTurn, Hooks, Steering, EventBus), WeChat/WeCom 통합, 보안 강화(`.security.yml`, 민감 정보 필터링), 새 프로바이더(AWS Bedrock, Azure, Xiaomi MiMo), 그리고 35건의 버그 수정이 포함되었습니다. PicoClaw는 **26K 스타**를 달성했습니다! + +2026-03-17 🚀 **v0.2.3 출시!** 시스템 트레이 UI(Windows 및 Linux), 서브에이전트 상태 조회(`spawn_status`), 실험적 게이트웨이 핫 리로드, Cron 보안 게이트, 그리고 2건의 보안 수정이 추가되었습니다. PicoClaw는 **25K 스타**를 달성했습니다! + +2026-03-09 🎉 **v0.2.1 — 역대 최대 업데이트!** MCP 프로토콜 지원, 4개의 새 채널(Matrix/IRC/WeCom/Discord Proxy), 3개의 새 프로바이더(Kimi/Minimax/Avian), 비전 파이프라인, JSONL 메모리 저장소, 모델 라우팅이 추가되었습니다. + +2026-02-28 📦 **v0.2.0** 이 Docker Compose 및 WebUI 런처 지원과 함께 출시되었습니다. + +
+이전 뉴스... + +2026-02-26 🎉 PicoClaw가 단 17일 만에 **20K 스타**를 달성했습니다! 채널 자동 오케스트레이션과 기능 인터페이스가 적용되었습니다. + +2026-02-16 🎉 PicoClaw가 1주일 만에 **12K 스타**를 돌파했습니다! 커뮤니티 메인터너 역할과 [로드맵](../../ROADMAP.md)이 공식적으로 공개되었습니다. + +2026-02-13 🎉 PicoClaw가 4일 만에 **5000 스타**를 돌파했습니다! 프로젝트 로드맵과 개발자 그룹이 준비 중입니다. + +2026-02-09 🎉 **PicoClaw 출시!** $10 하드웨어와 10MB 미만 RAM에서 동작하는 AI 에이전트를 단 1일 만에 만들었습니다. Let's Go, PicoClaw! + +
+ +## ✨ 기능 + +🪶 **초경량**: 코어 메모리 사용량이 10MB 미만으로 OpenClaw보다 99% 작습니다.* + +💰 **최소 비용**: $10짜리 하드웨어에서도 충분히 구동되어 Mac mini보다 98% 저렴합니다. + +⚡️ **초고속 부팅**: 시작 속도가 400배 빠릅니다. 0.6GHz 싱글코어 프로세서에서도 1초 미만에 부팅됩니다. + +🌍 **진정한 이식성**: RISC-V, ARM, MIPS, x86 아키텍처 전반에 단일 바이너리로 동작합니다. 하나의 바이너리로 어디서나 실행됩니다! + +🤖 **AI 부트스트래핑**: 순수 Go 네이티브 구현입니다. 코어 코드의 95%는 에이전트가 생성했고, 사람이 검토하며 다듬었습니다. + +🔌 **MCP 지원**: 네이티브 [Model Context Protocol](https://modelcontextprotocol.io/) 통합을 제공하여 어떤 MCP 서버든 연결해 에이전트 기능을 확장할 수 있습니다. + +👁️ **비전 파이프라인**: 이미지와 파일을 에이전트에 직접 보낼 수 있으며, 멀티모달 LLM용 base64 인코딩이 자동으로 처리됩니다. + +🧠 **스마트 라우팅**: 규칙 기반 모델 라우팅으로 간단한 질의는 경량 모델에 보내 API 비용을 절약합니다. + +_*최근 빌드는 급격한 PR 병합으로 인해 10~20MB를 사용할 수 있습니다. 리소스 최적화는 계획되어 있습니다. 부팅 속도 비교는 0.8GHz 싱글코어 벤치마크를 기준으로 합니다(아래 표 참고)._ + +
+ +| | OpenClaw | NanoBot | **PicoClaw** | +| ------------------------------ | ------------- | ------------------------ | -------------------------------------- | +| **언어** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB*** | +| **부팅 시간**
(0.8GHz 코어) | >500초 | >30초 | **<1초** | +| **비용** | Mac Mini $599 | 대부분의 Linux 보드 ~$50 | **모든 Linux 보드**
**최저 $10부터** | + +PicoClaw + +
+ +> **[하드웨어 호환 목록](../guides/hardware-compatibility.md)** — 테스트된 모든 보드를 확인하세요. $5 RISC-V 보드부터 Raspberry Pi, Android 스마트폰까지 포함됩니다. 사용 중인 보드가 없나요? PR을 보내주세요! + +

+PicoClaw Hardware Compatibility +

+ +## 🦾 데모 + +### 🛠️ 표준 어시스턴트 워크플로 + + + + + + + + + + + + + + + + + +

풀스택 엔지니어 모드

로깅 및 계획

웹 검색 및 학습

개발 · 배포 · 확장스케줄링 · 자동화 · 기억탐색 · 인사이트 · 트렌드
+ +### 🐜 혁신적인 초저사양 배포 + +PicoClaw는 사실상 거의 모든 Linux 장치에 배포할 수 있습니다! + +- 최소형 홈 어시스턴트를 위해 $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(이더넷) 또는 W(WiFi6) 에디션 +- 서버 자동 운영을 위해 $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html) 또는 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) +- 스마트 감시를 위해 $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) + + + +🌟 더 많은 배포 사례가 기다리고 있습니다! + +## 📦 설치 + +### picoclaw.io에서 다운로드(권장) + +**[picoclaw.io](https://picoclaw.io)** 를 방문하세요. 공식 웹사이트가 플랫폼을 자동 감지하고 원클릭 다운로드를 제공합니다. 아키텍처를 직접 고를 필요가 없습니다. + +### 사전 컴파일된 바이너리 다운로드 + +또는 [GitHub Releases](https://github.com/sipeed/picoclaw/releases) 페이지에서 플랫폼에 맞는 바이너리를 다운로드할 수 있습니다. + +### 소스에서 빌드(개발용) + +필수 사항: + +- Go 1.25+ +- Web UI / launcher 빌드에는 Node.js 22+와 pnpm 10.33.0+가 필요합니다 + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# 프런트엔드 의존성 설치 +(cd web/frontend && pnpm install --frozen-lockfile) + +# 코어 바이너리 빌드 +make build + +# WebUI 런처 빌드 (WebUI 모드에 필요) +make build-launcher + +# Makefile이 관리하는 모든 플랫폼용 코어 바이너리 빌드 +make build-all + +# Raspberry Pi Zero 2 W용 빌드 (32비트: make build-linux-arm, 64비트: make build-linux-arm64) +make build-pi-zero + +# 빌드 후 설치 +make install +``` + +**Raspberry Pi Zero 2 W:** OS에 맞는 바이너리를 사용하세요. 32비트 Raspberry Pi OS는 `make build-linux-arm`, 64비트는 `make build-linux-arm64`입니다. 또는 `make build-pi-zero`로 둘 다 빌드할 수 있습니다. + +## 🚀 빠른 시작 가이드 + +### 🌐 WebUI Launcher (데스크톱 권장) + +WebUI Launcher는 설정과 채팅을 위한 브라우저 기반 인터페이스를 제공합니다. 명령줄을 몰라도 가장 쉽게 시작할 수 있는 방법입니다. + +**옵션 1: 더블클릭(데스크톱)** + +[picoclaw.io](https://picoclaw.io)에서 다운로드한 뒤 `picoclaw-launcher`를 더블클릭하세요(Windows에서는 `picoclaw-launcher.exe`). 브라우저가 자동으로 `http://localhost:18800`을 엽니다. + +**옵션 2: 명령줄** + +```bash +picoclaw-launcher +# 브라우저에서 http://localhost:18800 열기 +``` + +> [!TIP] +> **원격 접속 / Docker / VM:** 모든 인터페이스에서 수신하려면 `-public` 플래그를 추가하세요. +> ```bash +> picoclaw-launcher -public +> ``` + +

+WebUI Launcher +

+ +**시작 방법:** + +WebUI를 연 뒤 다음 순서로 진행하세요. **1)** 프로바이더 설정(LLM API 키 추가) -> **2)** 채널 설정(예: Telegram) -> **3)** 게이트웨이 시작 -> **4)** 채팅! + +자세한 WebUI 문서는 [docs.picoclaw.io](https://docs.picoclaw.io)를 참고하세요. + +
+Docker(대안) + +```bash +# 1. 이 저장소를 클론 +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. 첫 실행 - docker/data/config.json을 자동 생성한 뒤 종료 +# (config.json과 workspace/가 모두 없을 때만 실행됨) +docker compose -f docker/docker-compose.yml --profile launcher up +# 컨테이너가 "First-run setup complete."를 출력하고 종료됩니다. + +# 3. API 키 설정 +vim docker/data/config.json + +# 4. 시작 +docker compose -f docker/docker-compose.yml --profile launcher up -d +# http://localhost:18800 열기 +``` + +> **Docker / VM 사용자:** 게이트웨이는 기본적으로 `127.0.0.1`에서 수신합니다. 호스트에서 접근 가능하게 하려면 `PICOCLAW_GATEWAY_HOST=0.0.0.0`을 설정하거나 `-public` 플래그를 사용하세요. + +```bash +# 로그 확인 +docker compose -f docker/docker-compose.yml logs -f + +# 중지 +docker compose -f docker/docker-compose.yml --profile launcher down + +# 업데이트 +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +
+ +
+macOS - 첫 실행 보안 경고 + +macOS에서는 인터넷에서 다운로드한 앱이고 Mac App Store 공증을 거치지 않았기 때문에, 첫 실행 시 `picoclaw-launcher`가 차단될 수 있습니다. + +**1단계:** `picoclaw-launcher`를 더블클릭합니다. 그러면 보안 경고가 표시됩니다. + +

+macOS Gatekeeper warning +

+ +> *"picoclaw-launcher"을(를) 열 수 없습니다. Apple에서 이 앱이 악성 소프트웨어가 없으며 Mac이나 개인 정보를 해치지 않는다고 확인할 수 없습니다.* + +**2단계:** **시스템 설정** -> **개인정보 보호 및 보안** 으로 이동한 뒤 **보안** 섹션까지 스크롤하여 **그래도 열기(Open Anyway)** 를 클릭하고, 대화상자에서 다시 한 번 **그래도 열기**를 확인합니다. + +

+macOS Privacy & Security — Open Anyway +

+ +이 과정을 한 번만 거치면 이후에는 `picoclaw-launcher`가 정상적으로 열립니다. + +
+ +### 📱 Android + +오래된 스마트폰에 새 생명을 불어넣어 보세요! PicoClaw를 설치하면 스마트 AI 어시스턴트로 바꿀 수 있습니다. + +**옵션 1: APK 설치** + +미리보기: + + + + + + + + +
+ +[picoclaw.io](https://picoclaw.io/download/)에서 APK를 다운로드해 바로 설치하세요. Termux가 필요 없습니다! + +**옵션 2: Termux** + +
+터미널 런처 (리소스 제약 환경용) + +1. [Termux](https://github.com/termux/termux-app)를 설치합니다([GitHub Releases](https://github.com/termux/termux-app/releases)에서 다운로드하거나 F-Droid / Google Play에서 검색). +2. 다음 명령을 실행합니다. + +```bash +# 최신 릴리스 다운로드 +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz +pkg install proot +termux-chroot ./picoclaw onboard # chroot가 표준 Linux 파일시스템 레이아웃을 제공합니다 +``` + +그다음 아래의 터미널 런처 섹션을 따라 설정을 마무리하세요. + +PicoClaw on Termux + +런처 UI 없이 `picoclaw` 코어 바이너리만 있는 최소 환경에서는 명령줄과 JSON 설정 파일만으로도 모든 설정을 마칠 수 있습니다. + +**1. 초기화** + +```bash +picoclaw onboard +``` + +그러면 `~/.picoclaw/config.json`과 워크스페이스 디렉터리가 생성됩니다. + +**2. 설정** (`~/.picoclaw/config.json`) + +```jsonc +{ + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + // api_key는 이제 .security.yml에서 로드됩니다. + } + ] +} +``` + +> 사용 가능한 모든 옵션이 포함된 전체 설정 템플릿은 저장소의 `config/config.example.json`을 참고하세요. +> +> 참고: `config.example.json` 형식은 버전 0이며 민감 정보가 포함되어 있습니다. 실행 시 자동으로 버전 1+로 마이그레이션되며, 이후 `config.json`에는 비민감 정보만 저장되고 민감 정보는 `.security.yml`에 저장됩니다. 민감 정보를 직접 수정해야 한다면 `../security/security_configuration.md`를 참고하세요. + +**3. 채팅** + +```bash +# 단발성 질문 +picoclaw agent -m "2+2는 얼마야?" + +# 대화형 모드 +picoclaw agent + +# 채팅 앱 연동용 게이트웨이 시작 +picoclaw gateway +``` + +
+ +## 🔌 프로바이더(LLM) + +PicoClaw는 `model_list` 설정을 통해 30개 이상의 LLM 프로바이더를 지원합니다. 형식은 `protocol/model`입니다. + +| 프로바이더 | 프로토콜 | API Key | 비고 | +|----------|----------|---------|------| +| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | 필수 | GPT-5.4, GPT-4o, o3 등 | +| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | 필수 | Claude Opus 4.6, Sonnet 4.6 등 | +| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | 필수 | Gemini 3 Flash, 2.5 Pro 등 | +| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | 필수 | 200개 이상의 모델, 통합 API | +| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | 필수 | GLM-4.7, GLM-5 등 | +| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | 필수 | DeepSeek-V3, DeepSeek-R1 | +| [Volcengine](https://console.volcengine.com) | `volcengine/` | 필수 | Doubao, Ark 모델 | +| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | 필수 | Qwen3, Qwen-Max 등 | +| [Groq](https://console.groq.com/keys) | `groq/` | 필수 | 빠른 추론(Llama, Mixtral) | +| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | 필수 | Kimi 모델 | +| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | 필수 | MiniMax 모델 | +| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | 필수 | Mistral Large, Codestral | +| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | 필수 | NVIDIA 호스팅 모델 | +| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | 필수 | 빠른 추론 | +| [Novita AI](https://novita.ai/) | `novita/` | 필수 | 다양한 오픈 모델 | +| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | 필수 | MiMo 모델 | +| [Ollama](https://ollama.com/) | `ollama/` | 불필요 | 로컬 모델, 셀프 호스팅 | +| [vLLM](https://docs.vllm.ai/) | `vllm/` | 불필요 | 로컬 배포, OpenAI 호환 | +| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | 환경에 따라 다름 | 100개 이상의 프로바이더를 위한 프록시 | +| [Azure OpenAI](https://portal.azure.com/) | `azure/` | 필수 | 엔터프라이즈 Azure 배포 | +| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | 디바이스 코드 로그인 | +| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI | +| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS 자격 증명 | AWS에서 Claude, Llama, Mistral 사용 | + +> \* AWS Bedrock은 빌드 태그 `go build -tags bedrock`이 필요합니다. 모든 AWS 파티션(aws, aws-cn, aws-us-gov)에서 엔드포인트를 자동 해석하려면 `api_base`를 리전명(예: `us-east-1`)으로 설정하세요. 전체 엔드포인트 URL을 직접 사용할 경우에는 환경 변수 또는 AWS config/profile을 통해 `AWS_REGION`도 함께 설정해야 합니다. + +
+로컬 배포(Ollama, vLLM 등) + +**Ollama:** +```json +{ + "model_list": [ + { + "model_name": "local-llama", + "model": "ollama/llama3.1:8b", + "api_base": "http://localhost:11434/v1" + } + ] +} +``` + +**vLLM:** +```json +{ + "model_list": [ + { + "model_name": "local-vllm", + "model": "vllm/your-model", + "api_base": "http://localhost:8000/v1" + } + ] +} +``` + +프로바이더 전체 설정은 [프로바이더와 모델](../guides/providers.md)을 참고하세요. + +
+ +## 💬 채널(채팅 앱) + +18개 이상의 메시징 플랫폼을 통해 PicoClaw와 대화할 수 있습니다. + +| 채널 | 설정 | 프로토콜 | 문서 | +|---------|------|----------|------| +| **Telegram** | 쉬움(봇 토큰) | Long polling | [가이드](../channels/telegram/README.md) | +| **Discord** | 쉬움(봇 토큰 + intents) | WebSocket | [가이드](../channels/discord/README.md) | +| **WhatsApp** | 쉬움(QR 스캔 또는 브리지 URL) | Native / Bridge | [가이드](../guides/chat-apps.md#whatsapp) | +| **Weixin** | 쉬움(네이티브 QR 스캔) | iLink API | [가이드](../guides/chat-apps.md#weixin) | +| **QQ** | 쉬움(AppID + AppSecret) | WebSocket | [가이드](../channels/qq/README.md) | +| **Slack** | 쉬움(봇 + 앱 토큰) | Socket Mode | [가이드](../channels/slack/README.md) | +| **Matrix** | 중간(homeserver + 토큰) | Sync API | [가이드](../channels/matrix/README.md) | +| **DingTalk** | 중간(클라이언트 자격 증명) | Stream | [가이드](../channels/dingtalk/README.md) | +| **Feishu / Lark** | 중간(App ID + Secret) | WebSocket/SDK | [가이드](../channels/feishu/README.md) | +| **LINE** | 중간(인증 정보 + webhook) | Webhook | [가이드](../channels/line/README.md) | +| **WeCom** | 쉬움(QR 로그인 또는 수동 설정) | WebSocket | [가이드](../channels/wecom/README.md) | +| **VK** | 쉬움(그룹 토큰) | Long Poll | [가이드](../channels/vk/README.md) | +| **IRC** | 중간(서버 + 닉네임) | IRC protocol | [가이드](../guides/chat-apps.md#irc) | +| **OneBot** | 중간(WebSocket URL) | OneBot v11 | [가이드](../channels/onebot/README.md) | +| **MaixCam** | 쉬움(활성화) | TCP socket | [가이드](../channels/maixcam/README.md) | +| **Pico** | 쉬움(활성화) | 네이티브 프로토콜 | 내장 | +| **Pico Client** | 쉬움(WebSocket URL) | WebSocket | 내장 | + +> webhook 기반 채널은 모두 하나의 게이트웨이 HTTP 서버(`gateway.host`:`gateway.port`, 기본값 `127.0.0.1:18790`)를 공유합니다. Feishu는 WebSocket/SDK 모드를 사용하며 이 공용 HTTP 서버를 사용하지 않습니다. + +> 로그 상세도는 `gateway.log_level`(기본값: `warn`)로 제어됩니다. 지원 값은 `debug`, `info`, `warn`, `error`, `fatal`입니다. `PICOCLAW_LOG_LEVEL` 환경 변수로도 설정할 수 있습니다. 자세한 내용은 [설정 문서](../guides/configuration.md#gateway-log-level)를 참고하세요. + +자세한 채널 설정 방법은 [채팅 앱 설정 가이드](../guides/chat-apps.md)를 참고하세요. + +## 🔧 도구 + +### 🔍 웹 검색 + +PicoClaw는 최신 정보를 제공하기 위해 웹 검색을 수행할 수 있습니다. `tools.web`에서 설정하세요. + +| 검색 엔진 | API Key | 무료 제공량 | 링크 | +|-----------|---------|-------------|------| +| DuckDuckGo | 불필요 | 무제한 | 내장 백업 검색 | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 필수 | 하루 1000회 쿼리 | AI 기반, 중국 시장 최적화 | +| [Tavily](https://tavily.com) | 필수 | 월 1000회 쿼리 | AI 에이전트에 최적화 | +| [Brave Search](https://brave.com/search/api) | 필수 | 월 2000회 쿼리 | 빠르고 프라이빗함 | +| [Perplexity](https://www.perplexity.ai) | 필수 | 유료 | AI 기반 검색 | +| [SearXNG](https://github.com/searxng/searxng) | 불필요 | 셀프 호스팅 | 무료 메타 검색 엔진 | +| [GLM Search](https://open.bigmodel.cn/) | 필수 | 상이함 | Zhipu 웹 검색 | + +### ⚙️ 기타 도구 + +PicoClaw에는 파일 작업, 코드 실행, 스케줄링 등을 위한 내장 도구가 포함되어 있습니다. 자세한 내용은 [도구 설정](../reference/tools_configuration.md)을 참고하세요. + +## 🎯 스킬 + +스킬은 에이전트 기능을 확장하는 모듈형 구성 요소입니다. 워크스페이스 안의 `SKILL.md` 파일에서 로드됩니다. + +**ClawHub에서 스킬 설치:** + +```bash +picoclaw skills search "web scraping" +picoclaw skills install +``` + +**ClawHub 토큰 설정**(선택 사항, 더 높은 호출 한도용): + +`config.json`에 다음을 추가하세요. +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "auth_token": "your-clawhub-token" + } + } + } + } +} +``` + +자세한 내용은 [도구 설정 - 스킬](../reference/tools_configuration.md#skills-tool)를 참고하세요. + +## 🔗 MCP (Model Context Protocol) + +PicoClaw는 [MCP](https://modelcontextprotocol.io/)를 기본 지원합니다. 어떤 MCP 서버든 연결하여 외부 도구와 데이터 소스로 에이전트 기능을 확장할 수 있습니다. + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } + } + } +} +``` + +MCP 전체 설정(stdio, SSE, HTTP 전송 방식, 도구 탐색)은 [도구 설정 - MCP](../reference/tools_configuration.md#mcp-tool)를 참고하세요. + +## ClawdChat 에이전트 소셜 네트워크 참여하기 + +CLI 또는 통합된 채팅 앱에서 메시지를 한 번만 보내면 PicoClaw를 에이전트 소셜 네트워크에 연결할 수 있습니다. + +**`https://clawdchat.ai/skill.md`를 읽고 안내에 따라 [ClawdChat.ai](https://clawdchat.ai)에 참여하세요** + +## 🖥️ CLI 레퍼런스 + +| 명령어 | 설명 | +| ------------------------- | ------------------------------ | +| `picoclaw onboard` | 설정 및 워크스페이스 초기화 | +| `picoclaw auth weixin` | QR로 WeChat 계정 연결 | +| `picoclaw agent -m "..."` | 에이전트와 채팅 | +| `picoclaw agent` | 대화형 채팅 모드 | +| `picoclaw gateway` | 게이트웨이 시작 | +| `picoclaw status` | 상태 표시 | +| `picoclaw version` | 버전 정보 표시 | +| `picoclaw model` | 기본 모델 조회 또는 변경 | +| `picoclaw cron list` | 모든 예약 작업 목록 표시 | +| `picoclaw cron add ...` | 예약 작업 추가 | +| `picoclaw cron disable` | 예약 작업 비활성화 | +| `picoclaw cron remove` | 예약 작업 삭제 | +| `picoclaw skills list` | 설치된 스킬 목록 표시 | +| `picoclaw skills install` | 스킬 설치 | +| `picoclaw migrate` | 이전 버전 데이터 마이그레이션 | +| `picoclaw auth login` | 프로바이더 인증 | + +### ⏰ 예약 작업 / 리마인더 + +PicoClaw는 `cron` 도구를 통해 예약 리마인더와 반복 작업을 지원합니다. + +* **1회성 리마인더**: "10분 후에 알려줘" -> 10분 후 한 번 실행 +* **반복 작업**: "2시간마다 알려줘" -> 2시간마다 실행 +* **Cron 표현식**: "매일 오전 9시에 알려줘" -> cron 표현식 사용 + +현재 지원하는 스케줄 유형, 실행 모드, 명령 작업 게이트, 저장 방식은 [docs/reference/cron.md](../reference/cron.md)를 참고하세요. + +## 📚 문서 + +이 README보다 더 자세한 가이드는 다음 문서를 참고하세요. + +| 주제 | 설명 | +|------|------| +| [도커 & 빠른 시작](../guides/docker.md) | Docker Compose 설정, 런처/에이전트 모드 | +| [채팅 앱](../guides/chat-apps.md) | 17개 이상의 채널 설정 가이드 | +| [설정](../guides/configuration.md) | 환경 변수, 워크스페이스 레이아웃, 보안 샌드박스 | +| [예약 작업과 Cron](../reference/cron.md) | Cron 스케줄 유형, 전달 모드, 명령 게이트, 작업 저장 | +| [프로바이더와 모델](../guides/providers.md) | 30개 이상의 LLM 프로바이더, 모델 라우팅, model_list 설정 | +| [Spawn & 비동기 작업](../guides/spawn-tasks.md) | 빠른 작업, spawn을 이용한 장기 작업, 비동기 서브에이전트 오케스트레이션 | +| [Hooks](../architecture/hooks/README.md) | 이벤트 기반 Hook 시스템: 관찰자, 인터셉터, 승인 훅 | +| [Steering](../architecture/steering.md) | 실행 중인 에이전트 루프에서 도구 호출 사이에 메시지 주입 | +| [SubTurn](../architecture/subturn.md) | 서브에이전트 조정, 동시성 제어, 생명주기 | +| [문제 해결](../operations/troubleshooting.md) | 자주 발생하는 문제와 해결 방법 | +| [도구 설정](../reference/tools_configuration.md) | 도구별 활성화/비활성화, exec 정책, MCP, 스킬 | +| [하드웨어 호환성](../guides/hardware-compatibility.md) | 테스트된 보드, 최소 요구사항 | + +## 🤝 기여 & 로드맵 + +PR은 언제든 환영합니다! 코드베이스는 의도적으로 작고 읽기 쉽게 유지하고 있습니다. + +가이드라인은 [커뮤니티 로드맵](https://github.com/sipeed/picoclaw/issues/988)과 [CONTRIBUTING.md](../../CONTRIBUTING.md)를 참고하세요. + +개발자 그룹도 준비 중입니다. 첫 PR이 머지되면 함께할 수 있습니다! + +커뮤니티 그룹: + +Discord: + +WeChat: +WeChat group QR code diff --git a/docs/project/README.ms.md b/docs/project/README.ms.md new file mode 100644 index 000000000..73c428f11 --- /dev/null +++ b/docs/project/README.ms.md @@ -0,0 +1,604 @@ +
+PicoClaw + +

PicoClaw: Pembantu AI Ultra-Cekap dalam Go

+ +

Perkakasan $10 · RAM 10MB · Boot ms · Jom, PicoClaw!

+

+ Go + Hardware + License +
+ Website + Docs + Wiki +
+ Twitter + + Discord +

+ +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.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) | **Malay** | [English](../../README.md) + +
+ +--- + +> **PicoClaw** adalah projek sumber terbuka bebas yang dilancarkan oleh [Sipeed](https://sipeed.com), ditulis sepenuhnya dalam **Go** dari awal — bukan cabang OpenClaw, NanoBot, atau projek lain. + +**PicoClaw** adalah pembantu AI peribadi ultra-ringan yang terinspirasi oleh [NanoBot](https://github.com/HKUDS/nanobot). Ia dibina semula dari awal dalam **Go** melalui proses "self-bootstrapping" — AI Agent itu sendiri yang memacu migrasi seni bina dan pengoptimuman kod. + +**Berjalan pada perkakasan $10 dengan RAM <10MB** — 99% lebih sedikit memori daripada OpenClaw dan 98% lebih murah daripada Mac mini! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **Notis Keselamatan** +> +> * **TIADA KRIPTO:** PicoClaw **tidak** mengeluarkan sebarang token atau mata wang kripto rasmi. Semua tuntutan di `pump.fun` atau platform dagangan lain adalah **penipuan**. +> * **DOMAIN RASMI:** Satu-satunya laman web rasmi ialah **[picoclaw.io](https://picoclaw.io)**, dan laman web syarikat ialah **[sipeed.com](https://sipeed.com)** +> * **BERHATI-HATI:** Banyak domain `.ai/.org/.com/.net/...` telah didaftarkan oleh pihak ketiga. Jangan percayai mereka. +> * **NOTA:** PicoClaw dalam pembangunan pesat awal. Mungkin terdapat isu keselamatan yang belum diselesaikan. Jangan deploy ke pengeluaran sebelum v1.0. + + +## 📢 Berita + +2026-03-31 📱 **Sokongan Android!** PicoClaw sekarang berjalan di Android! Muat turun APK di [picoclaw.io](https://picoclaw.io/download) + +2026-03-25 🚀 **v0.2.4 Dikeluarkan!** Penstrukturan semula seni bina Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keselamatan (.security.yml, penapisan data sensitif), penyedia baharu (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 pembetulan pepijat. PicoClaw mencapai **26K Stars**! + +2026-03-17 🚀 **v0.2.3 Dikeluarkan!** UI dulang sistem (Windows & Linux), pertanyaan status sub-agent (`spawn_status`), muat semula panas Gateway eksperimental, kawalan keselamatan Cron, dan 2 pembetulan keselamatan. PicoClaw mencapai **25K Stars**! + +2026-03-09 🎉 **v0.2.1 — Kemas kini terbesar setakat ini!** Sokongan protokol MCP, 4 saluran baharu (Matrix/IRC/WeCom/Discord Proxy), 3 penyedia baharu (Kimi/Minimax/Avian), saluran paip visi, storan memori JSONL, penghalaan model. + +2026-02-28 📦 **v0.2.0** dikeluarkan dengan sokongan Docker Compose dan Pelancar Web UI. + +
+Berita terdahulu... + +2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi saluran automatik dan antara muka keupayaan kini aktif. + +2026-02-16 🎉 PicoClaw melepasi 12K Stars dalam seminggu! Peranan penyelenggara komuniti dan [Peta Jalan](../../ROADMAP.md) dilancarkan secara rasmi. + +2026-02-13 🎉 PicoClaw melepasi 5000 Stars dalam 4 hari! Peta jalan projek dan kumpulan pembangun sedang dalam proses. + +2026-02-09 🎉 **PicoClaw Dikeluarkan!** Dibina dalam 1 hari untuk membawa AI Agent ke perkakasan $10 dengan RAM <10MB. Jom, PicoClaw! + +
+ +## ✨ Ciri-ciri + +🪶 **Ultra-ringan**: Jejak memori teras <10MB — 99% lebih kecil daripada OpenClaw.* + +💰 **Kos minimum**: Cukup cekap untuk berjalan pada perkakasan $10 — 98% lebih murah daripada Mac mini. + +⚡️ **Boot kilat**: 400x lebih pantas. Boot dalam <1s walaupun pada pemproses teras tunggal 0.6GHz. + +🌍 **Benar-benar mudah alih**: Binari tunggal merentasi seni bina RISC-V, ARM, MIPS, dan x86. + +🤖 **Dibantu AI**: Pelaksanaan Go tulen — 95% kod teras dijana oleh Agent dan diperhalusi melalui semakan manusia. + +🔌 **Sokongan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) natif. + +👁️ **Saluran paip visi**: Hantar imej dan fail terus ke Agent — pengekodan base64 automatik untuk LLM multimodal. + +🧠 **Penghalaan pintar**: Penghalaan model berasaskan peraturan — pertanyaan mudah ke model ringan, menjimatkan kos API. + +_*Binaan terkini mungkin menggunakan 10-20MB disebabkan penggabungan PR yang pesat. Pengoptimuman sumber dirancang. Perbandingan kelajuan boot berdasarkan penanda aras teras tunggal 0.8GHz (lihat jadual di bawah)._ + +
+ +| | OpenClaw | NanoBot | **PicoClaw** | +| ------------------------------ | ------------- | ------------------------ | -------------------------------------- | +| **Bahasa** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB*** | +| **Masa Boot** (teras 0.8GHz) | >500s | >30s | **<1s** | +| **Kos** | Mac Mini $599 | Kebanyakan papan Linux ~$50 | **Mana-mana papan Linux dari $10** | + +PicoClaw + +
+ +> **[Senarai Keserasian Perkakasan](../guides/hardware-compatibility.md)** — Lihat semua papan yang diuji, dari RISC-V $5 hingga Raspberry Pi hingga telefon Android. + +

+Keserasian Perkakasan PicoClaw +

+ +## 🦾 Demonstrasi + +### 🛠️ Aliran Kerja Pembantu Standard + + + + + + + + + + + + + + + + + +

Mod Jurutera Full-Stack

Pengelogan & Perancangan

Carian Web & Pembelajaran

Bangun · Deploy · SkalaJadual · Automatik · IngatTemui · Wawasan · Trend
+ +### 🐜 Deployment Jejak Rendah yang Inovatif + +PicoClaw boleh digunakan pada hampir mana-mana peranti Linux! + +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) untuk pembantu rumah minimal +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html) untuk operasi pelayan automatik +- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) untuk pengawasan pintar + + + +🌟 Lebih Banyak Kes Deployment Menanti! + + +## 📦 Pemasangan + +### Muat turun dari picoclaw.io (Disyorkan) + +Lawati **[picoclaw.io](https://picoclaw.io)** — laman web rasmi mengesan platform anda secara automatik dan menyediakan muat turun satu klik. + +### Muat turun binari pra-kompil + +Muat turun binari untuk platform anda dari halaman [GitHub Releases](https://github.com/sipeed/picoclaw/releases). + +### Bina dari sumber (untuk pembangunan) + +Prasyarat: + +- Go 1.25+ +- Node.js 22+ dan pnpm 10.33.0+ untuk binaan Web UI / launcher + +```bash +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw +make deps + +# Pasang dependensi frontend +(cd web/frontend && pnpm install --frozen-lockfile) + +# Bina binari teras +make build + +# Bina Pelancar Web UI (diperlukan untuk mod WebUI) +make build-launcher + +# Bina binari teras untuk semua platform yang diuruskan oleh Makefile +make build-all + +# Bina untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) +make build-pi-zero + +# Bina dan pasang +make install +``` + +**Raspberry Pi Zero 2 W:** Gunakan binari yang sepadan 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 membina kedua-duanya. + +## 🚀 Panduan Permulaan Pantas + +### 🌐 Pelancar WebUI (Disyorkan untuk Desktop) + +Pelancar WebUI menyediakan antara muka berasaskan pelayar untuk konfigurasi dan sembang. Ini adalah cara termudah untuk bermula — tiada pengetahuan baris arahan diperlukan. + +**Pilihan 1: Klik dua kali (Desktop)** + +Selepas memuat turun dari [picoclaw.io](https://picoclaw.io), klik dua kali `picoclaw-launcher` (atau `picoclaw-launcher.exe` pada Windows). Pelayar anda akan dibuka secara automatik di `http://localhost:18800`. + +**Pilihan 2: Baris arahan** + +```bash +picoclaw-launcher +# Buka http://localhost:18800 dalam pelayar anda +``` + +> [!TIP] +> **Akses jauh / Docker / VM:** Tambah bendera `-public` untuk mendengar pada semua antara muka: +> ```bash +> picoclaw-launcher -public +> ``` + +

+Pelancar WebUI +

+ +**Memulakan:** Buka WebUI, kemudian: **1)** Konfigurasikan Penyedia (tambah kunci API LLM) -> **2)** Konfigurasikan Saluran (cth. Telegram) -> **3)** Mulakan Gateway -> **4)** Sembang! + +Untuk dokumentasi WebUI terperinci, lihat [docs.picoclaw.io](https://docs.picoclaw.io). + +
+Docker (alternatif) + +```bash +# 1. Klon repo ini +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Jalankan pertama kali — jana docker/data/config.json secara automatik kemudian keluar +docker compose -f docker/docker-compose.yml --profile launcher up + +# 3. Tetapkan kunci API anda +vim docker/data/config.json + +# 4. Mulakan +docker compose -f docker/docker-compose.yml --profile launcher up -d +# Buka http://localhost:18800 +``` + +> **Pengguna Docker / VM:** Gateway mendengar pada `127.0.0.1` secara lalai. Tetapkan `PICOCLAW_GATEWAY_HOST=0.0.0.0` atau gunakan bendera `-public` untuk membolehkan akses dari hos. + +```bash +# Semak log +docker compose -f docker/docker-compose.yml logs -f + +# Henti +docker compose -f docker/docker-compose.yml --profile launcher down + +# Kemas kini +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +
+ + +
+macOS — Amaran Keselamatan Pelancaran Pertama + +macOS mungkin menyekat `picoclaw-launcher` pada pelancaran pertama kerana ia dimuat turun dari internet dan tidak disahkan melalui Mac App Store. + +**Langkah 1:** Klik dua kali `picoclaw-launcher`. Anda akan melihat amaran keselamatan: + +

+Amaran macOS Gatekeeper +

+ +> *"picoclaw-launcher" Tidak Dibuka — Apple tidak dapat mengesahkan "picoclaw-launcher" bebas daripada perisian hasad yang mungkin membahayakan Mac anda atau menjejaskan privasi anda.* + +**Langkah 2:** Buka **Tetapan Sistem** → **Privasi & Keselamatan** → tatal ke bawah ke bahagian **Keselamatan** → klik **Buka Juga** → sahkan dengan mengklik **Buka Juga** dalam dialog. + +

+macOS Privasi & Keselamatan — Buka Juga +

+ +Selepas langkah sekali ini, `picoclaw-launcher` akan dibuka secara normal pada pelancaran seterusnya. + +
+ +### 📱 Android + +Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw. + +**Pilihan 1: Pasang APK** + +Pratonton: + + + + + + + + +
+ +Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan! + +**Pilihan 2: Termux** + +
+Pelancar Terminal (untuk persekitaran terhad sumber) + +1. Pasang [Termux](https://github.com/termux/termux-app) (muat turun dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play) +2. Jalankan arahan berikut: + +```bash +# Muat turun keluaran terkini +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz +pkg install proot +termux-chroot ./picoclaw onboard # chroot menyediakan susun atur sistem fail Linux standard +``` + +Kemudian ikuti bahagian Pelancar Terminal di bawah untuk melengkapkan konfigurasi. + +PicoClaw pada Termux + +Untuk persekitaran minimal di mana hanya binari teras `picoclaw` tersedia (tiada UI Pelancar), anda boleh mengkonfigurasi semua melalui baris arahan dan fail konfigurasi JSON. + +**1. Mulakan** + +```bash +picoclaw onboard +``` + +Ini mencipta `~/.picoclaw/config.json` dan direktori ruang kerja. + +**2. Konfigurasikan** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4" + } + ] +} +``` + +> Lihat `config/config.example.json` dalam repo untuk templat konfigurasi lengkap. Nota: kunci API kini disimpan dalam `.security.yml`, bukan `config.json`. + +**3. Sembang** + +```bash +picoclaw agent -m "Apa itu 2+2?" + +# Mod interaktif +picoclaw agent + +# Mulakan gateway untuk integrasi aplikasi sembang +picoclaw gateway +``` + +
+ + +## 🔌 Penyedia (LLM) + +PicoClaw menyokong 30+ penyedia LLM melalui konfigurasi `model_list`. Gunakan format `protokol/model`: + +| Penyedia | Protokol | Kunci API | Nota | +|----------|----------|-----------|------| +| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Diperlukan | GPT-5.4, GPT-4o, o3, dll. | +| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Diperlukan | Claude Opus 4.6, Sonnet 4.6, dll. | +| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Diperlukan | Gemini 3 Flash, 2.5 Pro, dll. | +| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Diperlukan | 200+ model, API bersatu | +| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Diperlukan | GLM-4.7, GLM-5, dll. | +| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Diperlukan | DeepSeek-V3, DeepSeek-R1 | +| [Volcengine](https://console.volcengine.com) | `volcengine/` | Diperlukan | Doubao, model Ark | +| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Diperlukan | Qwen3, Qwen-Max, dll. | +| [Groq](https://console.groq.com/keys) | `groq/` | Diperlukan | Inferens pantas (Llama, Mixtral) | +| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Diperlukan | Model Kimi | +| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Diperlukan | Model MiniMax | +| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Diperlukan | Mistral Large, Codestral | +| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Diperlukan | Model hos NVIDIA | +| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Diperlukan | Inferens pantas | +| [Novita AI](https://novita.ai/) | `novita/` | Diperlukan | Pelbagai model terbuka | +| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Diperlukan | Model MiMo | +| [Ollama](https://ollama.com/) | `ollama/` | Tidak perlu | Model tempatan, self-hosted | +| [vLLM](https://docs.vllm.ai/) | `vllm/` | Tidak perlu | Deployment tempatan, serasi OpenAI | +| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Berbeza | Proksi untuk 100+ penyedia | +| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Diperlukan | Deployment Azure perusahaan | +| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Log masuk kod peranti | +| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI | +| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | Kelayakan AWS | Claude, Llama, Mistral pada AWS | + +> \* AWS Bedrock memerlukan tag binaan: `go build -tags bedrock`. Tetapkan `api_base` kepada nama rantau (cth. `us-east-1`) untuk resolusi endpoint automatik merentasi semua partition AWS. Apabila menggunakan URL endpoint penuh, anda juga perlu mengkonfigurasi `AWS_REGION` melalui pemboleh ubah persekitaran. + +
+Deployment tempatan (Ollama, vLLM, dll.) + +**Ollama:** +```json +{ + "model_list": [ + { + "model_name": "local-llama", + "model": "ollama/llama3.1:8b", + "api_base": "http://localhost:11434/v1" + } + ] +} +``` + +**vLLM:** +```json +{ + "model_list": [ + { + "model_name": "local-vllm", + "model": "vllm/your-model", + "api_base": "http://localhost:8000/v1" + } + ] +} +``` + +Untuk butiran konfigurasi penyedia penuh, lihat [Penyedia & Model](../guides/providers.md). + +
+ + +## 💬 Saluran (Aplikasi Sembang) + +Bercakap dengan PicoClaw anda melalui 17+ platform pemesejan: + +| Saluran | Persediaan | Protokol | Dok | +|---------|-----------|----------|-----| +| **Telegram** | Mudah (token bot) | Long polling | [Panduan](../channels/telegram/README.md) | +| **Discord** | Mudah (token bot + intents) | WebSocket | [Panduan](../channels/discord/README.md) | +| **WhatsApp** | Mudah (imbas QR atau URL jambatan) | Natif / Jambatan | [Panduan](../guides/chat-apps.ms.md#whatsapp) | +| **Weixin** | Mudah (imbas QR natif) | iLink API | [Panduan](../guides/chat-apps.ms.md#weixin) | +| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](../channels/qq/README.md) | +| **Slack** | Mudah (token bot + app) | Socket Mode | [Panduan](../channels/slack/README.md) | +| **Matrix** | Sederhana (homeserver + token) | Sync API | [Panduan](../channels/matrix/README.md) | +| **DingTalk** | Sederhana (kelayakan klien) | Stream | [Panduan](../channels/dingtalk/README.md) | +| **Feishu / Lark** | Sederhana (App ID + Secret) | WebSocket/SDK | [Panduan](../channels/feishu/README.md) | +| **LINE** | Sederhana (kelayakan + webhook) | Webhook | [Panduan](../channels/line/README.md) | +| **WeCom** | Mudah (log masuk QR atau manual) | WebSocket | [Panduan](../channels/wecom/README.md) | +| **IRC** | Sederhana (pelayan + nick) | Protokol IRC | [Panduan](../guides/chat-apps.ms.md#irc) | +| **OneBot** | Sederhana (URL WebSocket) | OneBot v11 | [Panduan](../channels/onebot/README.md) | +| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](../channels/maixcam/README.md) | +| **Pico** | Mudah (aktifkan) | Protokol natif | Terbina dalam | +| **Pico Client** | Mudah (URL WebSocket) | WebSocket | Terbina dalam | + +> Semua saluran berasaskan webhook berkongsi satu pelayan HTTP Gateway (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP yang dikongsi. + +> Tahap perincian log dikawal oleh `gateway.log_level` (lalai: `warn`). Nilai yang disokong: `debug`, `info`, `warn`, `error`, `fatal`. Boleh juga ditetapkan melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](../guides/configuration.ms.md#gateway-log-level) untuk butiran. + +Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](../guides/chat-apps.ms.md). + +## 🔧 Alat + +### 🔍 Carian Web + +PicoClaw boleh mencari web untuk menyediakan maklumat terkini. Konfigurasikan dalam `tools.web`: + +| Enjin Carian | Kunci API | Peringkat Percuma | Pautan | +|-------------|-----------|-------------------|--------| +| DuckDuckGo | Tidak perlu | Tanpa had | Sandaran terbina dalam | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 pertanyaan/hari | Dikuasai AI, dioptimumkan untuk China | +| [Tavily](https://tavily.com) | Diperlukan | 1000 pertanyaan/bulan | Dioptimumkan untuk AI Agent | +| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 pertanyaan/bulan | Pantas dan peribadi | +| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Carian dikuasai AI | +| [SearXNG](https://github.com/searxng/searxng) | Tidak perlu | Self-hosted | Enjin metasearch percuma | +| [GLM Search](https://open.bigmodel.cn/) | Diperlukan | Berbeza | Carian web Zhipu | + +### ⚙️ Alat Lain + +PicoClaw menyertakan alat terbina dalam untuk operasi fail, pelaksanaan kod, penjadualan, dan banyak lagi. Lihat [Konfigurasi Alat](../reference/tools_configuration.md) untuk butiran. + +## 🎯 Kemahiran + +Kemahiran adalah keupayaan modular yang melanjutkan Agent anda. Ia dimuatkan dari fail `SKILL.md` dalam ruang kerja anda. + +**Pasang kemahiran dari ClawHub:** + +```bash +picoclaw skills search "web scraping" +picoclaw skills install +``` + +**Konfigurasikan token ClawHub** (pilihan, untuk had kadar lebih tinggi): + +Tambah ke `config.json` anda: +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "auth_token": "your-clawhub-token" + } + } + } + } +} +``` + +Untuk butiran lanjut, lihat [Konfigurasi Alat - Kemahiran](../reference/tools_configuration.md#skills-tool). + +## 🔗 MCP (Protokol Konteks Model) + +PicoClaw menyokong [MCP](https://modelcontextprotocol.io/) secara natif — sambungkan mana-mana pelayan MCP untuk melanjutkan keupayaan Agent anda dengan alat dan sumber data luaran. + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } + } + } +} +``` + +Untuk konfigurasi MCP penuh (pengangkutan stdio, SSE, HTTP, Penemuan Alat), lihat [Konfigurasi Alat - MCP](../reference/tools_configuration.md#mcp-tool). + +## ClawdChat Sertai Rangkaian Sosial Agent + +Sambungkan PicoClaw ke Rangkaian Sosial Agent dengan menghantar satu mesej melalui CLI atau mana-mana Aplikasi Sembang yang disepadukan. + +**Baca `https://clawdchat.ai/skill.md` dan ikuti arahan untuk menyertai [ClawdChat.ai](https://clawdchat.ai)** + +## 🖥️ Rujukan CLI + +| Arahan | Penerangan | +| ------ | ---------- | +| `picoclaw onboard` | Mulakan konfigurasi & ruang kerja | +| `picoclaw auth weixin` | Sambungkan akaun WeChat melalui QR | +| `picoclaw agent -m "..."` | Sembang dengan agent | +| `picoclaw agent` | Mod sembang interaktif | +| `picoclaw gateway` | Mulakan gateway | +| `picoclaw status` | Tunjukkan status | +| `picoclaw version` | Tunjukkan maklumat versi | +| `picoclaw model` | Lihat atau tukar model lalai | +| `picoclaw cron list` | Senaraikan semua kerja berjadual | +| `picoclaw cron add ...` | Tambah kerja berjadual | +| `picoclaw cron disable` | Lumpuhkan kerja berjadual | +| `picoclaw cron remove` | Buang kerja berjadual | +| `picoclaw skills list` | Senaraikan kemahiran yang dipasang | +| `picoclaw skills install` | Pasang kemahiran | +| `picoclaw migrate` | Migrasi data dari versi lama | +| `picoclaw auth login` | Sahkan dengan penyedia | + +### ⏰ Tugasan Berjadual / Peringatan + +PicoClaw menyokong peringatan berjadual dan tugasan berulang melalui alat `cron`: + +* **Peringatan sekali**: "Ingatkan saya dalam 10 minit" -> pencetus sekali selepas 10 minit +* **Tugasan berulang**: "Ingatkan saya setiap 2 jam" -> pencetus setiap 2 jam +* **Ungkapan Cron**: "Ingatkan saya pada pukul 9 pagi setiap hari" -> menggunakan ungkapan cron + +## 📚 Dokumentasi + +Untuk panduan terperinci melebihi README ini: + +| Topik | Penerangan | +|-------|------------| +| [Docker & Permulaan Pantas](../guides/docker.ms.md) | Persediaan Docker Compose, mod Launcher/Agent | +| [Aplikasi Sembang](../guides/chat-apps.ms.md) | Panduan persediaan 17+ saluran | +| [Konfigurasi](../guides/configuration.ms.md) | Pemboleh ubah persekitaran, susun atur ruang kerja | +| [Penyedia & Model](../guides/providers.md) | 30+ penyedia LLM, penghalaan model | +| [Spawn & Tugasan Async](../guides/spawn-tasks.ms.md) | Tugasan pantas, tugasan panjang dengan spawn | +| [Penyelesaian Masalah](../operations/troubleshooting.ms.md) | Isu biasa dan penyelesaian | +| [Konfigurasi Alat](../reference/tools_configuration.md) | Aktif/nyahaktif alat, dasar exec, MCP, Kemahiran | +| [Keserasian Perkakasan](../guides/hardware-compatibility.md) | Papan yang diuji, keperluan minimum | + +## 🤝 Sumbangan & Peta Jalan + +PR dialu-alukan! Kod sumber sengaja dibuat kecil dan mudah dibaca. + +Lihat [Peta Jalan Komuniti](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](../../CONTRIBUTING.md) untuk panduan. + +Kumpulan pembangun sedang dibina, sertai selepas PR pertama anda digabungkan! + +Kumpulan Pengguna: + +Discord: + +WeChat: +Kod QR kumpulan WeChat diff --git a/docs/project/README.pt-br.md b/docs/project/README.pt-br.md new file mode 100644 index 000000000..74cb967de --- /dev/null +++ b/docs/project/README.pt-br.md @@ -0,0 +1,608 @@ +
+PicoClaw + +

PicoClaw: Assistente de IA Ultra-Eficiente em Go

+ +

Hardware de $10 · 10MB de RAM · Boot em ms · Let's Go, PicoClaw!

+

+ Go + Hardware + License +
+ Website + Docs + Wiki +
+ Twitter + + Discord +

+ +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md) + +
+ +--- + +> **PicoClaw** é um projeto open-source independente iniciado pela [Sipeed](https://sipeed.com), escrito inteiramente em **Go** do zero — não é um fork do OpenClaw, NanoBot ou qualquer outro projeto. + +**PicoClaw** é um assistente de IA pessoal ultra-leve inspirado no [NanoBot](https://github.com/HKUDS/nanobot). Foi reconstruído do zero em **Go** por meio de um processo de "auto-bootstrapping" — o próprio AI Agent conduziu a migração de arquitetura e a otimização do código. + +**Roda em hardware de $10 com menos de 10MB de RAM** — isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **Aviso de Segurança** +> +> * **SEM CRIPTO:** O PicoClaw **não** emitiu nenhum token oficial ou criptomoeda. Todas as alegações no `pump.fun` ou outras plataformas de negociação são **golpes**. +> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é **[sipeed.com](https://sipeed.com)** +> * **ATENÇÃO:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros. Não confie neles. +> * **NOTA:** O PicoClaw está em desenvolvimento rápido inicial. Podem existir problemas de segurança não resolvidos. Não implante em produção antes da v1.0. +> * **NOTA:** O PicoClaw mesclou muitos PRs recentemente. Builds recentes podem usar 10-20MB de RAM. A otimização de recursos está planejada após a estabilização de funcionalidades. + +## 📢 Novidades + +2026-03-31 📱 **Suporte Android!** PicoClaw agora roda no Android! Baixe o APK em [picoclaw.io](https://picoclaw.io/download) + +2026-03-25 🚀 **v0.2.4 Lançada!** Reformulação da arquitetura Agent (SubTurn, Hooks, Steering, EventBus), integração WeChat/WeCom, fortalecimento de segurança (.security.yml, filtragem de dados sensíveis), novos providers (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correções de bugs. O PicoClaw atingiu **26K Stars**! + +2026-03-17 🚀 **v0.2.3 Lançada!** UI na bandeja do sistema (Windows e Linux), consulta de status de sub-agent (`spawn_status`), hot-reload experimental do Gateway, controle de segurança do Cron e 2 correções de segurança. O PicoClaw atingiu **25K Stars**! + +2026-03-09 🎉 **v0.2.1 — Maior atualização até agora!** Suporte ao protocolo MCP, 4 novos channels (Matrix/IRC/WeCom/Discord Proxy), 3 novos providers (Kimi/Minimax/Avian), pipeline de visão, armazenamento de memória JSONL, roteamento de modelos. + +2026-02-28 📦 **v0.2.0** lançada com suporte a Docker Compose e Web UI Launcher. + +
+Notícias anteriores... + +2026-02-26 🎉 O PicoClaw atinge **20K Stars** em apenas 17 dias! Orquestração automática de channels e interfaces de capacidade estão disponíveis. + +2026-02-16 🎉 O PicoClaw ultrapassa 12K Stars em uma semana! Funções de mantenedor da comunidade e [Roadmap](../../ROADMAP.md) lançados oficialmente. + +2026-02-13 🎉 O PicoClaw ultrapassa 5000 Stars em 4 dias! Roadmap do projeto e grupos de desenvolvedores em andamento. + +2026-02-09 🎉 **PicoClaw Lançado!** Construído em 1 dia para levar AI Agents a hardware de $10 com menos de 10MB de RAM. Let's Go, PicoClaw! + +
+ +## ✨ Funcionalidades + +🪶 **Ultra-leve**: Footprint de memória do núcleo <10MB — 99% menor que o OpenClaw.* + +💰 **Custo mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini. + +⚡️ **Boot ultrarrápido**: Inicialização 400x mais rápida. Boot em menos de 1s mesmo em um processador single-core de 0,6GHz. + +🌍 **Verdadeiramente portátil**: Binário único para arquiteturas RISC-V, ARM, MIPS e x86. Um binário, roda em qualquer lugar! + +🤖 **Bootstrapped por IA**: Implementação nativa pura em Go — 95% do código principal foi gerado por um Agent e refinado por revisão humana. + +🔌 **Suporte a MCP**: Integração nativa com o [Model Context Protocol](https://modelcontextprotocol.io/) — conecte qualquer servidor MCP para estender as capacidades do Agent. + +👁️ **Pipeline de visão**: Envie imagens e arquivos diretamente ao Agent — codificação base64 automática para LLMs multimodais. + +🧠 **Roteamento inteligente**: Roteamento de modelos baseado em regras — consultas simples vão para modelos leves, economizando custos de API. + +_*Builds recentes podem usar 10-20MB devido a merges rápidos de PRs. Otimização de recursos está planejada. Comparação de velocidade de boot baseada em benchmarks de single-core a 0,8GHz (veja tabela abaixo)._ + +
+ +| | OpenClaw | NanoBot | **PicoClaw** | +| ------------------------------ | ------------- | ------------------------ | -------------------------------------- | +| **Linguagem** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB*** | +| **Tempo de boot**
(core 0,8GHz) | >500s | >30s | **<1s** | +| **Custo** | Mac Mini $599 | Maioria das placas Linux ~$50 | **Qualquer placa Linux**
**a partir de $10** | + +PicoClaw + +
+ +> **[Lista de Compatibilidade de Hardware](../guides/hardware-compatibility.pt-br.md)** — Veja todas as placas testadas, de RISC-V de $5 ao Raspberry Pi e celulares Android. Sua placa não está listada? Envie um PR! + +

+PicoClaw Hardware Compatibility +

+ +## 🦾 Demonstração + +### 🛠️ Fluxos de Trabalho Padrão do Assistente + + + + + + + + + + + + + + + + + +

Modo Engenheiro Full-Stack

Registro e Planejamento

Busca na Web e Aprendizado

Desenvolver · Implantar · EscalarAgendar · Automatizar · LembrarDescobrir · Insights · Tendências
+ +### 🐜 Implantação Inovadora de Baixo Consumo + +O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux! + +- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) edição E(Ethernet) ou W(WiFi6), para um assistente doméstico mínimo +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), para operações automatizadas de servidor +- $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 vigilância inteligente + + + +🌟 Mais Casos de Implantação Aguardam! + +## 📦 Instalação + +### Download pelo picoclaw.io (Recomendado) + +Acesse **[picoclaw.io](https://picoclaw.io)** — o site oficial detecta automaticamente sua plataforma e fornece download com um clique. Não é necessário selecionar a arquitetura manualmente. + +### Download do binário pré-compilado + +Alternativamente, baixe o binário para sua plataforma na página de [GitHub Releases](https://github.com/sipeed/picoclaw/releases). + +### Compilar a partir do código-fonte (para desenvolvimento) + +Pré-requisitos: + +- Go 1.25+ +- Node.js 22+ e pnpm 10.33.0+ para builds do Web UI / launcher + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Instalar dependências do frontend +(cd web/frontend && pnpm install --frozen-lockfile) + +# Compilar o binário principal +make build + +# Compilar o Web UI Launcher (necessário para o modo WebUI) +make build-launcher + +# Compilar os binários core para todas as plataformas gerenciadas pelo Makefile +make build-all + +# Compilar para Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) +make build-pi-zero + +# Compilar e instalar +make install +``` + +**Raspberry Pi Zero 2 W:** Use o binário que corresponde 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. + +## 🚀 Guia de Início Rápido + +### 🌐 WebUI Launcher (Recomendado para Desktop) + +O WebUI Launcher fornece uma interface baseada em navegador para configuração e chat. Esta é a maneira mais fácil de começar — sem necessidade de conhecimento de linha de comando. + +**Opção 1: Duplo clique (Desktop)** + +Após baixar de [picoclaw.io](https://picoclaw.io), dê duplo clique em `picoclaw-launcher` (ou `picoclaw-launcher.exe` no Windows). Seu navegador abrirá automaticamente em `http://localhost:18800`. + +**Opção 2: Linha de comando** + +```bash +picoclaw-launcher +# Abra http://localhost:18800 no seu navegador +``` + +> [!TIP] +> **Acesso remoto / Docker / VM:** Adicione a flag `-public` para escutar em todas as interfaces: +> ```bash +> picoclaw-launcher -public +> ``` + +

+WebUI Launcher +

+ +**Primeiros passos:** + +Abra o WebUI e então: **1)** Configure um Provider (adicione sua API key de LLM) -> **2)** Configure um Channel (ex.: Telegram) -> **3)** Inicie o Gateway -> **4)** Converse! + +Para documentação detalhada do WebUI, veja [docs.picoclaw.io](https://docs.picoclaw.io). + +
+Docker (alternativa) + +```bash +# 1. Clone este repositório +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Primeira execução — gera automaticamente docker/data/config.json e encerra +# (só é acionado quando config.json e workspace/ estão ausentes) +docker compose -f docker/docker-compose.yml --profile launcher up +# O container imprime "First-run setup complete." e para. + +# 3. Configure suas API keys +vim docker/data/config.json + +# 4. Iniciar +docker compose -f docker/docker-compose.yml --profile launcher up -d +# Abra http://localhost:18800 +``` + +> **Usuários de Docker / VM:** O Gateway escuta em `127.0.0.1` por padrão. Defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` ou use a flag `-public` para torná-lo acessível pelo host. + +```bash +# Verificar logs +docker compose -f docker/docker-compose.yml logs -f + +# Parar +docker compose -f docker/docker-compose.yml --profile launcher down + +# Atualizar +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +
+ +
+macOS — Aviso de segurança no primeiro lançamento + +O macOS pode bloquear o `picoclaw-launcher` no primeiro lançamento porque ele foi baixado da internet e não é notarizado pela Mac App Store. + +**Passo 1:** Dê um duplo clique em `picoclaw-launcher`. Você verá um aviso de segurança: + +

+Aviso do macOS Gatekeeper +

+ +> *"picoclaw-launcher" não foi aberto — A Apple não conseguiu verificar se "picoclaw-launcher" está livre de malware que possa prejudicar seu Mac ou comprometer sua privacidade.* + +**Passo 2:** Abra **Configurações do Sistema** → **Privacidade e Segurança** → role até a seção **Segurança** → clique em **Abrir Mesmo Assim** → confirme clicando em **Abrir Mesmo Assim** na caixa de diálogo. + +

+macOS Privacidade e Segurança — Abrir Mesmo Assim +

+ +Após esta etapa única, o `picoclaw-launcher` abrirá normalmente nos lançamentos seguintes. + +
+ + +### 📱 Android + +Dê uma segunda vida ao seu celular de uma década! Transforme-o em um Assistente de IA inteligente com o PicoClaw. + +**Opção 1: Instalação via APK** + +Pré-visualização: + + + + + + + + +
+ +Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux! + +**Opção 2: Termux** + +
+Terminal Launcher (para ambientes com recursos limitados) + +1. Instale o [Termux](https://github.com/termux/termux-app) (baixe nas [GitHub Releases](https://github.com/termux/termux-app/releases), ou pesquise no F-Droid / Google Play) +2. Execute os seguintes comandos: + +```bash +# Baixar a versão mais recente +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz +pkg install proot +termux-chroot ./picoclaw onboard # chroot fornece um layout padrão de sistema de arquivos Linux +``` + +Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuração. + +PicoClaw on Termux + +Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON. + +**1. Inicializar** + +```bash +picoclaw onboard +``` + +Isso cria `~/.picoclaw/config.json` e o diretório workspace. + +**2. Configurar** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-your-api-key" + } + ] +} +``` + +> Veja `config/config.example.json` no repositório para um template de configuração completo com todas as opções disponíveis. + +**3. Conversar** + +```bash +# Pergunta única +picoclaw agent -m "What is 2+2?" + +# Modo interativo +picoclaw agent + +# Iniciar gateway para integração com app de chat +picoclaw gateway +``` + +
+ +## 🔌 Providers (LLM) + +O PicoClaw suporta mais de 30 providers de LLM através da configuração `model_list`. Use o formato `protocolo/modelo`: + +| Provider | Protocolo | API Key | Notas | +|----------|-----------|---------|-------| +| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Obrigatória | GPT-5.4, GPT-4o, o3, etc. | +| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Obrigatória | Claude Opus 4.6, Sonnet 4.6, etc. | +| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Obrigatória | Gemini 3 Flash, 2.5 Pro, etc. | +| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Obrigatória | 200+ modelos, API unificada | +| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Obrigatória | GLM-4.7, GLM-5, etc. | +| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Obrigatória | DeepSeek-V3, DeepSeek-R1 | +| [Volcengine](https://console.volcengine.com) | `volcengine/` | Obrigatória | Modelos Doubao, Ark | +| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Obrigatória | Qwen3, Qwen-Max, etc. | +| [Groq](https://console.groq.com/keys) | `groq/` | Obrigatória | Inferência rápida (Llama, Mixtral) | +| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Obrigatória | Modelos Kimi | +| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Obrigatória | Modelos MiniMax | +| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Obrigatória | Mistral Large, Codestral | +| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Obrigatória | Modelos hospedados pela NVIDIA | +| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Obrigatória | Inferência rápida | +| [Novita AI](https://novita.ai/) | `novita/` | Obrigatória | Vários modelos abertos | +| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Obrigatória | Modelos MiMo | +| [Ollama](https://ollama.com/) | `ollama/` | Não necessária | Modelos locais, self-hosted | +| [vLLM](https://docs.vllm.ai/) | `vllm/` | Não necessária | Implantação local, compatível com OpenAI | +| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Varia | Proxy para 100+ providers | +| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Obrigatória | Implantação Azure Enterprise | +| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login por código de dispositivo | +| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI | + +
+Implantação local (Ollama, vLLM, etc.) + +**Ollama:** +```json +{ + "model_list": [ + { + "model_name": "local-llama", + "model": "ollama/llama3.1:8b", + "api_base": "http://localhost:11434/v1" + } + ] +} +``` + +**vLLM:** +```json +{ + "model_list": [ + { + "model_name": "local-vllm", + "model": "vllm/your-model", + "api_base": "http://localhost:8000/v1" + } + ] +} +``` + +Para detalhes completos de configuração de providers, veja [Providers & Models](../guides/providers.pt-br.md). + +
+ +## 💬 Channels (Apps de Chat) + +Converse com seu PicoClaw por meio de mais de 17 plataformas de mensagens: + +| Channel | Configuração | Protocolo | Docs | +|---------|--------------|-----------|------| +| **Telegram** | Fácil (bot token) | Long polling | [Guia](../channels/telegram/README.pt-br.md) | +| **Discord** | Fácil (bot token + intents) | WebSocket | [Guia](../channels/discord/README.pt-br.md) | +| **WhatsApp** | Fácil (QR scan ou bridge URL) | Nativo / Bridge | [Guia](../guides/chat-apps.pt-br.md#whatsapp) | +| **Weixin** | Fácil (scan QR nativo) | iLink API | [Guia](../guides/chat-apps.pt-br.md#weixin) | +| **QQ** | Fácil (AppID + AppSecret) | WebSocket | [Guia](../channels/qq/README.pt-br.md) | +| **Slack** | Fácil (bot + app token) | Socket Mode | [Guia](../channels/slack/README.pt-br.md) | +| **Matrix** | Médio (homeserver + token) | Sync API | [Guia](../channels/matrix/README.pt-br.md) | +| **DingTalk** | Médio (credenciais do cliente) | Stream | [Guia](../channels/dingtalk/README.pt-br.md) | +| **Feishu / Lark** | Médio (App ID + Secret) | WebSocket/SDK | [Guia](../channels/feishu/README.pt-br.md) | +| **LINE** | Médio (credenciais + webhook) | Webhook | [Guia](../channels/line/README.pt-br.md) | +| **WeCom** | Fácil (login QR ou manual) | WebSocket | [Guia](../channels/wecom/README.pt-br.md) | +| **IRC** | Médio (servidor + nick) | Protocolo IRC | [Guia](../guides/chat-apps.pt-br.md#irc) | +| **OneBot** | Médio (WebSocket URL) | OneBot v11 | [Guia](../channels/onebot/README.pt-br.md) | +| **MaixCam** | Fácil (habilitar) | TCP socket | [Guia](../channels/maixcam/README.pt-br.md) | +| **Pico** | Fácil (habilitar) | Protocolo nativo | Integrado | +| **Pico Client** | Fácil (WebSocket URL) | WebSocket | Integrado | + +> Todos os channels baseados em webhook compartilham um único servidor HTTP do Gateway (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). O Feishu usa modo WebSocket/SDK e não utiliza o servidor HTTP compartilhado. + +> A verbosidade dos logs é controlada por `gateway.log_level` (padrão: `warn`). Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. Também pode ser definido via `PICOCLAW_LOG_LEVEL`. Veja [Configuração](../guides/configuration.pt-br.md#nível-de-log-do-gateway) para detalhes. + +Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](../guides/chat-apps.pt-br.md). + +## 🔧 Ferramentas + +### 🔍 Busca na Web + +O PicoClaw pode pesquisar na web para fornecer informações atualizadas. Configure em `tools.web`: + +| Motor de Busca | API Key | Nível Gratuito | Link | +|----------------|---------|----------------|------| +| DuckDuckGo | Não necessária | Ilimitado | Fallback integrado | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Obrigatória | 1000 consultas/dia | IA, otimizado para chinês | +| [Tavily](https://tavily.com) | Obrigatória | 1000 consultas/mês | Otimizado para AI Agents | +| [Brave Search](https://brave.com/search/api) | Obrigatória | 2000 consultas/mês | Rápido e privado | +| [Perplexity](https://www.perplexity.ai) | Obrigatória | Pago | Busca com IA | +| [SearXNG](https://github.com/searxng/searxng) | Não necessária | Self-hosted | Metabuscador gratuito | +| [GLM Search](https://open.bigmodel.cn/) | Obrigatória | Varia | Busca web Zhipu | + +### ⚙️ Outras Ferramentas + +O PicoClaw inclui ferramentas integradas para operações de arquivo, execução de código, agendamento e mais. Veja [Configuração de Ferramentas](../reference/tools_configuration.pt-br.md) para detalhes. + +## 🎯 Skills + +Skills são capacidades modulares que estendem seu Agent. Elas são carregadas a partir de arquivos `SKILL.md` no seu workspace. + +**Instalar skills do ClawHub:** + +```bash +picoclaw skills search "web scraping" +picoclaw skills install +``` + +**Configurar token do ClawHub** (opcional, para limites de taxa mais altos): + +Adicione ao seu `config.json`: +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "auth_token": "your-clawhub-token" + } + } + } + } +} +``` + +Para mais detalhes, veja [Configuração de Ferramentas - Skills](../reference/tools_configuration.pt-br.md#skills-tool). + +## 🔗 MCP (Model Context Protocol) + +O PicoClaw suporta nativamente o [MCP](https://modelcontextprotocol.io/) — conecte qualquer servidor MCP para estender as capacidades do seu Agent com ferramentas externas e fontes de dados. + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } + } + } +} +``` + +Para configuração completa de MCP (transportes stdio, SSE, HTTP, Tool Discovery), veja [Configuração de Ferramentas - MCP](../reference/tools_configuration.pt-br.md#mcp-tool). + +## ClawdChat Junte-se à Rede Social de Agents + +Conecte o PicoClaw à Rede Social de Agents simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado. + +**Leia `https://clawdchat.ai/skill.md` e siga as instruções para entrar no [ClawdChat.ai](https://clawdchat.ai)** + +## 🖥️ Referência CLI + +| Comando | Descrição | +| ------------------------- | -------------------------------------- | +| `picoclaw onboard` | Inicializar config e workspace | +| `picoclaw auth weixin` | Conectar conta WeChat via QR | +| `picoclaw agent -m "..."` | Conversar com o agent | +| `picoclaw agent` | Modo de chat interativo | +| `picoclaw gateway` | Iniciar o gateway | +| `picoclaw status` | Exibir status | +| `picoclaw version` | Exibir informações de versão | +| `picoclaw model` | Ver ou trocar o modelo padrão | +| `picoclaw cron list` | Listar todos os jobs agendados | +| `picoclaw cron add ...` | Adicionar um job agendado | +| `picoclaw cron disable` | Desabilitar um job agendado | +| `picoclaw cron remove` | Remover um job agendado | +| `picoclaw skills list` | Listar skills instaladas | +| `picoclaw skills install` | Instalar uma skill | +| `picoclaw migrate` | Migrar dados de versões anteriores | +| `picoclaw auth login` | Autenticar com providers | + +### ⏰ Tarefas Agendadas / Lembretes + +O PicoClaw suporta lembretes agendados e tarefas recorrentes através da ferramenta `cron`: + +* **Lembretes únicos**: "Lembre-me em 10 minutos" -> dispara uma vez após 10min +* **Tarefas recorrentes**: "Lembre-me a cada 2 horas" -> dispara a cada 2 horas +* **Expressões cron**: "Lembre-me às 9h diariamente" -> usa expressão cron + +## 📚 Documentação + +Para guias detalhados além deste README: + +| Tópico | Descrição | +|--------|-----------| +| [Docker & Início Rápido](../guides/docker.pt-br.md) | Configuração do Docker Compose, modos Launcher/Agent | +| [Apps de Chat](../guides/chat-apps.pt-br.md) | Guias de configuração para todos os 17+ channels | +| [Configuração](../guides/configuration.pt-br.md) | Variáveis de ambiente, layout do workspace, sandbox de segurança | +| [Providers & Models](../guides/providers.pt-br.md) | 30+ providers de LLM, roteamento de modelos, configuração de model_list | +| [Spawn & Tarefas Assíncronas](../guides/spawn-tasks.pt-br.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agents | +| [Hooks](../architecture/hooks/README.md) | Sistema de hooks orientado a eventos: observadores, interceptores, hooks de aprovação | +| [Steering](../architecture/steering.md) | Injetar mensagens em um loop de agente em execução | +| [SubTurn](../architecture/subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida | +| [Solução de Problemas](../operations/troubleshooting.pt-br.md) | Problemas comuns e soluções | +| [Configuração de Ferramentas](../reference/tools_configuration.pt-br.md) | Habilitar/desabilitar por ferramenta, políticas de exec, MCP, Skills | +| [Compatibilidade de Hardware](../guides/hardware-compatibility.pt-br.md) | Placas testadas, requisitos mínimos | + +## 🤝 Contribuir & Roadmap + +PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível. + +Veja nosso [Roadmap da Comunidade](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](../../CONTRIBUTING.md) para diretrizes. + +Grupo de desenvolvedores em formação, entre após seu primeiro PR mesclado! + +Grupos de Usuários: + +Discord: + +WeChat: +WeChat group QR code diff --git a/docs/project/README.vi.md b/docs/project/README.vi.md new file mode 100644 index 000000000..743069021 --- /dev/null +++ b/docs/project/README.vi.md @@ -0,0 +1,608 @@ +
+PicoClaw + +

PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go

+ +

Phần cứng $10 · RAM 10MB · Khởi động ms · Let's Go, PicoClaw!

+

+ Go + Hardware + License +
+ Website + Docs + Wiki +
+ Twitter + + Discord +

+ +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.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) | [Malay](README.ms.md) | [English](../../README.md) + +
+ +--- + +> **PicoClaw** là một dự án mã nguồn mở độc lập do [Sipeed](https://sipeed.com) khởi xướng, được viết hoàn toàn bằng **Go** từ đầu — không phải fork của OpenClaw, NanoBot hay bất kỳ dự án nào khác. + +**PicoClaw** là trợ lý AI cá nhân siêu nhẹ lấy cảm hứng từ [NanoBot](https://github.com/HKUDS/nanobot). Nó được xây dựng lại từ đầu bằng **Go** thông qua quá trình "tự khởi động" — chính AI Agent đã dẫn dắt quá trình di chuyển kiến trúc và tối ưu hóa mã nguồn. + +**Chạy trên phần cứng $10 với <10MB RAM** — ít hơn 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **Thông báo Bảo mật** +> +> * **KHÔNG CÓ CRYPTO:** PicoClaw **chưa** phát hành bất kỳ token hay tiền điện tử chính thức nào. Mọi thông tin trên `pump.fun` hoặc các nền tảng giao dịch khác đều là **lừa đảo**. +> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, và website công ty là **[sipeed.com](https://sipeed.com)** +> * **CẢNH BÁO:** Nhiều domain `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký. Đừng tin tưởng chúng. +> * **LƯU Ý:** PicoClaw đang trong giai đoạn phát triển nhanh. Có thể còn các vấn đề bảo mật chưa được giải quyết. Không triển khai lên môi trường production trước v1.0. +> * **LƯU Ý:** PicoClaw gần đây đã merge nhiều PR. Các bản build gần đây có thể dùng 10-20MB RAM. Tối ưu hóa tài nguyên được lên kế hoạch sau khi tính năng ổn định. + +## 📢 Tin tức + +2026-03-31 📱 **Hỗ trợ Android!** PicoClaw giờ chạy trên Android! Tải APK tại [picoclaw.io](https://picoclaw.io/download) + +2026-03-25 🚀 **v0.2.4 đã phát hành!** Tái cấu trúc kiến trúc Agent (SubTurn, Hooks, Steering, EventBus), tích hợp WeChat/WeCom, tăng cường bảo mật (.security.yml, lọc dữ liệu nhạy cảm), provider mới (AWS Bedrock, Azure, Xiaomi MiMo) và 35 bản vá lỗi. PicoClaw đã đạt **26K Stars**! + +2026-03-17 🚀 **v0.2.3 đã phát hành!** Giao diện system tray (Windows & Linux), truy vấn trạng thái sub-agent (`spawn_status`), thử nghiệm Gateway hot-reload, bảo mật Cron, và 2 bản vá bảo mật. PicoClaw đã đạt **25K Stars**! + +2026-03-09 🎉 **v0.2.1 — Bản cập nhật lớn nhất từ trước đến nay!** Hỗ trợ giao thức MCP, 4 Channel mới (Matrix/IRC/WeCom/Discord Proxy), 3 Provider mới (Kimi/Minimax/Avian), pipeline thị giác, bộ nhớ JSONL, định tuyến mô hình. + +2026-02-28 📦 **v0.2.0** phát hành với hỗ trợ Docker Compose và Web UI Launcher. + +
+Tin tức trước đó... + +2026-02-26 🎉 PicoClaw đạt **20K Stars** chỉ trong 17 ngày! Tự động điều phối Channel và giao diện khả năng đã hoạt động. + +2026-02-16 🎉 PicoClaw vượt 12K Stars trong một tuần! Vai trò người duy trì cộng đồng và [Lộ trình](../../ROADMAP.md) chính thức ra mắt. + +2026-02-13 🎉 PicoClaw vượt 5000 Stars trong 4 ngày! Lộ trình dự án và nhóm nhà phát triển đang được xây dựng. + +2026-02-09 🎉 **PicoClaw ra mắt!** Được xây dựng trong 1 ngày để đưa AI Agent lên phần cứng $10 với <10MB RAM. Let's Go, PicoClaw! + +
+ +## ✨ Tính năng + +🪶 **Siêu nhẹ**: Bộ nhớ lõi <10MB — nhỏ hơn 99% so với OpenClaw.* + +💰 **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. + +⚡️ **Khởi động cực nhanh**: Khởi động nhanh hơn 400 lần. Khởi động trong <1 giây ngay cả trên bộ xử lý đơn nhân 0.6GHz. + +🌍 **Thực sự di động**: Một binary duy nhất cho các kiến trúc RISC-V, ARM, MIPS và x86. Một binary, chạy mọi nơi! + +🤖 **Được AI khởi động**: Triển khai Go thuần túy — 95% mã lõi được tạo bởi Agent và tinh chỉnh qua quy trình human-in-the-loop. + +🔌 **Hỗ trợ MCP**: Tích hợp [Model Context Protocol](https://modelcontextprotocol.io/) gốc — kết nối bất kỳ MCP server nào để mở rộng khả năng Agent. + +👁️ **Pipeline thị giác**: Gửi hình ảnh và tệp trực tiếp đến Agent — tự động mã hóa base64 cho LLM đa phương thức. + +🧠 **Định tuyến thông minh**: Định tuyến mô hình dựa trên quy tắc — các truy vấn đơn giản đến mô hình nhẹ, tiết kiệm chi phí API. + +_*Các bản build gần đây có thể dùng 10-20MB do merge PR nhanh. Tối ưu hóa tài nguyên đang được lên kế hoạch. So sánh tốc độ khởi động dựa trên benchmark lõi đơn 0.8GHz (xem bảng bên dưới)._ + +
+ +| | OpenClaw | NanoBot | **PicoClaw** | +| ------------------------------ | ------------- | ------------------------ | -------------------------------------- | +| **Ngôn ngữ** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB*** | +| **Thời gian khởi động**
(lõi 0.8GHz) | >500s | >30s | **<1s** | +| **Chi phí** | Mac Mini $599 | Hầu hết board Linux ~$50 | **Bất kỳ board Linux**
**từ $10** | + +PicoClaw + +
+ +> **[Danh sách Tương thích Phần cứng](../guides/hardware-compatibility.vi.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi đến điện thoại Android. Board của bạn chưa có trong danh sách? Gửi PR! + +

+PicoClaw Hardware Compatibility +

+ +## 🦾 Minh họa + +### 🛠️ Quy trình Trợ lý Tiêu chuẩn + + + + + + + + + + + + + + + + + +

Chế độ Kỹ sư Full-Stack

Ghi nhật ký & Lập kế hoạch

Tìm kiếm Web & Học tập

Phát triển · Triển khai · Mở rộngLên lịch · Tự động hóa · Ghi nhớKhám phá · Thông tin · Xu hướng
+ +### 🐜 Triển khai Sáng tạo với Dấu chân Nhỏ + +PicoClaw có thể được triển khai trên hầu hết mọi thiết bị Linux! + +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) phiên bản E(Ethernet) hoặc W(WiFi6), cho trợ lý gia đình tối giản +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), hoặc $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), cho vận hành máy chủ tự động +- $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), cho giám sát thông minh + + + +🌟 Còn nhiều trường hợp triển khai đang chờ đón! + +## 📦 Cài đặt + +### Tải xuống từ picoclaw.io (Khuyến nghị) + +Truy cập **[picoclaw.io](https://picoclaw.io)** — website chính thức tự động phát hiện nền tảng của bạn và cung cấp tải xuống một cú nhấp. Không cần chọn kiến trúc thủ công. + +### Tải xuống binary đã biên dịch sẵn + +Ngoài ra, tải binary cho nền tảng của bạn từ trang [GitHub Releases](https://github.com/sipeed/picoclaw/releases). + +### Xây dựng từ mã nguồn (để phát triển) + +Yêu cầu: + +- Go 1.25+ +- Node.js 22+ và pnpm 10.33.0+ cho các bản build Web UI / launcher + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Cài đặt dependencies frontend +(cd web/frontend && pnpm install --frozen-lockfile) + +# Build binary lõi +make build + +# Build Web UI Launcher (cần cho chế độ WebUI) +make build-launcher + +# Build các binary lõi cho mọi nền tảng do Makefile quản lý +make build-all + +# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) +make build-pi-zero + +# Build and install +make install +``` + +**Raspberry Pi Zero 2 W:** Sử dụng binary phù hợp với hệ điều hành của bạn: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Hoặc chạy `make build-pi-zero` để xây dựng cả hai. + +## 🚀 Hướng dẫn Khởi động Nhanh + +### 🌐 WebUI Launcher (Khuyến nghị cho Desktop) + +WebUI Launcher cung cấp giao diện dựa trên trình duyệt để cấu hình và trò chuyện. Đây là cách dễ nhất để bắt đầu — không cần kiến thức dòng lệnh. + +**Tùy chọn 1: Nhấp đúp (Desktop)** + +Sau khi tải xuống từ [picoclaw.io](https://picoclaw.io), nhấp đúp vào `picoclaw-launcher` (hoặc `picoclaw-launcher.exe` trên Windows). Trình duyệt của bạn sẽ tự động mở tại `http://localhost:18800`. + +**Tùy chọn 2: Dòng lệnh** + +```bash +picoclaw-launcher +# Mở http://localhost:18800 trong trình duyệt của bạn +``` + +> [!TIP] +> **Truy cập từ xa / Docker / VM:** Thêm cờ `-public` để lắng nghe trên tất cả giao diện: +> ```bash +> picoclaw-launcher -public +> ``` + +

+WebUI Launcher +

+ +**Bắt đầu:** + +Mở WebUI, sau đó: **1)** Cấu hình Provider (thêm API key LLM của bạn) -> **2)** Cấu hình Channel (ví dụ: Telegram) -> **3)** Khởi động Gateway -> **4)** Trò chuyện! + +Để biết tài liệu WebUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io). + +
+Docker (thay thế) + +```bash +# 1. Clone this repo +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. First run — auto-generates docker/data/config.json then exits +# (only triggers when both config.json and workspace/ are missing) +docker compose -f docker/docker-compose.yml --profile launcher up +# The container prints "First-run setup complete." and stops. + +# 3. Set your API keys +vim docker/data/config.json + +# 4. Start +docker compose -f docker/docker-compose.yml --profile launcher up -d +# Open http://localhost:18800 +``` + +> **Người dùng Docker / VM:** Gateway lắng nghe trên `127.0.0.1` theo mặc định. Đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` hoặc dùng cờ `-public` để có thể truy cập từ host. + +```bash +# Check logs +docker compose -f docker/docker-compose.yml logs -f + +# Stop +docker compose -f docker/docker-compose.yml --profile launcher down + +# Update +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +
+ +
+macOS — Cảnh báo bảo mật khi khởi chạy lần đầu + +macOS có thể chặn `picoclaw-launcher` khi khởi chạy lần đầu vì nó được tải từ internet và chưa được công chứng qua Mac App Store. + +**Bước 1:** Nhấp đúp vào `picoclaw-launcher`. Bạn sẽ thấy cảnh báo bảo mật: + +

+Cảnh báo macOS Gatekeeper +

+ +> *"picoclaw-launcher" Không Mở Được — Apple không thể xác minh "picoclaw-launcher" không chứa phần mềm độc hại có thể gây hại cho Mac hoặc xâm phạm quyền riêng tư của bạn.* + +**Bước 2:** Mở **Cài đặt Hệ thống** → **Quyền riêng tư & Bảo mật** → cuộn xuống phần **Bảo mật** → nhấp **Vẫn Mở** → xác nhận bằng cách nhấp **Vẫn Mở** trong hộp thoại. + +

+macOS Quyền riêng tư & Bảo mật — Vẫn Mở +

+ +Sau bước này, `picoclaw-launcher` sẽ mở bình thường trong các lần khởi chạy tiếp theo. + +
+ + +### 📱 Android + +Hãy cho chiếc điện thoại cũ của bạn một cuộc sống mới! Biến nó thành Trợ lý AI thông minh với PicoClaw. + +**Tùy chọn 1: Cài đặt APK** + +Xem trước: + + + + + + + + +
+ +Tải APK từ [picoclaw.io](https://picoclaw.io/download/) và cài đặt trực tiếp. Không cần Termux! + +**Tùy chọn 2: Termux** + +
+Terminal Launcher (cho môi trường hạn chế tài nguyên) + +1. 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 kiếm trong F-Droid / Google Play) +2. Chạy các lệnh sau: + +```bash +# Download the latest release +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz +pkg install proot +termux-chroot ./picoclaw onboard # chroot provides a standard Linux filesystem layout +``` + +Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu hình. + +PicoClaw on Termux + +Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON. + +**1. Khởi tạo** + +```bash +picoclaw onboard +``` + +Lệnh này tạo `~/.picoclaw/config.json` và thư mục workspace. + +**2. Cấu hình** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-your-api-key" + } + ] +} +``` + +> Xem `config/config.example.json` trong repo để có mẫu cấu hình đầy đủ với tất cả các tùy chọn có sẵn. + +**3. Trò chuyện** + +```bash +# One-shot question +picoclaw agent -m "What is 2+2?" + +# Interactive mode +picoclaw agent + +# Start gateway for chat app integration +picoclaw gateway +``` + +
+ +## 🔌 Providers (LLM) + +PicoClaw hỗ trợ 30+ Provider LLM thông qua cấu hình `model_list`. Sử dụng định dạng `protocol/model`: + +| Provider | Protocol | API Key | Ghi chú | +|----------|----------|---------|---------| +| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Bắt buộc | GPT-5.4, GPT-4o, o3, v.v. | +| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Bắt buộc | Claude Opus 4.6, Sonnet 4.6, v.v. | +| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Bắt buộc | Gemini 3 Flash, 2.5 Pro, v.v. | +| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Bắt buộc | 200+ mô hình, API thống nhất | +| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Bắt buộc | GLM-4.7, GLM-5, v.v. | +| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Bắt buộc | DeepSeek-V3, DeepSeek-R1 | +| [Volcengine](https://console.volcengine.com) | `volcengine/` | Bắt buộc | Doubao, Ark models | +| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Bắt buộc | Qwen3, Qwen-Max, v.v. | +| [Groq](https://console.groq.com/keys) | `groq/` | Bắt buộc | Suy luận nhanh (Llama, Mixtral) | +| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Bắt buộc | Kimi models | +| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Bắt buộc | MiniMax models | +| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Bắt buộc | Mistral Large, Codestral | +| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Bắt buộc | Mô hình do NVIDIA lưu trữ | +| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Bắt buộc | Suy luận nhanh | +| [Novita AI](https://novita.ai/) | `novita/` | Bắt buộc | Nhiều mô hình mở | +| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Bắt buộc | Mô hình MiMo | +| [Ollama](https://ollama.com/) | `ollama/` | Không cần | Mô hình cục bộ, tự lưu trữ | +| [vLLM](https://docs.vllm.ai/) | `vllm/` | Không cần | Triển khai cục bộ, tương thích OpenAI | +| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Tùy | Proxy cho 100+ provider | +| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Bắt buộc | Triển khai Azure doanh nghiệp | +| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Đăng nhập bằng device code | +| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI | + +
+Triển khai cục bộ (Ollama, vLLM, v.v.) + +**Ollama:** +```json +{ + "model_list": [ + { + "model_name": "local-llama", + "model": "ollama/llama3.1:8b", + "api_base": "http://localhost:11434/v1" + } + ] +} +``` + +**vLLM:** +```json +{ + "model_list": [ + { + "model_name": "local-vllm", + "model": "vllm/your-model", + "api_base": "http://localhost:8000/v1" + } + ] +} +``` + +Để biết chi tiết cấu hình provider đầy đủ, xem [Providers & Models](../guides/providers.vi.md). + +
+ +## 💬 Channels (Ứng dụng Chat) + +Trò chuyện với PicoClaw của bạn qua 17+ nền tảng nhắn tin: + +| Channel | Thiết lập | Protocol | Tài liệu | +|---------|-----------|----------|----------| +| **Telegram** | Dễ (bot token) | Long polling | [Hướng dẫn](../channels/telegram/README.vi.md) | +| **Discord** | Dễ (bot token + intents) | WebSocket | [Hướng dẫn](../channels/discord/README.vi.md) | +| **WhatsApp** | Dễ (quét QR hoặc bridge URL) | Native / Bridge | [Hướng dẫn](../guides/chat-apps.vi.md#whatsapp) | +| **Weixin** | Dễ (quét QR gốc) | iLink API | [Hướng dẫn](../guides/chat-apps.vi.md#weixin) | +| **QQ** | Dễ (AppID + AppSecret) | WebSocket | [Hướng dẫn](../channels/qq/README.vi.md) | +| **Slack** | Dễ (bot + app token) | Socket Mode | [Hướng dẫn](../channels/slack/README.vi.md) | +| **Matrix** | Trung bình (homeserver + token) | Sync API | [Hướng dẫn](../channels/matrix/README.vi.md) | +| **DingTalk** | Trung bình (client credentials) | Stream | [Hướng dẫn](../channels/dingtalk/README.vi.md) | +| **Feishu / Lark** | Trung bình (App ID + Secret) | WebSocket/SDK | [Hướng dẫn](../channels/feishu/README.vi.md) | +| **LINE** | Trung bình (credentials + webhook) | Webhook | [Hướng dẫn](../channels/line/README.vi.md) | +| **WeCom** | Dễ (đăng nhập QR hoặc thủ công) | WebSocket | [Hướng dẫn](../channels/wecom/README.vi.md) | +| **IRC** | Trung bình (server + nick) | IRC protocol | [Hướng dẫn](../guides/chat-apps.vi.md#irc) | +| **OneBot** | Trung bình (WebSocket URL) | OneBot v11 | [Hướng dẫn](../channels/onebot/README.vi.md) | +| **MaixCam** | Dễ (bật) | TCP socket | [Hướng dẫn](../channels/maixcam/README.vi.md) | +| **Pico** | Dễ (bật) | Native protocol | Tích hợp sẵn | +| **Pico Client** | Dễ (WebSocket URL) | WebSocket | Tích hợp sẵn | + +> Tất cả các Channel dựa trên webhook dùng chung một Gateway HTTP server (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Feishu sử dụng chế độ WebSocket/SDK và không dùng HTTP server chung. + +> Mức độ chi tiết log được kiểm soát bởi `gateway.log_level` (mặc định: `warn`). Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. Cũng có thể đặt qua `PICOCLAW_LOG_LEVEL`. Xem [Cấu hình](../guides/configuration.vi.md#mức-log-của-gateway) để biết thêm chi tiết. + +Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](../guides/chat-apps.vi.md). + +## 🔧 Tools + +### 🔍 Tìm kiếm Web + +PicoClaw có thể tìm kiếm web để cung cấp thông tin cập nhật. Cấu hình trong `tools.web`: + +| Công cụ Tìm kiếm | API Key | Gói miễn phí | Liên kết | +|------------------|---------|--------------|----------| +| DuckDuckGo | Không cần | Không giới hạn | Dự phòng tích hợp sẵn | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Bắt buộc | 1000 truy vấn/ngày | AI, tối ưu cho tiếng Trung | +| [Tavily](https://tavily.com) | Bắt buộc | 1000 truy vấn/tháng | Tối ưu cho AI Agent | +| [Brave Search](https://brave.com/search/api) | Bắt buộc | 2000 truy vấn/tháng | Nhanh và riêng tư | +| [Perplexity](https://www.perplexity.ai) | Bắt buộc | Trả phí | Tìm kiếm hỗ trợ AI | +| [SearXNG](https://github.com/searxng/searxng) | Không cần | Tự lưu trữ | Metasearch engine miễn phí | +| [GLM Search](https://open.bigmodel.cn/) | Bắt buộc | Tùy | Tìm kiếm web Zhipu | + +### ⚙️ Các Tools Khác + +PicoClaw bao gồm các tool tích hợp sẵn cho thao tác tệp, thực thi mã, lên lịch và nhiều hơn nữa. Xem [Cấu hình Tools](../reference/tools_configuration.vi.md) để biết chi tiết. + +## 🎯 Skills + +Skills là các khả năng mô-đun mở rộng Agent của bạn. Chúng được tải từ các tệp `SKILL.md` trong workspace của bạn. + +**Cài đặt Skills từ ClawHub:** + +```bash +picoclaw skills search "web scraping" +picoclaw skills install +``` + +**Cấu hình token ClawHub** (tùy chọn, để có giới hạn tốc độ cao hơn): + +Thêm vào `config.json` của bạn: +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "auth_token": "your-clawhub-token" + } + } + } + } +} +``` + +Để biết thêm chi tiết, xem [Cấu hình Tools - Skills](../reference/tools_configuration.vi.md#skills-tool). + +## 🔗 MCP (Model Context Protocol) + +PicoClaw hỗ trợ [MCP](https://modelcontextprotocol.io/) gốc — kết nối bất kỳ MCP server nào để mở rộng khả năng Agent của bạn với các tool và nguồn dữ liệu bên ngoài. + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } + } + } +} +``` + +Để biết cấu hình MCP đầy đủ (stdio, SSE, HTTP transports, Tool Discovery), xem [Cấu hình Tools - MCP](../reference/tools_configuration.vi.md#mcp-tool). + +## ClawdChat Tham gia Mạng xã hội Agent + +Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn duy nhất qua CLI hoặc bất kỳ Ứng dụng Chat nào đã tích hợp. + +**Đọc `https://clawdchat.ai/skill.md` và làm theo hướng dẫn để tham gia [ClawdChat.ai](https://clawdchat.ai)** + +## 🖥️ Tham chiếu CLI + +| Lệnh | Mô tả | +| ------------------------- | ---------------------------------------- | +| `picoclaw onboard` | Khởi tạo cấu hình & workspace | +| `picoclaw auth weixin` | Kết nối tài khoản WeChat qua QR | +| `picoclaw agent -m "..."` | Trò chuyện với agent | +| `picoclaw agent` | Chế độ trò chuyện tương tác | +| `picoclaw gateway` | Khởi động gateway | +| `picoclaw status` | Hiển thị trạng thái | +| `picoclaw version` | Hiển thị thông tin phiên bản | +| `picoclaw model` | Xem hoặc chuyển đổi mô hình mặc định | +| `picoclaw cron list` | Liệt kê tất cả công việc đã lên lịch | +| `picoclaw cron add ...` | Thêm công việc đã lên lịch | +| `picoclaw cron disable` | Vô hiệu hóa công việc đã lên lịch | +| `picoclaw cron remove` | Xóa công việc đã lên lịch | +| `picoclaw skills list` | Liệt kê các Skill đã cài đặt | +| `picoclaw skills install` | Cài đặt một Skill | +| `picoclaw migrate` | Di chuyển dữ liệu từ các phiên bản cũ | +| `picoclaw auth login` | Xác thực với các provider | + +### ⏰ Tác vụ Đã lên lịch / Nhắc nhở + +PicoClaw hỗ trợ nhắc nhở đã lên lịch và tác vụ định kỳ thông qua tool `cron`: + +* **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 +* **Tác vụ định kỳ**: "Nhắc tôi mỗi 2 giờ" -> kích hoạt mỗi 2 giờ +* **Biểu thức Cron**: "Nhắc tôi lúc 9 giờ sáng hàng ngày" -> sử dụng biểu thức cron + +## 📚 Tài liệu + +Để biết các hướng dẫn chi tiết ngoài README này: + +| Chủ đề | Mô tả | +|--------|-------| +| [Docker & Khởi động Nhanh](../guides/docker.vi.md) | Thiết lập Docker Compose, chế độ Launcher/Agent | +| [Ứng dụng Chat](../guides/chat-apps.vi.md) | Hướng dẫn thiết lập 17+ Channel | +| [Cấu hình](../guides/configuration.vi.md) | Biến môi trường, bố cục workspace, sandbox bảo mật | +| [Providers & Models](../guides/providers.vi.md) | 30+ Provider LLM, định tuyến mô hình, cấu hình model_list | +| [Spawn & Tác vụ Bất đồng bộ](../guides/spawn-tasks.vi.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ | +| [Hooks](../architecture/hooks/README.md) | Hệ thống hook hướng sự kiện: observer, interceptor, approval hook | +| [Steering](../architecture/steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy | +| [SubTurn](../architecture/subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời | +| [Khắc phục sự cố](../operations/troubleshooting.vi.md) | Các vấn đề thường gặp và giải pháp | +| [Cấu hình Tools](../reference/tools_configuration.vi.md) | Bật/tắt từng tool, chính sách exec, MCP, Skills | +| [Tương thích Phần cứng](../guides/hardware-compatibility.vi.md) | Các board đã kiểm tra, yêu cầu tối thiểu | + +## 🤝 Đóng góp & Lộ trình + +PR luôn được chào đón! Codebase được thiết kế nhỏ gọn và dễ đọc. + +Xem [Lộ trình Cộng đồng](https://github.com/sipeed/picoclaw/issues/988) và [CONTRIBUTING.md](../../CONTRIBUTING.md) để biết hướng dẫn. + +Nhóm nhà phát triển đang được xây dựng, tham gia sau khi PR đầu tiên của bạn được merge! + +Nhóm Người dùng: + +Discord: + +WeChat: +WeChat group QR code diff --git a/docs/project/README.zh.md b/docs/project/README.zh.md new file mode 100644 index 000000000..253bb84ed --- /dev/null +++ b/docs/project/README.zh.md @@ -0,0 +1,616 @@ +
+PicoClaw + +

PicoClaw: 基于Go语言的超高效 AI 助手

+ +

$10 硬件 · 10MB 内存 · 毫秒启动 · 皮皮虾,我们走!

+

+ Go + Hardware + License +
+ Website + Docs + Wiki +
+ Twitter + + Discord +

+ +**中文** | [日本語](README.ja.md) | [한국어](README.ko.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) | [Malay](README.ms.md) | [English](../../README.md) + +
+ +--- + +> **PicoClaw** 是由 [矽速科技 (Sipeed)](https://sipeed.com) 发起的独立开源项目,完全使用 **Go 语言**从零编写——不是 OpenClaw、NanoBot 或其他项目的分支。 + +🦐 **PicoClaw** 是一个受 [NanoBot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个"自举"过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。 + +⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **🚨 安全声明** +> +> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。 +> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。 +> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。 +> - **注意:** PicoClaw 正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在 1.0 正式版发布前,请不要将其部署到生产环境中。 +> - **注意:** PicoClaw 最近合并了大量 PR,近期版本可能内存占用较大 (10~20MB),我们将在功能较为收敛后进行资源占用优化。 + +## 📢 新闻 + +2026-03-31 📱 **Android 支持!** PicoClaw 现可在 Android 上运行!APK 下载地址:[picoclaw.io](https://picoclaw.io/download) + +2026-03-25 🚀 **v0.2.4 发布!** Agent 架构全面重构(SubTurn、Hook、Steering、EventBus)、微信/企业微信深度集成、安全体系升级(.security.yml、敏感数据过滤)、新增 Provider(AWS Bedrock、Azure、小米 MiMo),以及 35 项 Bug 修复。PicoClaw 已达 **26K ⭐**! + +2026-03-17 🚀 **v0.2.3 发布!** 系统托盘 UI(Windows & Linux)、子 Agent 状态查询 (`spawn_status`)、实验性 Gateway 热重载、Cron 安全门控,以及 2 项安全修复。PicoClaw 已达 **25K ⭐**! + +2026-03-09 🎉 **v0.2.1 — 史上最大更新!** MCP 协议支持、4 个新频道 (Matrix/IRC/WeCom/Discord Proxy)、3 个新 Provider (Kimi/Minimax/Avian)、视觉管线、JSONL 记忆存储、模型路由。 + +2026-02-28 📦 **v0.2.0** 发布,支持 Docker Compose 和 Web UI 启动器。 + +
+更早的新闻... + +2026-02-26 🎉 PicoClaw 仅 17 天突破 **20K Stars**!频道自动编排和能力接口上线。 + +2026-02-16 🎉 PicoClaw 一周内突破 12K Stars!社区维护者角色和 [路线图](../../ROADMAP.md) 正式发布。 + +2026-02-13 🎉 PicoClaw 4 天内突破 5000 Stars!项目路线图和开发者群组筹建中。 + +2026-02-09 🎉 **PicoClaw 正式发布!** 仅用 1 天构建,将 AI Agent 带入 $10 硬件与 <10MB 内存的世界。🦐 皮皮虾,我们走! + +
+ +## ✨ 特性 + +🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 OpenClaw 小 99%。* + +💰 **极低成本**: 高效到足以在 $10 的硬件上运行 — 比 Mac mini 便宜 98%。 + +⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。 + +🌍 **真正可移植**: 跨 RISC-V、ARM、MIPS 和 x86 架构的单二进制文件,一键运行! + +🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由"人机回环"微调。 + +🔌 **MCP 支持**: 原生 [Model Context Protocol](https://modelcontextprotocol.io/) 集成 — 连接任意 MCP 服务器扩展 Agent 能力。 + +👁️ **视觉管线**: 直接向 Agent 发送图片和文件 — 自动 base64 编码对接多模态 LLM。 + +🧠 **智能路由**: 基于规则的模型路由 — 简单查询走轻量模型,节省 API 成本。 + +_*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入计划。启动速度对比基于 0.8GHz 单核实测(见下方对比表)。_ + +
+ +| | OpenClaw | NanoBot | **PicoClaw** | +| ------------------------------ | ------------- | ------------------------ | -------------------------------------- | +| **语言** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB*** | +| **启动时间**
(0.8GHz core) | >500s | >30s | **<1s** | +| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**
**低至 $10** | + +PicoClaw + +
+ +> 📋 **[硬件兼容列表](../guides/hardware-compatibility.zh.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR! + +

+PicoClaw Hardware Compatibility +

+ +## 🦾 演示 + +### 🛠️ 标准助手工作流 + + + + + + + + + + + + + + + + + +

🧩 全栈工程师模式

🗂️ 日志与规划管理

🔎 网络搜索与学习

开发 • 部署 • 扩展日程 • 自动化 • 记忆发现 • 洞察 • 趋势
+ +### 🐜 创新的低占用部署 + +PicoClaw 几乎可以部署在任何 Linux 设备上! + +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手 +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维 +- $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),用于智能监控 + + + +🌟 更多部署案例敬请期待! + +## 📦 安装 + +### 从 picoclaw.io 下载(推荐) + +访问 **[picoclaw.io](https://picoclaw.io)** — 官网自动检测你的平台,提供一键下载,无需手动选择架构。 + +### 下载预编译二进制文件 + +也可以从 [GitHub Releases](https://github.com/sipeed/picoclaw/releases) 页面手动下载对应平台的二进制文件。 + +### 从源码构建(开发用) + +前置要求: + +- Go 1.25+ +- Node.js 22+ 和 pnpm 10.33.0+(用于 Web UI / launcher 构建) + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# 安装前端依赖 +(cd web/frontend && pnpm install --frozen-lockfile) + +# 构建核心二进制文件 +make build + +# 构建 Web UI Launcher(WebUI 模式必需) +make build-launcher + +# 为 Makefile 管理的所有平台构建核心二进制文件 +make build-all + +# 为 Raspberry Pi Zero 2 W 构建(32位: make build-linux-arm; 64位: make build-linux-arm64) +make build-pi-zero + +# 构建并安装 +make install +``` + +**Raspberry Pi Zero 2 W:** 请使用与系统匹配的二进制文件:32 位 Raspberry Pi OS → `make build-linux-arm`;64 位 → `make build-linux-arm64`。或运行 `make build-pi-zero` 同时构建两者。 + +## 🚀 快速开始 + +### 🌐 WebUI Launcher(推荐桌面用户) + +WebUI Launcher 提供基于浏览器的配置与聊天界面,是最简单的上手方式——无需命令行知识。 + +**方式一:双击启动(桌面)** + +从 [picoclaw.io](https://picoclaw.io) 下载后,双击 `picoclaw-launcher`(Windows 上为 `picoclaw-launcher.exe`),浏览器将自动打开 `http://localhost:18800`。 + +**方式二:命令行** + +```bash +picoclaw-launcher +# 在浏览器中打开 http://localhost:18800 +``` + +> [!TIP] +> **远程访问 / Docker / 虚拟机:** 添加 `-public` 参数以监听所有网络接口: +> ```bash +> picoclaw-launcher -public +> ``` + +

+WebUI Launcher +

+ +**开始使用:** + +打开 WebUI,然后:**1)** 配置 Provider(填入 LLM API Key)-> **2)** 配置 Channel(如 Telegram)-> **3)** 启动 Gateway -> **4)** 开始聊天! + +详细 WebUI 文档请参阅 [docs.picoclaw.io](https://docs.picoclaw.io)。 + +
+Docker(备选方案) + +```bash +# 1. 克隆本仓库 +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. 首次运行——自动生成 docker/data/config.json 后退出 +# (仅在 config.json 和 workspace/ 均不存在时触发) +docker compose -f docker/docker-compose.yml --profile launcher up +# 容器打印 "First-run setup complete." 后停止。 + +# 3. 填写 API Key +vim docker/data/config.json + +# 4. 启动 +docker compose -f docker/docker-compose.yml --profile launcher up -d +# 打开 http://localhost:18800 +``` + +> **Docker / 虚拟机用户:** Gateway 默认监听 `127.0.0.1`。设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或使用 `-public` 参数以允许从宿主机访问。 + +```bash +# 查看日志 +docker compose -f docker/docker-compose.yml logs -f + +# 停止 +docker compose -f docker/docker-compose.yml --profile launcher down + +# 更新 +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +
+ +
+macOS — 首次启动安全警告 + +macOS 可能会在首次启动时拦截 `picoclaw-launcher`,因为它从互联网下载,未经 Mac App Store 公证。 + +**第一步:** 双击 `picoclaw-launcher`,会出现安全警告: + +

+macOS Gatekeeper 警告 +

+ +> *"picoclaw-launcher" 无法打开 — Apple 无法验证 "picoclaw-launcher" 不含可能损害 Mac 或危及隐私的恶意软件。* + +**第二步:** 打开**系统设置** → **隐私与安全性** → 向下滚动找到**安全性**部分 → 点击**仍要打开** → 在弹窗中再次点击**打开**。 + +

+macOS 隐私与安全性 — 仍要打开 +

+ +完成这一次操作后,后续启动 `picoclaw-launcher` 将不再弹出警告。 + +
+ + +### 📱 Android + +让你十年前的旧手机焕发新生!将它变成你的 AI 助手。 + +**方式一:APK 安装** + +预览: + + + + + + + + +
+ +从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux! + +**方式二:Termux** + +
+Terminal Launcher(适用于资源受限环境) + +1. 安装 [Termux](https://github.com/termux/termux-app)(可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载,或在 F-Droid / Google Play 中搜索) +2. 执行以下命令: + +```bash +# 从 Release 页面下载最新版本 +wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz +tar xzf picoclaw_Linux_arm64.tar.gz +pkg install proot +termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布局 +``` + +然后跟随下面的"Terminal Launcher"章节继续配置。 + +PicoClaw on Termux + +对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。 + +**1. 初始化** + +```bash +picoclaw onboard +``` + +此命令会创建 `~/.picoclaw/config.json` 和工作区目录。 + +**2. 配置** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-your-api-key" + } + ] +} +``` + +> 完整配置模板请参阅仓库中的 `config/config.example.json`。 + +**3. 开始聊天** + +```bash +# 单次提问 +picoclaw agent -m "What is 2+2?" + +# 交互式对话模式 +picoclaw agent + +# 启动 Gateway 以接入聊天应用 +picoclaw gateway +``` + +
+ +## 🔌 Providers (LLM) + +PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模型` 格式: + +| Provider | 协议 | API Key | 备注 | +|----------|------|---------|------| +| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | 必填 | GPT-5.4、GPT-4o、o3 等 | +| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | 必填 | Claude Opus 4.6、Sonnet 4.6 等 | +| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | 必填 | Gemini 3 Flash、2.5 Pro 等 | +| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | 必填 | 200+ 模型,统一 API | +| [智谱 (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | 必填 | GLM-4.7、GLM-5 等 | +| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | 必填 | DeepSeek-V3、DeepSeek-R1 | +| [火山引擎](https://console.volcengine.com) | `volcengine/` | 必填 | 豆包、Ark 系列模型 | +| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | 必填 | Qwen3、Qwen-Max 等 | +| [Groq](https://console.groq.com/keys) | `groq/` | 必填 | 快速推理(Llama、Mixtral) | +| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | 必填 | Kimi 系列模型 | +| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | 必填 | MiniMax 系列模型 | +| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | 必填 | Mistral Large、Codestral | +| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | 必填 | NVIDIA 托管模型 | +| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | 必填 | 快速推理 | +| [Novita AI](https://novita.ai/) | `novita/` | 必填 | 多种开源模型 | +| [小米 MiMo](https://platform.xiaomimimo.com/) | `mimo/` | 必填 | MiMo 系列模型 | +| [Ollama](https://ollama.com/) | `ollama/` | 无需 | 本地模型,自托管 | +| [vLLM](https://docs.vllm.ai/) | `vllm/` | 无需 | 本地部署,兼容 OpenAI | +| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | 视情况 | 100+ Provider 代理 | +| [Azure OpenAI](https://portal.azure.com/) | `azure/` | 必填 | 企业级 Azure 部署 | +| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | 设备码登录 | +| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI | + +
+本地部署(Ollama、vLLM 等) + +**Ollama:** +```json +{ + "model_list": [ + { + "model_name": "local-llama", + "model": "ollama/llama3.1:8b", + "api_base": "http://localhost:11434/v1" + } + ] +} +``` + +**vLLM:** +```json +{ + "model_list": [ + { + "model_name": "local-vllm", + "model": "vllm/your-model", + "api_base": "http://localhost:8000/v1" + } + ] +} +``` + +完整 Provider 配置详情请参阅 [Providers & Models](../guides/providers.zh.md)。 + +
+ +## 💬 Channels(聊天应用) + +通过 18+ 消息平台与你的 PicoClaw 对话: + +| Channel | 配置难度 | 协议 | 文档 | +|---------|----------|------|------| +| **Telegram** | 简单(bot token) | 长轮询 | [指南](../channels/telegram/README.zh.md) | +| **Discord** | 简单(bot token + intents) | WebSocket | [指南](../channels/discord/README.zh.md) | +| **WhatsApp** | 简单(扫码或 bridge URL) | 原生 / Bridge | [指南](../guides/chat-apps.zh.md#whatsapp) | +| **微信 (Weixin)** | 简单(扫码登录) | iLink API | [指南](../guides/chat-apps.zh.md#weixin) | +| **QQ** | 简单(AppID + AppSecret) | WebSocket | [指南](../channels/qq/README.zh.md) | +| **Slack** | 简单(bot + app token) | Socket Mode | [指南](../channels/slack/README.zh.md) | +| **Matrix** | 中等(homeserver + token) | Sync API | [指南](../channels/matrix/README.zh.md) | +| **钉钉** | 中等(client credentials) | Stream | [指南](../channels/dingtalk/README.zh.md) | +| **飞书 / Lark** | 中等(App ID + Secret) | WebSocket/SDK | [指南](../channels/feishu/README.zh.md) | +| **LINE** | 中等(credentials + webhook) | Webhook | [指南](../channels/line/README.zh.md) | +| **企业微信** | 简单(扫码登录或手动配置) | WebSocket | [指南](../channels/wecom/README.zh.md) | +| **VK** | 简单(群组 token) | Long Poll | [指南](../channels/vk/README.md) | +| **IRC** | 中等(server + nick) | IRC 协议 | [指南](../guides/chat-apps.zh.md#irc) | +| **OneBot** | 中等(WebSocket URL) | OneBot v11 | [指南](../channels/onebot/README.zh.md) | +| **MaixCam** | 简单(启用即可) | TCP socket | [指南](../channels/maixcam/README.zh.md) | +| **Pico** | 简单(启用即可) | 原生协议 | 内置 | +| **Pico Client** | 简单(WebSocket URL) | WebSocket | 内置 | + +> 所有基于 Webhook 的 Channel 共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。飞书使用 WebSocket/SDK 模式,不使用共享 HTTP 服务器。 + +> 日志详细程度通过 `gateway.log_level` 控制(默认:`warn`)。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。也可通过 `PICOCLAW_LOG_LEVEL` 环境变量设置。详见[配置指南](../guides/configuration.zh.md#gateway-日志等级)。 + +详细 Channel 配置说明请参阅 [聊天应用配置](../guides/chat-apps.zh.md)。 + +## 🔧 Tools + +### 🔍 网络搜索 + +PicoClaw 可以搜索网络以提供最新信息。在 `tools.web` 中配置: + +| 搜索引擎 | API Key | 免费额度 | 链接 | +|---------|---------|---------|------| +| [百度搜索](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必填 | 1000 次/天 | AI 搜索,国内首选 | +| [Tavily](https://tavily.com) | 必填 | 1000 次/月 | 专为 AI Agent 优化 | +| [GLM Search](https://open.bigmodel.cn/) | 必填 | 视情况 | 智谱网络搜索 | +| DuckDuckGo | 无需 | 无限制 | 内置备用(国内访问困难) | +| [Perplexity](https://www.perplexity.ai) | 必填 | 付费 | AI 驱动搜索(国内访问困难) | +| [Brave Search](https://brave.com/search/api) | 必填 | 2000 次/月 | 快速且注重隐私(国内访问困难) | +| [SearXNG](https://github.com/searxng/searxng) | 无需 | 自托管 | 免费元搜索引擎 | + +### ⚙️ 其他工具 + +PicoClaw 内置文件操作、代码执行、定时任务等工具。详情请参阅 [工具配置](../reference/tools_configuration.zh.md)。 + +## 🎯 Skills + +Skills 是扩展 Agent 能力的模块化插件,从工作区的 `SKILL.md` 文件加载。 + +**从 ClawHub 安装 Skills:** + +```bash +picoclaw skills search "web scraping" +picoclaw skills install +``` + +**配置 Skills 仓库源**: + +在 `config.json` 中添加: +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "auth_token": "your-clawhub-token" + }, + "github": { + "base_url": "https://github.com", + "auth_token": "your-github-token", + "proxy": "" + } + } + } + } +} +``` + +`tools.skills.github.*` 已废弃,请改用 `tools.skills.registries.github.*`。 + +更多详情请参阅 [工具配置 - Skills](../reference/tools_configuration.zh.md#skills-tool)。 + +## 🔗 MCP (Model Context Protocol) + +PicoClaw 原生支持 [MCP](https://modelcontextprotocol.io/) — 连接任意 MCP 服务器,通过外部工具和数据源扩展 Agent 能力。 + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } + } + } +} +``` + +完整 MCP 配置(stdio、SSE、HTTP 传输、Tool Discovery)请参阅 [工具配置 - MCP](../reference/tools_configuration.zh.md#mcp-tool)。 + +## ClawdChat 加入 Agent 社交网络 + +通过 CLI 或任何已集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 + +**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)** + +## 🖥️ CLI 命令行参考 + +| 命令 | 说明 | +| ------------------------- | ---------------------- | +| `picoclaw onboard` | 初始化配置与工作区 | +| `picoclaw auth weixin` | 扫码连接微信个人号 | +| `picoclaw agent -m "..."` | 与 Agent 对话 | +| `picoclaw agent` | 交互式对话模式 | +| `picoclaw gateway` | 启动网关 | +| `picoclaw status` | 查看状态 | +| `picoclaw version` | 查看版本信息 | +| `picoclaw model` | 查看或切换默认模型 | +| `picoclaw cron list` | 列出所有定时任务 | +| `picoclaw cron add ...` | 添加定时任务 | +| `picoclaw cron disable` | 禁用定时任务 | +| `picoclaw cron remove` | 删除定时任务 | +| `picoclaw skills list` | 列出已安装 Skills | +| `picoclaw skills install` | 安装 Skill | +| `picoclaw migrate` | 从旧版本迁移数据 | +| `picoclaw auth login` | 认证 Provider | + +### ⏰ 定时任务 / 提醒 + +PicoClaw 通过 `cron` 工具支持定时提醒和重复任务: + +* **一次性提醒**: "10分钟后提醒我" → 10分钟后触发一次 +* **重复任务**: "每2小时提醒我" → 每2小时触发 +* **Cron 表达式**: "每天上午9点提醒我" → 使用 cron 表达式 + +## 📚 文档 + +详细指南请参阅以下文档,README 仅涵盖快速入门。 + +| 主题 | 说明 | +|------|------| +| 🐳 [Docker 与快速开始](../guides/docker.zh.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 | +| 💬 [聊天应用配置](../guides/chat-apps.zh.md) | 全部 17+ Channel 配置指南 | +| ⚙️ [配置指南](../guides/configuration.zh.md) | 环境变量、工作区布局、安全沙箱 | +| 🔌 [提供商与模型配置](../guides/providers.zh.md) | 30+ LLM Provider、模型路由、model_list 配置 | +| 🔄 [异步任务与 Spawn](../guides/spawn-tasks.zh.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 | +| 🪝 [Hook 系统](../architecture/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook | +| 🎯 [Steering](../architecture/steering.md) | 在工具调用间向运行中的 Agent 注入消息 | +| 🔀 [SubTurn](../architecture/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 | +| 🐛 [疑难解答](../operations/troubleshooting.zh.md) | 常见问题与解决方案 | +| 🔧 [工具配置](../reference/tools_configuration.zh.md) | 工具启用/禁用、执行策略、MCP、Skills | +| 📋 [硬件兼容列表](../guides/hardware-compatibility.zh.md) | 已测试板卡、最低要求 | + +## 🤝 贡献与路线图 + +欢迎提交 PR!代码库刻意保持小巧和可读。🤗 + +查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/issues/988) 和 [CONTRIBUTING.md](../../CONTRIBUTING.md)。 + +开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。 + +用户群组: + +Discord: + +WeChat: +WeChat group QR code diff --git a/docs/reference/README.md b/docs/reference/README.md new file mode 100644 index 000000000..2e0f53cf7 --- /dev/null +++ b/docs/reference/README.md @@ -0,0 +1,9 @@ +# Reference + +Reference docs for precise configuration, runtime behavior, and tool semantics. + +- [Tools Configuration](tools_configuration.md): per-tool configuration, execution policies, MCP, and Skills. +- [MCP Server CLI](mcp-cli.md): add, list, test, edit, and remove MCP server entries from the command line. +- [Scheduled Tasks and Cron Jobs](cron.md): schedule types, delivery modes, command gates, and storage. +- [Config Schema Versioning Guide](config-versioning.md): config schema migration and compatibility notes. +- [Dynamic Rate Limiting](rate-limiting.md): request throttling behavior for LLM providers. diff --git a/docs/reference/config-versioning.md b/docs/reference/config-versioning.md new file mode 100644 index 000000000..36f327e8c --- /dev/null +++ b/docs/reference/config-versioning.md @@ -0,0 +1,285 @@ +# Config Schema Versioning Guide + +## Overview + +PicoClaw uses a schema versioning system for `config.json` to ensure smooth upgrades as the configuration format evolves. + +## Version History + +### Version 1 +- **Introduction**: Initial version with version field support +- **Changes**: Added `version` field to Config struct +- **Migration**: No structural changes needed for existing configs + +### Version 2 +- **Introduction**: Model enable/disable support and channel config unification +- **Changes**: + - Added `enabled` field to `ModelConfig` — allows disabling individual model entries without removing them + - During V1→V2 migration, `enabled` is auto-inferred: models with API keys or the reserved `local-model` name are enabled; others default to disabled + - Migrated legacy channel fields: Discord `mention_only` → `group_trigger.mention_only`, OneBot `group_trigger_prefix` → `group_trigger.prefixes` + - V0 configs now migrate directly to CurrentVersion (V2) instead of going through V1 + - `makeBackup()` now uses date-only suffix (e.g., `config.json.20260330.bak`) and also backs up `.security.yml` + +### Version 3 +- **Introduction**: Enhanced type safety and improved error handling +- **Changes**: + - Added comma-ok type assertions in channel configuration decoding to prevent potential panics + - Improved error logging for Weixin channel configuration decoding + - Enhanced security configuration documentation and examples + - **Auto-migration**: V2 configs are automatically migrated to V3 on load with no user action required + - **Backup**: Before migration, the system creates a date-stamped backup (e.g., `config.json.20260413.bak`) in the same directory + - **Downgrade risk**: Once migrated to V3, the config cannot be safely loaded by older V2-only versions. To downgrade, restore from the auto-created backup file. + +## How It Works + +### Automatic Migration +When you load a config file: +1. The system first reads the `version` field from the JSON +2. Based on the detected version, it loads the appropriate config struct (`configV0`, `configV1`, etc.) +3. If the loaded version is less than the latest, migrations are applied incrementally +4. Before saving, the system automatically creates a date-stamped backup of `config.json` and `.security.yml` +5. The version number is updated automatically +6. The migrated config is automatically saved back to disk + +### Version Field +The `version` field in `config.json` indicates the schema version: +- `0` or missing: Legacy config (no version field) +- `1`: Previous version (will be auto-migrated to V2 on load) +- `2`: Current version + +```json +{ + "version": 3, + "agents": {...}, + ... +} +``` + +## Adding a New Migration + +When making breaking changes to the config schema: + +### Step 1: Define the New Version Struct + +Create a new struct for the new version if the structure changes significantly: + +```go +// ConfigV2 represents version 2 config structure +type ConfigV2 struct { + Version int `json:"version"` + Agents AgentsConfig `json:"agents"` + // ... other fields with new structure +} +``` + +### Step 2: Update Current Config Version + +```go +const CurrentVersion = 2 // Increment this +``` + +### Step 3: Add a Loader Function + +```go +// loadConfigV3 loads a version 3 config +func loadConfigV3(data []byte) (*Config, error) { + cfg := DefaultConfig() + + // Parse to ConfigV3 struct + var v3 ConfigV3 + if err := json.Unmarshal(data, &v3); err != nil { + return nil, err + } + + // Convert to current Config + cfg.Version = v3.Version + cfg.Agents = v3.Agents + // ... map other fields + + return cfg, nil +} +``` + +### Step 4: Add Migration Logic + +```go +func (c *configV2) Migrate() (*Config, error) { + // Apply V2→V3 structural changes here + migrated := &c.Config + migrated.Version = 3 + // Apply structural changes + return migrated, nil +} +``` + +### Step 5: Update LoadConfig Switch + +```go +func LoadConfig(path string) (*Config, error) { + // ... read file ... + + switch versionInfo.Version { + case 0: + cfg, err = loadConfigV0(data) + case 1: + cfg, err = loadConfigV1(data) + case 2: + cfg, err = loadConfig(data) + case 3: + cfg, err = loadConfigV3(data) + default: + return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version) + } + + // ... migrate and validate ... +} +``` + +### Step 6: Test Your Migration + +Create a test in `config_migration_test.go`: + +```go +func TestMigrateV2ToV3(t *testing.T) { + // Create a version 2 config + v2Config := Config{ + Version: 2, + // ... set up test data + } + + // Apply migration + migrated, err := v2Config.Migrate() + if err != nil { + t.Fatalf("Migration failed: %v", err) + } + + // Verify version is updated + if migrated.Version != 3 { + t.Errorf("Expected version 3, got %d", migrated.Version) + } + + // Verify data is preserved/transformed correctly + // ... +} +``` + +## Migration Best Practices + +1. **Version-Specific Structs**: Define a separate struct for each version that has structural changes +2. **Backward Compatibility**: Ensure old configs can still be loaded with their specific structs +3. **No Data Loss**: Migrations should preserve all user settings +4. **Idempotent**: Running the same migration multiple times should be safe +5. **Auto-Save**: Migrated configs are automatically saved to update the user's file +6. **Auto-Backup**: Before saving, the system creates a date-stamped backup of `config.json` and `.security.yml` +7. **Test Thoroughly**: Test with real user config files +8. **Update Defaults**: Keep `defaults.go` in sync with the latest schema + +## V2→V3 Migration Guide + +### What Changed? + +Version 3 introduces improved type safety and error handling: + +- **Type-safe channel decoding**: All channel type assertions now use comma-ok pattern (`val, ok := v.(*Settings)`) to prevent panics if Type and Settings are mismatched +- **Enhanced error logging**: Weixin channel now logs errors on `GetDecoded()` failure for consistency with other channels +- **Documentation fixes**: Corrected stray quotes in JSON configuration examples + +### Auto-Migration Behavior + +When you run PicoClaw with a V2 config file: + +1. **Detection**: PicoClaw reads the `version` field and detects V2 +2. **Backup**: Before any changes, creates `config.json.YYYYMMDD.bak` (e.g., `config.json.20260413.bak`) +3. **Migration**: Applies V2→V3 structural changes (primarily internal type safety improvements) +4. **Save**: Writes the updated config with `"version": 3` +5. **Continue**: Starts normally with the V3 config + +**No user action required** — the migration happens automatically on first load. + +### Backup Location + +Backups are created in the same directory as your config file: + +- **Default**: `~/.picoclaw/config.json.20260413.bak` +- **Custom path**: If using `PICOCLAW_CONFIG`, backup is created next to that file +- **Security file**: `.security.yml` is also backed up as `.security.yml.YYYYMMDD.bak` + +### Downgrade Risk + +⚠️ **Important**: Once migrated to V3, the config **cannot** be safely loaded by older PicoClaw versions that only support V2. + +**To downgrade:** + +1. Stop PicoClaw +2. Restore the backup: + ```bash + cp ~/.picoclaw/config.json.20260413.bak ~/.picoclaw/config.json + cp ~/.picoclaw/.security.yml.20260413.bak ~/.picoclaw/.security.yml # if it exists + ``` +3. Use a PicoClaw version that supports V2 configs + +**Alternative**: Manually edit `config.json` and change `"version": 3` to `"version": 2`. This works because V3 changes are primarily code-level safety improvements, not structural schema changes. + +## Example Migration + +### Scenario: Adding a new field with default value + +Old config (version 2): +```json +{ + "version": 3, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4" + } + ] +} +``` + +Migration to version 3: +```go +func (c *configV2) Migrate() (*Config, error) { + migrated := &c.Config + migrated.Version = 3 + + // Add new field with default value if not set + // ... + + return migrated, nil +} +``` + +New config (version 3): +```json +{ + "version": 3, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "new_option": true + } + ] +} +``` + +## Troubleshooting + +### Config Not Upgrading +- Check that `CurrentVersion` is incremented +- Verify migration logic handles the target version +- Ensure `Migrate()` is called in `LoadConfig()` + +### Migration Errors +- Check error messages for specific migration failures +- Review migration logic for edge cases +- Ensure all required fields are properly initialized +- Verify the loader function for the source version + +### Data Loss After Migration +- Ensure all fields are copied during migration +- Check that the migration doesn't overwrite values with defaults unnecessarily +- Review the conversion logic in the loader functions +- Check the auto-backup files (e.g., `config.json.20260330.bak`) to recover original data + diff --git a/docs/reference/cron.md b/docs/reference/cron.md new file mode 100644 index 000000000..6483fa137 --- /dev/null +++ b/docs/reference/cron.md @@ -0,0 +1,125 @@ +# Scheduled Tasks and Cron Jobs + +> Back to [README](../README.md) + +PicoClaw stores scheduled jobs in the current workspace and can run them either as reminders, full agent turns, or shell commands. + +## Schedule Types + +PicoClaw currently uses three schedule forms in the cron tool: + +- `at_seconds`: one-time job, relative to now. After it runs, the job is removed from the store. +- `every_seconds`: recurring interval, in seconds. +- `cron_expr`: recurring cron expression such as `0 9 * * *`. + +The CLI command `picoclaw cron add` currently supports recurring jobs only: + +- `--every ` +- `--cron ''` + +There is no CLI flag for a one-time `at` job today. + +Examples: + +```bash +picoclaw cron add --name "Daily summary" --message "Summarize today's logs" --cron "0 18 * * *" +picoclaw cron add --name "Ping" --message "heartbeat" --every 300 --deliver +``` + +## Execution Modes + +Jobs are stored with a message payload and can execute in three stable user-facing modes: + +### `deliver: false` + +This is the default for the cron tool. + +When the job fires, PicoClaw sends the saved message back through the agent loop as a new agent turn. Use this for scheduled work that may need reasoning, tools, or a generated reply. + +### `deliver: true` + +When the job fires, PicoClaw publishes the saved message directly to the target channel and recipient without agent processing. + +The CLI `picoclaw cron add --deliver` flag uses this mode. + +### `command` + +When a cron-tool job includes `command`, PicoClaw runs that shell command through the `exec` tool and publishes the command output back to the channel. + +For command jobs, `deliver` is forced to `false` when the job is created. The saved `message` becomes descriptive text only; the scheduled action is the shell command. + +The current CLI `picoclaw cron add` command does not expose a `command` flag. + +## Config and Security Gates + +### `tools.cron` + +`tools.cron.enabled` controls whether the agent-facing `cron` tool is registered. Default: `true`. + +If you disable `tools.cron`, users can no longer create or manage jobs through the agent tool. The gateway still starts `CronService`, but it does not install the job execution callback. As a result, due jobs do not actually run; one-time jobs may be deleted and recurring jobs may be rescheduled without executing their payload. The CLI still uses the same job store. + +`tools.cron.exec_timeout_minutes` sets the timeout used for scheduled command execution. Default: `5`. Set `0` for no timeout. + +### `tools.exec` + +Scheduled command jobs depend on `tools.exec.enabled`. Default: `true`. + +If `tools.exec.enabled` is `false`: + +- new command jobs are rejected by the cron tool +- existing command jobs publish a `command execution is disabled` error when they fire + +`tools.exec.allow_remote` is still enforced by the exec tool, but cron command scheduling already requires an internal channel when the job is created. In practice, reminder jobs can be scheduled from remote channels, while scheduled command jobs are limited to internal channels. + +### `allow_command` + +`tools.cron.allow_command` defaults to `true`. + +This is not a hard disable switch. If you set `allow_command` to `false`, PicoClaw still allows a command job when the caller explicitly passes `command_confirm: true`. + +Command jobs also require an internal channel. Non-command reminders do not have that restriction. + +Example: + +```json +{ + "tools": { + "cron": { + "enabled": true, + "exec_timeout_minutes": 5, + "allow_command": true + }, + "exec": { + "enabled": true + } + } +} +``` + +## Persistence and Location + +Cron jobs are stored in: + +```text +/cron/jobs.json +``` + +By default, the workspace is: + +```text +~/.picoclaw/workspace +``` + +If `PICOCLAW_HOME` is set, the default workspace becomes: + +```text +$PICOCLAW_HOME/workspace +``` + +Both the gateway and `picoclaw cron` CLI subcommands use the same `cron/jobs.json` file. + +Notes: + +- one-time `at_seconds` jobs are deleted after they run +- recurring jobs stay in the store until removed +- disabled jobs stay in the store and still appear in `picoclaw cron list` diff --git a/docs/reference/mcp-cli.md b/docs/reference/mcp-cli.md new file mode 100644 index 000000000..18b2b4c1c --- /dev/null +++ b/docs/reference/mcp-cli.md @@ -0,0 +1,361 @@ +# MCP Server CLI + +> Back to [README](../README.md) + +PicoClaw includes an `mcp` CLI command group for managing MCP server entries in `config.json`. + +This CLI acts as a **configuration manager**: + +- it adds, updates, removes, and validates entries under `tools.mcp.servers` +- it does **not** keep MCP servers running itself +- the gateway / host still starts the configured servers when MCP is enabled + +## Where It Writes + +The CLI updates the same config file used by the rest of PicoClaw: + +- `PICOCLAW_CONFIG` if set +- otherwise `~/.picoclaw/config.json` + +When the CLI writes the file, it: + +- saves atomically +- preserves the standard 2-space JSON formatting used by PicoClaw +- validates the generated JSON before writing + +Behavior notes: + +- `picoclaw mcp add ...` enables `tools.mcp.enabled` +- removing the last server with `picoclaw mcp remove ...` disables `tools.mcp.enabled` + +## Quick Start + +Add a stdio server via `npx`: + +```bash +picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp +``` + +Add a stdio server with environment variables saved in config: + +```bash +picoclaw mcp add github --env GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx -- npx -y @modelcontextprotocol/server-github +``` + +Add a stdio server using an env file for secrets: + +```bash +picoclaw mcp add github --env-file .env.github -- npx -y @modelcontextprotocol/server-github +``` + +Add a remote HTTP server: + +```bash +picoclaw mcp add context7 --transport http https://mcp.context7.com/mcp +``` + +Add a remote HTTP server with auth header, even with flags after the URL: + +```bash +picoclaw mcp add apify "https://mcp.apify.com/" -t http --header "Authorization: Bearer OMITTED" +``` + +Add a stdio server using an explicit command separator: + +```bash +picoclaw mcp add --transport stdio --env AIRTABLE_API_KEY=YOUR_KEY airtable -- npx -y airtable-mcp-server +``` + +Inspect the configured entries: + +```bash +picoclaw mcp list +picoclaw mcp list --status +``` + +Inspect one server's full details and its exposed tools: + +```bash +picoclaw mcp show filesystem +``` + +Probe a single server entry: + +```bash +picoclaw mcp test filesystem +``` + +Open the raw config for advanced editing: + +```bash +picoclaw mcp edit +``` + +## Command Summary + +| Command | Purpose | +|---------|---------| +| `picoclaw mcp add [flags] [args...]` | Add or update an MCP server entry | +| `picoclaw mcp remove ` | Remove a server entry from config | +| `picoclaw mcp list` | List configured MCP servers | +| `picoclaw mcp show ` | Show full details and tools for one server | +| `picoclaw mcp test ` | Try connecting to one configured server | +| `picoclaw mcp edit` | Open `config.json` in `$EDITOR` | + +## `picoclaw mcp add` + +Syntax: + +```bash +picoclaw mcp add [flags] [args...] +``` + +Supported flags: + +| Flag | Meaning | +|------|---------| +| `--env`, `-e` | Add a stdio environment variable in `KEY=value` format. Repeatable. Values are saved to config. | +| `--env-file` | Attach an env file path to a stdio server. Recommended for secrets you do not want stored inline in `config.json`. | +| `--header`, `-H` | Add an HTTP header in `Name: Value` or `Name=Value` format. Repeatable. | +| `--transport`, `-t` | Transport type: `stdio` (default), `http`, or `sse`. | +| `--force`, `-f` | Overwrite an existing server entry without confirmation. | +| `--deferred` | Mark the server as deferred: tools are hidden and discoverable on demand. | +| `--no-deferred` | Mark the server as non-deferred: tools are always loaded into context. | + +When neither `--deferred` nor `--no-deferred` is passed, the `deferred` field is omitted from the stored config and the global `discovery.enabled` value applies at runtime. + +Supported forms: + +```bash +picoclaw mcp add [flags] [args...] +picoclaw mcp add [flags] -- [args...] +``` + +Parsing behavior: + +- CLI flags can appear before the name, between the name and target, or after the URL for remote transports +- for `stdio`, the most robust form is `-- [args...]` +- use the `--` separator when the stdio command itself has arguments that may look like PicoClaw CLI flags +- without `--`, PicoClaw treats the first two non-flag tokens as `` and `` + +Secret handling: + +- `--env KEY=value` stores the resolved value directly in `config.json` +- use `--env-file` instead when the value is sensitive and should stay outside the main config file + +Example: + +```bash +picoclaw mcp add sqlite npx -y @modelcontextprotocol/server-sqlite --db ./mydb.db +``` + +This stores: + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "sqlite": { + "enabled": true, + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"] + } + } + } + } +} +``` + +Adding the same server with `--deferred` stores the extra field: + +```bash +picoclaw mcp add --deferred sqlite npx -y @modelcontextprotocol/server-sqlite --db ./mydb.db +``` + +```json +{ + "sqlite": { + "enabled": true, + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"], + "deferred": true + } +} +``` + +### Add Command Rules + +For `stdio`: + +- `` is treated as the command +- `[args...]` are stored in `args` +- `--env` is supported +- `--env-file` is supported and stored in `env_file` +- `--header` is rejected +- `-- [args...]` is supported and recommended for unambiguous parsing + +For `http` / `sse`: + +- `` must be a valid URL +- extra command args are rejected +- `--env` is rejected +- `--env-file` is rejected +- `--header` is supported and stored in `headers` + +Overwrite behavior: + +- if `` already exists, PicoClaw asks for confirmation +- use `--force` to skip the prompt + +Local path validation: + +- if the command looks like a local path such as `./server.py` or `/opt/mcp/server` +- PicoClaw checks that the file exists +- on non-Windows platforms, it also checks that the file is executable + +Clear URL/transport error: + +- if the target looks like `https://...` but transport is still `stdio`, PicoClaw returns an explicit error telling you to use `--transport http` or `--transport sse` + +## `picoclaw mcp remove` + +Syntax: + +```bash +picoclaw mcp remove +``` + +This removes the named entry from `tools.mcp.servers`. + +If the removed server was the last configured MCP server, PicoClaw also disables `tools.mcp.enabled`. + +## `picoclaw mcp list` + +Syntax: + +```bash +picoclaw mcp list +picoclaw mcp list --status +``` + +On wide terminals the output is a styled box (same look as `mcp show`). On narrow terminals or when stdout is not a TTY, a plain ASCII table is printed instead. + +Output fields: + +| Field | Meaning | +|-------|---------| +| `Name` | Server key inside `tools.mcp.servers` | +| `Type` | Effective transport: `stdio`, `http`, or `sse` | +| `Command` / `Target` | Stored command line for stdio servers, or URL for remote servers | +| `Status` | `enabled` / `disabled` by default; with `--status`: `ok (N tools)` or `error` | +| `Deferred` | `deferred` if the per-server override is `true`; `eager` if `false`; omitted if not set | + +Notes: + +- without `--status`, PicoClaw prints configuration state only +- with `--status`, PicoClaw tries to connect to each enabled server and reports `ok (N tools)` or `error` +- to see the full list of tools a server exposes, use `picoclaw mcp show ` + +## `picoclaw mcp show` + +Syntax: + +```bash +picoclaw mcp show +picoclaw mcp show --timeout 15s +``` + +This connects to the named server and prints: + +- server metadata: name, transport type, target, enabled state, deferred override, env var names, env file, header names +- every tool the server exposes, with its name, description, and parameters (name, type, required/optional, description) + +On wide terminals the output is a styled box matching the `mcp list` look. On narrow terminals or non-TTY stdout, plain text is printed instead. + +Example output (wide terminal): + +``` +╭──────────────────────────────────────────────────────────╮ +│ ⬡ filesystem │ +│ │ +│ Type stdio │ +│ Target npx -y @modelcontextprotocol/server-fs /tmp │ +│ Enabled yes │ +│ Deferred no │ +│ │ +│ Tools (3) │ +│ │ +│ read_file [1/3] │ +│ Read the complete contents of a file from the disk │ +│ │ +│ path required │ +│ Path to the file to read │ +│ ──────────────────────────────────────────────────────── │ +│ ... │ +╰──────────────────────────────────────────────────────────╯ +``` + +Flags: + +| Flag | Default | Meaning | +|------|---------|---------| +| `--timeout` | `10s` | Connection timeout | + +Notes: + +- if the server is disabled in config, `mcp show` prints the metadata only and skips tool discovery +- `mcp show` always connects live to fetch the tool list; use `mcp test` if you only need a reachability check + +## `picoclaw mcp test` + +Syntax: + +```bash +picoclaw mcp test +``` + +This performs a direct connection test for one configured entry and prints the number of discovered tools when successful. + +It is useful when: + +- you want to verify a newly added server before starting the gateway +- you want to debug one server without probing the whole list +- the entry is currently disabled in config but you still want to validate its definition + +## `picoclaw mcp edit` + +Syntax: + +```bash +picoclaw mcp edit +``` + +This opens the config file in the editor pointed to by `$EDITOR`. + +Use it when you need to configure MCP fields that are not exposed directly by `picoclaw mcp add`. + +If `$EDITOR` is not set, the command fails with an explicit error. + +## Recommended Workflow + +For common cases: + +1. Add the server with `picoclaw mcp add` (include `--deferred` if you want tools hidden by default). +2. Verify connectivity and inspect the exposed tools with `picoclaw mcp show `. +3. Check all servers at a glance with `picoclaw mcp list --status`. +4. Start PicoClaw normally so the configured MCP server is loaded by the host. + +For advanced cases: + +1. Add the base entry with `picoclaw mcp add`. +2. Run `picoclaw mcp edit` to fill in fields that are not exposed as CLI flags. +3. Run `picoclaw mcp show ` to confirm the final configuration and tool list. + +## Related Docs + +- [Tools Configuration](tools_configuration.md#mcp-tool): MCP config structure, transports, discovery, and examples +- [README](../README.md): high-level overview diff --git a/docs/reference/rate-limiting.md b/docs/reference/rate-limiting.md new file mode 100644 index 000000000..d491c9c56 --- /dev/null +++ b/docs/reference/rate-limiting.md @@ -0,0 +1,99 @@ +# Dynamic Rate Limiting + +PicoClaw prevents 429 errors from LLM provider APIs by enforcing configurable per-model request-rate limits **before** sending each request. Unlike the reactive cooldown/fallback system (which activates *after* a 429 is received), rate limiting is **proactive**: it keeps outbound QPS within the provider's free-tier or plan limits. + +## How it works + +### Token-bucket algorithm + +Each rate-limited model gets a token bucket: + +- **Capacity** = `rpm` (burst size equals the per-minute limit) +- **Refill rate** = `rpm / 60` tokens per second +- Tokens are consumed one per LLM call; if the bucket is empty, the call blocks until a token refills or the request context is cancelled + +### Call chain integration + +``` +AgentLoop.callLLM() + └─ FallbackChain.Execute() ← iterate candidates + ├─ CooldownTracker.IsAvailable() ← skip if post-429 cooldown active + ├─ RateLimiterRegistry.Wait() ← NEW: block until token available + └─ provider.Chat() ← actual LLM HTTP call +``` + +The rate limiter runs **after** the cooldown check and **before** the provider call, so: +- Candidates already in cooldown are skipped entirely (no token consumed) +- Candidates that are available get throttled to the configured RPM + +The same check applies in `ExecuteImage`. + +### Thread safety + +`RateLimiterRegistry` is safe for concurrent use. The per-limiter token bucket uses a fine-grained mutex so concurrent goroutines each acquire their own token independently. + +## Configuration + +Set `rpm` on any model in `model_list`: + +```yaml +model_list: + - model_name: gpt-4o-free + provider: openai + model: gpt-4o + api_base: https://api.openai.com/v1 + rpm: 3 # max 3 requests per minute + api_keys: + - sk-... + + - model_name: claude-haiku + provider: anthropic + model: claude-haiku-4-5 + rpm: 60 # 60 rpm (Anthropic free tier) + api_keys: + - sk-ant-... + + - model_name: local-llm + provider: ollama + model: llama3 + api_base: http://localhost:11434/v1 + # no rpm → unrestricted +``` + +| Field | Type | Default | Description | +|---|---|---|---| +| `rpm` | `int` | `0` | Requests per minute. `0` means no limit. | + +### Interaction with fallbacks + +When a model has fallbacks configured, each candidate is rate-limited **independently**: + +```yaml +model_list: + - model_name: gpt4-with-fallback + provider: openai + model: gpt-4o + rpm: 5 + fallbacks: + - gpt-4o-mini # must also be in model_list; its own rpm applies +``` + +If the current candidate's bucket is empty and there are more candidates available, PicoClaw skips the locally saturated candidate and tries the next fallback immediately. Only the last remaining candidate waits for a token to refill. If the context deadline is hit while waiting on that last candidate, the wait error propagates. + +For `model_list` aliases that resolve to the same underlying provider/model, rate limiting is keyed by the stable config identity (for example `model_name`) rather than the resolved runtime model string. This preserves distinct RPM settings for multi-key and alias-based configurations. + +### Burst behaviour + +The bucket starts **full** (burst = RPM). For `rpm: 3`, the first 3 requests fire instantly; subsequent requests are spaced ~20 s apart. + +To reduce burstiness for strict APIs, set a lower `rpm` and rely on the steady-state refill. + +## Files changed + +| File | What | +|---|---| +| `pkg/providers/ratelimiter.go` | `RateLimiter` (token bucket) + `RateLimiterRegistry` | +| `pkg/providers/ratelimiter_test.go` | Unit tests for limiter and registry | +| `pkg/providers/fallback.go` | `FallbackCandidate.RPM` field; `FallbackChain.rl`; `Wait()` call in `Execute`/`ExecuteImage` | +| `pkg/agent/model_resolution.go` | Resolves candidates from `model_list`, preserving stable config identity and propagating `RPM` into `FallbackCandidate` | +| `pkg/agent/loop.go` | Build `RateLimiterRegistry`, register all agents' candidates, pass to `NewFallbackChain` | diff --git a/docs/reference/tools_configuration.fr.md b/docs/reference/tools_configuration.fr.md new file mode 100644 index 000000000..109c9cd6f --- /dev/null +++ b/docs/reference/tools_configuration.fr.md @@ -0,0 +1,415 @@ +# 🔧 Configuration des Outils + +> Retour au [README](../project/README.fr.md) + +La configuration des outils de PicoClaw se trouve dans le champ `tools` de `config.json`. + +## Structure du répertoire + +```json +{ + "tools": { + "web": { + ... + }, + "mcp": { + ... + }, + "exec": { + ... + }, + "cron": { + ... + }, + "skills": { + ... + } + } +} +``` + +## Outils Web + +Les outils web sont utilisés pour la recherche et la récupération de pages web. + +### Web Fetcher +Paramètres généraux pour la récupération et le traitement du contenu des pages web. + +| Config | Type | Par défaut | Description | +|---------------------|--------|---------------|-----------------------------------------------------------------------------------------------| +| `enabled` | bool | true | Activer la capacité de récupération de pages web. | +| `fetch_limit_bytes` | int | 10485760 | Taille maximale du contenu de la page web à récupérer, en octets (par défaut 10 Mo). | +| `format` | string | "plaintext" | Format de sortie du contenu récupéré. Options : `plaintext` ou `markdown` (recommandé). | + +### DuckDuckGo + +| Config | Type | Par défaut | Description | +|---------------|------|------------|--------------------------------| +| `enabled` | bool | true | Activer la recherche DuckDuckGo | +| `max_results` | int | 5 | Nombre maximum de résultats | + +### Baidu Search + +| Config | Type | Par défaut | Description | +|---------------|--------|-----------------------------------------------------------------|------------------------------------| +| `enabled` | bool | false | Activer la recherche Baidu | +| `api_key` | string | - | Clé API Qianfan | +| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | URL de l'API Baidu Search | +| `max_results` | int | 10 | Nombre maximum de résultats | + +```json +{ + "tools": { + "web": { + "baidu_search": { + "enabled": true, + "api_key": "YOUR_BAIDU_QIANFAN_API_KEY", + "max_results": 10 + } + } + } +} +``` + +### Perplexity + +| Config | Type | Par défaut | Description | +|---------------|--------|------------|--------------------------------| +| `enabled` | bool | false | Activer la recherche Perplexity | +| `api_key` | string | - | Clé API Perplexity | +| `api_keys` | string[] | - | Plusieurs clés API Perplexity pour la rotation (`api_key` prioritaire) | +| `max_results` | int | 5 | Nombre maximum de résultats | + +### Brave + +| Config | Type | Par défaut | Description | +|---------------|--------|------------|---------------------------| +| `enabled` | bool | false | Activer la recherche Brave | +| `api_key` | string | - | Clé API Brave Search | +| `api_keys` | string[] | - | Plusieurs clés API Brave Search pour la rotation (`api_key` prioritaire) | +| `max_results` | int | 5 | Nombre maximum de résultats | + +### Tavily + +| Config | Type | Par défaut | Description | +|---------------|--------|------------|------------------------------------| +| `enabled` | bool | false | Activer la recherche Tavily | +| `api_key` | string | - | Clé API Tavily | +| `base_url` | string | - | URL de base Tavily personnalisée | +| `max_results` | int | 0 | Nombre maximum de résultats (0 = défaut) | + +### SearXNG + +| Config | Type | Par défaut | Description | +|---------------|--------|--------------------------|--------------------------------| +| `enabled` | bool | false | Activer la recherche SearXNG | +| `base_url` | string | `http://localhost:8888` | URL de l'instance SearXNG | +| `max_results` | int | 5 | Nombre maximum de résultats | + +### GLM Search + +| Config | Type | Par défaut | Description | +|-----------------|--------|------------------------------------------------------|---------------------------| +| `enabled` | bool | false | Activer GLM Search | +| `api_key` | string | - | Clé API GLM | +| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | URL de l'API GLM Search | +| `search_engine` | string | `search_std` | Type de moteur de recherche | +| `max_results` | int | 5 | Nombre maximum de résultats | + +## Outil Exec + +L'outil exec est utilisé pour exécuter des commandes shell. + +| Config | Type | Par défaut | Description | +|------------------------|-------|------------|------------------------------------------------| +| `enabled` | bool | true | Activer l'outil exec | +| `enable_deny_patterns` | bool | true | Activer le blocage par défaut des commandes dangereuses | +| `custom_deny_patterns` | array | [] | Modèles de refus personnalisés (expressions régulières) | + +### Désactivation de l'Outil Exec + +Pour désactiver complètement l'outil `exec`, définissez `enabled` à `false` : + +**Via le fichier de configuration :** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**Via la variable d'environnement :** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **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. + +### Fonctionnalité + +- **`enable_deny_patterns`** : Définir à `false` pour désactiver complètement les modèles de blocage par défaut des commandes dangereuses +- **`custom_deny_patterns`** : Ajouter des modèles regex de refus personnalisés ; les commandes correspondantes seront bloquées + +### Modèles de commandes bloquées par défaut + +Par défaut, PicoClaw bloque les commandes dangereuses suivantes : + +- Commandes de suppression : `rm -rf`, `del /f/q`, `rmdir /s` +- Opérations disque : `format`, `mkfs`, `diskpart`, `dd if=`, écriture vers `/dev/sd*` +- Opérations système : `shutdown`, `reboot`, `poweroff` +- Substitution de commandes : `$()`, `${}`, backticks +- Pipe vers shell : `| sh`, `| bash` +- Élévation de privilèges : `sudo`, `chmod`, `chown` +- Contrôle de processus : `pkill`, `killall`, `kill -9` +- Opérations distantes : `curl | sh`, `wget | sh`, `ssh` +- Gestion de paquets : `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` +- Conteneurs : `docker run`, `docker exec` +- Git : `git push`, `git force` +- Autres : `eval`, `source *.sh` + +### Limitation architecturale connue + +Le 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. + +Exemples de workflows pouvant contourner le garde de commande directe une fois la commande initiale autorisée : + +- `make run` +- `go run ./cmd/...` +- `cargo run` +- `npm run build` + +Cela 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. + +### Exemple de configuration + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ] + } + } +} +``` + +## Outil Cron + +L'outil cron est utilisé pour planifier des tâches périodiques. + +| Config | Type | Par défaut | Description | +|------------------------|------|------------|----------------------------------------------------| +| `exec_timeout_minutes` | int | 5 | Délai d'expiration en minutes, 0 signifie sans limite | + + +## Outil MCP + +L'outil MCP permet l'intégration avec des serveurs Model Context Protocol externes. + +### Découverte d'outils (chargement paresseux) + +Lors 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. + +Au 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`). + +### Configuration globale + +| Config | Type | Par défaut | Description | +|-------------|--------|------------|----------------------------------------------| +| `enabled` | bool | false | Activer l'intégration MCP globalement | +| `discovery` | object | `{}` | Configuration de la découverte d'outils (voir ci-dessous) | +| `servers` | object | `{}` | Mappage du nom de serveur à la configuration du serveur | + +### Configuration Discovery (`discovery`) + +| Config | Type | Par défaut | Description | +|----------------------|------|------------|-----------------------------------------------------------------------------------------------------------------------------------| +| `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 | +| `ttl` | int | 5 | Nombre de tours de conversation pendant lesquels un outil découvert reste déverrouillé | +| `max_search_results` | int | 5 | Nombre maximum d'outils retournés par requête de recherche | +| `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 | +| `use_regex` | bool | false | Activer l'outil de recherche par motif regex (`tool_search_tool_regex`) | + +> **Note :** Si `discovery.enabled` est `true`, vous **devez** activer au moins un moteur de recherche (`use_bm25` ou `use_regex`), +> sinon l'application ne démarrera pas. + +### Configuration par serveur + +| Config | Type | Requis | Description | +|------------|--------|----------|--------------------------------------------| +| `enabled` | bool | oui | Activer ce serveur MCP | +| `type` | string | non | Type de transport : `stdio`, `sse`, `http` | +| `command` | string | stdio | Commande exécutable pour le transport stdio | +| `args` | array | non | Arguments de commande pour le transport stdio | +| `env` | object | non | Variables d'environnement pour le processus stdio | +| `env_file` | string | non | Chemin vers le fichier d'environnement pour le processus stdio | +| `url` | string | sse/http | URL du point de terminaison pour le transport `sse`/`http` | +| `headers` | object | non | En-têtes HTTP pour le transport `sse`/`http` | + +### Comportement du transport + +- Si `type` est omis, le transport est détecté automatiquement : + - `url` est défini → `sse` + - `command` est défini → `stdio` +- `http` et `sse` utilisent tous deux `url` + `headers` optionnels. +- `env` et `env_file` ne sont appliqués qu'aux serveurs `stdio`. + +### Exemples de configuration + +#### 1) Serveur MCP Stdio + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ] + } + } + } + } +} +``` + +#### 2) Serveur MCP distant SSE/HTTP + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "remote-mcp": { + "enabled": true, + "type": "sse", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } + } + } +} +``` + +#### 3) Configuration MCP massive avec découverte d'outils activée + +*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.* + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "discovery": { + "enabled": true, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, + "servers": { + "github": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + } + }, + "postgres": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + }, + "slack": { + "enabled": true, + "type": "slack", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-slack" + ], + "env": { + "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", + "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + } + } + } + } + } +} +``` + + +## Outil Skills + +L'outil skills configure la découverte et l'installation de compétences via des registres comme ClawHub. + +### Registres + +| Config | Type | Par défaut | Description | +|------------------------------------|--------|----------------------|----------------------------------------------| +| `registries.clawhub.enabled` | bool | true | Activer le registre ClawHub | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | URL de base ClawHub | +| `registries.clawhub.auth_token` | string | `""` | Jeton Bearer optionnel pour des limites de débit plus élevées | +| `registries.clawhub.search_path` | string | `/api/v1/search` | Chemin de l'API de recherche | +| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Chemin de l'API Skills | +| `registries.clawhub.download_path` | string | `/api/v1/download` | Chemin de l'API de téléchargement | + +### Exemple de configuration + +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "enabled": true, + "base_url": "https://clawhub.ai", + "auth_token": "", + "search_path": "/api/v1/search", + "skills_path": "/api/v1/skills", + "download_path": "/api/v1/download" + } + } + } + } +} +``` + +## Variables d'environnement + +Toutes les options de configuration peuvent être remplacées via des variables d'environnement au format `PICOCLAW_TOOLS_
_` : + +Par exemple : + +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` +- `PICOCLAW_TOOLS_MCP_ENABLED=true` + +Note : La configuration de type map imbriquée (par exemple `tools.mcp.servers..*`) est configurée dans `config.json` plutôt que via des variables d'environnement. diff --git a/docs/reference/tools_configuration.ja.md b/docs/reference/tools_configuration.ja.md new file mode 100644 index 000000000..a331c869e --- /dev/null +++ b/docs/reference/tools_configuration.ja.md @@ -0,0 +1,415 @@ +# 🔧 ツール設定 + +> [README](../project/README.ja.md) に戻る + +PicoClaw のツール設定は `config.json` の `tools` フィールドにあります。 + +## ディレクトリ構造 + +```json +{ + "tools": { + "web": { + ... + }, + "mcp": { + ... + }, + "exec": { + ... + }, + "cron": { + ... + }, + "skills": { + ... + } + } +} +``` + +## Web ツール + +Web ツールはウェブ検索とフェッチに使用されます。 + +### Web Fetcher +ウェブページコンテンツの取得と処理に関する一般設定。 + +| 設定項目 | 型 | デフォルト | 説明 | +|---------------------|--------|---------------|----------------------------------------------------------------------------------------| +| `enabled` | bool | true | ウェブページ取得機能を有効にする。 | +| `fetch_limit_bytes` | int | 10485760 | 取得するウェブページペイロードの最大サイズ(バイト単位、デフォルトは10MB)。 | +| `format` | string | "plaintext" | 取得コンテンツの出力形式。オプション:`plaintext` または `markdown`(推奨)。 | + +### DuckDuckGo + +| 設定項目 | 型 | デフォルト | 説明 | +|---------------|------|------------|---------------------------| +| `enabled` | bool | true | DuckDuckGo 検索を有効にする | +| `max_results` | int | 5 | 最大結果数 | + +### Baidu Search + +| 設定項目 | 型 | デフォルト | 説明 | +|---------------|--------|-----------------------------------------------------------------|-------------------------------| +| `enabled` | bool | false | Baidu 検索を有効にする | +| `api_key` | string | - | Qianfan API キー | +| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | Baidu Search API URL | +| `max_results` | int | 10 | 最大結果数 | + +```json +{ + "tools": { + "web": { + "baidu_search": { + "enabled": true, + "api_key": "YOUR_BAIDU_QIANFAN_API_KEY", + "max_results": 10 + } + } + } +} +``` + +### Perplexity + +| 設定項目 | 型 | デフォルト | 説明 | +|---------------|--------|------------|---------------------------| +| `enabled` | bool | false | Perplexity 検索を有効にする | +| `api_key` | string | - | Perplexity API キー | +| `api_keys` | string[] | - | 複数の Perplexity API キー(ローテーション用、`api_key` より優先) | +| `max_results` | int | 5 | 最大結果数 | + +### Brave + +| 設定項目 | 型 | デフォルト | 説明 | +|---------------|--------|------------|-----------------------| +| `enabled` | bool | false | Brave 検索を有効にする | +| `api_key` | string | - | Brave Search API キー | +| `api_keys` | string[] | - | 複数の Brave Search API キー(ローテーション用、`api_key` より優先) | +| `max_results` | int | 5 | 最大結果数 | + +### Tavily + +| 設定項目 | 型 | デフォルト | 説明 | +|---------------|--------|------------|-----------------------------------| +| `enabled` | bool | false | Tavily 検索を有効にする | +| `api_key` | string | - | Tavily API キー | +| `base_url` | string | - | カスタム Tavily API ベース URL | +| `max_results` | int | 0 | 最大結果数(0 = デフォルト) | + +### SearXNG + +| 設定項目 | 型 | デフォルト | 説明 | +|---------------|--------|--------------------------|---------------------------| +| `enabled` | bool | false | SearXNG 検索を有効にする | +| `base_url` | string | `http://localhost:8888` | SearXNG インスタンス URL | +| `max_results` | int | 5 | 最大結果数 | + +### GLM Search + +| 設定項目 | 型 | デフォルト | 説明 | +|-----------------|--------|------------------------------------------------------|---------------------------| +| `enabled` | bool | false | GLM Search を有効にする | +| `api_key` | string | - | GLM API キー | +| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | GLM Search API URL | +| `search_engine` | string | `search_std` | 検索エンジンタイプ | +| `max_results` | int | 5 | 最大結果数 | + +## Exec ツール + +Exec ツールはシェルコマンドの実行に使用されます。 + +| 設定項目 | 型 | デフォルト | 説明 | +|------------------------|-------|------------|------------------------------------| +| `enabled` | bool | true | Exec ツールを有効にする | +| `enable_deny_patterns` | bool | true | デフォルトの危険コマンドブロックを有効にする | +| `custom_deny_patterns` | array | [] | カスタム拒否パターン(正規表現) | + +### Exec ツールの無効化 + +`exec` ツールを完全に無効にするには、`enabled` を `false` に設定します: + +**設定ファイル経由:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**環境変数経由:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **注意:** 無効にすると、エージェントはシェルコマンドを実行できなくなります。これは Cron ツールがスケジュールされたシェルコマンドを実行する能力にも影響します。 + +### 機能 + +- **`enable_deny_patterns`**:`false` に設定すると、デフォルトの危険コマンドブロックパターンを完全に無効にします +- **`custom_deny_patterns`**:カスタム拒否正規表現パターンを追加します。一致するコマンドはブロックされます + +### デフォルトでブロックされるコマンドパターン + +デフォルトで、PicoClaw は以下の危険なコマンドをブロックします: + +- 削除コマンド:`rm -rf`、`del /f/q`、`rmdir /s` +- ディスク操作:`format`、`mkfs`、`diskpart`、`dd if=`、`/dev/sd*` への書き込み +- システム操作:`shutdown`、`reboot`、`poweroff` +- コマンド置換:`$()`、`${}`、バッククォート +- シェルへのパイプ:`| sh`、`| bash` +- 権限昇格:`sudo`、`chmod`、`chown` +- プロセス制御:`pkill`、`killall`、`kill -9` +- リモート操作:`curl | sh`、`wget | sh`、`ssh` +- パッケージ管理:`apt`、`yum`、`dnf`、`npm install -g`、`pip install --user` +- コンテナ:`docker run`、`docker exec` +- Git:`git push`、`git force` +- その他:`eval`、`source *.sh` + +### 既知のアーキテクチャ上の制限 + +exec ガードは PicoClaw に送信されたトップレベルのコマンドのみを検証します。そのコマンドの実行開始後にビルドツールやスクリプトが生成する子プロセスを再帰的に検査することは**ありません**。 + +初期コマンドが許可された後、直接コマンドガードをバイパスできるワークフローの例: + +- `make run` +- `go run ./cmd/...` +- `cargo run` +- `npm run build` + +これは、明らかに危険な直接コマンドのブロックには有用ですが、未レビューのビルドパイプラインに対する完全なサンドボックスでは**ありません**。脅威モデルにワークスペース内の信頼できないコードが含まれる場合は、コンテナ、VM、またはビルド・実行コマンドに対する承認フローなど、より強力な分離を使用してください。 + +### 設定例 + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ] + } + } +} +``` + +## Cron ツール + +Cron ツールは定期タスクのスケジューリングに使用されます。 + +| 設定項目 | 型 | デフォルト | 説明 | +|------------------------|-----|------------|-----------------------------------------| +| `exec_timeout_minutes` | int | 5 | 実行タイムアウト(分)、0 は無制限 | + + +## MCP ツール + +MCP ツールは外部の Model Context Protocol サーバーとの統合を可能にします。 + +### ツールディスカバリ(遅延読み込み) + +複数の MCP サーバーに接続する場合、数百のツールを同時に公開すると LLM のコンテキストウィンドウを使い果たし、API コストが増加する可能性があります。**Discovery** 機能は、MCP ツールをデフォルトで*非表示*にすることでこの問題を解決します。 + +すべてのツールを読み込む代わりに、LLM には軽量な検索ツール(BM25 キーワードマッチングまたは正規表現を使用)が提供されます。LLM が特定の機能を必要とする場合、非表示のライブラリを検索します。一致するツールは一時的に「アンロック」され、設定されたターン数(`ttl`)の間コンテキストに注入されます。 + +### グローバル設定 + +| 設定項目 | 型 | デフォルト | 説明 | +|-------------|--------|------------|--------------------------------------| +| `enabled` | bool | false | MCP 統合をグローバルに有効にする | +| `discovery` | object | `{}` | ツールディスカバリ設定(下記参照) | +| `servers` | object | `{}` | サーバー名からサーバー設定へのマップ | + +### Discovery 設定(`discovery`) + +| 設定項目 | 型 | デフォルト | 説明 | +|----------------------|------|------------|---------------------------------------------------------------------------------------------------------------| +| `enabled` | bool | false | true の場合、MCP ツールは非表示になり、検索を通じてオンデマンドで読み込まれます。false の場合、すべてのツールが読み込まれます | +| `ttl` | int | 5 | 発見されたツールがアンロック状態を維持する会話ターン数 | +| `max_search_results` | int | 5 | 検索クエリごとに返されるツールの最大数 | +| `use_bm25` | bool | true | 自然言語/キーワード検索ツール(`tool_search_tool_bm25`)を有効にする。**警告**:正規表現検索よりリソースを消費します | +| `use_regex` | bool | false | 正規表現パターン検索ツール(`tool_search_tool_regex`)を有効にする | + +> **注意:** `discovery.enabled` が `true` の場合、少なくとも1つの検索エンジン(`use_bm25` または `use_regex`)を有効にする**必要があります**。 +> そうしないとアプリケーションの起動に失敗します。 + +### サーバーごとの設定 + +| 設定項目 | 型 | 必須 | 説明 | +|------------|--------|----------|----------------------------------------| +| `enabled` | bool | はい | この MCP サーバーを有効にする | +| `type` | string | いいえ | トランスポートタイプ:`stdio`、`sse`、`http` | +| `command` | string | stdio | stdio トランスポートの実行コマンド | +| `args` | array | いいえ | stdio トランスポートのコマンド引数 | +| `env` | object | いいえ | stdio プロセスの環境変数 | +| `env_file` | string | いいえ | stdio プロセスの環境ファイルパス | +| `url` | string | sse/http | `sse`/`http` トランスポートのエンドポイント URL | +| `headers` | object | いいえ | `sse`/`http` トランスポートの HTTP ヘッダー | + +### トランスポートの動作 + +- `type` を省略した場合、トランスポートは自動検出されます: + - `url` が設定されている → `sse` + - `command` が設定されている → `stdio` +- `http` と `sse` はどちらも `url` + オプションの `headers` を使用します。 +- `env` と `env_file` は `stdio` サーバーにのみ適用されます。 + +### 設定例 + +#### 1) Stdio MCP サーバー + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ] + } + } + } + } +} +``` + +#### 2) リモート SSE/HTTP MCP サーバー + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "remote-mcp": { + "enabled": true, + "type": "sse", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } + } + } +} +``` + +#### 3) ツールディスカバリを有効にした大規模 MCP セットアップ + +*この例では、LLM は `tool_search_tool_bm25` のみを認識します。ユーザーからリクエストがあった場合にのみ、Github や Postgres のツールを動的に検索してアンロックします。* + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "discovery": { + "enabled": true, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, + "servers": { + "github": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + } + }, + "postgres": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + }, + "slack": { + "enabled": true, + "type": "slack", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-slack" + ], + "env": { + "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", + "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + } + } + } + } + } +} +``` + + +## Skills ツール + +Skills ツールは ClawHub などのレジストリを通じたスキルの発見とインストールを設定します。 + +### レジストリ + +| 設定項目 | 型 | デフォルト | 説明 | +|------------------------------------|--------|----------------------|----------------------------------------------| +| `registries.clawhub.enabled` | bool | true | ClawHub レジストリを有効にする | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub ベース URL | +| `registries.clawhub.auth_token` | string | `""` | より高いレート制限のためのオプションの Bearer トークン | +| `registries.clawhub.search_path` | string | `/api/v1/search` | 検索 API パス | +| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API パス | +| `registries.clawhub.download_path` | string | `/api/v1/download` | ダウンロード API パス | + +### 設定例 + +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "enabled": true, + "base_url": "https://clawhub.ai", + "auth_token": "", + "search_path": "/api/v1/search", + "skills_path": "/api/v1/skills", + "download_path": "/api/v1/download" + } + } + } + } +} +``` + +## 環境変数 + +すべての設定オプションは `PICOCLAW_TOOLS_
_` 形式の環境変数で上書きできます: + +例: + +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` +- `PICOCLAW_TOOLS_MCP_ENABLED=true` + +注意:ネストされたマップ形式の設定(例:`tools.mcp.servers..*`)は環境変数ではなく `config.json` で設定します。 diff --git a/docs/reference/tools_configuration.md b/docs/reference/tools_configuration.md new file mode 100644 index 000000000..810d91ef2 --- /dev/null +++ b/docs/reference/tools_configuration.md @@ -0,0 +1,568 @@ +# Tools Configuration + +PicoClaw's tools configuration is located in the `tools` field of `config.json`. + +## Directory Structure + +```json +{ + "tools": { + "web": { + ... + }, + "mcp": { + ... + }, + "exec": { + ... + }, + "cron": { + ... + }, + "skills": { + ... + } + } +} +``` + +## Sensitive Data Filtering + +Before tool results are sent to the LLM, PicoClaw can filter sensitive values (API keys, tokens, secrets) from the output. This prevents the LLM from seeing its own credentials. + +See [Sensitive Data Filtering](../security/sensitive_data_filtering.md) for full documentation. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `filter_sensitive_data` | bool | `true` | Enable/disable filtering | +| `filter_min_length` | int | `8` | Minimum content length to trigger filtering | + +## Web Tools + +Web tools are used for web search and fetching. + +### Web Fetcher +General settings for fetching and processing webpage content. + +| Config | Type | Default | Description | +|---------------------|--------|---------------|-----------------------------------------------------------------------------------------------| +| `enabled` | bool | true | Enable the webpage fetching capability. | +| `fetch_limit_bytes` | int | 10485760 | Maximum size of the webpage payload to fetch, in bytes (default is 10MB). | +| `format` | string | "plaintext" | Output format of the fetched content. Options: `plaintext` or `markdown` (recommended). | + +### Brave + +| Config | Type | Default | Description | +|---------------|----------|---------|------------------------------------------------| +| `enabled` | bool | false | Enable Brave search | +| `api_key` | string | - | Brave Search API key | +| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) | +| `max_results` | int | 5 | Maximum number of results | + +### DuckDuckGo + +| Config | Type | Default | Description | +|---------------|------|---------|---------------------------| +| `enabled` | bool | true | Enable DuckDuckGo search | +| `max_results` | int | 5 | Maximum number of results | + +### Baidu Search + +Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5), which is AI-powered and optimized for Chinese-language queries. + +| Config | Type | Default | Description | +|---------------|--------|--------------------------------------------------------|---------------------------| +| `enabled` | bool | false | Enable Baidu Search | +| `api_key` | string | - | Qianfan API key | +| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | Baidu Search API URL | +| `max_results` | int | 5 | Maximum number of results | + +```json +{ + "tools": { + "web": { + "baidu_search": { + "enabled": true, + "api_key": "YOUR_BAIDU_QIANFAN_API_KEY", + "max_results": 10 + } + } + } +} +``` + +### Perplexity + +| Config | Type | Default | Description | +|---------------|----------|---------|------------------------------------------------| +| `enabled` | bool | false | Enable Perplexity search | +| `api_key` | string | - | Perplexity API key | +| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) | +| `max_results` | int | 5 | Maximum number of results | + +### Tavily + +| Config | Type | Default | Description | +|---------------|--------|---------|---------------------------| +| `enabled` | bool | false | Enable Tavily search | +| `api_key` | string | - | Tavily API key | +| `base_url` | string | - | Custom Tavily API base URL | +| `max_results` | int | 5 | Maximum number of results | + +### SearXNG + +| Config | Type | Default | Description | +|---------------|--------|-------------------------|---------------------------| +| `enabled` | bool | false | Enable SearXNG search | +| `base_url` | string | `http://localhost:8888` | SearXNG instance URL | +| `max_results` | int | 5 | Maximum number of results | + +### GLM Search + +| Config | Type | Default | Description | +|-----------------|--------|---------------------------------------------------|---------------------------| +| `enabled` | bool | false | Enable GLM Search | +| `api_key` | string | - | GLM API key | +| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | GLM Search API URL | +| `search_engine` | string | `search_std` | Search engine type | +| `max_results` | int | 5 | Maximum number of results | + +### Additional Web Settings + +| Config | Type | Default | Description | +|--------------------------|----------|---------|----------------------------------------------------------------| +| `prefer_native` | bool | true | Prefer provider's native search over configured search engines | +| `private_host_whitelist` | string[] | `[]` | Private/internal hosts allowed for web fetching | + +### `web_search` Tool Parameters + +At runtime, the `web_search` tool accepts the following parameters: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `query` | string | yes | Search query string | +| `count` | integer | no | Number of results to return. Default: `10`, max: `10` | +| `range` | string | no | Optional time filter: `d` (day), `w` (week), `m` (month), `y` (year) | + +If `range` is omitted, PicoClaw performs an unrestricted search. + +### Example `web_search` Call + +```json +{ + "query": "ai agent news", + "count": 10, + "range": "w" +} +``` + +## Exec Tool + +The exec tool is used to execute shell commands. + +| Config | Type | Default | Description | +|------------------------|-------|---------|--------------------------------------------| +| `enabled` | bool | true | Enable the exec tool | +| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | +| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | + +### Disabling the Exec Tool + +To completely disable the `exec` tool, set `enabled` to `false`: + +**Via config file:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**Via environment variable:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **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. + +### Functionality + +- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns +- **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked + +### Default Blocked Command Patterns + +By default, PicoClaw blocks the following dangerous commands: + +- Delete commands: `rm -rf`, `del /f/q`, `rmdir /s` +- Disk operations: `format`, `mkfs`, `diskpart`, `dd if=`, writing to `/dev/sd*` +- System operations: `shutdown`, `reboot`, `poweroff` +- Command substitution: `$()`, `${}`, backticks +- Pipe to shell: `| sh`, `| bash` +- Privilege escalation: `sudo`, `chmod`, `chown` +- Process control: `pkill`, `killall`, `kill -9` +- Remote operations: `curl | sh`, `wget | sh`, `ssh` +- Package management: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` +- Containers: `docker run`, `docker exec` +- Git: `git push`, `git force` +- Other: `eval`, `source *.sh` + +### Known Architectural Limitation + +The exec guard only validates the top-level command sent to PicoClaw. It does **not** recursively inspect child +processes spawned by build tools or scripts after that command starts running. + +Examples of workflows that can bypass the direct command guard once the initial command is allowed: + +- `make run` +- `go run ./cmd/...` +- `cargo run` +- `npm run build` + +This means the guard is useful for blocking obviously dangerous direct commands, but it is **not** a full sandbox for +unreviewed build pipelines. If your threat model includes untrusted code in the workspace, use stronger isolation such +as containers, VMs, or an approval flow around build-and-run commands. + +### Configuration Example + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ] + } + } +} +``` + +## Cron Tool + +The cron tool is used for scheduling periodic tasks. + +| Config | Type | Default | Description | +|------------------------|------|---------|------------------------------------------------| +| `enabled` | bool | true | Register the agent-facing cron tool | +| `allow_command` | bool | true | Allow command jobs without extra confirmation | +| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | + +For schedule types, execution modes (`deliver`, agent turn, and command jobs), persistence, and the current command-security gates, see [Scheduled Tasks and Cron Jobs](cron.md). + +## MCP Tool + +The MCP tool enables integration with external Model Context Protocol servers. + +If you prefer not to edit JSON manually, PicoClaw also provides an MCP configuration manager CLI: + +- `picoclaw mcp add` — add or update a server (supports `--deferred` / `--no-deferred`) +- `picoclaw mcp list` — list all configured servers with status and deferred state +- `picoclaw mcp show ` — show full details and the tool list for one server +- `picoclaw mcp test ` — connectivity check for one server +- `picoclaw mcp remove ` — remove a server entry +- `picoclaw mcp edit` — open `config.json` in `$EDITOR` for advanced edits + +These commands manage the same `tools.mcp.servers` section documented below. See [MCP Server CLI](mcp-cli.md) for command syntax, examples, and behavior details. + +### Tool Discovery (Lazy Loading) + +When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window +and increase API costs. The **Discovery** feature solves this by keeping MCP tools *hidden* by default. + +Instead of loading all tools, the LLM is provided with a lightweight search tool (using BM25 keyword matching or Regex). +When the LLM needs a specific capability, it searches the hidden library. Matching tools are then temporarily "unlocked" +and injected into the context for a configured number of turns (`ttl`). + +### Global Config + +| Config | Type | Default | Description | +|-------------|--------|---------|----------------------------------------------| +| `enabled` | bool | false | Enable MCP integration globally | +| `discovery` | object | `{}` | Configuration for Tool Discovery (see below) | +| `servers` | object | `{}` | Map of server name to server config | + +### Discovery Config (`discovery`) + +| Config | Type | Default | Description | +|----------------------|------|---------|-----------------------------------------------------------------------------------------------------------------------------------| +| `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. | +| `ttl` | int | 5 | Number of conversational turns a discovered tool remains unlocked | +| `max_search_results` | int | 5 | Maximum number of tools returned per search query | +| `use_bm25` | bool | true | Enable the natural language/keyword search tool (`tool_search_tool_bm25`). **Warning**: consumes more resources than regex search | +| `use_regex` | bool | false | Enable the regex pattern search tool (`tool_search_tool_regex`) | + +> **Note:** If `discovery.enabled` is `true`, you MUST enable at least one search engine (`use_bm25` or `use_regex`), +> otherwise the application will fail to start. + +### Per-Server Config + +| Config | Type | Required | Description | +|------------|---------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `enabled` | bool | yes | Enable this MCP server | +| `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. | +| `type` | string | no | Transport type: `stdio`, `sse`, `http` | +| `command` | string | stdio | Executable command for stdio transport | +| `args` | array | no | Command arguments for stdio transport | +| `env` | object | no | Environment variables for stdio process | +| `env_file` | string | no | Path to environment file for stdio process | +| `url` | string | sse/http | Endpoint URL for `sse`/`http` transport | +| `headers` | object | no | HTTP headers for `sse`/`http` transport | + +### Transport Behavior + +- If `type` is omitted, transport is auto-detected: + - `url` is set → `sse` + - `command` is set → `stdio` +- `http` and `sse` both use `url` + optional `headers`. +- `env` and `env_file` are only applied to `stdio` servers. + +### Configuration Examples + +#### 1) Stdio MCP server + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ] + } + } + } + } +} +``` + +#### 2) Remote SSE/HTTP MCP server + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "remote-mcp": { + "enabled": true, + "type": "sse", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } + } + } +} +``` + +#### 3) Massive MCP setup with Tool Discovery enabled + +*In this example, the LLM will only see the `tool_search_tool_bm25`. It will search and unlock Github or Postgres tools +dynamically only when requested by the user.* + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "discovery": { + "enabled": true, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, + "servers": { + "github": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + } + }, + "postgres": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + }, + "slack": { + "enabled": true, + "type": "slack", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-slack" + ], + "env": { + "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", + "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + } + } + } + } + } +} +``` + +#### 4) Mixed setup: per-server deferred override + +*Discovery is enabled globally, but `filesystem` is pinned as always-visible while `context7` follows the global +default (deferred). `aws` explicitly opts in to deferred mode even though it is the same as the global default.* + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "discovery": { + "enabled": true, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true + }, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], + "deferred": false + }, + "context7": { + "enabled": true, + "command": "npx", + "args": ["-y", "@upstash/context7-mcp"] + }, + "aws": { + "enabled": true, + "command": "npx", + "args": ["-y", "aws-mcp-server"], + "deferred": true + } + } + } + } +} +``` + +> **Tip:** `deferred` on a per-server basis is independent of `discovery.enabled`. You can keep +> `discovery.enabled: false` globally (all tools visible by default) and still mark individual +> high-volume servers as `"deferred": true` to avoid polluting the context with their tools. + +## Skills Tool + +The skills tool configures skill discovery and installation via registries like ClawHub and GitHub. + +### Registries + +| Config | Type | Default | Description | +|------------------------------------|--------|----------------------|----------------------------------------------| +| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | +| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits | +| `registries.clawhub.search_path` | string | `""` | Search API path | +| `registries.clawhub.skills_path` | string | `""` | Skills API path | +| `registries.clawhub.download_path` | string | `""` | Download API path | +| `registries.clawhub.timeout` | int | 0 | Request timeout in seconds (0 = default) | +| `registries.clawhub.max_zip_size` | int | 0 | Max skill zip size in bytes (0 = default) | +| `registries.clawhub.max_response_size` | int | 0 | Max API response size in bytes (0 = default) | +| `registries.github.enabled` | bool | true | Enable GitHub installs via registry config | +| `registries.github.base_url` | string | `https://github.com` | GitHub or GitHub Enterprise base URL | +| `registries.github.auth_token` | string | `""` | GitHub personal access token | +| `registries.github.proxy` | string | `""` | HTTP proxy for GitHub API requests | + +### Legacy GitHub Config + +`github.*` is deprecated. Use `registries.github.*` instead. The legacy fields are still supported for compatibility and will be removed later. + +| Config | Type | Default | Description | +|--------------------|--------|----------------------|--------------------------------| +| `github.base_url` | string | `https://github.com` | Deprecated GitHub base URL | +| `github.proxy` | string | `""` | Deprecated GitHub proxy | +| `github.token` | string | `""` | Deprecated GitHub token | + +### Search Settings + +| Config | Type | Default | Description | +|---------------------------|------|---------|--------------------------------------------| +| `max_concurrent_searches` | int | 2 | Max concurrent skill search requests | +| `search_cache.max_size` | int | 50 | Max cached search results | +| `search_cache.ttl_seconds`| int | 300 | Cache TTL in seconds | + +### Configuration Example + +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "enabled": true, + "base_url": "https://clawhub.ai", + "auth_token": "", + "search_path": "", + "skills_path": "", + "download_path": "", + "timeout": 0, + "max_zip_size": 0, + "max_response_size": 0 + }, + "github": { + "enabled": true, + "base_url": "https://github.com", + "auth_token": "", + "proxy": "" + } + }, + "github": { + "base_url": "https://github.com", + "proxy": "", + "token": "" + }, + "max_concurrent_searches": 2, + "search_cache": { + "max_size": 50, + "ttl_seconds": 300 + } + } + } +} +``` + +## Environment Variables + +All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_
_`: + +For example: + +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` +- `PICOCLAW_TOOLS_MCP_ENABLED=true` +- `PICOCLAW_TOOLS_MCP_MAX_INLINE_TEXT_CHARS=16384` + +Note: Nested map-style config (for example `tools.mcp.servers..*`) is configured in `config.json` rather than +environment variables. + +For MCP tools, `tools.mcp.max_inline_text_chars` controls how much text result is kept inline in model context. The threshold is counted in Unicode characters (Go runes), not bytes. For example, `16384` means up to 16,384 characters inline, which may occupy more than 16 KB for multibyte text such as CJK. Above this threshold, PicoClaw saves the MCP text result as a local artifact in the agent workspace and gives the model a short note plus a structured `[file:...]` artifact path instead of injecting the full payload into context. diff --git a/docs/reference/tools_configuration.pt-br.md b/docs/reference/tools_configuration.pt-br.md new file mode 100644 index 000000000..3dae0f908 --- /dev/null +++ b/docs/reference/tools_configuration.pt-br.md @@ -0,0 +1,415 @@ +# 🔧 Configuração de Ferramentas + +> Voltar ao [README](../project/README.pt-br.md) + +A configuração de ferramentas do PicoClaw está localizada no campo `tools` do `config.json`. + +## Estrutura de diretórios + +```json +{ + "tools": { + "web": { + ... + }, + "mcp": { + ... + }, + "exec": { + ... + }, + "cron": { + ... + }, + "skills": { + ... + } + } +} +``` + +## Ferramentas Web + +As ferramentas web são usadas para pesquisa e busca de páginas web. + +### Web Fetcher +Configurações gerais para busca e processamento de conteúdo de páginas web. + +| Config | Tipo | Padrão | Descrição | +|---------------------|--------|---------------|-----------------------------------------------------------------------------------------------| +| `enabled` | bool | true | Habilitar a capacidade de busca de páginas web. | +| `fetch_limit_bytes` | int | 10485760 | Tamanho máximo do payload da página web a ser buscado, em bytes (padrão é 10MB). | +| `format` | string | "plaintext" | Formato de saída do conteúdo buscado. Opções: `plaintext` ou `markdown` (recomendado). | + +### DuckDuckGo + +| Config | Tipo | Padrão | Descrição | +|---------------|------|--------|--------------------------------| +| `enabled` | bool | true | Habilitar pesquisa DuckDuckGo | +| `max_results` | int | 5 | Número máximo de resultados | + +### Baidu Search + +| Config | Tipo | Padrão | Descrição | +|---------------|--------|-----------------------------------------------------------------|------------------------------------| +| `enabled` | bool | false | Habilitar pesquisa Baidu | +| `api_key` | string | - | Chave API Qianfan | +| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | URL da API Baidu Search | +| `max_results` | int | 10 | Número máximo de resultados | + +```json +{ + "tools": { + "web": { + "baidu_search": { + "enabled": true, + "api_key": "YOUR_BAIDU_QIANFAN_API_KEY", + "max_results": 10 + } + } + } +} +``` + +### Perplexity + +| Config | Tipo | Padrão | Descrição | +|---------------|--------|--------|--------------------------------| +| `enabled` | bool | false | Habilitar pesquisa Perplexity | +| `api_key` | string | - | Chave API do Perplexity | +| `api_keys` | string[] | - | Várias chaves API do Perplexity para rotação (prioridade sobre `api_key`) | +| `max_results` | int | 5 | Número máximo de resultados | + +### Brave + +| Config | Tipo | Padrão | Descrição | +|---------------|--------|--------|----------------------------| +| `enabled` | bool | false | Habilitar pesquisa Brave | +| `api_key` | string | - | Chave API única do Brave Search | +| `api_keys` | string[] | - | Várias chaves API do Brave para rotação (prioridade sobre `api_key`) | +| `max_results` | int | 5 | Número máximo de resultados | + +### Tavily + +| Config | Tipo | Padrão | Descrição | +|---------------|--------|--------|------------------------------------| +| `enabled` | bool | false | Habilitar pesquisa Tavily | +| `api_key` | string | - | Chave API do Tavily | +| `base_url` | string | - | URL base personalizada do Tavily | +| `max_results` | int | 0 | Número máximo de resultados (0 = padrão) | + +### SearXNG + +| Config | Tipo | Padrão | Descrição | +|---------------|--------|--------------------------|--------------------------------| +| `enabled` | bool | false | Habilitar pesquisa SearXNG | +| `base_url` | string | `http://localhost:8888` | URL da instância SearXNG | +| `max_results` | int | 5 | Número máximo de resultados | + +### GLM Search + +| Config | Tipo | Padrão | Descrição | +|-----------------|--------|------------------------------------------------------|----------------------------| +| `enabled` | bool | false | Habilitar GLM Search | +| `api_key` | string | - | Chave API GLM | +| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | URL da API GLM Search | +| `search_engine` | string | `search_std` | Tipo de motor de busca | +| `max_results` | int | 5 | Número máximo de resultados | + +## Ferramenta Exec + +A ferramenta exec é usada para executar comandos shell. + +| Config | Tipo | Padrão | Descrição | +|------------------------|-------|--------|-------------------------------------------------| +| `enabled` | bool | true | Habilitar a ferramenta exec | +| `enable_deny_patterns` | bool | true | Habilitar bloqueio padrão de comandos perigosos | +| `custom_deny_patterns` | array | [] | Padrões de negação personalizados (expressões regulares) | + +### Desabilitando a Ferramenta Exec + +Para desabilitar completamente a ferramenta `exec`, defina `enabled` como `false`: + +**Via arquivo de configuração:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**Via variável de ambiente:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **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. + +### Funcionalidade + +- **`enable_deny_patterns`**: Defina como `false` para desabilitar completamente os padrões de bloqueio de comandos perigosos padrão +- **`custom_deny_patterns`**: Adicione padrões regex de negação personalizados; comandos correspondentes serão bloqueados + +### Padrões de comandos bloqueados por padrão + +Por padrão, o PicoClaw bloqueia os seguintes comandos perigosos: + +- Comandos de exclusão: `rm -rf`, `del /f/q`, `rmdir /s` +- Operações de disco: `format`, `mkfs`, `diskpart`, `dd if=`, escrita em `/dev/sd*` +- Operações do sistema: `shutdown`, `reboot`, `poweroff` +- Substituição de comandos: `$()`, `${}`, crases +- Pipe para shell: `| sh`, `| bash` +- Escalação de privilégios: `sudo`, `chmod`, `chown` +- Controle de processos: `pkill`, `killall`, `kill -9` +- Operações remotas: `curl | sh`, `wget | sh`, `ssh` +- Gerenciamento de pacotes: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` +- Contêineres: `docker run`, `docker exec` +- Git: `git push`, `git force` +- Outros: `eval`, `source *.sh` + +### Limitação arquitetural conhecida + +O 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. + +Exemplos de fluxos de trabalho que podem contornar o guarda de comando direto uma vez que o comando inicial é permitido: + +- `make run` +- `go run ./cmd/...` +- `cargo run` +- `npm run build` + +Isso 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. + +### Exemplo de configuração + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ] + } + } +} +``` + +## Ferramenta Cron + +A ferramenta cron é usada para agendar tarefas periódicas. + +| Config | Tipo | Padrão | Descrição | +|------------------------|------|--------|-----------------------------------------------------| +| `exec_timeout_minutes` | int | 5 | Tempo limite de execução em minutos, 0 significa sem limite | + + +## Ferramenta MCP + +A ferramenta MCP permite a integração com servidores Model Context Protocol externos. + +### Descoberta de ferramentas (carregamento preguiçoso) + +Ao 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. + +Em 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`). + +### Configuração global + +| Config | Tipo | Padrão | Descrição | +|-------------|--------|--------|----------------------------------------------| +| `enabled` | bool | false | Habilitar integração MCP globalmente | +| `discovery` | object | `{}` | Configuração de descoberta de ferramentas (veja abaixo) | +| `servers` | object | `{}` | Mapa de nome do servidor para configuração do servidor | + +### Configuração Discovery (`discovery`) + +| Config | Tipo | Padrão | Descrição | +|----------------------|------|--------|-----------------------------------------------------------------------------------------------------------------------------------| +| `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 | +| `ttl` | int | 5 | Número de turnos de conversa que uma ferramenta descoberta permanece desbloqueada | +| `max_search_results` | int | 5 | Número máximo de ferramentas retornadas por consulta de pesquisa | +| `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 | +| `use_regex` | bool | false | Habilitar a ferramenta de pesquisa por padrão regex (`tool_search_tool_regex`) | + +> **Nota:** Se `discovery.enabled` for `true`, você **deve** habilitar pelo menos um mecanismo de pesquisa (`use_bm25` ou `use_regex`), +> caso contrário a aplicação falhará ao iniciar. + +### Configuração por servidor + +| Config | Tipo | Obrigatório | Descrição | +|------------|--------|-------------|--------------------------------------------| +| `enabled` | bool | sim | Habilitar este servidor MCP | +| `type` | string | não | Tipo de transporte: `stdio`, `sse`, `http` | +| `command` | string | stdio | Comando executável para transporte stdio | +| `args` | array | não | Argumentos do comando para transporte stdio | +| `env` | object | não | Variáveis de ambiente para processo stdio | +| `env_file` | string | não | Caminho para arquivo de ambiente para processo stdio | +| `url` | string | sse/http | URL do endpoint para transporte `sse`/`http` | +| `headers` | object | não | Cabeçalhos HTTP para transporte `sse`/`http` | + +### Comportamento do transporte + +- Se `type` for omitido, o transporte é detectado automaticamente: + - `url` está definido → `sse` + - `command` está definido → `stdio` +- `http` e `sse` ambos usam `url` + `headers` opcionais. +- `env` e `env_file` são aplicados apenas a servidores `stdio`. + +### Exemplos de configuração + +#### 1) Servidor MCP Stdio + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ] + } + } + } + } +} +``` + +#### 2) Servidor MCP remoto SSE/HTTP + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "remote-mcp": { + "enabled": true, + "type": "sse", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } + } + } +} +``` + +#### 3) Configuração MCP massiva com descoberta de ferramentas habilitada + +*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.* + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "discovery": { + "enabled": true, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, + "servers": { + "github": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + } + }, + "postgres": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + }, + "slack": { + "enabled": true, + "type": "slack", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-slack" + ], + "env": { + "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", + "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + } + } + } + } + } +} +``` + + +## Ferramenta Skills + +A ferramenta skills configura a descoberta e instalação de habilidades via registros como o ClawHub. + +### Registros + +| Config | Tipo | Padrão | Descrição | +|------------------------------------|--------|-----------------------|----------------------------------------------| +| `registries.clawhub.enabled` | bool | true | Habilitar registro ClawHub | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | URL base do ClawHub | +| `registries.clawhub.auth_token` | string | `""` | Token Bearer opcional para limites de taxa mais altos | +| `registries.clawhub.search_path` | string | `/api/v1/search` | Caminho da API de pesquisa | +| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Caminho da API de Skills | +| `registries.clawhub.download_path` | string | `/api/v1/download` | Caminho da API de download | + +### Exemplo de configuração + +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "enabled": true, + "base_url": "https://clawhub.ai", + "auth_token": "", + "search_path": "/api/v1/search", + "skills_path": "/api/v1/skills", + "download_path": "/api/v1/download" + } + } + } + } +} +``` + +## Variáveis de ambiente + +Todas as opções de configuração podem ser substituídas via variáveis de ambiente com o formato `PICOCLAW_TOOLS_
_`: + +Por exemplo: + +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` +- `PICOCLAW_TOOLS_MCP_ENABLED=true` + +Nota: Configuração de tipo mapa aninhado (por exemplo `tools.mcp.servers..*`) é configurada no `config.json` em vez de variáveis de ambiente. diff --git a/docs/reference/tools_configuration.vi.md b/docs/reference/tools_configuration.vi.md new file mode 100644 index 000000000..7d65ca377 --- /dev/null +++ b/docs/reference/tools_configuration.vi.md @@ -0,0 +1,415 @@ +# 🔧 Cấu Hình Công Cụ + +> Quay lại [README](../project/README.vi.md) + +Cấu hình công cụ của PicoClaw nằm trong trường `tools` của `config.json`. + +## Cấu trúc thư mục + +```json +{ + "tools": { + "web": { + ... + }, + "mcp": { + ... + }, + "exec": { + ... + }, + "cron": { + ... + }, + "skills": { + ... + } + } +} +``` + +## Công cụ Web + +Các công cụ web được sử dụng để tìm kiếm và tải nội dung web. + +### Web Fetcher +Cài đặt chung để tải và xử lý nội dung trang web. + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|----------------------|--------|---------------|-----------------------------------------------------------------------------------------------| +| `enabled` | bool | true | Bật khả năng tải trang web. | +| `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). | +| `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ị). | + +### DuckDuckGo + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|----------------|------|----------|-------------------------------| +| `enabled` | bool | true | Bật tìm kiếm DuckDuckGo | +| `max_results` | int | 5 | Số kết quả tối đa | + +### Baidu Search + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|----------------|--------|-----------------------------------------------------------------|------------------------------------| +| `enabled` | bool | false | Bật tìm kiếm Baidu | +| `api_key` | string | - | Khóa API Qianfan | +| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | URL API Baidu Search | +| `max_results` | int | 10 | Số kết quả tối đa | + +```json +{ + "tools": { + "web": { + "baidu_search": { + "enabled": true, + "api_key": "YOUR_BAIDU_QIANFAN_API_KEY", + "max_results": 10 + } + } + } +} +``` + +### Perplexity + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|----------------|--------|----------|-------------------------------| +| `enabled` | bool | false | Bật tìm kiếm Perplexity | +| `api_key` | string | - | Khóa API Perplexity | +| `api_keys` | string[] | - | Nhiều khóa API Perplexity để xoay vòng (ưu tiên hơn `api_key`) | +| `max_results` | int | 5 | Số kết quả tối đa | + +### Brave + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|----------------|--------|----------|----------------------------| +| `enabled` | bool | false | Bật tìm kiếm Brave | +| `api_key` | string | - | Khóa API Brave Search | +| `api_keys` | string[] | - | Nhiều khóa API Brave Search để xoay vòng (ưu tiên hơn `api_key`) | +| `max_results` | int | 5 | Số kết quả tối đa | + +### Tavily + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|----------------|--------|----------|------------------------------------| +| `enabled` | bool | false | Bật tìm kiếm Tavily | +| `api_key` | string | - | Khóa API Tavily | +| `base_url` | string | - | URL cơ sở Tavily tùy chỉnh | +| `max_results` | int | 0 | Số kết quả tối đa (0 = mặc định) | + +### SearXNG + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|----------------|--------|--------------------------|----------------------------| +| `enabled` | bool | false | Bật tìm kiếm SearXNG | +| `base_url` | string | `http://localhost:8888` | URL phiên bản SearXNG | +| `max_results` | int | 5 | Số kết quả tối đa | + +### GLM Search + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|------------------|--------|------------------------------------------------------|----------------------------| +| `enabled` | bool | false | Bật GLM Search | +| `api_key` | string | - | Khóa API GLM | +| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | URL API GLM Search | +| `search_engine` | string | `search_std` | Loại công cụ tìm kiếm | +| `max_results` | int | 5 | Số kết quả tối đa | + +## Công cụ Exec + +Công cụ exec được sử dụng để thực thi các lệnh shell. + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|--------------------------|-------|----------|------------------------------------------------| +| `enabled` | bool | true | Bật công cụ exec | +| `enable_deny_patterns` | bool | true | Bật chặn lệnh nguy hiểm mặc định | +| `custom_deny_patterns` | array | [] | Mẫu từ chối tùy chỉnh (biểu thức chính quy) | + +### Vô hiệu hóa Công cụ Exec + +Để hoàn toàn vô hiệu hóa công cụ `exec`, đặt `enabled` thành `false`: + +**Qua tệp cấu hình:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**Qua biến môi trường:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **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. + +### Chức năng + +- **`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 +- **`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 + +### Các mẫu lệnh bị chặn mặc định + +Theo mặc định, PicoClaw chặn các lệnh nguy hiểm sau: + +- Lệnh xóa: `rm -rf`, `del /f/q`, `rmdir /s` +- Thao tác đĩa: `format`, `mkfs`, `diskpart`, `dd if=`, ghi vào `/dev/sd*` +- Thao tác hệ thống: `shutdown`, `reboot`, `poweroff` +- Thay thế lệnh: `$()`, `${}`, dấu backtick +- Pipe đến shell: `| sh`, `| bash` +- Leo thang đặc quyền: `sudo`, `chmod`, `chown` +- Điều khiển tiến trình: `pkill`, `killall`, `kill -9` +- Thao tác từ xa: `curl | sh`, `wget | sh`, `ssh` +- Quản lý gói: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` +- Container: `docker run`, `docker exec` +- Git: `git push`, `git force` +- Khác: `eval`, `source *.sh` + +### Hạn chế kiến trúc đã biết + +Bộ 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. + +Ví 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: + +- `make run` +- `go run ./cmd/...` +- `cargo run` +- `npm run build` + +Đ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. + +### Ví dụ cấu hình + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ] + } + } +} +``` + +## Công cụ Cron + +Công cụ cron được sử dụng để lên lịch các tác vụ định kỳ. + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|--------------------------|------|----------|-----------------------------------------------------| +| `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 | + + +## Công cụ MCP + +Công cụ MCP cho phép tích hợp với các máy chủ Model Context Protocol bên ngoài. + +### Khám phá công cụ (tải chậm) + +Khi 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. + +Thay 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`). + +### Cấu hình toàn cục + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|-------------|--------|----------|-----------------------------------------------| +| `enabled` | bool | false | Bật tích hợp MCP toàn cục | +| `discovery` | object | `{}` | Cấu hình khám phá công cụ (xem bên dưới) | +| `servers` | object | `{}` | Ánh xạ tên máy chủ đến cấu hình máy chủ | + +### Cấu hình Discovery (`discovery`) + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|----------------------|------|----------|-----------------------------------------------------------------------------------------------------------------------------------| +| `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 | +| `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 | +| `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 | +| `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 | +| `use_regex` | bool | false | Bật công cụ tìm kiếm mẫu regex (`tool_search_tool_regex`) | + +> **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ếu không ứng dụng sẽ không khởi động được. + +### Cấu hình từng máy chủ + +| Cấu hình | Kiểu | Bắt buộc | Mô tả | +|------------|--------|----------|--------------------------------------------| +| `enabled` | bool | có | Bật máy chủ MCP này | +| `type` | string | không | Loại truyền tải: `stdio`, `sse`, `http` | +| `command` | string | stdio | Lệnh thực thi cho truyền tải stdio | +| `args` | array | không | Đối số lệnh cho truyền tải stdio | +| `env` | object | không | Biến môi trường cho tiến trình stdio | +| `env_file` | string | không | Đường dẫn đến tệp môi trường cho tiến trình stdio | +| `url` | string | sse/http | URL endpoint cho truyền tải `sse`/`http` | +| `headers` | object | không | Header HTTP cho truyền tải `sse`/`http` | + +### Hành vi truyền tải + +- Nếu bỏ qua `type`, truyền tải được tự động phát hiện: + - `url` được đặt → `sse` + - `command` được đặt → `stdio` +- `http` và `sse` đều sử dụng `url` + `headers` tùy chọn. +- `env` và `env_file` chỉ được áp dụng cho máy chủ `stdio`. + +### Ví dụ cấu hình + +#### 1) Máy chủ MCP Stdio + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ] + } + } + } + } +} +``` + +#### 2) Máy chủ MCP từ xa SSE/HTTP + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "remote-mcp": { + "enabled": true, + "type": "sse", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } + } + } +} +``` + +#### 3) Thiết lập MCP quy mô lớn với khám phá công cụ được bật + +*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.* + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "discovery": { + "enabled": true, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, + "servers": { + "github": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + } + }, + "postgres": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + }, + "slack": { + "enabled": true, + "type": "slack", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-slack" + ], + "env": { + "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", + "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + } + } + } + } + } +} +``` + + +## Công cụ Skills + +Cô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. + +### Registry + +| Cấu hình | Kiểu | Mặc định | Mô tả | +|------------------------------------|--------|-----------------------|----------------------------------------------| +| `registries.clawhub.enabled` | bool | true | Bật registry ClawHub | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | URL cơ sở ClawHub | +| `registries.clawhub.auth_token` | string | `""` | Token Bearer tùy chọn để có giới hạn tốc độ cao hơn | +| `registries.clawhub.search_path` | string | `/api/v1/search` | Đường dẫn API tìm kiếm | +| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Đường dẫn API Skills | +| `registries.clawhub.download_path` | string | `/api/v1/download` | Đường dẫn API tải xuống | + +### Ví dụ cấu hình + +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "enabled": true, + "base_url": "https://clawhub.ai", + "auth_token": "", + "search_path": "/api/v1/search", + "skills_path": "/api/v1/skills", + "download_path": "/api/v1/download" + } + } + } + } +} +``` + +## Biến môi trường + +Tấ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_
_`: + +Ví dụ: + +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` +- `PICOCLAW_TOOLS_MCP_ENABLED=true` + +Lưu ý: Cấu hình kiểu map lồng nhau (ví dụ `tools.mcp.servers..*`) được cấu hình trong `config.json` thay vì qua biến môi trường. diff --git a/docs/reference/tools_configuration.zh.md b/docs/reference/tools_configuration.zh.md new file mode 100644 index 000000000..3937a6254 --- /dev/null +++ b/docs/reference/tools_configuration.zh.md @@ -0,0 +1,492 @@ +# 🔧 工具配置 + +> 返回 [README](../project/README.zh.md) + +PicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。 + +## 目录结构 + +```json +{ + "tools": { + "web": { + ... + }, + "mcp": { + ... + }, + "exec": { + ... + }, + "cron": { + ... + }, + "skills": { + ... + } + } +} +``` + +## 敏感数据过滤 + +在将工具结果发送给 LLM 之前,PicoClaw 可以从输出中过滤敏感值(API 密钥、令牌、密码)。这可以防止 LLM 看到自己的凭据。 + +详细说明请参阅[敏感数据过滤](../security/sensitive_data_filtering.zh.md)。 + +| 配置项 | 类型 | 默认值 | 描述 | +|--------|------|--------|------| +| `filter_sensitive_data` | bool | `true` | 启用/禁用过滤 | +| `filter_min_length` | int | `8` | 触发过滤的最小内容长度 | + +## Web 工具 + +Web 工具用于网页搜索和抓取。 + +### Web Fetcher +用于抓取和处理网页内容的通用设置。 + +| 配置项 | 类型 | 默认值 | 描述 | +|---------------------|--------|---------------|----------------------------------------------------------------------------------------| +| `enabled` | bool | true | 启用网页抓取功能。 | +| `fetch_limit_bytes` | int | 10485760 | 抓取网页负载的最大大小,单位为字节(默认 10MB)。 | +| `format` | string | "plaintext" | 抓取内容的输出格式。选项:`plaintext` 或 `markdown`(推荐)。 | + +### 百度搜索 + +使用[千帆 AI 搜索 API](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5),国内访问稳定,中文搜索效果好。 + +| 配置项 | 类型 | 默认值 | 描述 | +|---------------|--------|----------------------------------------------------------------|-----------------------| +| `enabled` | bool | false | 启用百度搜索 | +| `api_key` | string | - | 千帆 API 密钥 | +| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | 百度搜索 API URL | +| `max_results` | int | 10 | 最大结果数 | + +```json +{ + "tools": { + "web": { + "baidu_search": { + "enabled": true, + "api_key": "YOUR_BAIDU_QIANFAN_API_KEY", + "max_results": 10 + } + } + } +} +``` + +### Tavily + +| 配置项 | 类型 | 默认值 | 描述 | +|---------------|--------|--------|-----------------------------------| +| `enabled` | bool | false | 启用 Tavily 搜索 | +| `api_key` | string | - | Tavily API 密钥 | +| `base_url` | string | - | 自定义 Tavily API 基础 URL | +| `max_results` | int | 0 | 最大结果数(0 = 默认) | + +### GLM Search + +| 配置项 | 类型 | 默认值 | 描述 | +|-----------------|--------|------------------------------------------------------|-----------------------| +| `enabled` | bool | false | 启用 GLM 搜索 | +| `api_key` | string | - | GLM API 密钥 | +| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | GLM Search API URL | +| `search_engine` | string | `search_std` | 搜索引擎类型 | +| `max_results` | int | 5 | 最大结果数 | + +### DuckDuckGo + +> ⚠️ 国内访问困难,建议搭配代理使用。 + +| 配置项 | 类型 | 默认值 | 描述 | +|---------------|------|--------|-----------------------| +| `enabled` | bool | true | 启用 DuckDuckGo 搜索 | +| `max_results` | int | 5 | 最大结果数 | + +### Perplexity + +> ⚠️ 国内访问困难,建议搭配代理使用。 + +| 配置项 | 类型 | 默认值 | 描述 | +|---------------|----------|--------|------------------------------------------------| +| `enabled` | bool | false | 启用 Perplexity 搜索 | +| `api_key` | string | - | Perplexity API 密钥 | +| `api_keys` | string[] | - | 多个 API 密钥轮换(优先于 `api_key`) | +| `max_results` | int | 5 | 最大结果数 | + +### Brave + +> ⚠️ 国内访问困难,建议搭配代理使用。 + +| 配置项 | 类型 | 默认值 | 描述 | +|---------------|----------|--------|------------------------------------------------| +| `enabled` | bool | false | 启用 Brave 搜索 | +| `api_key` | string | - | Brave Search API 密钥 | +| `api_keys` | string[] | - | 多个 API 密钥轮换(优先于 `api_key`) | +| `max_results` | int | 5 | 最大结果数 | + +### SearXNG + +| 配置项 | 类型 | 默认值 | 描述 | +|---------------|--------|--------------------------|-----------------------| +| `enabled` | bool | false | 启用 SearXNG 搜索 | +| `base_url` | string | `http://localhost:8888` | SearXNG 实例 URL | +| `max_results` | int | 5 | 最大结果数 | + +### 其他 Web 设置 + +| 配置项 | 类型 | 默认值 | 描述 | +|--------------------------|----------|--------|-------------------------------------------------| +| `prefer_native` | bool | true | 优先使用 provider 原生搜索而非配置的搜索引擎 | +| `private_host_whitelist` | string[] | `[]` | 允许 Web 抓取的私有/内部主机白名单 | + +## Exec 工具 + +Exec 工具用于执行 shell 命令。 + +| 配置项 | 类型 | 默认值 | 描述 | +|------------------------|-------|--------|--------------------------------| +| `enabled` | bool | true | 启用 exec 工具 | +| `enable_deny_patterns` | bool | true | 启用默认的危险命令拦截 | +| `custom_deny_patterns` | array | [] | 自定义拒绝模式(正则表达式) | + +### 禁用 Exec 工具 + +要完全禁用 `exec` 工具,请将 `enabled` 设置为 `false`: + +**通过配置文件:** +```json +{ + "tools": { + "exec": { + "enabled": false + } + } +} +``` + +**通过环境变量:** +```bash +PICOCLAW_TOOLS_EXEC_ENABLED=false +``` + +> **注意:** 禁用后,代理将无法执行 shell 命令。这也会影响 Cron 工具运行计划 shell 命令的能力。 + +### 功能说明 + +- **`enable_deny_patterns`**:设为 `false` 可完全禁用默认的危险命令拦截模式 +- **`custom_deny_patterns`**:添加自定义拒绝正则模式;匹配的命令将被拦截 + +### 默认拦截的命令模式 + +默认情况下,PicoClaw 会拦截以下危险命令: + +- 删除命令:`rm -rf`、`del /f/q`、`rmdir /s` +- 磁盘操作:`format`、`mkfs`、`diskpart`、`dd if=`、写入 `/dev/sd*` +- 系统操作:`shutdown`、`reboot`、`poweroff` +- 命令替换:`$()`、`${}`、反引号 +- 管道到 shell:`| sh`、`| bash` +- 权限提升:`sudo`、`chmod`、`chown` +- 进程控制:`pkill`、`killall`、`kill -9` +- 远程操作:`curl | sh`、`wget | sh`、`ssh` +- 包管理:`apt`、`yum`、`dnf`、`npm install -g`、`pip install --user` +- 容器:`docker run`、`docker exec` +- Git:`git push`、`git force` +- 其他:`eval`、`source *.sh` + +### 已知架构限制 + +exec 守卫仅验证发送给 PicoClaw 的顶层命令。它**不会**递归检查该命令启动后由构建工具或脚本生成的子进程。 + +以下工作流在初始命令被允许后可以绕过直接命令守卫: + +- `make run` +- `go run ./cmd/...` +- `cargo run` +- `npm run build` + +这意味着守卫对于拦截明显危险的直接命令很有用,但它**不是**未审查构建管道的完整沙箱。如果你的威胁模型包括工作区中的不受信任代码,请使用更强的隔离措施,如容器、虚拟机或围绕构建和运行命令的审批流程。 + +### 配置示例 + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ] + } + } +} +``` + +## Cron 工具 + +Cron 工具用于调度周期性任务。 + +| 配置项 | 类型 | 默认值 | 描述 | +|------------------------|------|--------|-------------------------------------| +| `exec_timeout_minutes` | int | 5 | 执行超时时间(分钟),0 表示无限制 | +| `allow_command` | bool | false | 允许 cron 任务执行 shell 命令 | + + +## MCP 工具 + +MCP 工具支持与外部 Model Context Protocol 服务器集成。 + +### 工具发现(延迟加载) + +当连接多个 MCP 服务器时,同时暴露数百个工具可能会耗尽 LLM 的上下文窗口并增加 API 成本。**Discovery** 功能通过默认*隐藏* MCP 工具来解决此问题。 + +LLM 不会加载所有工具,而是获得一个轻量级搜索工具(使用 BM25 关键词匹配或正则表达式)。当 LLM 需要特定功能时,它会搜索隐藏的工具库。匹配的工具随后被临时"解锁"并注入上下文中,持续配置的轮数(`ttl`)。 + +### 全局配置 + +| 配置项 | 类型 | 默认值 | 描述 | +|-------------|--------|--------|--------------------------------------| +| `enabled` | bool | false | 全局启用 MCP 集成 | +| `discovery` | object | `{}` | 工具发现配置(见下文) | +| `servers` | object | `{}` | 服务器名称到服务器配置的映射 | + +### Discovery 配置(`discovery`) + +| 配置项 | 类型 | 默认值 | 描述 | +|----------------------|------|--------|---------------------------------------------------------------------------------------------------------------| +| `enabled` | bool | false | 如果为 true,MCP 工具将被隐藏并按需通过搜索加载。如果为 false,所有工具都会被加载 | +| `ttl` | int | 5 | 已发现工具保持解锁状态的对话轮数 | +| `max_search_results` | int | 5 | 每次搜索查询返回的最大工具数 | +| `use_bm25` | bool | true | 启用自然语言/关键词搜索工具(`tool_search_tool_bm25`)。**警告**:比正则搜索消耗更多资源 | +| `use_regex` | bool | false | 启用正则模式搜索工具(`tool_search_tool_regex`) | + +> **注意:** 如果 `discovery.enabled` 为 `true`,你**必须**启用至少一个搜索引擎(`use_bm25` 或 `use_regex`), +> 否则应用程序将无法启动。 + +### 单服务器配置 + +| 配置项 | 类型 | 必需 | 描述 | +|------------|--------|----------|------------------------------------| +| `enabled` | bool | 是 | 启用此 MCP 服务器 | +| `type` | string | 否 | 传输类型:`stdio`、`sse`、`http` | +| `command` | string | stdio | stdio 传输的可执行命令 | +| `args` | array | 否 | stdio 传输的命令参数 | +| `env` | object | 否 | stdio 进程的环境变量 | +| `env_file` | string | 否 | stdio 进程的环境文件路径 | +| `url` | string | sse/http | `sse`/`http` 传输的端点 URL | +| `headers` | object | 否 | `sse`/`http` 传输的 HTTP 头 | + +### 传输行为 + +- 如果省略 `type`,传输方式将自动检测: + - 设置了 `url` → `sse` + - 设置了 `command` → `stdio` +- `http` 和 `sse` 都使用 `url` + 可选的 `headers`。 +- `env` 和 `env_file` 仅应用于 `stdio` 服务器。 + +### 配置示例 + +#### 1) Stdio MCP 服务器 + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "filesystem": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ] + } + } + } + } +} +``` + +#### 2) 远程 SSE/HTTP MCP 服务器 + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "remote-mcp": { + "enabled": true, + "type": "sse", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } + } + } +} +``` + +#### 3) 启用工具发现的大规模 MCP 设置 + +*在此示例中,LLM 只会看到 `tool_search_tool_bm25`。它将仅在用户请求时动态搜索并解锁 Github 或 Postgres 工具。* + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "discovery": { + "enabled": true, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, + "servers": { + "github": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + } + }, + "postgres": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + }, + "slack": { + "enabled": true, + "type": "slack", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-slack" + ], + "env": { + "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", + "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + } + } + } + } + } +} +``` + + +## Skills 工具 + +Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。 + +### 注册表 + +| 配置项 | 类型 | 默认值 | 描述 | +|------------------------------------|--------|----------------------|--------------------------------------| +| `registries.clawhub.enabled` | bool | true | 启用 ClawHub 注册表 | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub 基础 URL | +| `registries.clawhub.auth_token` | string | `""` | 可选的 Bearer 令牌,用于更高速率限制 | +| `registries.clawhub.search_path` | string | `""` | 搜索 API 路径 | +| `registries.clawhub.skills_path` | string | `""` | Skills API 路径 | +| `registries.clawhub.download_path` | string | `""` | 下载 API 路径 | +| `registries.clawhub.timeout` | int | 0 | 请求超时时间(秒),0 = 默认 | +| `registries.clawhub.max_zip_size` | int | 0 | 技能 zip 最大大小(字节),0 = 默认 | +| `registries.clawhub.max_response_size` | int | 0 | API 响应最大大小(字节),0 = 默认 | + +### GitHub 集成 + +| 配置项 | 类型 | 默认值 | 描述 | +|------------------|--------|--------|-------------------------------| +| `github.proxy` | string | `""` | GitHub API 请求的 HTTP 代理 | +| `github.token` | string | `""` | GitHub 个人访问令牌 | + +### 搜索设置 + +| 配置项 | 类型 | 默认值 | 描述 | +|----------------------------|------|--------|--------------------------| +| `max_concurrent_searches` | int | 2 | 最大并发技能搜索请求数 | +| `search_cache.max_size` | int | 50 | 最大缓存搜索结果数 | +| `search_cache.ttl_seconds` | int | 300 | 缓存 TTL(秒) | + +### 配置示例 + +```json +{ + "tools": { + "skills": { + "registries": { + "clawhub": { + "enabled": true, + "base_url": "https://clawhub.ai", + "auth_token": "" + } + }, + "github": { + "proxy": "", + "token": "" + }, + "max_concurrent_searches": 2, + "search_cache": { + "max_size": 50, + "ttl_seconds": 300 + } + } + } +} +``` + +## 环境变量 + +所有配置选项都可以通过格式为 `PICOCLAW_TOOLS_
_` 的环境变量覆盖: + +例如: + +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLED=false` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` +- `PICOCLAW_TOOLS_MCP_ENABLED=true` + +注意:嵌套的映射式配置(例如 `tools.mcp.servers..*`)在 `config.json` 中配置,而非通过环境变量。 + +## Skills Tool + +Skills 工具用于通过仓库源发现和安装 Skill,支持 ClawHub 与 GitHub。 + +### Registries + +| 配置项 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| `registries.clawhub.enabled` | bool | true | 是否启用 ClawHub | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub 基础地址 | +| `registries.clawhub.auth_token` | string | `""` | ClawHub 认证令牌 | +| `registries.github.enabled` | bool | true | 是否启用 GitHub | +| `registries.github.base_url` | string | `https://github.com` | GitHub 或 GitHub Enterprise 基础地址 | +| `registries.github.auth_token` | string | `""` | GitHub 访问令牌 | +| `registries.github.proxy` | string | `""` | GitHub 请求代理 | + +### 旧版 GitHub 配置 + +`github.*` 已废弃,建议迁移到 `registries.github.*`。当前仍保留兼容,后续可移除。 + +| 配置项 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| `github.base_url` | string | `https://github.com` | 已废弃 | +| `github.proxy` | string | `""` | 已废弃 | +| `github.token` | string | `""` | 已废弃 | diff --git a/docs/security/ANTIGRAVITY_AUTH.fr.md b/docs/security/ANTIGRAVITY_AUTH.fr.md new file mode 100644 index 000000000..8550c94e3 --- /dev/null +++ b/docs/security/ANTIGRAVITY_AUTH.fr.md @@ -0,0 +1,809 @@ +> Retour au [README](../project/README.fr.md) + +# Guide d'authentification et d'intégration Antigravity + +## Aperçu + +**Antigravity** (Google Cloud Code Assist) est un fournisseur de modèles IA soutenu par Google qui offre l'accès à des modèles tels que Claude Opus 4.6 et Gemini via l'infrastructure cloud de Google. Ce document fournit un guide complet sur le fonctionnement de l'authentification, la récupération des modèles et l'implémentation d'un nouveau fournisseur dans PicoClaw. + +--- + +## Table des matières + +1. [Flux d'authentification](#flux-dauthentification) +2. [Détails de l'implémentation OAuth](#détails-de-limplémentation-oauth) +3. [Gestion des jetons](#gestion-des-jetons) +4. [Récupération de la liste des modèles](#récupération-de-la-liste-des-modèles) +5. [Suivi de l'utilisation](#suivi-de-lutilisation) +6. [Structure du plugin fournisseur](#structure-du-plugin-fournisseur) +7. [Exigences d'intégration](#exigences-dintégration) +8. [Points de terminaison API](#points-de-terminaison-api) +9. [Configuration](#configuration) +10. [Créer un nouveau fournisseur dans PicoClaw](#créer-un-nouveau-fournisseur-dans-picoclaw) + +--- + +## Flux d'authentification + +### 1. OAuth 2.0 avec PKCE + +Antigravity utilise **OAuth 2.0 avec PKCE (Proof Key for Code Exchange)** pour une authentification sécurisée : + +``` +┌─────────────┐ ┌─────────────────┐ +│ Client │ ───(1) Generate PKCE Pair────────> │ │ +│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │ +│ │ │ Server │ +│ │ <──(3) Redirect with Code───────── │ │ +│ │ └─────────────────┘ +│ │ ───(4) Exchange Code for Tokens──> │ Token URL │ +│ │ │ │ +│ │ <──(5) Access + Refresh Tokens──── │ │ +└─────────────┘ └─────────────────┘ +``` + +### 2. Étapes détaillées + +#### Étape 1 : Générer les paramètres PKCE +```typescript +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} +``` + +#### Étape 2 : Construire l'URL d'autorisation +```typescript +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const REDIRECT_URI = "http://localhost:51121/oauth-callback"; + +function buildAuthUrl(params: { challenge: string; state: string }): string { + const url = new URL(AUTH_URL); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPES.join(" ")); + url.searchParams.set("code_challenge", params.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", params.state); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + return url.toString(); +} +``` + +**Portées requises :** +```typescript +const SCOPES = [ + "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", +]; +``` + +#### Étape 3 : Gérer le callback OAuth + +**Mode automatique (développement local) :** +- Démarrer un serveur HTTP local sur le port 51121 +- Attendre la redirection de Google +- Extraire le code d'autorisation des paramètres de requête + +**Mode manuel (distant/sans interface graphique) :** +- Afficher l'URL d'autorisation à l'utilisateur +- L'utilisateur complète l'authentification dans son navigateur +- L'utilisateur colle l'URL de redirection complète dans le terminal +- Analyser le code depuis l'URL collée + +#### Étape 4 : Échanger le code contre des jetons +```typescript +const TOKEN_URL = "https://oauth2.googleapis.com/token"; + +async function exchangeCode(params: { + code: string; + verifier: string; +}): Promise<{ access: string; refresh: string; expires: number }> { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: params.code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: params.verifier, + }), + }); + + const data = await response.json(); + + return { + access: data.access_token, + refresh: data.refresh_token, + expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer + }; +} +``` + +#### Étape 5 : Récupérer les données utilisateur supplémentaires + +**E-mail de l'utilisateur :** +```typescript +async function fetchUserEmail(accessToken: string): Promise { + const response = await fetch( + "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + const data = await response.json(); + return data.email; +} +``` + +**ID du projet (requis pour les appels API) :** +```typescript +async function fetchProjectId(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }), + }; + + const response = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + const data = await response.json(); + return data.cloudaicompanionProject || "rising-fact-p41fc"; // Valeur par défaut +} +``` + +--- + +## Détails de l'implémentation OAuth + +### Identifiants client + +**Important :** Ceux-ci sont encodés en base64 dans le code source pour la synchronisation avec pi-ai : + +```typescript +const decode = (s: string) => Buffer.from(s, "base64").toString(); + +const CLIENT_ID = decode( + "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==" +); +const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); +``` + +### Modes de flux OAuth + +1. **Flux automatique** (machines locales avec navigateur) : + - Ouvre le navigateur automatiquement + - Le serveur de callback local capture la redirection + - Aucune interaction utilisateur requise après l'authentification initiale + +2. **Flux manuel** (distant/sans interface/WSL2) : + - URL affichée pour copier-coller manuellement + - L'utilisateur complète l'authentification dans un navigateur externe + - L'utilisateur colle l'URL de redirection complète + +```typescript +function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2Sync(); +} +``` + +--- + +## Gestion des jetons + +### Structure du profil d'authentification + +```typescript +type OAuthCredential = { + type: "oauth"; + provider: "google-antigravity"; + access: string; // Jeton d'accès + refresh: string; // Jeton de rafraîchissement + expires: number; // Horodatage d'expiration (ms depuis epoch) + email?: string; // E-mail de l'utilisateur + projectId?: string; // ID du projet Google Cloud +}; +``` + +### Rafraîchissement des jetons + +Les identifiants incluent un jeton de rafraîchissement qui peut être utilisé pour obtenir de nouveaux jetons d'accès lorsque le jeton actuel expire. L'expiration est définie avec un tampon de 5 minutes pour éviter les conditions de concurrence. + +--- + +## Récupération de la liste des modèles + +### Récupérer les modèles disponibles + +```typescript +const BASE_URL = "https://cloudcode-pa.googleapis.com"; + +async function fetchAvailableModels( + accessToken: string, + projectId: string +): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + }; + + const response = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers, + body: JSON.stringify({ project: projectId }), + } + ); + + const data = await response.json(); + + // Retourne les modèles avec les informations de quota + return Object.entries(data.models).map(([modelId, modelInfo]) => ({ + id: modelId, + displayName: modelInfo.displayName, + quotaInfo: { + remainingFraction: modelInfo.quotaInfo?.remainingFraction, + resetTime: modelInfo.quotaInfo?.resetTime, + isExhausted: modelInfo.quotaInfo?.isExhausted, + }, + })); +} +``` + +### Format de réponse + +```typescript +type FetchAvailableModelsResponse = { + models?: Record; +}; +``` + +--- + +## Suivi de l'utilisation + +### Récupérer les données d'utilisation + +```typescript +export async function fetchAntigravityUsage( + token: string, + timeoutMs: number +): Promise { + // 1. Récupérer les crédits et les informations du plan + const loadCodeAssistRes = await fetch( + `${BASE_URL}/v1internal:loadCodeAssist`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: { + ideType: "ANTIGRAVITY", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + // Extraire les informations de crédits + const { availablePromptCredits, planInfo, currentTier } = data; + + // 2. Récupérer les quotas des modèles + const modelsRes = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ project: projectId }), + } + ); + + // Construire les fenêtres d'utilisation + return { + provider: "google-antigravity", + displayName: "Google Antigravity", + windows: [ + { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) }, + // Quotas individuels des modèles... + ], + plan: currentTier?.name || planType, + }; +} +``` + +### Structure de la réponse d'utilisation + +```typescript +type ProviderUsageSnapshot = { + provider: "google-antigravity"; + displayName: string; + windows: UsageWindow[]; + plan?: string; + error?: string; +}; + +type UsageWindow = { + label: string; // "Credits" ou ID du modèle + usedPercent: number; // 0-100 + resetAt?: number; // Horodatage de réinitialisation du quota +}; +``` + +--- + +## Structure du plugin fournisseur + +### Définition du plugin + +```typescript +const antigravityPlugin = { + id: "google-antigravity-auth", + name: "Google Antigravity Auth", + description: "OAuth flow for Google Antigravity (Cloud Code Assist)", + configSchema: emptyPluginConfigSchema(), + + register(api: PicoClawPluginApi) { + api.registerProvider({ + id: "google-antigravity", + label: "Google Antigravity", + docsPath: "/providers/models", + aliases: ["antigravity"], + + auth: [ + { + id: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + kind: "oauth", + run: async (ctx: ProviderAuthContext) => { + // Implémentation OAuth ici + }, + }, + ], + }); + }, +}; +``` + +### ProviderAuthContext + +```typescript +type ProviderAuthContext = { + config: PicoClawConfig; + agentDir?: string; + workspaceDir?: string; + prompter: WizardPrompter; // Invites/notifications UI + runtime: RuntimeEnv; // Journalisation, etc. + isRemote: boolean; // Exécution à distance ou non + openUrl: (url: string) => Promise; // Ouverture du navigateur + oauth: { + createVpsAwareHandlers: Function; + }; +}; +``` + +### ProviderAuthResult + +```typescript +type ProviderAuthResult = { + profiles: Array<{ + profileId: string; + credential: AuthProfileCredential; + }>; + configPatch?: Partial; + defaultModel?: string; + notes?: string[]; +}; +``` + +--- + +## Exigences d'intégration + +### 1. Environnement/dépendances requis + +- Go ≥ 1.25 +- Base de code PicoClaw (`pkg/providers/` et `pkg/auth/`) +- Packages de la bibliothèque standard `crypto` et `net/http` + +### 2. En-têtes requis pour les appels API + +```typescript +const REQUIRED_HEADERS = { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", // ou "google-api-nodejs-client/9.15.1" + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", +}; + +// Pour les appels loadCodeAssist, inclure également : +const CLIENT_METADATA = { + ideType: "ANTIGRAVITY", // ou "IDE_UNSPECIFIED" + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +}; +``` + +### 3. Assainissement des schémas de modèles + +Antigravity utilise des modèles compatibles Gemini, les schémas d'outils doivent donc être assainis : + +```typescript +const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ + "patternProperties", + "additionalProperties", + "$schema", + "$id", + "$ref", + "$defs", + "definitions", + "examples", + "minLength", + "maxLength", + "minimum", + "maximum", + "multipleOf", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems", + "minProperties", + "maxProperties", +]); + +// Nettoyer le schéma avant l'envoi +function cleanToolSchemaForGemini(schema: Record): unknown { + // Supprimer les mots-clés non supportés + // S'assurer que le niveau supérieur a type: "object" + // Aplatir les unions anyOf/oneOf +} +``` + +### 4. Gestion des blocs de réflexion (modèles Claude) + +Pour les modèles Claude via Antigravity, les blocs de réflexion nécessitent un traitement spécial : + +```typescript +const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +export function sanitizeAntigravityThinkingBlocks( + messages: AgentMessage[] +): AgentMessage[] { + // Valider les signatures de réflexion + // Normaliser les champs de signature + // Rejeter les blocs de réflexion non signés +} +``` + +--- + +## Points de terminaison API + +### Points de terminaison d'authentification + +| Point de terminaison | Méthode | Objectif | +|---------------------|---------|----------| +| `https://accounts.google.com/o/oauth2/v2/auth` | GET | Autorisation OAuth | +| `https://oauth2.googleapis.com/token` | POST | Échange de jetons | +| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | Informations utilisateur (e-mail) | + +### Points de terminaison Cloud Code Assist + +| Point de terminaison | Méthode | Objectif | +|---------------------|---------|----------| +| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Charger les infos du projet, crédits, plan | +| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | Lister les modèles disponibles avec quotas | +| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Point de terminaison de streaming de chat | + +**Format de requête API (chat) :** +Le point de terminaison `v1internal:streamGenerateContent` attend une enveloppe encapsulant la requête Gemini standard : + +```json +{ + "project": "your-project-id", + "model": "model-id", + "request": { + "contents": [...], + "systemInstruction": {...}, + "generationConfig": {...}, + "tools": [...] + }, + "requestType": "agent", + "userAgent": "antigravity", + "requestId": "agent-timestamp-random" +} +``` + +**Format de réponse API (SSE) :** +Chaque message SSE (`data: {...}`) est encapsulé dans un champ `response` : + +```json +{ + "response": { + "candidates": [...], + "usageMetadata": {...}, + "modelVersion": "...", + "responseId": "..." + }, + "traceId": "...", + "metadata": {} +} +``` + +--- + +## Configuration + +### Configuration config.json + +```json +{ + "model_list": [ + { + "model_name": "gemini-flash", + "model": "antigravity/gemini-3-flash", + "auth_method": "oauth" + } + ], + "agents": { + "defaults": { + "model_name": "gemini-flash" + } + } +} +``` + +### Stockage du profil d'authentification + +Les profils d'authentification sont stockés dans `~/.picoclaw/auth.json` : + +```json +{ + "credentials": { + "google-antigravity": { + "access_token": "ya29...", + "refresh_token": "1//...", + "expires_at": "2026-01-01T00:00:00Z", + "provider": "google-antigravity", + "auth_method": "oauth", + "email": "user@example.com", + "project_id": "my-project-id" + } + } +} +``` + +--- + +## Créer un nouveau fournisseur dans PicoClaw + +Les fournisseurs PicoClaw sont implémentés en tant que packages Go sous `pkg/providers/`. Pour ajouter un nouveau fournisseur : + +### Implémentation étape par étape + +#### 1. Créer le fichier du fournisseur + +Créez un nouveau fichier Go dans `pkg/providers/` : + +``` +pkg/providers/ +└── your_provider.go +``` + +#### 2. Implémenter l'interface Provider + +Votre fournisseur doit implémenter l'interface `Provider` définie dans `pkg/providers/types.go` : + +```go +package providers + +type YourProvider struct { + apiKey string + apiBase string +} + +func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider { + if apiBase == "" { + apiBase = "https://api.your-provider.com/v1" + } + return &YourProvider{apiKey: apiKey, apiBase: apiBase} +} + +func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error { + // Implémenter la complétion de chat avec streaming +} +``` + +#### 3. Enregistrer dans la factory + +Ajoutez votre fournisseur au switch de protocole dans `pkg/providers/factory.go` : + +```go +case "your-provider": + return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil +``` + +#### 4. Ajouter la configuration par défaut (optionnel) + +Ajoutez une entrée par défaut dans `pkg/config/defaults.go` : + +```go +{ + ModelName: "your-model", + Model: "your-provider/model-name", + APIKey: "", +}, +``` + +#### 5. Ajouter le support d'authentification (optionnel) + +Si votre fournisseur nécessite OAuth ou une authentification spéciale, ajoutez un cas dans `cmd/picoclaw/internal/auth/helpers.go` : + +```go +case "your-provider": + authLoginYourProvider() +``` + +#### 6. Configurer via `config.json` + +```json +{ + "model_list": [ + { + "model_name": "your-model", + "model": "your-provider/model-name", + "api_key": "your-api-key", + "api_base": "https://api.your-provider.com/v1" + } + ] +} +``` + +--- + +## Tester votre implémentation + +### Commandes CLI + +```bash +# S'authentifier avec un fournisseur +picoclaw auth login --provider your-provider + +# Lister les modèles (pour Antigravity) +picoclaw auth models + +# Démarrer la passerelle +picoclaw gateway + +# Exécuter un agent avec un modèle spécifique +picoclaw agent -m "Hello" --model your-model +``` + +### Variables d'environnement pour les tests + +```bash +# Remplacer le modèle par défaut +export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model + +# Remplacer les paramètres du fournisseur +export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]' +``` + +--- + +## Références + +- **Fichiers source :** + - `pkg/providers/antigravity_provider.go` - Implémentation du fournisseur Antigravity + - `pkg/auth/oauth.go` - Implémentation du flux OAuth + - `pkg/auth/store.go` - Stockage des identifiants d'authentification (`~/.picoclaw/auth.json`) + - `pkg/providers/factory.go` - Factory des fournisseurs et routage de protocole + - `pkg/providers/types.go` - Définitions de l'interface fournisseur + - `cmd/picoclaw/internal/auth/helpers.go` - Commandes CLI d'authentification + +- **Documentation :** + - `docs/ANTIGRAVITY_USAGE.md` - Guide d'utilisation d'Antigravity + - `docs/migration/model-list-migration.md` - Guide de migration + +--- + +## Notes + +1. **Projet Google Cloud :** Antigravity nécessite que Gemini for Google Cloud soit activé sur votre projet Google Cloud +2. **Quotas :** Utilise les quotas du projet Google Cloud (pas de facturation séparée) +3. **Accès aux modèles :** Les modèles disponibles dépendent de la configuration de votre projet Google Cloud +4. **Blocs de réflexion :** Les modèles Claude via Antigravity nécessitent un traitement spécial des blocs de réflexion avec signatures +5. **Assainissement des schémas :** Les schémas d'outils doivent être assainis pour supprimer les mots-clés JSON Schema non supportés + +--- + +--- + +## Gestion des erreurs courantes + +### 1. Limitation de débit (HTTP 429) + +Antigravity retourne une erreur 429 lorsque les quotas du projet/modèle sont épuisés. La réponse d'erreur contient souvent un `quotaResetDelay` dans le champ `details`. + +**Exemple d'erreur 429 :** +```json +{ + "error": { + "code": 429, + "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "metadata": { + "quotaResetDelay": "4h30m28.060903746s" + } + } + ] + } +} +``` + +### 2. Réponses vides (modèles restreints) + +Certains modèles peuvent apparaître dans la liste des modèles disponibles mais retourner une réponse vide (200 OK mais flux SSE vide). Cela se produit généralement pour les modèles en préversion ou restreints que le projet actuel n'a pas la permission d'utiliser. + +**Traitement :** Traiter les réponses vides comme des erreurs informant l'utilisateur que le modèle pourrait être restreint ou invalide pour son projet. + +--- + +## Dépannage + +### "Token expired" (jeton expiré) +- Rafraîchir les jetons OAuth : `picoclaw auth login --provider antigravity` + +### "Gemini for Google Cloud is not enabled" (Gemini for Google Cloud n'est pas activé) +- Activer l'API dans votre Google Cloud Console + +### "Project not found" (projet non trouvé) +- Vérifier que votre projet Google Cloud a les API nécessaires activées +- Vérifier que l'ID du projet est correctement récupéré lors de l'authentification + +### Les modèles n'apparaissent pas dans la liste +- Vérifier que l'authentification OAuth s'est terminée avec succès +- Vérifier le stockage du profil d'authentification : `~/.picoclaw/auth.json` +- Relancer `picoclaw auth login --provider antigravity` diff --git a/docs/security/ANTIGRAVITY_AUTH.ja.md b/docs/security/ANTIGRAVITY_AUTH.ja.md new file mode 100644 index 000000000..e5ba91f8e --- /dev/null +++ b/docs/security/ANTIGRAVITY_AUTH.ja.md @@ -0,0 +1,809 @@ +> [README](../project/README.ja.md) に戻る + +# Antigravity 認証・統合ガイド + +## 概要 + +**Antigravity**(Google Cloud Code Assist)は、Google が提供する AI モデルプロバイダーで、Google のクラウドインフラストラクチャを通じて Claude Opus 4.6 や Gemini などのモデルへのアクセスを提供します。本ドキュメントでは、認証の仕組み、モデルの取得方法、PicoClaw での新しいプロバイダーの実装方法について完全なガイドを提供します。 + +--- + +## 目次 + +1. [認証フロー](#認証フロー) +2. [OAuth 実装の詳細](#oauth-実装の詳細) +3. [トークン管理](#トークン管理) +4. [モデルリストの取得](#モデルリストの取得) +5. [使用量トラッキング](#使用量トラッキング) +6. [プロバイダープラグイン構造](#プロバイダープラグイン構造) +7. [統合要件](#統合要件) +8. [API エンドポイント](#api-エンドポイント) +9. [設定](#設定) +10. [PicoClaw での新しいプロバイダーの作成](#picoclaw-での新しいプロバイダーの作成) + +--- + +## 認証フロー + +### 1. PKCE 付き OAuth 2.0 + +Antigravity はセキュアな認証のために **OAuth 2.0 with PKCE(Proof Key for Code Exchange)** を使用します: + +``` +┌─────────────┐ ┌─────────────────┐ +│ Client │ ───(1) Generate PKCE Pair────────> │ │ +│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │ +│ │ │ Server │ +│ │ <──(3) Redirect with Code───────── │ │ +│ │ └─────────────────┘ +│ │ ───(4) Exchange Code for Tokens──> │ Token URL │ +│ │ │ │ +│ │ <──(5) Access + Refresh Tokens──── │ │ +└─────────────┘ └─────────────────┘ +``` + +### 2. 詳細手順 + +#### ステップ 1:PKCE パラメータの生成 +```typescript +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} +``` + +#### ステップ 2:認可 URL の構築 +```typescript +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const REDIRECT_URI = "http://localhost:51121/oauth-callback"; + +function buildAuthUrl(params: { challenge: string; state: string }): string { + const url = new URL(AUTH_URL); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPES.join(" ")); + url.searchParams.set("code_challenge", params.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", params.state); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + return url.toString(); +} +``` + +**必要なスコープ:** +```typescript +const SCOPES = [ + "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", +]; +``` + +#### ステップ 3:OAuth コールバックの処理 + +**自動モード(ローカル開発):** +- ポート 51121 でローカル HTTP サーバーを起動 +- Google からのリダイレクトを待機 +- クエリパラメータから認可コードを抽出 + +**手動モード(リモート/ヘッドレス):** +- ユーザーに認可 URL を表示 +- ユーザーがブラウザで認証を完了 +- ユーザーが完全なリダイレクト URL をターミナルに貼り付け +- 貼り付けられた URL からコードを解析 + +#### ステップ 4:コードをトークンに交換 +```typescript +const TOKEN_URL = "https://oauth2.googleapis.com/token"; + +async function exchangeCode(params: { + code: string; + verifier: string; +}): Promise<{ access: string; refresh: string; expires: number }> { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: params.code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: params.verifier, + }), + }); + + const data = await response.json(); + + return { + access: data.access_token, + refresh: data.refresh_token, + expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer + }; +} +``` + +#### ステップ 5:追加のユーザーデータの取得 + +**ユーザーメール:** +```typescript +async function fetchUserEmail(accessToken: string): Promise { + const response = await fetch( + "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + const data = await response.json(); + return data.email; +} +``` + +**プロジェクト ID(API 呼び出しに必須):** +```typescript +async function fetchProjectId(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }), + }; + + const response = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + const data = await response.json(); + return data.cloudaicompanionProject || "rising-fact-p41fc"; // デフォルトのフォールバック +} +``` + +--- + +## OAuth 実装の詳細 + +### クライアント認証情報 + +**重要:** これらは pi-ai との同期のためにソースコード内で base64 エンコードされています: + +```typescript +const decode = (s: string) => Buffer.from(s, "base64").toString(); + +const CLIENT_ID = decode( + "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==" +); +const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); +``` + +### OAuth フローモード + +1. **自動フロー**(ブラウザのあるローカルマシン): + - ブラウザを自動的に開く + - ローカルコールバックサーバーがリダイレクトをキャプチャ + - 初回認証後はユーザー操作不要 + +2. **手動フロー**(リモート/ヘッドレス/WSL2): + - 手動コピー&ペースト用の URL を表示 + - ユーザーが外部ブラウザで認証を完了 + - ユーザーが完全なリダイレクト URL を貼り付け + +```typescript +function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2Sync(); +} +``` + +--- + +## トークン管理 + +### 認証プロファイル構造 + +```typescript +type OAuthCredential = { + type: "oauth"; + provider: "google-antigravity"; + access: string; // アクセストークン + refresh: string; // リフレッシュトークン + expires: number; // 有効期限タイムスタンプ(エポックからのミリ秒) + email?: string; // ユーザーメール + projectId?: string; // Google Cloud プロジェクト ID +}; +``` + +### トークンの更新 + +認証情報にはリフレッシュトークンが含まれており、現在のアクセストークンが期限切れになった際に新しいアクセストークンを取得するために使用できます。有効期限は競合状態を防ぐために 5 分のバッファを設けています。 + +--- + +## モデルリストの取得 + +### 利用可能なモデルの取得 + +```typescript +const BASE_URL = "https://cloudcode-pa.googleapis.com"; + +async function fetchAvailableModels( + accessToken: string, + projectId: string +): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + }; + + const response = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers, + body: JSON.stringify({ project: projectId }), + } + ); + + const data = await response.json(); + + // クォータ情報付きのモデルを返す + return Object.entries(data.models).map(([modelId, modelInfo]) => ({ + id: modelId, + displayName: modelInfo.displayName, + quotaInfo: { + remainingFraction: modelInfo.quotaInfo?.remainingFraction, + resetTime: modelInfo.quotaInfo?.resetTime, + isExhausted: modelInfo.quotaInfo?.isExhausted, + }, + })); +} +``` + +### レスポンス形式 + +```typescript +type FetchAvailableModelsResponse = { + models?: Record; +}; +``` + +--- + +## 使用量トラッキング + +### 使用量データの取得 + +```typescript +export async function fetchAntigravityUsage( + token: string, + timeoutMs: number +): Promise { + // 1. クレジットとプラン情報を取得 + const loadCodeAssistRes = await fetch( + `${BASE_URL}/v1internal:loadCodeAssist`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: { + ideType: "ANTIGRAVITY", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + // クレジット情報を抽出 + const { availablePromptCredits, planInfo, currentTier } = data; + + // 2. モデルクォータを取得 + const modelsRes = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ project: projectId }), + } + ); + + // 使用量ウィンドウを構築 + return { + provider: "google-antigravity", + displayName: "Google Antigravity", + windows: [ + { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) }, + // 個別モデルクォータ... + ], + plan: currentTier?.name || planType, + }; +} +``` + +### 使用量レスポンス構造 + +```typescript +type ProviderUsageSnapshot = { + provider: "google-antigravity"; + displayName: string; + windows: UsageWindow[]; + plan?: string; + error?: string; +}; + +type UsageWindow = { + label: string; // "Credits" またはモデル ID + usedPercent: number; // 0-100 + resetAt?: number; // クォータがリセットされるタイムスタンプ +}; +``` + +--- + +## プロバイダープラグイン構造 + +### プラグイン定義 + +```typescript +const antigravityPlugin = { + id: "google-antigravity-auth", + name: "Google Antigravity Auth", + description: "OAuth flow for Google Antigravity (Cloud Code Assist)", + configSchema: emptyPluginConfigSchema(), + + register(api: PicoClawPluginApi) { + api.registerProvider({ + id: "google-antigravity", + label: "Google Antigravity", + docsPath: "/providers/models", + aliases: ["antigravity"], + + auth: [ + { + id: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + kind: "oauth", + run: async (ctx: ProviderAuthContext) => { + // OAuth 実装はここに記述 + }, + }, + ], + }); + }, +}; +``` + +### ProviderAuthContext + +```typescript +type ProviderAuthContext = { + config: PicoClawConfig; + agentDir?: string; + workspaceDir?: string; + prompter: WizardPrompter; // UI プロンプト/通知 + runtime: RuntimeEnv; // ログなど + isRemote: boolean; // リモート実行かどうか + openUrl: (url: string) => Promise; // ブラウザオープナー + oauth: { + createVpsAwareHandlers: Function; + }; +}; +``` + +### ProviderAuthResult + +```typescript +type ProviderAuthResult = { + profiles: Array<{ + profileId: string; + credential: AuthProfileCredential; + }>; + configPatch?: Partial; + defaultModel?: string; + notes?: string[]; +}; +``` + +--- + +## 統合要件 + +### 1. 必要な環境/依存関係 + +- Go ≥ 1.25 +- PicoClaw コードベース(`pkg/providers/` および `pkg/auth/`) +- `crypto` および `net/http` 標準ライブラリパッケージ + +### 2. API 呼び出しに必要なヘッダー + +```typescript +const REQUIRED_HEADERS = { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", // または "google-api-nodejs-client/9.15.1" + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", +}; + +// loadCodeAssist 呼び出しには以下も含める: +const CLIENT_METADATA = { + ideType: "ANTIGRAVITY", // または "IDE_UNSPECIFIED" + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +}; +``` + +### 3. モデルスキーマのサニタイズ + +Antigravity は Gemini 互換モデルを使用するため、ツールスキーマのサニタイズが必要です: + +```typescript +const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ + "patternProperties", + "additionalProperties", + "$schema", + "$id", + "$ref", + "$defs", + "definitions", + "examples", + "minLength", + "maxLength", + "minimum", + "maximum", + "multipleOf", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems", + "minProperties", + "maxProperties", +]); + +// 送信前にスキーマをクリーンアップ +function cleanToolSchemaForGemini(schema: Record): unknown { + // サポートされていないキーワードを削除 + // トップレベルに type: "object" があることを確認 + // anyOf/oneOf ユニオンをフラット化 +} +``` + +### 4. 思考ブロックの処理(Claude モデル) + +Antigravity の Claude モデルでは、思考ブロックに特別な処理が必要です: + +```typescript +const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +export function sanitizeAntigravityThinkingBlocks( + messages: AgentMessage[] +): AgentMessage[] { + // 思考シグネチャを検証 + // シグネチャフィールドを正規化 + // 署名されていない思考ブロックを破棄 +} +``` + +--- + +## API エンドポイント + +### 認証エンドポイント + +| エンドポイント | メソッド | 用途 | +|---------------|---------|------| +| `https://accounts.google.com/o/oauth2/v2/auth` | GET | OAuth 認可 | +| `https://oauth2.googleapis.com/token` | POST | トークン交換 | +| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | ユーザー情報(メール) | + +### Cloud Code Assist エンドポイント + +| エンドポイント | メソッド | 用途 | +|---------------|---------|------| +| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | プロジェクト情報、クレジット、プランの読み込み | +| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | クォータ付き利用可能モデルの一覧 | +| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | チャットストリーミングエンドポイント | + +**API リクエスト形式(チャット):** +`v1internal:streamGenerateContent` エンドポイントは、標準の Gemini リクエストをラップするエンベロープ形式を期待します: + +```json +{ + "project": "your-project-id", + "model": "model-id", + "request": { + "contents": [...], + "systemInstruction": {...}, + "generationConfig": {...}, + "tools": [...] + }, + "requestType": "agent", + "userAgent": "antigravity", + "requestId": "agent-timestamp-random" +} +``` + +**API レスポンス形式(SSE):** +各 SSE メッセージ(`data: {...}`)は `response` フィールドでラップされます: + +```json +{ + "response": { + "candidates": [...], + "usageMetadata": {...}, + "modelVersion": "...", + "responseId": "..." + }, + "traceId": "...", + "metadata": {} +} +``` + +--- + +## 設定 + +### config.json の設定 + +```json +{ + "model_list": [ + { + "model_name": "gemini-flash", + "model": "antigravity/gemini-3-flash", + "auth_method": "oauth" + } + ], + "agents": { + "defaults": { + "model_name": "gemini-flash" + } + } +} +``` + +### 認証プロファイルの保存 + +認証プロファイルは `~/.picoclaw/auth.json` に保存されます: + +```json +{ + "credentials": { + "google-antigravity": { + "access_token": "ya29...", + "refresh_token": "1//...", + "expires_at": "2026-01-01T00:00:00Z", + "provider": "google-antigravity", + "auth_method": "oauth", + "email": "user@example.com", + "project_id": "my-project-id" + } + } +} +``` + +--- + +## PicoClaw での新しいプロバイダーの作成 + +PicoClaw のプロバイダーは `pkg/providers/` 配下の Go パッケージとして実装されます。新しいプロバイダーを追加するには: + +### ステップバイステップの実装 + +#### 1. プロバイダーファイルの作成 + +`pkg/providers/` に新しい Go ファイルを作成します: + +``` +pkg/providers/ +└── your_provider.go +``` + +#### 2. Provider インターフェースの実装 + +プロバイダーは `pkg/providers/types.go` で定義された `Provider` インターフェースを実装する必要があります: + +```go +package providers + +type YourProvider struct { + apiKey string + apiBase string +} + +func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider { + if apiBase == "" { + apiBase = "https://api.your-provider.com/v1" + } + return &YourProvider{apiKey: apiKey, apiBase: apiBase} +} + +func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error { + // ストリーミング付きチャット補完を実装 +} +``` + +#### 3. ファクトリーへの登録 + +`pkg/providers/factory.go` のプロトコルスイッチにプロバイダーを追加します: + +```go +case "your-provider": + return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil +``` + +#### 4. デフォルト設定の追加(オプション) + +`pkg/config/defaults.go` にデフォルトエントリを追加します: + +```go +{ + ModelName: "your-model", + Model: "your-provider/model-name", + APIKey: "", +}, +``` + +#### 5. 認証サポートの追加(オプション) + +プロバイダーが OAuth や特別な認証を必要とする場合、`cmd/picoclaw/internal/auth/helpers.go` にケースを追加します: + +```go +case "your-provider": + authLoginYourProvider() +``` + +#### 6. `config.json` での設定 + +```json +{ + "model_list": [ + { + "model_name": "your-model", + "model": "your-provider/model-name", + "api_key": "your-api-key", + "api_base": "https://api.your-provider.com/v1" + } + ] +} +``` + +--- + +## 実装のテスト + +### CLI コマンド + +```bash +# プロバイダーで認証 +picoclaw auth login --provider your-provider + +# モデルの一覧表示(Antigravity 用) +picoclaw auth models + +# ゲートウェイの起動 +picoclaw gateway + +# 特定のモデルでエージェントを実行 +picoclaw agent -m "Hello" --model your-model +``` + +### テスト用環境変数 + +```bash +# デフォルトモデルの上書き +export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model + +# プロバイダー設定の上書き +export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]' +``` + +--- + +## 参考資料 + +- **ソースファイル:** + - `pkg/providers/antigravity_provider.go` - Antigravity プロバイダー実装 + - `pkg/auth/oauth.go` - OAuth フロー実装 + - `pkg/auth/store.go` - 認証情報ストレージ(`~/.picoclaw/auth.json`) + - `pkg/providers/factory.go` - プロバイダーファクトリーとプロトコルルーティング + - `pkg/providers/types.go` - プロバイダーインターフェース定義 + - `cmd/picoclaw/internal/auth/helpers.go` - 認証 CLI コマンド + +- **ドキュメント:** + - `docs/ANTIGRAVITY_USAGE.md` - Antigravity 使用ガイド + - `docs/migration/model-list-migration.md` - 移行ガイド + +--- + +## 注意事項 + +1. **Google Cloud プロジェクト:** Antigravity は Google Cloud プロジェクトで Gemini for Google Cloud が有効になっている必要があります +2. **クォータ:** Google Cloud プロジェクトのクォータを使用します(個別の課金ではありません) +3. **モデルアクセス:** 利用可能なモデルは Google Cloud プロジェクトの設定に依存します +4. **思考ブロック:** Antigravity 経由の Claude モデルは、署名付き思考ブロックの特別な処理が必要です +5. **スキーマサニタイズ:** ツールスキーマはサポートされていない JSON Schema キーワードを削除するためにサニタイズが必要です + +--- + +--- + +## 一般的なエラー処理 + +### 1. レート制限(HTTP 429) + +プロジェクト/モデルのクォータが枯渇すると、Antigravity は 429 エラーを返します。エラーレスポンスには通常、`details` フィールドに `quotaResetDelay` が含まれます。 + +**429 エラーの例:** +```json +{ + "error": { + "code": 429, + "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "metadata": { + "quotaResetDelay": "4h30m28.060903746s" + } + } + ] + } +} +``` + +### 2. 空のレスポンス(制限付きモデル) + +一部のモデルは利用可能モデルリストに表示されますが、空のレスポンスを返す場合があります(200 OK だが SSE ストリームが空)。これは通常、現在のプロジェクトに使用権限がないプレビュー版または制限付きモデルで発生します。 + +**対処法:** 空のレスポンスをエラーとして扱い、そのモデルがプロジェクトに対して制限されているか無効である可能性があることをユーザーに通知します。 + +--- + +## トラブルシューティング + +### "Token expired"(トークン期限切れ) +- OAuth トークンを更新:`picoclaw auth login --provider antigravity` + +### "Gemini for Google Cloud is not enabled"(Gemini for Google Cloud が有効になっていない) +- Google Cloud Console で API を有効にしてください + +### "Project not found"(プロジェクトが見つからない) +- Google Cloud プロジェクトで必要な API が有効になっていることを確認してください +- 認証中にプロジェクト ID が正しく取得されているか確認してください + +### モデルがリストに表示されない +- OAuth 認証が正常に完了したことを確認してください +- 認証プロファイルストレージを確認:`~/.picoclaw/auth.json` +- `picoclaw auth login --provider antigravity` を再実行してください diff --git a/docs/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.md similarity index 99% rename from docs/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.md index 89261d899..d88d73c8d 100644 --- a/docs/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.md @@ -438,7 +438,7 @@ type ProviderAuthResult = { ### 1. Required Environment/Dependencies -- Go ≥ 1.21 +- Go ≥ 1.25 - PicoClaw codebase (`pkg/providers/` and `pkg/auth/`) - `crypto` and `net/http` standard library packages @@ -584,7 +584,7 @@ Each SSE message (`data: {...}`) is wrapped in a `response` field: ], "agents": { "defaults": { - "model": "gemini-flash" + "model_name": "gemini-flash" } } } @@ -674,7 +674,7 @@ Add a default entry in `pkg/config/defaults.go`: #### 5. Add Auth Support (Optional) -If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/cmd_auth.go`: +If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/internal/auth/helpers.go`: ```go case "your-provider": @@ -736,7 +736,7 @@ export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/m - `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`) - `pkg/providers/factory.go` - Provider factory and protocol routing - `pkg/providers/types.go` - Provider interface definitions - - `cmd/picoclaw/cmd_auth.go` - Auth CLI commands + - `cmd/picoclaw/internal/auth/helpers.go` - Auth CLI commands - **Documentation:** - `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide diff --git a/docs/security/ANTIGRAVITY_AUTH.pt-br.md b/docs/security/ANTIGRAVITY_AUTH.pt-br.md new file mode 100644 index 000000000..626dc7433 --- /dev/null +++ b/docs/security/ANTIGRAVITY_AUTH.pt-br.md @@ -0,0 +1,809 @@ +> Voltar ao [README](../project/README.pt-br.md) + +# Guia de Autenticação e Integração do Antigravity + +## Visão Geral + +**Antigravity** (Google Cloud Code Assist) é um provedor de modelos de IA apoiado pelo Google que oferece acesso a modelos como Claude Opus 4.6 e Gemini através da infraestrutura de nuvem do Google. Este documento fornece um guia completo sobre como a autenticação funciona, como buscar modelos e como implementar um novo provedor no PicoClaw. + +--- + +## Índice + +1. [Fluxo de Autenticação](#fluxo-de-autenticação) +2. [Detalhes da Implementação OAuth](#detalhes-da-implementação-oauth) +3. [Gerenciamento de Tokens](#gerenciamento-de-tokens) +4. [Busca da Lista de Modelos](#busca-da-lista-de-modelos) +5. [Rastreamento de Uso](#rastreamento-de-uso) +6. [Estrutura do Plugin do Provedor](#estrutura-do-plugin-do-provedor) +7. [Requisitos de Integração](#requisitos-de-integração) +8. [Endpoints da API](#endpoints-da-api) +9. [Configuração](#configuração) +10. [Criando um Novo Provedor no PicoClaw](#criando-um-novo-provedor-no-picoclaw) + +--- + +## Fluxo de Autenticação + +### 1. OAuth 2.0 com PKCE + +O Antigravity utiliza **OAuth 2.0 com PKCE (Proof Key for Code Exchange)** para autenticação segura: + +``` +┌─────────────┐ ┌─────────────────┐ +│ Client │ ───(1) Generate PKCE Pair────────> │ │ +│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │ +│ │ │ Server │ +│ │ <──(3) Redirect with Code───────── │ │ +│ │ └─────────────────┘ +│ │ ───(4) Exchange Code for Tokens──> │ Token URL │ +│ │ │ │ +│ │ <──(5) Access + Refresh Tokens──── │ │ +└─────────────┘ └─────────────────┘ +``` + +### 2. Etapas Detalhadas + +#### Etapa 1: Gerar Parâmetros PKCE +```typescript +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} +``` + +#### Etapa 2: Construir a URL de Autorização +```typescript +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const REDIRECT_URI = "http://localhost:51121/oauth-callback"; + +function buildAuthUrl(params: { challenge: string; state: string }): string { + const url = new URL(AUTH_URL); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPES.join(" ")); + url.searchParams.set("code_challenge", params.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", params.state); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + return url.toString(); +} +``` + +**Escopos Necessários:** +```typescript +const SCOPES = [ + "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", +]; +``` + +#### Etapa 3: Tratar o Callback OAuth + +**Modo Automático (Desenvolvimento Local):** +- Iniciar um servidor HTTP local na porta 51121 +- Aguardar o redirecionamento do Google +- Extrair o código de autorização dos parâmetros da query + +**Modo Manual (Remoto/Sem Interface Gráfica):** +- Exibir a URL de autorização para o usuário +- O usuário completa a autenticação no navegador +- O usuário cola a URL de redirecionamento completa no terminal +- Analisar o código da URL colada + +#### Etapa 4: Trocar o Código por Tokens +```typescript +const TOKEN_URL = "https://oauth2.googleapis.com/token"; + +async function exchangeCode(params: { + code: string; + verifier: string; +}): Promise<{ access: string; refresh: string; expires: number }> { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: params.code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: params.verifier, + }), + }); + + const data = await response.json(); + + return { + access: data.access_token, + refresh: data.refresh_token, + expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer + }; +} +``` + +#### Etapa 5: Buscar Dados Adicionais do Usuário + +**E-mail do Usuário:** +```typescript +async function fetchUserEmail(accessToken: string): Promise { + const response = await fetch( + "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + const data = await response.json(); + return data.email; +} +``` + +**ID do Projeto (Necessário para chamadas de API):** +```typescript +async function fetchProjectId(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }), + }; + + const response = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + const data = await response.json(); + return data.cloudaicompanionProject || "rising-fact-p41fc"; // Valor padrão de fallback +} +``` + +--- + +## Detalhes da Implementação OAuth + +### Credenciais do Cliente + +**Importante:** Estas são codificadas em base64 no código-fonte para sincronização com pi-ai: + +```typescript +const decode = (s: string) => Buffer.from(s, "base64").toString(); + +const CLIENT_ID = decode( + "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==" +); +const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); +``` + +### Modos do Fluxo OAuth + +1. **Fluxo Automático** (máquinas locais com navegador): + - Abre o navegador automaticamente + - O servidor de callback local captura o redirecionamento + - Nenhuma interação do usuário necessária após a autenticação inicial + +2. **Fluxo Manual** (remoto/sem interface/WSL2): + - URL exibida para copiar e colar manualmente + - O usuário completa a autenticação em um navegador externo + - O usuário cola a URL de redirecionamento completa de volta + +```typescript +function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2Sync(); +} +``` + +--- + +## Gerenciamento de Tokens + +### Estrutura do Perfil de Autenticação + +```typescript +type OAuthCredential = { + type: "oauth"; + provider: "google-antigravity"; + access: string; // Token de acesso + refresh: string; // Token de atualização + expires: number; // Timestamp de expiração (ms desde epoch) + email?: string; // E-mail do usuário + projectId?: string; // ID do projeto Google Cloud +}; +``` + +### Atualização de Tokens + +A credencial inclui um token de atualização que pode ser usado para obter novos tokens de acesso quando o atual expira. A expiração é definida com um buffer de 5 minutos para evitar condições de corrida. + +--- + +## Busca da Lista de Modelos + +### Buscar Modelos Disponíveis + +```typescript +const BASE_URL = "https://cloudcode-pa.googleapis.com"; + +async function fetchAvailableModels( + accessToken: string, + projectId: string +): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + }; + + const response = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers, + body: JSON.stringify({ project: projectId }), + } + ); + + const data = await response.json(); + + // Retorna modelos com informações de cota + return Object.entries(data.models).map(([modelId, modelInfo]) => ({ + id: modelId, + displayName: modelInfo.displayName, + quotaInfo: { + remainingFraction: modelInfo.quotaInfo?.remainingFraction, + resetTime: modelInfo.quotaInfo?.resetTime, + isExhausted: modelInfo.quotaInfo?.isExhausted, + }, + })); +} +``` + +### Formato da Resposta + +```typescript +type FetchAvailableModelsResponse = { + models?: Record; +}; +``` + +--- + +## Rastreamento de Uso + +### Buscar Dados de Uso + +```typescript +export async function fetchAntigravityUsage( + token: string, + timeoutMs: number +): Promise { + // 1. Buscar créditos e informações do plano + const loadCodeAssistRes = await fetch( + `${BASE_URL}/v1internal:loadCodeAssist`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: { + ideType: "ANTIGRAVITY", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + // Extrair informações de créditos + const { availablePromptCredits, planInfo, currentTier } = data; + + // 2. Buscar cotas dos modelos + const modelsRes = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ project: projectId }), + } + ); + + // Construir janelas de uso + return { + provider: "google-antigravity", + displayName: "Google Antigravity", + windows: [ + { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) }, + // Cotas individuais dos modelos... + ], + plan: currentTier?.name || planType, + }; +} +``` + +### Estrutura da Resposta de Uso + +```typescript +type ProviderUsageSnapshot = { + provider: "google-antigravity"; + displayName: string; + windows: UsageWindow[]; + plan?: string; + error?: string; +}; + +type UsageWindow = { + label: string; // "Credits" ou ID do modelo + usedPercent: number; // 0-100 + resetAt?: number; // Timestamp de quando a cota é redefinida +}; +``` + +--- + +## Estrutura do Plugin do Provedor + +### Definição do Plugin + +```typescript +const antigravityPlugin = { + id: "google-antigravity-auth", + name: "Google Antigravity Auth", + description: "OAuth flow for Google Antigravity (Cloud Code Assist)", + configSchema: emptyPluginConfigSchema(), + + register(api: PicoClawPluginApi) { + api.registerProvider({ + id: "google-antigravity", + label: "Google Antigravity", + docsPath: "/providers/models", + aliases: ["antigravity"], + + auth: [ + { + id: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + kind: "oauth", + run: async (ctx: ProviderAuthContext) => { + // Implementação OAuth aqui + }, + }, + ], + }); + }, +}; +``` + +### ProviderAuthContext + +```typescript +type ProviderAuthContext = { + config: PicoClawConfig; + agentDir?: string; + workspaceDir?: string; + prompter: WizardPrompter; // Prompts/notificações da UI + runtime: RuntimeEnv; // Logging, etc. + isRemote: boolean; // Se está executando remotamente + openUrl: (url: string) => Promise; // Abridor de navegador + oauth: { + createVpsAwareHandlers: Function; + }; +}; +``` + +### ProviderAuthResult + +```typescript +type ProviderAuthResult = { + profiles: Array<{ + profileId: string; + credential: AuthProfileCredential; + }>; + configPatch?: Partial; + defaultModel?: string; + notes?: string[]; +}; +``` + +--- + +## Requisitos de Integração + +### 1. Ambiente/Dependências Necessários + +- Go ≥ 1.25 +- Base de código do PicoClaw (`pkg/providers/` e `pkg/auth/`) +- Pacotes da biblioteca padrão `crypto` e `net/http` + +### 2. Cabeçalhos Necessários para Chamadas de API + +```typescript +const REQUIRED_HEADERS = { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", // ou "google-api-nodejs-client/9.15.1" + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", +}; + +// Para chamadas loadCodeAssist, incluir também: +const CLIENT_METADATA = { + ideType: "ANTIGRAVITY", // ou "IDE_UNSPECIFIED" + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +}; +``` + +### 3. Sanitização de Schemas de Modelos + +O Antigravity usa modelos compatíveis com Gemini, então os schemas de ferramentas devem ser sanitizados: + +```typescript +const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ + "patternProperties", + "additionalProperties", + "$schema", + "$id", + "$ref", + "$defs", + "definitions", + "examples", + "minLength", + "maxLength", + "minimum", + "maximum", + "multipleOf", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems", + "minProperties", + "maxProperties", +]); + +// Limpar schema antes de enviar +function cleanToolSchemaForGemini(schema: Record): unknown { + // Remover palavras-chave não suportadas + // Garantir que o nível superior tenha type: "object" + // Achatar uniões anyOf/oneOf +} +``` + +### 4. Tratamento de Blocos de Pensamento (Modelos Claude) + +Para modelos Claude via Antigravity, os blocos de pensamento requerem tratamento especial: + +```typescript +const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +export function sanitizeAntigravityThinkingBlocks( + messages: AgentMessage[] +): AgentMessage[] { + // Validar assinaturas de pensamento + // Normalizar campos de assinatura + // Descartar blocos de pensamento não assinados +} +``` + +--- + +## Endpoints da API + +### Endpoints de Autenticação + +| Endpoint | Método | Finalidade | +|----------|--------|-----------| +| `https://accounts.google.com/o/oauth2/v2/auth` | GET | Autorização OAuth | +| `https://oauth2.googleapis.com/token` | POST | Troca de tokens | +| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | Informações do usuário (e-mail) | + +### Endpoints do Cloud Code Assist + +| Endpoint | Método | Finalidade | +|----------|--------|-----------| +| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Carregar informações do projeto, créditos, plano | +| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | Listar modelos disponíveis com cotas | +| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Endpoint de streaming de chat | + +**Formato de Requisição da API (Chat):** +O endpoint `v1internal:streamGenerateContent` espera um envelope encapsulando a requisição Gemini padrão: + +```json +{ + "project": "your-project-id", + "model": "model-id", + "request": { + "contents": [...], + "systemInstruction": {...}, + "generationConfig": {...}, + "tools": [...] + }, + "requestType": "agent", + "userAgent": "antigravity", + "requestId": "agent-timestamp-random" +} +``` + +**Formato de Resposta da API (SSE):** +Cada mensagem SSE (`data: {...}`) é encapsulada em um campo `response`: + +```json +{ + "response": { + "candidates": [...], + "usageMetadata": {...}, + "modelVersion": "...", + "responseId": "..." + }, + "traceId": "...", + "metadata": {} +} +``` + +--- + +## Configuração + +### Configuração do config.json + +```json +{ + "model_list": [ + { + "model_name": "gemini-flash", + "model": "antigravity/gemini-3-flash", + "auth_method": "oauth" + } + ], + "agents": { + "defaults": { + "model_name": "gemini-flash" + } + } +} +``` + +### Armazenamento do Perfil de Autenticação + +Os perfis de autenticação são armazenados em `~/.picoclaw/auth.json`: + +```json +{ + "credentials": { + "google-antigravity": { + "access_token": "ya29...", + "refresh_token": "1//...", + "expires_at": "2026-01-01T00:00:00Z", + "provider": "google-antigravity", + "auth_method": "oauth", + "email": "user@example.com", + "project_id": "my-project-id" + } + } +} +``` + +--- + +## Criando um Novo Provedor no PicoClaw + +Os provedores do PicoClaw são implementados como pacotes Go em `pkg/providers/`. Para adicionar um novo provedor: + +### Implementação Passo a Passo + +#### 1. Criar o Arquivo do Provedor + +Crie um novo arquivo Go em `pkg/providers/`: + +``` +pkg/providers/ +└── your_provider.go +``` + +#### 2. Implementar a Interface Provider + +Seu provedor deve implementar a interface `Provider` definida em `pkg/providers/types.go`: + +```go +package providers + +type YourProvider struct { + apiKey string + apiBase string +} + +func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider { + if apiBase == "" { + apiBase = "https://api.your-provider.com/v1" + } + return &YourProvider{apiKey: apiKey, apiBase: apiBase} +} + +func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error { + // Implementar conclusão de chat com streaming +} +``` + +#### 3. Registrar na Factory + +Adicione seu provedor ao switch de protocolo em `pkg/providers/factory.go`: + +```go +case "your-provider": + return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil +``` + +#### 4. Adicionar Configuração Padrão (Opcional) + +Adicione uma entrada padrão em `pkg/config/defaults.go`: + +```go +{ + ModelName: "your-model", + Model: "your-provider/model-name", + APIKey: "", +}, +``` + +#### 5. Adicionar Suporte de Autenticação (Opcional) + +Se seu provedor requer OAuth ou autenticação especial, adicione um caso em `cmd/picoclaw/internal/auth/helpers.go`: + +```go +case "your-provider": + authLoginYourProvider() +``` + +#### 6. Configurar via `config.json` + +```json +{ + "model_list": [ + { + "model_name": "your-model", + "model": "your-provider/model-name", + "api_key": "your-api-key", + "api_base": "https://api.your-provider.com/v1" + } + ] +} +``` + +--- + +## Testando Sua Implementação + +### Comandos CLI + +```bash +# Autenticar com um provedor +picoclaw auth login --provider your-provider + +# Listar modelos (para Antigravity) +picoclaw auth models + +# Iniciar o gateway +picoclaw gateway + +# Executar um agente com um modelo específico +picoclaw agent -m "Hello" --model your-model +``` + +### Variáveis de Ambiente para Testes + +```bash +# Substituir o modelo padrão +export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model + +# Substituir configurações do provedor +export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]' +``` + +--- + +## Referências + +- **Arquivos Fonte:** + - `pkg/providers/antigravity_provider.go` - Implementação do provedor Antigravity + - `pkg/auth/oauth.go` - Implementação do fluxo OAuth + - `pkg/auth/store.go` - Armazenamento de credenciais de autenticação (`~/.picoclaw/auth.json`) + - `pkg/providers/factory.go` - Factory de provedores e roteamento de protocolo + - `pkg/providers/types.go` - Definições da interface do provedor + - `cmd/picoclaw/internal/auth/helpers.go` - Comandos CLI de autenticação + +- **Documentação:** + - `docs/ANTIGRAVITY_USAGE.md` - Guia de uso do Antigravity + - `docs/migration/model-list-migration.md` - Guia de migração + +--- + +## Observações + +1. **Projeto Google Cloud:** O Antigravity requer que o Gemini for Google Cloud esteja habilitado no seu projeto Google Cloud +2. **Cotas:** Usa cotas do projeto Google Cloud (sem cobrança separada) +3. **Acesso a Modelos:** Os modelos disponíveis dependem da configuração do seu projeto Google Cloud +4. **Blocos de Pensamento:** Modelos Claude via Antigravity requerem tratamento especial de blocos de pensamento com assinaturas +5. **Sanitização de Schemas:** Os schemas de ferramentas devem ser sanitizados para remover palavras-chave JSON Schema não suportadas + +--- + +--- + +## Tratamento de Erros Comuns + +### 1. Limitação de Taxa (HTTP 429) + +O Antigravity retorna um erro 429 quando as cotas do projeto/modelo estão esgotadas. A resposta de erro frequentemente contém um `quotaResetDelay` no campo `details`. + +**Exemplo de Erro 429:** +```json +{ + "error": { + "code": 429, + "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "metadata": { + "quotaResetDelay": "4h30m28.060903746s" + } + } + ] + } +} +``` + +### 2. Respostas Vazias (Modelos Restritos) + +Alguns modelos podem aparecer na lista de modelos disponíveis, mas retornar uma resposta vazia (200 OK mas stream SSE vazio). Isso geralmente acontece com modelos em preview ou restritos que o projeto atual não tem permissão para usar. + +**Tratamento:** Tratar respostas vazias como erros informando ao usuário que o modelo pode estar restrito ou inválido para seu projeto. + +--- + +## Solução de Problemas + +### "Token expired" (token expirado) +- Atualizar tokens OAuth: `picoclaw auth login --provider antigravity` + +### "Gemini for Google Cloud is not enabled" (Gemini for Google Cloud não está habilitado) +- Habilitar a API no seu Google Cloud Console + +### "Project not found" (projeto não encontrado) +- Verificar se seu projeto Google Cloud tem as APIs necessárias habilitadas +- Verificar se o ID do projeto foi obtido corretamente durante a autenticação + +### Modelos não aparecem na lista +- Verificar se a autenticação OAuth foi concluída com sucesso +- Verificar o armazenamento do perfil de autenticação: `~/.picoclaw/auth.json` +- Executar novamente `picoclaw auth login --provider antigravity` diff --git a/docs/security/ANTIGRAVITY_AUTH.vi.md b/docs/security/ANTIGRAVITY_AUTH.vi.md new file mode 100644 index 000000000..0800ce0f2 --- /dev/null +++ b/docs/security/ANTIGRAVITY_AUTH.vi.md @@ -0,0 +1,807 @@ +> Quay lại [README](../project/README.vi.md) + +# Hướng dẫn Xác thực và Tích hợp Antigravity + +## Tổng quan + +**Antigravity** (Google Cloud Code Assist) là nhà cung cấp mô hình AI được Google hỗ trợ, cung cấp quyền truy cập vào các mô hình như Claude Opus 4.6 và Gemini thông qua hạ tầng đám mây của Google. Tài liệu này cung cấp hướng dẫn đầy đủ về cách xác thực hoạt động, cách lấy danh sách mô hình và cách triển khai nhà cung cấp mới trong PicoClaw. + +--- + +## Mục lục + +1. [Luồng xác thực](#luồng-xác-thực) +2. [Chi tiết triển khai OAuth](#chi-tiết-triển-khai-oauth) +3. [Quản lý token](#quản-lý-token) +4. [Lấy danh sách mô hình](#lấy-danh-sách-mô-hình) +5. [Theo dõi mức sử dụng](#theo-dõi-mức-sử-dụng) +6. [Cấu trúc plugin nhà cung cấp](#cấu-trúc-plugin-nhà-cung-cấp) +7. [Yêu cầu tích hợp](#yêu-cầu-tích-hợp) +8. [Các endpoint API](#các-endpoint-api) +9. [Cấu hình](#cấu-hình) +10. [Tạo nhà cung cấp mới trong PicoClaw](#tạo-nhà-cung-cấp-mới-trong-picoclaw) + +--- + +## Luồng xác thực + +### 1. OAuth 2.0 với PKCE + +Antigravity sử dụng **OAuth 2.0 với PKCE (Proof Key for Code Exchange)** để xác thực an toàn: + +``` +┌─────────────┐ ┌─────────────────┐ +│ Client │ ───(1) Generate PKCE Pair────────> │ │ +│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │ +│ │ │ Server │ +│ │ <──(3) Redirect with Code───────── │ │ +│ │ └─────────────────┘ +│ │ ───(4) Exchange Code for Tokens──> │ Token URL │ +│ │ │ │ +│ │ <──(5) Access + Refresh Tokens──── │ │ +└─────────────┘ └─────────────────┘ +``` + +### 2. Các bước chi tiết + +#### Bước 1: Tạo tham số PKCE +```typescript +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} +``` + +#### Bước 2: Xây dựng URL ủy quyền +```typescript +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const REDIRECT_URI = "http://localhost:51121/oauth-callback"; + +function buildAuthUrl(params: { challenge: string; state: string }): string { + const url = new URL(AUTH_URL); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPES.join(" ")); + url.searchParams.set("code_challenge", params.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", params.state); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + return url.toString(); +} +``` + +**Các phạm vi quyền cần thiết:** +```typescript +const SCOPES = [ + "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", +]; +``` + +#### Bước 3: Xử lý callback OAuth + +**Chế độ tự động (Phát triển cục bộ):** +- Khởi động máy chủ HTTP cục bộ trên cổng 51121 +- Chờ chuyển hướng từ Google +- Trích xuất mã ủy quyền từ tham số truy vấn + +**Chế độ thủ công (Từ xa/Không có giao diện):** +- Hiển thị URL ủy quyền cho người dùng +- Người dùng hoàn tất xác thực trong trình duyệt +- Người dùng dán URL chuyển hướng đầy đủ vào terminal +- Phân tích mã từ URL đã dán + +#### Bước 4: Đổi mã lấy token +```typescript +const TOKEN_URL = "https://oauth2.googleapis.com/token"; + +async function exchangeCode(params: { + code: string; + verifier: string; +}): Promise<{ access: string; refresh: string; expires: number }> { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: params.code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: params.verifier, + }), + }); + + const data = await response.json(); + + return { + access: data.access_token, + refresh: data.refresh_token, + expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer + }; +} +``` + +#### Bước 5: Lấy dữ liệu người dùng bổ sung + +**Email người dùng:** +```typescript +async function fetchUserEmail(accessToken: string): Promise { + const response = await fetch( + "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + const data = await response.json(); + return data.email; +} +``` + +**ID dự án (Bắt buộc cho các lệnh gọi API):** +```typescript +async function fetchProjectId(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }), + }; + + const response = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + const data = await response.json(); + return data.cloudaicompanionProject || "rising-fact-p41fc"; // Giá trị mặc định dự phòng +} +``` + +--- + +## Chi tiết triển khai OAuth + +### Thông tin xác thực client + +**Quan trọng:** Các giá trị này được mã hóa base64 trong mã nguồn để đồng bộ với pi-ai: + +```typescript +const decode = (s: string) => Buffer.from(s, "base64").toString(); + +const CLIENT_ID = decode( + "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==" +); +const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); +``` + +### Các chế độ luồng OAuth + +1. **Luồng tự động** (Máy cục bộ có trình duyệt): + - Tự động mở trình duyệt + - Máy chủ callback cục bộ bắt chuyển hướng + - Không cần tương tác người dùng sau xác thực ban đầu + +2. **Luồng thủ công** (Từ xa/Không có giao diện/WSL2): + - Hiển thị URL để sao chép-dán thủ công + - Người dùng hoàn tất xác thực trong trình duyệt bên ngoài + - Người dùng dán lại URL chuyển hướng đầy đủ + +```typescript +function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2Sync(); +} +``` + +--- + +## Quản lý token + +### Cấu trúc hồ sơ xác thực + +```typescript +type OAuthCredential = { + type: "oauth"; + provider: "google-antigravity"; + access: string; // Token truy cập + refresh: string; // Token làm mới + expires: number; // Dấu thời gian hết hạn (ms kể từ epoch) + email?: string; // Email người dùng + projectId?: string; // ID dự án Google Cloud +}; +``` + +### Làm mới token + +Thông tin xác thực bao gồm token làm mới có thể được sử dụng để lấy token truy cập mới khi token hiện tại hết hạn. Thời gian hết hạn được đặt với bộ đệm 5 phút để tránh điều kiện tranh chấp. + +--- + +## Lấy danh sách mô hình + +### Lấy các mô hình khả dụng + +```typescript +const BASE_URL = "https://cloudcode-pa.googleapis.com"; + +async function fetchAvailableModels( + accessToken: string, + projectId: string +): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + }; + + const response = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers, + body: JSON.stringify({ project: projectId }), + } + ); + + const data = await response.json(); + + // Trả về các mô hình kèm thông tin hạn mức + return Object.entries(data.models).map(([modelId, modelInfo]) => ({ + id: modelId, + displayName: modelInfo.displayName, + quotaInfo: { + remainingFraction: modelInfo.quotaInfo?.remainingFraction, + resetTime: modelInfo.quotaInfo?.resetTime, + isExhausted: modelInfo.quotaInfo?.isExhausted, + }, + })); +} +``` + +### Định dạng phản hồi + +```typescript +type FetchAvailableModelsResponse = { + models?: Record; +}; +``` + +--- + +## Theo dõi mức sử dụng + +### Lấy dữ liệu sử dụng + +```typescript +export async function fetchAntigravityUsage( + token: string, + timeoutMs: number +): Promise { + // 1. Lấy thông tin tín dụng và gói dịch vụ + const loadCodeAssistRes = await fetch( + `${BASE_URL}/v1internal:loadCodeAssist`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: { + ideType: "ANTIGRAVITY", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + // Trích xuất thông tin tín dụng + const { availablePromptCredits, planInfo, currentTier } = data; + + // 2. Lấy hạn mức mô hình + const modelsRes = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ project: projectId }), + } + ); + + // Xây dựng cửa sổ sử dụng + return { + provider: "google-antigravity", + displayName: "Google Antigravity", + windows: [ + { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) }, + // Hạn mức từng mô hình... + ], + plan: currentTier?.name || planType, + }; +} +``` + +### Cấu trúc phản hồi sử dụng + +```typescript +type ProviderUsageSnapshot = { + provider: "google-antigravity"; + displayName: string; + windows: UsageWindow[]; + plan?: string; + error?: string; +}; + +type UsageWindow = { + label: string; // "Credits" hoặc ID mô hình + usedPercent: number; // 0-100 + resetAt?: number; // Dấu thời gian khi hạn mức được đặt lại +}; +``` + +--- + +## Cấu trúc plugin nhà cung cấp + +### Định nghĩa plugin + +```typescript +const antigravityPlugin = { + id: "google-antigravity-auth", + name: "Google Antigravity Auth", + description: "OAuth flow for Google Antigravity (Cloud Code Assist)", + configSchema: emptyPluginConfigSchema(), + + register(api: PicoClawPluginApi) { + api.registerProvider({ + id: "google-antigravity", + label: "Google Antigravity", + docsPath: "/providers/models", + aliases: ["antigravity"], + + auth: [ + { + id: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + kind: "oauth", + run: async (ctx: ProviderAuthContext) => { + // Triển khai OAuth tại đây + }, + }, + ], + }); + }, +}; +``` + +### ProviderAuthContext + +```typescript +type ProviderAuthContext = { + config: PicoClawConfig; + agentDir?: string; + workspaceDir?: string; + prompter: WizardPrompter; // Lời nhắc/thông báo UI + runtime: RuntimeEnv; // Ghi log, v.v. + isRemote: boolean; // Có đang chạy từ xa không + openUrl: (url: string) => Promise; // Mở trình duyệt + oauth: { + createVpsAwareHandlers: Function; + }; +}; +``` + +### ProviderAuthResult + +```typescript +type ProviderAuthResult = { + profiles: Array<{ + profileId: string; + credential: AuthProfileCredential; + }>; + configPatch?: Partial; + defaultModel?: string; + notes?: string[]; +}; +``` + +--- + +## Yêu cầu tích hợp + +### 1. Môi trường/Phụ thuộc cần thiết + +- Go ≥ 1.25 +- Mã nguồn PicoClaw (`pkg/providers/` và `pkg/auth/`) +- Các gói thư viện chuẩn `crypto` và `net/http` + +### 2. Các header bắt buộc cho lệnh gọi API + +```typescript +const REQUIRED_HEADERS = { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", // hoặc "google-api-nodejs-client/9.15.1" + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", +}; + +// Đối với các lệnh gọi loadCodeAssist, cũng bao gồm: +const CLIENT_METADATA = { + ideType: "ANTIGRAVITY", // hoặc "IDE_UNSPECIFIED" + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +}; +``` + +### 3. Làm sạch schema mô hình + +Antigravity sử dụng các mô hình tương thích Gemini, vì vậy schema công cụ phải được làm sạch: + +```typescript +const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ + "patternProperties", + "additionalProperties", + "$schema", + "$id", + "$ref", + "$defs", + "definitions", + "examples", + "minLength", + "maxLength", + "minimum", + "maximum", + "multipleOf", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems", + "minProperties", + "maxProperties", +]); + +// Làm sạch schema trước khi gửi +function cleanToolSchemaForGemini(schema: Record): unknown { + // Xóa các từ khóa không được hỗ trợ + // Đảm bảo cấp cao nhất có type: "object" + // Làm phẳng các union anyOf/oneOf +} +``` + +### 4. Xử lý khối suy nghĩ (Mô hình Claude) + +Đối với các mô hình Claude qua Antigravity, khối suy nghĩ cần xử lý đặc biệt: + +```typescript +const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +export function sanitizeAntigravityThinkingBlocks( + messages: AgentMessage[] +): AgentMessage[] { + // Xác thực chữ ký suy nghĩ + // Chuẩn hóa các trường chữ ký + // Loại bỏ các khối suy nghĩ chưa ký +} +``` + +--- + +## Các endpoint API + +### Endpoint xác thực + +| Endpoint | Phương thức | Mục đích | +|----------|------------|----------| +| `https://accounts.google.com/o/oauth2/v2/auth` | GET | Ủy quyền OAuth | +| `https://oauth2.googleapis.com/token` | POST | Trao đổi token | +| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | Thông tin người dùng (email) | + +### Endpoint Cloud Code Assist + +| Endpoint | Phương thức | Mục đích | +|----------|------------|----------| +| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Tải thông tin dự án, tín dụng, gói dịch vụ | +| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | Liệt kê các mô hình khả dụng kèm hạn mức | +| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Endpoint streaming chat | + +**Định dạng yêu cầu API (Chat):** +Endpoint `v1internal:streamGenerateContent` yêu cầu một envelope bao bọc yêu cầu Gemini tiêu chuẩn: + +```json +{ + "project": "your-project-id", + "model": "model-id", + "request": { + "contents": [...], + "systemInstruction": {...}, + "generationConfig": {...}, + "tools": [...] + }, + "requestType": "agent", + "userAgent": "antigravity", + "requestId": "agent-timestamp-random" +} +``` + +**Định dạng phản hồi API (SSE):** +Mỗi thông điệp SSE (`data: {...}`) được bao bọc trong trường `response`: + +```json +{ + "response": { + "candidates": [...], + "usageMetadata": {...}, + "modelVersion": "...", + "responseId": "..." + }, + "traceId": "...", + "metadata": {} +} +``` + +--- + +## Cấu hình + +### Cấu hình config.json + +```json +{ + "model_list": [ + { + "model_name": "gemini-flash", + "model": "antigravity/gemini-3-flash", + "auth_method": "oauth" + } + ], + "agents": { + "defaults": { + "model_name": "gemini-flash" + } + } +} +``` + +### Lưu trữ hồ sơ xác thực + +Hồ sơ xác thực được lưu trữ trong `~/.picoclaw/auth.json`: + +```json +{ + "credentials": { + "google-antigravity": { + "access_token": "ya29...", + "refresh_token": "1//...", + "expires_at": "2026-01-01T00:00:00Z", + "provider": "google-antigravity", + "auth_method": "oauth", + "email": "user@example.com", + "project_id": "my-project-id" + } + } +} +``` + +--- + +## Tạo nhà cung cấp mới trong PicoClaw + +Các nhà cung cấp PicoClaw được triển khai dưới dạng gói Go trong `pkg/providers/`. Để thêm nhà cung cấp mới: + +### Triển khai từng bước + +#### 1. Tạo file nhà cung cấp + +Tạo file Go mới trong `pkg/providers/`: + +``` +pkg/providers/ +└── your_provider.go +``` + +#### 2. Triển khai interface Provider + +Nhà cung cấp của bạn phải triển khai interface `Provider` được định nghĩa trong `pkg/providers/types.go`: + +```go +package providers + +type YourProvider struct { + apiKey string + apiBase string +} + +func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider { + if apiBase == "" { + apiBase = "https://api.your-provider.com/v1" + } + return &YourProvider{apiKey: apiKey, apiBase: apiBase} +} + +func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error { + // Triển khai hoàn thành chat với streaming +} +``` + +#### 3. Đăng ký trong factory + +Thêm nhà cung cấp của bạn vào switch giao thức trong `pkg/providers/factory.go`: + +```go +case "your-provider": + return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil +``` + +#### 4. Thêm cấu hình mặc định (Tùy chọn) + +Thêm mục mặc định trong `pkg/config/defaults.go`: + +```go +{ + ModelName: "your-model", + Model: "your-provider/model-name", + APIKey: "", +}, +``` + +#### 5. Thêm hỗ trợ xác thực (Tùy chọn) + +Nếu nhà cung cấp của bạn yêu cầu OAuth hoặc xác thực đặc biệt, thêm case vào `cmd/picoclaw/internal/auth/helpers.go`: + +```go +case "your-provider": + authLoginYourProvider() +``` + +#### 6. Cấu hình qua `config.json` + +```json +{ + "model_list": [ + { + "model_name": "your-model", + "model": "your-provider/model-name", + "api_key": "your-api-key", + "api_base": "https://api.your-provider.com/v1" + } + ] +} +``` + +--- + +## Kiểm thử triển khai của bạn + +### Lệnh CLI + +```bash +# Xác thực với nhà cung cấp +picoclaw auth login --provider your-provider + +# Liệt kê mô hình (cho Antigravity) +picoclaw auth models + +# Khởi động gateway +picoclaw gateway + +# Chạy agent với mô hình cụ thể +picoclaw agent -m "Hello" --model your-model +``` + +### Biến môi trường cho kiểm thử + +```bash +# Ghi đè mô hình mặc định +export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model + +# Ghi đè cài đặt nhà cung cấp +export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]' +``` + +--- + +## Tài liệu tham khảo + +- **File nguồn:** + - `pkg/providers/antigravity_provider.go` - Triển khai nhà cung cấp Antigravity + - `pkg/auth/oauth.go` - Triển khai luồng OAuth + - `pkg/auth/store.go` - Lưu trữ thông tin xác thực (`~/.picoclaw/auth.json`) + - `pkg/providers/factory.go` - Factory nhà cung cấp và định tuyến giao thức + - `pkg/providers/types.go` - Định nghĩa interface nhà cung cấp + - `cmd/picoclaw/internal/auth/helpers.go` - Lệnh CLI xác thực + +- **Tài liệu:** + - `docs/ANTIGRAVITY_USAGE.md` - Hướng dẫn sử dụng Antigravity + - `docs/migration/model-list-migration.md` - Hướng dẫn di chuyển + +--- + +## Lưu ý + +1. **Dự án Google Cloud:** Antigravity yêu cầu Gemini for Google Cloud được bật trên dự án Google Cloud của bạn +2. **Hạn mức:** Sử dụng hạn mức dự án Google Cloud (không tính phí riêng) +3. **Truy cập mô hình:** Các mô hình khả dụng phụ thuộc vào cấu hình dự án Google Cloud của bạn +4. **Khối suy nghĩ:** Mô hình Claude qua Antigravity yêu cầu xử lý đặc biệt khối suy nghĩ có chữ ký +5. **Làm sạch schema:** Schema công cụ phải được làm sạch để loại bỏ các từ khóa JSON Schema không được hỗ trợ + +--- + +## Xử lý lỗi thường gặp + +### 1. Giới hạn tốc độ (HTTP 429) + +Antigravity trả về lỗi 429 khi hạn mức dự án/mô hình đã cạn kiệt. Phản hồi lỗi thường chứa `quotaResetDelay` trong trường `details`. + +**Ví dụ lỗi 429:** +```json +{ + "error": { + "code": 429, + "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "metadata": { + "quotaResetDelay": "4h30m28.060903746s" + } + } + ] + } +} +``` + +### 2. Phản hồi trống (Mô hình bị hạn chế) + +Một số mô hình có thể xuất hiện trong danh sách mô hình khả dụng nhưng trả về phản hồi trống (200 OK nhưng luồng SSE trống). Điều này thường xảy ra với các mô hình xem trước hoặc bị hạn chế mà dự án hiện tại không có quyền sử dụng. + +**Cách xử lý:** Coi phản hồi trống là lỗi, thông báo cho người dùng rằng mô hình có thể bị hạn chế hoặc không hợp lệ cho dự án của họ. + +--- + +## Khắc phục sự cố + +### "Token expired" (Token đã hết hạn) +- Làm mới token OAuth: `picoclaw auth login --provider antigravity` + +### "Gemini for Google Cloud is not enabled" (Gemini for Google Cloud chưa được bật) +- Bật API trong Google Cloud Console của bạn + +### "Project not found" (Không tìm thấy dự án) +- Đảm bảo dự án Google Cloud của bạn đã bật các API cần thiết +- Kiểm tra xem ID dự án có được lấy chính xác trong quá trình xác thực không + +### Mô hình không xuất hiện trong danh sách +- Xác minh xác thực OAuth đã hoàn tất thành công +- Kiểm tra lưu trữ hồ sơ xác thực: `~/.picoclaw/auth.json` +- Chạy lại `picoclaw auth login --provider antigravity` diff --git a/docs/security/ANTIGRAVITY_AUTH.zh.md b/docs/security/ANTIGRAVITY_AUTH.zh.md new file mode 100644 index 000000000..5ae5c8afe --- /dev/null +++ b/docs/security/ANTIGRAVITY_AUTH.zh.md @@ -0,0 +1,809 @@ +> 返回 [README](../project/README.zh.md) + +# Antigravity 认证与集成指南 + +## 概述 + +**Antigravity**(Google Cloud Code Assist)是由 Google 支持的 AI 模型提供商,通过 Google 的云基础设施提供对 Claude Opus 4.6 和 Gemini 等模型的访问。本文档提供了关于认证工作原理、如何获取模型以及如何在 PicoClaw 中实现新提供商的完整指南。 + +--- + +## 目录 + +1. [认证流程](#认证流程) +2. [OAuth 实现细节](#oauth-实现细节) +3. [令牌管理](#令牌管理) +4. [模型列表获取](#模型列表获取) +5. [用量追踪](#用量追踪) +6. [提供商插件结构](#提供商插件结构) +7. [集成要求](#集成要求) +8. [API 端点](#api-端点) +9. [配置](#配置) +10. [在 PicoClaw 中创建新提供商](#在-picoclaw-中创建新提供商) + +--- + +## 认证流程 + +### 1. 带 PKCE 的 OAuth 2.0 + +Antigravity 使用 **OAuth 2.0 with PKCE(Proof Key for Code Exchange)** 进行安全认证: + +``` +┌─────────────┐ ┌─────────────────┐ +│ Client │ ───(1) Generate PKCE Pair────────> │ │ +│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │ +│ │ │ Server │ +│ │ <──(3) Redirect with Code───────── │ │ +│ │ └─────────────────┘ +│ │ ───(4) Exchange Code for Tokens──> │ Token URL │ +│ │ │ │ +│ │ <──(5) Access + Refresh Tokens──── │ │ +└─────────────┘ └─────────────────┘ +``` + +### 2. 详细步骤 + +#### 步骤 1:生成 PKCE 参数 +```typescript +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} +``` + +#### 步骤 2:构建授权 URL +```typescript +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const REDIRECT_URI = "http://localhost:51121/oauth-callback"; + +function buildAuthUrl(params: { challenge: string; state: string }): string { + const url = new URL(AUTH_URL); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPES.join(" ")); + url.searchParams.set("code_challenge", params.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", params.state); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + return url.toString(); +} +``` + +**所需权限范围:** +```typescript +const SCOPES = [ + "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", +]; +``` + +#### 步骤 3:处理 OAuth 回调 + +**自动模式(本地开发):** +- 在端口 51121 上启动本地 HTTP 服务器 +- 等待来自 Google 的重定向 +- 从查询参数中提取授权码 + +**手动模式(远程/无头环境):** +- 向用户显示授权 URL +- 用户在浏览器中完成认证 +- 用户将完整的重定向 URL 粘贴回终端 +- 从粘贴的 URL 中解析授权码 + +#### 步骤 4:用授权码交换令牌 +```typescript +const TOKEN_URL = "https://oauth2.googleapis.com/token"; + +async function exchangeCode(params: { + code: string; + verifier: string; +}): Promise<{ access: string; refresh: string; expires: number }> { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: params.code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: params.verifier, + }), + }); + + const data = await response.json(); + + return { + access: data.access_token, + refresh: data.refresh_token, + expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer + }; +} +``` + +#### 步骤 5:获取额外的用户数据 + +**用户邮箱:** +```typescript +async function fetchUserEmail(accessToken: string): Promise { + const response = await fetch( + "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + const data = await response.json(); + return data.email; +} +``` + +**项目 ID(API 调用必需):** +```typescript +async function fetchProjectId(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }), + }; + + const response = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + const data = await response.json(); + return data.cloudaicompanionProject || "rising-fact-p41fc"; // 默认回退值 +} +``` + +--- + +## OAuth 实现细节 + +### 客户端凭据 + +**重要:** 这些凭据在源代码中以 base64 编码存储,用于与 pi-ai 同步: + +```typescript +const decode = (s: string) => Buffer.from(s, "base64").toString(); + +const CLIENT_ID = decode( + "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==" +); +const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); +``` + +### OAuth 流程模式 + +1. **自动流程**(有浏览器的本地机器): + - 自动打开浏览器 + - 本地回调服务器捕获重定向 + - 初始认证后无需用户交互 + +2. **手动流程**(远程/无头/WSL2 环境): + - 显示 URL 供手动复制粘贴 + - 用户在外部浏览器中完成认证 + - 用户将完整的重定向 URL 粘贴回来 + +```typescript +function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2Sync(); +} +``` + +--- + +## 令牌管理 + +### 认证配置文件结构 + +```typescript +type OAuthCredential = { + type: "oauth"; + provider: "google-antigravity"; + access: string; // 访问令牌 + refresh: string; // 刷新令牌 + expires: number; // 过期时间戳(毫秒,自 epoch 起) + email?: string; // 用户邮箱 + projectId?: string; // Google Cloud 项目 ID +}; +``` + +### 令牌刷新 + +凭据包含一个刷新令牌,可在当前访问令牌过期时用于获取新的访问令牌。过期时间设置了 5 分钟的缓冲区以防止竞态条件。 + +--- + +## 模型列表获取 + +### 获取可用模型 + +```typescript +const BASE_URL = "https://cloudcode-pa.googleapis.com"; + +async function fetchAvailableModels( + accessToken: string, + projectId: string +): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + }; + + const response = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers, + body: JSON.stringify({ project: projectId }), + } + ); + + const data = await response.json(); + + // 返回带有配额信息的模型 + return Object.entries(data.models).map(([modelId, modelInfo]) => ({ + id: modelId, + displayName: modelInfo.displayName, + quotaInfo: { + remainingFraction: modelInfo.quotaInfo?.remainingFraction, + resetTime: modelInfo.quotaInfo?.resetTime, + isExhausted: modelInfo.quotaInfo?.isExhausted, + }, + })); +} +``` + +### 响应格式 + +```typescript +type FetchAvailableModelsResponse = { + models?: Record; +}; +``` + +--- + +## 用量追踪 + +### 获取用量数据 + +```typescript +export async function fetchAntigravityUsage( + token: string, + timeoutMs: number +): Promise { + // 1. 获取额度和计划信息 + const loadCodeAssistRes = await fetch( + `${BASE_URL}/v1internal:loadCodeAssist`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: { + ideType: "ANTIGRAVITY", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + } + ); + + // 提取额度信息 + const { availablePromptCredits, planInfo, currentTier } = data; + + // 2. 获取模型配额 + const modelsRes = await fetch( + `${BASE_URL}/v1internal:fetchAvailableModels`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ project: projectId }), + } + ); + + // 构建用量窗口 + return { + provider: "google-antigravity", + displayName: "Google Antigravity", + windows: [ + { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) }, + // 各模型配额... + ], + plan: currentTier?.name || planType, + }; +} +``` + +### 用量响应结构 + +```typescript +type ProviderUsageSnapshot = { + provider: "google-antigravity"; + displayName: string; + windows: UsageWindow[]; + plan?: string; + error?: string; +}; + +type UsageWindow = { + label: string; // "Credits" 或模型 ID + usedPercent: number; // 0-100 + resetAt?: number; // 配额重置的时间戳 +}; +``` + +--- + +## 提供商插件结构 + +### 插件定义 + +```typescript +const antigravityPlugin = { + id: "google-antigravity-auth", + name: "Google Antigravity Auth", + description: "OAuth flow for Google Antigravity (Cloud Code Assist)", + configSchema: emptyPluginConfigSchema(), + + register(api: PicoClawPluginApi) { + api.registerProvider({ + id: "google-antigravity", + label: "Google Antigravity", + docsPath: "/providers/models", + aliases: ["antigravity"], + + auth: [ + { + id: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + kind: "oauth", + run: async (ctx: ProviderAuthContext) => { + // OAuth 实现在此处 + }, + }, + ], + }); + }, +}; +``` + +### ProviderAuthContext + +```typescript +type ProviderAuthContext = { + config: PicoClawConfig; + agentDir?: string; + workspaceDir?: string; + prompter: WizardPrompter; // UI 提示/通知 + runtime: RuntimeEnv; // 日志等 + isRemote: boolean; // 是否在远程运行 + openUrl: (url: string) => Promise; // 浏览器打开器 + oauth: { + createVpsAwareHandlers: Function; + }; +}; +``` + +### ProviderAuthResult + +```typescript +type ProviderAuthResult = { + profiles: Array<{ + profileId: string; + credential: AuthProfileCredential; + }>; + configPatch?: Partial; + defaultModel?: string; + notes?: string[]; +}; +``` + +--- + +## 集成要求 + +### 1. 所需环境/依赖 + +- Go ≥ 1.25 +- PicoClaw 代码库(`pkg/providers/` 和 `pkg/auth/`) +- `crypto` 和 `net/http` 标准库包 + +### 2. API 调用所需的请求头 + +```typescript +const REQUIRED_HEADERS = { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", // 或 "google-api-nodejs-client/9.15.1" + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", +}; + +// 对于 loadCodeAssist 调用,还需包含: +const CLIENT_METADATA = { + ideType: "ANTIGRAVITY", // 或 "IDE_UNSPECIFIED" + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +}; +``` + +### 3. 模型 Schema 清理 + +Antigravity 使用兼容 Gemini 的模型,因此工具 schema 必须进行清理: + +```typescript +const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ + "patternProperties", + "additionalProperties", + "$schema", + "$id", + "$ref", + "$defs", + "definitions", + "examples", + "minLength", + "maxLength", + "minimum", + "maximum", + "multipleOf", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems", + "minProperties", + "maxProperties", +]); + +// 发送前清理 schema +function cleanToolSchemaForGemini(schema: Record): unknown { + // 移除不支持的关键字 + // 确保顶层有 type: "object" + // 展平 anyOf/oneOf 联合类型 +} +``` + +### 4. 思维块处理(Claude 模型) + +对于 Antigravity 的 Claude 模型,思维块需要特殊处理: + +```typescript +const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +export function sanitizeAntigravityThinkingBlocks( + messages: AgentMessage[] +): AgentMessage[] { + // 验证思维签名 + // 规范化签名字段 + // 丢弃未签名的思维块 +} +``` + +--- + +## API 端点 + +### 认证端点 + +| 端点 | 方法 | 用途 | +|------|------|------| +| `https://accounts.google.com/o/oauth2/v2/auth` | GET | OAuth 授权 | +| `https://oauth2.googleapis.com/token` | POST | 令牌交换 | +| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | 用户信息(邮箱) | + +### Cloud Code Assist 端点 + +| 端点 | 方法 | 用途 | +|------|------|------| +| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | 加载项目信息、额度、计划 | +| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | 列出可用模型及配额 | +| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | 聊天流式端点 | + +**API 请求格式(聊天):** +`v1internal:streamGenerateContent` 端点期望一个包装标准 Gemini 请求的信封格式: + +```json +{ + "project": "your-project-id", + "model": "model-id", + "request": { + "contents": [...], + "systemInstruction": {...}, + "generationConfig": {...}, + "tools": [...] + }, + "requestType": "agent", + "userAgent": "antigravity", + "requestId": "agent-timestamp-random" +} +``` + +**API 响应格式(SSE):** +每条 SSE 消息(`data: {...}`)被包装在 `response` 字段中: + +```json +{ + "response": { + "candidates": [...], + "usageMetadata": {...}, + "modelVersion": "...", + "responseId": "..." + }, + "traceId": "...", + "metadata": {} +} +``` + +--- + +## 配置 + +### config.json 配置 + +```json +{ + "model_list": [ + { + "model_name": "gemini-flash", + "model": "antigravity/gemini-3-flash", + "auth_method": "oauth" + } + ], + "agents": { + "defaults": { + "model_name": "gemini-flash" + } + } +} +``` + +### 认证配置文件存储 + +认证配置文件存储在 `~/.picoclaw/auth.json` 中: + +```json +{ + "credentials": { + "google-antigravity": { + "access_token": "ya29...", + "refresh_token": "1//...", + "expires_at": "2026-01-01T00:00:00Z", + "provider": "google-antigravity", + "auth_method": "oauth", + "email": "user@example.com", + "project_id": "my-project-id" + } + } +} +``` + +--- + +## 在 PicoClaw 中创建新提供商 + +PicoClaw 提供商以 Go 包的形式实现,位于 `pkg/providers/` 下。要添加新提供商: + +### 分步实现 + +#### 1. 创建提供商文件 + +在 `pkg/providers/` 中创建新的 Go 文件: + +``` +pkg/providers/ +└── your_provider.go +``` + +#### 2. 实现 Provider 接口 + +你的提供商必须实现 `pkg/providers/types.go` 中定义的 `Provider` 接口: + +```go +package providers + +type YourProvider struct { + apiKey string + apiBase string +} + +func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider { + if apiBase == "" { + apiBase = "https://api.your-provider.com/v1" + } + return &YourProvider{apiKey: apiKey, apiBase: apiBase} +} + +func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error { + // 实现带流式传输的聊天补全 +} +``` + +#### 3. 在工厂中注册 + +将你的提供商添加到 `pkg/providers/factory.go` 中的协议分支: + +```go +case "your-provider": + return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil +``` + +#### 4. 添加默认配置(可选) + +在 `pkg/config/defaults.go` 中添加默认条目: + +```go +{ + ModelName: "your-model", + Model: "your-provider/model-name", + APIKey: "", +}, +``` + +#### 5. 添加认证支持(可选) + +如果你的提供商需要 OAuth 或特殊认证,在 `cmd/picoclaw/internal/auth/helpers.go` 中添加分支: + +```go +case "your-provider": + authLoginYourProvider() +``` + +#### 6. 通过 `config.json` 配置 + +```json +{ + "model_list": [ + { + "model_name": "your-model", + "model": "your-provider/model-name", + "api_key": "your-api-key", + "api_base": "https://api.your-provider.com/v1" + } + ] +} +``` + +--- + +## 测试你的实现 + +### CLI 命令 + +```bash +# 使用提供商进行认证 +picoclaw auth login --provider your-provider + +# 列出模型(用于 Antigravity) +picoclaw auth models + +# 启动网关 +picoclaw gateway + +# 使用指定模型运行代理 +picoclaw agent -m "Hello" --model your-model +``` + +### 测试用环境变量 + +```bash +# 覆盖默认模型 +export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model + +# 覆盖提供商设置 +export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]' +``` + +--- + +## 参考资料 + +- **源文件:** + - `pkg/providers/antigravity_provider.go` - Antigravity 提供商实现 + - `pkg/auth/oauth.go` - OAuth 流程实现 + - `pkg/auth/store.go` - 认证凭据存储(`~/.picoclaw/auth.json`) + - `pkg/providers/factory.go` - 提供商工厂和协议路由 + - `pkg/providers/types.go` - 提供商接口定义 + - `cmd/picoclaw/internal/auth/helpers.go` - 认证 CLI 命令 + +- **文档:** + - `docs/ANTIGRAVITY_USAGE.md` - Antigravity 使用指南 + - `docs/migration/model-list-migration.md` - 迁移指南 + +--- + +## 注意事项 + +1. **Google Cloud 项目:** Antigravity 要求在你的 Google Cloud 项目上启用 Gemini for Google Cloud +2. **配额:** 使用 Google Cloud 项目配额(非独立计费) +3. **模型访问:** 可用模型取决于你的 Google Cloud 项目配置 +4. **思维块:** 通过 Antigravity 使用的 Claude 模型需要对带签名的思维块进行特殊处理 +5. **Schema 清理:** 工具 schema 必须清理以移除不支持的 JSON Schema 关键字 + +--- + +--- + +## 常见错误处理 + +### 1. 速率限制(HTTP 429) + +当项目/模型配额耗尽时,Antigravity 会返回 429 错误。错误响应通常在 `details` 字段中包含 `quotaResetDelay`。 + +**429 错误示例:** +```json +{ + "error": { + "code": 429, + "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "metadata": { + "quotaResetDelay": "4h30m28.060903746s" + } + } + ] + } +} +``` + +### 2. 空响应(受限模型) + +某些模型可能出现在可用模型列表中,但返回空响应(200 OK 但 SSE 流为空)。这通常发生在当前项目没有权限使用的预览版或受限模型上。 + +**处理方式:** 将空响应视为错误,通知用户该模型可能对其项目受限或无效。 + +--- + +## 故障排除 + +### "Token expired"(令牌已过期) +- 刷新 OAuth 令牌:`picoclaw auth login --provider antigravity` + +### "Gemini for Google Cloud is not enabled"(Gemini for Google Cloud 未启用) +- 在 Google Cloud Console 中启用该 API + +### "Project not found"(项目未找到) +- 确保你的 Google Cloud 项目已启用必要的 API +- 检查认证过程中项目 ID 是否正确获取 + +### 模型未出现在列表中 +- 验证 OAuth 认证是否成功完成 +- 检查认证配置文件存储:`~/.picoclaw/auth.json` +- 重新运行 `picoclaw auth login --provider antigravity` diff --git a/docs/security/README.md b/docs/security/README.md new file mode 100644 index 000000000..7bd42da18 --- /dev/null +++ b/docs/security/README.md @@ -0,0 +1,8 @@ +# Security + +Security-focused docs covering configuration, secrets handling, and provider auth. + +- [Security Configuration](security_configuration.md): security-related config knobs and hardening guidance. +- [Sensitive Data Filtering](sensitive_data_filtering.md): filtering secrets from tool output before model use. +- [Credential Encryption](credential_encryption.md): encrypting stored API keys and credentials. +- [Antigravity Authentication & Integration Guide](ANTIGRAVITY_AUTH.md): auth flow and integration notes for the Antigravity provider. diff --git a/docs/security/credential_encryption.fr.md b/docs/security/credential_encryption.fr.md new file mode 100644 index 000000000..67e2ed123 --- /dev/null +++ b/docs/security/credential_encryption.fr.md @@ -0,0 +1,159 @@ +> Retour au [README](../project/README.fr.md) + +# Chiffrement des identifiants + +PicoClaw prend en charge le chiffrement des valeurs `api_key` dans les entrées de configuration `model_list`. +Les clés chiffrées sont stockées sous forme de chaînes `enc://` et déchiffrées automatiquement au démarrage. + +--- + +## Démarrage rapide + +**1. Définir votre phrase secrète** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. Chiffrer une clé API** + +Exécutez `picoclaw onboard` — il vous demande votre phrase secrète et génère la clé SSH, +puis re-chiffre automatiquement toutes les entrées `api_key` en clair dans votre configuration +lors du prochain appel à `SaveConfig`. La valeur `enc://` résultante ressemblera à : + +``` +enc://AAAA...base64... +``` + +**3. Coller la sortie dans votre configuration** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "model": "openai/gpt-4o", + "api_key": "enc://AAAA...base64...", + "api_base": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## Formats `api_key` pris en charge + +| Format | Exemple | Comportement | +|--------|---------|--------------| +| Texte clair | `sk-abc123` | Utilisé tel quel | +| Référence fichier | `file://openai.key` | Contenu lu depuis le même répertoire que le fichier de configuration | +| Chiffré | `enc://` | Déchiffré au démarrage avec `PICOCLAW_KEY_PASSPHRASE` | +| Vide | `""` | Transmis tel quel (utilisé avec `auth_method: oauth`) | + +--- + +## Conception cryptographique + +### Dérivation de clé + +Le chiffrement utilise **HKDF-SHA256** avec une clé privée SSH comme second facteur. + +``` +sshHash = SHA256(ssh_private_key_file_bytes) +ikm = HMAC-SHA256(key=sshHash, message=passphrase) +aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### Chiffrement + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### Format de transmission + +``` +enc:// +``` + +| Champ | Taille | Description | +|-------|--------|-------------| +| `salt` | 16 octets | Aléatoire par chiffrement ; fourni à HKDF | +| `nonce` | 12 octets | Aléatoire par chiffrement ; IV AES-GCM | +| `ciphertext` | variable | Texte chiffré AES-256-GCM + tag d'authentification de 16 octets | + +Le tag d'authentification GCM est automatiquement ajouté au texte chiffré. Toute altération provoque l'échec du déchiffrement avec une erreur plutôt que de retourner un texte clair corrompu. + +### Performance + +| Opération | Durée (ARM Cortex-A) | +|-----------|----------------------| +| Dérivation de clé (HKDF) | < 1 ms | +| Déchiffrement AES-256-GCM | < 1 ms | +| **Surcoût total au démarrage** | **< 2 ms par clé** | + +--- + +## Sécurité à deux facteurs avec clé SSH + +Lorsqu'une clé privée SSH est fournie, casser le chiffrement nécessite **les deux** : + +1. La **phrase secrète** (`PICOCLAW_KEY_PASSPHRASE`) +2. Le **fichier de clé privée SSH** + +Cela signifie qu'un fichier de configuration divulgué seul ne suffit pas pour récupérer la clé API, même si la phrase secrète est faible. La clé SSH apporte 256 bits d'entropie (Ed25519) indépendamment de la force de la phrase secrète. + +### Modèle de menace + +| Ce que l'attaquant possède | Peut-il déchiffrer ? | +|---------------------------|---------------------| +| Fichier de configuration uniquement | Non — nécessite la phrase secrète + la clé SSH | +| Clé SSH uniquement | Non — nécessite la phrase secrète | +| Phrase secrète uniquement | Non — nécessite la clé SSH | +| Fichier de configuration + clé SSH + phrase secrète | Oui — compromission totale | + +--- + +## Variables d'environnement + +| Variable | Requis | Description | +|----------|--------|-------------| +| `PICOCLAW_KEY_PASSPHRASE` | Oui (pour `enc://`) | Phrase secrète utilisée pour la dérivation de clé | +| `PICOCLAW_SSH_KEY_PATH` | Non | Chemin vers la clé privée SSH. Si non défini, détection automatique depuis `~/.ssh/picoclaw_ed25519.key` | + +### Détection automatique de la clé SSH + +Si `PICOCLAW_SSH_KEY_PATH` n'est pas défini, PicoClaw recherche la clé dédiée : + +``` +~/.ssh/picoclaw_ed25519.key +``` + +Ce fichier dédié évite les conflits avec les clés SSH existantes de l'utilisateur. +Exécutez `picoclaw onboard` pour le générer automatiquement. + +`os.UserHomeDir()` est utilisé pour la résolution multiplateforme du répertoire personnel (lit `USERPROFILE` sous Windows, `HOME` sous Unix/macOS). + +> **Remarque :** Un fichier de clé SSH est requis pour le chiffrement des identifiants. Si aucune clé n'est trouvée et que `PICOCLAW_SSH_KEY_PATH` n'est pas défini, le chiffrement/déchiffrement échouera. Exécutez `picoclaw onboard` pour générer la clé automatiquement. + +--- + +## Migration + +Étant donné que les seuls éléments secrets sont `PICOCLAW_KEY_PASSPHRASE` et le fichier de clé privée SSH, la migration est simple : + +1. Copiez le fichier de configuration sur la nouvelle machine. +2. Définissez `PICOCLAW_KEY_PASSPHRASE` avec la même valeur. +3. Copiez le fichier de clé privée SSH au même chemin (ou définissez `PICOCLAW_SSH_KEY_PATH` vers son nouvel emplacement). + +Aucun re-chiffrement n'est nécessaire. + +--- + +## Considérations de sécurité + +- **La phrase secrète et la clé SSH sont toutes deux requises.** La clé SSH agit comme un second facteur — sans elle, le chiffrement/déchiffrement échouera. Exécutez `picoclaw onboard` pour générer la clé si elle n'existe pas. +- **La clé SSH est en lecture seule à l'exécution.** PicoClaw n'écrit ni ne modifie jamais le fichier de clé SSH. +- **Les clés en texte clair restent prises en charge.** Les configurations existantes sans `enc://` ne sont pas affectées. +- **Le format `enc://` est versionné** via le champ `info` de HKDF (`picoclaw-credential-v1`), permettant de futures mises à niveau d'algorithme sans casser les valeurs chiffrées existantes. diff --git a/docs/security/credential_encryption.ja.md b/docs/security/credential_encryption.ja.md new file mode 100644 index 000000000..9eeba98b4 --- /dev/null +++ b/docs/security/credential_encryption.ja.md @@ -0,0 +1,158 @@ +> [README](../project/README.ja.md) に戻る + +# クレデンシャル暗号化 + +PicoClaw は `model_list` 設定エントリの `api_key` 値の暗号化をサポートしています。 +暗号化されたキーは `enc://` 文字列として保存され、起動時に自動的に復号されます。 + +--- + +## クイックスタート + +**1. パスフレーズを設定する** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. API キーを暗号化する** + +`picoclaw onboard` を実行します — パスフレーズの入力を求められ、SSH キーが生成されます。 +その後、次の `SaveConfig` 呼び出し時に、設定内のすべての平文 `api_key` エントリが自動的に再暗号化されます。生成される `enc://` 値は以下のようになります: + +``` +enc://AAAA...base64... +``` + +**3. 出力を設定に貼り付ける** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "model": "openai/gpt-4o", + "api_key": "enc://AAAA...base64...", + "api_base": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## サポートされる `api_key` 形式 + +| 形式 | 例 | 動作 | +|------|---|------| +| 平文 | `sk-abc123` | そのまま使用 | +| ファイル参照 | `file://openai.key` | 設定ファイルと同じディレクトリから内容を読み取り | +| 暗号化 | `enc://` | 起動時に `PICOCLAW_KEY_PASSPHRASE` を使用して復号 | +| 空 | `""` | そのまま渡される(`auth_method: oauth` で使用) | + +--- + +## 暗号設計 + +### 鍵導出 + +暗号化には **HKDF-SHA256** を使用し、SSH 秘密鍵を第二要素とします。 + +``` +sshHash = SHA256(ssh_private_key_file_bytes) +ikm = HMAC-SHA256(key=sshHash, message=passphrase) +aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### 暗号化 + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### ワイヤーフォーマット + +``` +enc:// +``` + +| フィールド | サイズ | 説明 | +|-----------|--------|------| +| `salt` | 16 バイト | 暗号化ごとにランダム生成;HKDF に入力 | +| `nonce` | 12 バイト | 暗号化ごとにランダム生成;AES-GCM IV | +| `ciphertext` | 可変 | AES-256-GCM 暗号文 + 16 バイト認証タグ | + +GCM 認証タグは暗号文に自動的に付加されます。改ざんがあった場合、破損した平文を返すのではなく、エラーで復号が失敗します。 + +### パフォーマンス + +| 操作 | 所要時間 (ARM Cortex-A) | +|------|------------------------| +| 鍵導出 (HKDF) | < 1 ms | +| AES-256-GCM 復号 | < 1 ms | +| **起動時の総オーバーヘッド** | **キーあたり < 2 ms** | + +--- + +## SSH キーによる二要素セキュリティ + +SSH 秘密鍵が提供されている場合、暗号を破るには**両方**が必要です: + +1. **パスフレーズ** (`PICOCLAW_KEY_PASSPHRASE`) +2. **SSH 秘密鍵ファイル** + +これは、設定ファイルが漏洩しただけでは、パスフレーズが弱い場合でも API キーを復元できないことを意味します。SSH キーはパスフレーズの強度に関係なく、256 ビットのエントロピー(Ed25519)を提供します。 + +### 脅威モデル + +| 攻撃者が持っているもの | 復号可能か? | +|----------------------|-------------| +| 設定ファイルのみ | いいえ — パスフレーズ + SSH キーが必要 | +| SSH キーのみ | いいえ — パスフレーズが必要 | +| パスフレーズのみ | いいえ — SSH キーが必要 | +| 設定ファイル + SSH キー + パスフレーズ | はい — 完全な侵害 | + +--- + +## 環境変数 + +| 変数 | 必須 | 説明 | +|------|------|------| +| `PICOCLAW_KEY_PASSPHRASE` | はい(`enc://` 使用時) | 鍵導出に使用するパスフレーズ | +| `PICOCLAW_SSH_KEY_PATH` | いいえ | SSH 秘密鍵のパス。未設定の場合、`~/.ssh/picoclaw_ed25519.key` から自動検出 | + +### SSH キーの自動検出 + +`PICOCLAW_SSH_KEY_PATH` が設定されていない場合、PicoClaw は専用キーを探します: + +``` +~/.ssh/picoclaw_ed25519.key +``` + +この専用ファイルにより、ユーザーの既存の SSH キーとの競合を回避します。 +`picoclaw onboard` を実行すると自動的に生成されます。 + +`os.UserHomeDir()` はクロスプラットフォームのホームディレクトリ解決に使用されます(Windows では `USERPROFILE`、Unix/macOS では `HOME` を読み取ります)。 + +> **注意:** SSH キーファイルはクレデンシャル暗号化に必須です。キーが見つからず `PICOCLAW_SSH_KEY_PATH` も設定されていない場合、暗号化/復号は失敗します。`picoclaw onboard` を実行してキーを自動生成してください。 + +--- + +## 移行 + +唯一の秘密情報は `PICOCLAW_KEY_PASSPHRASE` と SSH 秘密鍵ファイルであるため、移行は簡単です: + +1. 設定ファイルを新しいマシンにコピーします。 +2. `PICOCLAW_KEY_PASSPHRASE` を同じ値に設定します。 +3. SSH 秘密鍵ファイルを同じパスにコピーします(または `PICOCLAW_SSH_KEY_PATH` を新しい場所に設定します)。 + +再暗号化は不要です。 + +--- + +## セキュリティに関する考慮事項 + +- **パスフレーズと SSH キーの両方が必須です。** SSH キーは第二要素として機能します — これがなければ暗号化/復号は失敗します。キーが存在しない場合は `picoclaw onboard` を実行して生成してください。 +- **SSH キーは実行時に読み取り専用です。** PicoClaw は SSH キーファイルへの書き込みや変更を行いません。 +- **平文キーは引き続きサポートされます。** `enc://` を使用しない既存の設定は影響を受けません。 +- **`enc://` 形式はバージョン管理されています。** HKDF `info` フィールド(`picoclaw-credential-v1`)により、既存の暗号化値を壊すことなく将来のアルゴリズムアップグレードが可能です。 diff --git a/docs/security/credential_encryption.md b/docs/security/credential_encryption.md new file mode 100644 index 000000000..54c2ee5f9 --- /dev/null +++ b/docs/security/credential_encryption.md @@ -0,0 +1,159 @@ +# Credential Encryption + +PicoClaw supports encrypting `api_key`/`api_keys` values in `model_list` configuration entries. +Encrypted keys are stored as `enc://` strings and decrypted automatically at startup. + +--- + +## Quick Start + +**1. Set your passphrase** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. Encrypt an API key** + +Run `picoclaw onboard` — it prompts for your passphrase and generates the SSH key, +then automatically re-encrypts any plaintext `api_key` entries in your config on +the next `SaveConfig` call. The resulting `enc://` value will look like: + +``` +enc://AAAA...base64... +``` + +**3. Paste the output into your config** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "model": "openai/gpt-4o", + // "api_key": "enc://AAAA...base64..." move to .security.yml + "api_base": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## Supported `api_key` Formats + +The same formats apply to both `api_key` (singular) and individual elements in the `api_keys` (array) field: + +| Format | Example | Behaviour | +|--------|---------|-----------| +| Plaintext | `sk-abc123` | Used as-is | +| File reference | `file://openai.key` | Content read from the same directory as the config file | +| Encrypted | `enc://` | Decrypted at startup using `PICOCLAW_KEY_PASSPHRASE` | +| Empty | `""` | Passed through unchanged (used with `auth_method: oauth`) | + +--- + +## Cryptographic Design + +### Key Derivation + +Encryption uses **HKDF-SHA256** with an SSH private key as a second factor. + +``` +sshHash = SHA256(ssh_private_key_file_bytes) +ikm = HMAC-SHA256(key=sshHash, message=passphrase) +aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### Encryption + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### Wire Format + +``` +enc:// +``` + +| Field | Size | Description | +|-------|------|-------------| +| `salt` | 16 bytes | Random per encryption; fed into HKDF | +| `nonce` | 12 bytes | Random per encryption; AES-GCM IV | +| `ciphertext` | variable | AES-256-GCM ciphertext + 16-byte authentication tag | + +The GCM authentication tag is appended to the ciphertext automatically. Any tampering causes decryption to fail with an error rather than returning corrupt plaintext. + +### Performance + +| Operation | Time (ARM Cortex-A) | +|-----------|---------------------| +| Key derivation (HKDF) | < 1 ms | +| AES-256-GCM decrypt | < 1 ms | +| **Total startup overhead** | **< 2 ms per key** | + +--- + +## Two-Factor Security with SSH Key + +When a SSH private key is provided, breaking the encryption requires **both**: + +1. The **passphrase** (`PICOCLAW_KEY_PASSPHRASE`) +2. The **SSH private key file** + +This 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. + +### Threat Model + +| Attacker Has | Can Decrypt? | +|---|---| +| Config file only | No — needs passphrase + SSH key | +| SSH key only | No — needs passphrase | +| Passphrase only | No — needs SSH key | +| Config file + SSH key + passphrase | Yes — full compromise | + +--- + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `PICOCLAW_KEY_PASSPHRASE` | Yes (for `enc://`) | Passphrase used for key derivation | +| `PICOCLAW_SSH_KEY_PATH` | No | Path to SSH private key. If not set, auto-detects from `~/.ssh/picoclaw_ed25519.key` | + +### SSH Key Auto-Detection + +If `PICOCLAW_SSH_KEY_PATH` is not set, PicoClaw looks for the picoclaw-specific key: + +``` +~/.ssh/picoclaw_ed25519.key +``` + +This dedicated file avoids conflicts with the user's existing SSH keys. +Run `picoclaw onboard` to generate it automatically. + +`os.UserHomeDir()` is used for cross-platform home directory resolution (reads `USERPROFILE` on Windows, `HOME` on Unix/macOS). + +> **Note:** An SSH key file is required for credential encryption. If no key is found and `PICOCLAW_SSH_KEY_PATH` is not set, encryption/decryption will fail. Run `picoclaw onboard` to generate the key automatically. + +--- + +## Migration + +Because the only secret material is `PICOCLAW_KEY_PASSPHRASE` and the SSH private key file, migration is straightforward: + +1. Copy the config file to the new machine. +2. Set `PICOCLAW_KEY_PASSPHRASE` to the same value. +3. Copy the SSH private key file to the same path (or set `PICOCLAW_SSH_KEY_PATH` to its new location). + +No re-encryption is needed. + +--- + +## Security Considerations + +- **Both passphrase and SSH key are required.** The SSH key acts as a second factor — without it, encryption/decryption will fail. Run `picoclaw onboard` to generate the key if it doesn't exist. +- **The SSH key is read-only at runtime.** PicoClaw never writes to or modifies the SSH key file. +- **Plaintext keys remain supported.** Existing configs without `enc://` are unaffected. +- **The `enc://` format is versioned** via the HKDF `info` field (`picoclaw-credential-v1`), allowing future algorithm upgrades without breaking existing encrypted values. diff --git a/docs/security/credential_encryption.pt-br.md b/docs/security/credential_encryption.pt-br.md new file mode 100644 index 000000000..d4a84be8e --- /dev/null +++ b/docs/security/credential_encryption.pt-br.md @@ -0,0 +1,159 @@ +> Voltar ao [README](../project/README.pt-br.md) + +# Criptografia de Credenciais + +O PicoClaw suporta a criptografia de valores `api_key` nas entradas de configuração `model_list`. +As chaves criptografadas são armazenadas como strings `enc://` e descriptografadas automaticamente na inicialização. + +--- + +## Início Rápido + +**1. Defina sua frase secreta** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. Criptografe uma chave de API** + +Execute `picoclaw onboard` — ele solicita sua frase secreta e gera a chave SSH, +depois recriptografa automaticamente quaisquer entradas `api_key` em texto simples na sua configuração +na próxima chamada `SaveConfig`. O valor `enc://` resultante será semelhante a: + +``` +enc://AAAA...base64... +``` + +**3. Cole a saída na sua configuração** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "model": "openai/gpt-4o", + "api_key": "enc://AAAA...base64...", + "api_base": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## Formatos de `api_key` Suportados + +| Formato | Exemplo | Comportamento | +|---------|---------|---------------| +| Texto simples | `sk-abc123` | Usado como está | +| Referência de arquivo | `file://openai.key` | Conteúdo lido do mesmo diretório do arquivo de configuração | +| Criptografado | `enc://` | Descriptografado na inicialização usando `PICOCLAW_KEY_PASSPHRASE` | +| Vazio | `""` | Passado sem alteração (usado com `auth_method: oauth`) | + +--- + +## Design Criptográfico + +### Derivação de Chave + +A criptografia utiliza **HKDF-SHA256** com uma chave privada SSH como segundo fator. + +``` +sshHash = SHA256(ssh_private_key_file_bytes) +ikm = HMAC-SHA256(key=sshHash, message=passphrase) +aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### Criptografia + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### Formato de Transmissão + +``` +enc:// +``` + +| Campo | Tamanho | Descrição | +|-------|---------|-----------| +| `salt` | 16 bytes | Aleatório por criptografia; alimentado no HKDF | +| `nonce` | 12 bytes | Aleatório por criptografia; IV do AES-GCM | +| `ciphertext` | variável | Texto cifrado AES-256-GCM + tag de autenticação de 16 bytes | + +O tag de autenticação GCM é anexado automaticamente ao texto cifrado. Qualquer adulteração faz com que a descriptografia falhe com um erro em vez de retornar texto simples corrompido. + +### Desempenho + +| Operação | Tempo (ARM Cortex-A) | +|----------|----------------------| +| Derivação de chave (HKDF) | < 1 ms | +| Descriptografia AES-256-GCM | < 1 ms | +| **Sobrecarga total na inicialização** | **< 2 ms por chave** | + +--- + +## Segurança de Dois Fatores com Chave SSH + +Quando uma chave privada SSH é fornecida, quebrar a criptografia requer **ambos**: + +1. A **frase secreta** (`PICOCLAW_KEY_PASSPHRASE`) +2. O **arquivo de chave privada SSH** + +Isso significa que um arquivo de configuração vazado sozinho não é suficiente para recuperar a chave de API, mesmo que a frase secreta seja fraca. A chave SSH contribui com 256 bits de entropia (Ed25519) independentemente da força da frase secreta. + +### Modelo de Ameaça + +| O que o atacante possui | Pode descriptografar? | +|------------------------|----------------------| +| Apenas o arquivo de configuração | Não — necessita da frase secreta + chave SSH | +| Apenas a chave SSH | Não — necessita da frase secreta | +| Apenas a frase secreta | Não — necessita da chave SSH | +| Arquivo de configuração + chave SSH + frase secreta | Sim — comprometimento total | + +--- + +## Variáveis de Ambiente + +| Variável | Obrigatório | Descrição | +|----------|-------------|-----------| +| `PICOCLAW_KEY_PASSPHRASE` | Sim (para `enc://`) | Frase secreta usada para derivação de chave | +| `PICOCLAW_SSH_KEY_PATH` | Não | Caminho para a chave privada SSH. Se não definido, detecta automaticamente em `~/.ssh/picoclaw_ed25519.key` | + +### Detecção Automática da Chave SSH + +Se `PICOCLAW_SSH_KEY_PATH` não estiver definido, o PicoClaw procura a chave dedicada: + +``` +~/.ssh/picoclaw_ed25519.key +``` + +Este arquivo dedicado evita conflitos com as chaves SSH existentes do usuário. +Execute `picoclaw onboard` para gerá-lo automaticamente. + +`os.UserHomeDir()` é usado para resolução multiplataforma do diretório home (lê `USERPROFILE` no Windows, `HOME` no Unix/macOS). + +> **Nota:** Um arquivo de chave SSH é obrigatório para a criptografia de credenciais. Se nenhuma chave for encontrada e `PICOCLAW_SSH_KEY_PATH` não estiver definido, a criptografia/descriptografia falhará. Execute `picoclaw onboard` para gerar a chave automaticamente. + +--- + +## Migração + +Como os únicos materiais secretos são `PICOCLAW_KEY_PASSPHRASE` e o arquivo de chave privada SSH, a migração é simples: + +1. Copie o arquivo de configuração para a nova máquina. +2. Defina `PICOCLAW_KEY_PASSPHRASE` com o mesmo valor. +3. Copie o arquivo de chave privada SSH para o mesmo caminho (ou defina `PICOCLAW_SSH_KEY_PATH` para sua nova localização). + +Nenhuma recriptografia é necessária. + +--- + +## Considerações de Segurança + +- **Tanto a frase secreta quanto a chave SSH são obrigatórias.** A chave SSH atua como um segundo fator — sem ela, a criptografia/descriptografia falhará. Execute `picoclaw onboard` para gerar a chave se ela não existir. +- **A chave SSH é somente leitura em tempo de execução.** O PicoClaw nunca escreve ou modifica o arquivo de chave SSH. +- **Chaves em texto simples continuam sendo suportadas.** Configurações existentes sem `enc://` não são afetadas. +- **O formato `enc://` é versionado** através do campo `info` do HKDF (`picoclaw-credential-v1`), permitindo futuras atualizações de algoritmo sem quebrar valores criptografados existentes. diff --git a/docs/security/credential_encryption.vi.md b/docs/security/credential_encryption.vi.md new file mode 100644 index 000000000..38d568b94 --- /dev/null +++ b/docs/security/credential_encryption.vi.md @@ -0,0 +1,159 @@ +> Quay lại [README](../project/README.vi.md) + +# Mã hóa Thông tin Xác thực + +PicoClaw hỗ trợ mã hóa các giá trị `api_key` trong các mục cấu hình `model_list`. +Các khóa đã mã hóa được lưu trữ dưới dạng chuỗi `enc://` và được giải mã tự động khi khởi động. + +--- + +## Bắt đầu Nhanh + +**1. Đặt cụm mật khẩu** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. Mã hóa khóa API** + +Chạy `picoclaw onboard` — nó yêu cầu nhập cụm mật khẩu và tạo khóa SSH, +sau đó tự động mã hóa lại tất cả các mục `api_key` dạng văn bản thuần trong cấu hình +ở lần gọi `SaveConfig` tiếp theo. Giá trị `enc://` kết quả sẽ có dạng: + +``` +enc://AAAA...base64... +``` + +**3. Dán kết quả vào cấu hình** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "model": "openai/gpt-4o", + "api_key": "enc://AAAA...base64...", + "api_base": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## Các Định dạng `api_key` được Hỗ trợ + +| Định dạng | Ví dụ | Hành vi | +|-----------|-------|---------| +| Văn bản thuần | `sk-abc123` | Sử dụng nguyên trạng | +| Tham chiếu tệp | `file://openai.key` | Nội dung được đọc từ cùng thư mục với tệp cấu hình | +| Đã mã hóa | `enc://` | Giải mã khi khởi động bằng `PICOCLAW_KEY_PASSPHRASE` | +| Trống | `""` | Truyền qua không thay đổi (dùng với `auth_method: oauth`) | + +--- + +## Thiết kế Mật mã + +### Dẫn xuất Khóa + +Mã hóa sử dụng **HKDF-SHA256** với khóa riêng SSH làm yếu tố thứ hai. + +``` +sshHash = SHA256(ssh_private_key_file_bytes) +ikm = HMAC-SHA256(key=sshHash, message=passphrase) +aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### Mã hóa + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### Định dạng Truyền tải + +``` +enc:// +``` + +| Trường | Kích thước | Mô tả | +|--------|-----------|-------| +| `salt` | 16 byte | Ngẫu nhiên mỗi lần mã hóa; đưa vào HKDF | +| `nonce` | 12 byte | Ngẫu nhiên mỗi lần mã hóa; IV của AES-GCM | +| `ciphertext` | thay đổi | Bản mã AES-256-GCM + thẻ xác thực 16 byte | + +Thẻ xác thực GCM được tự động nối vào bản mã. Bất kỳ sự giả mạo nào đều khiến giải mã thất bại với lỗi thay vì trả về văn bản thuần bị hỏng. + +### Hiệu suất + +| Thao tác | Thời gian (ARM Cortex-A) | +|----------|--------------------------| +| Dẫn xuất khóa (HKDF) | < 1 ms | +| Giải mã AES-256-GCM | < 1 ms | +| **Tổng chi phí khởi động** | **< 2 ms mỗi khóa** | + +--- + +## Bảo mật Hai Yếu tố với Khóa SSH + +Khi khóa riêng SSH được cung cấp, việc phá vỡ mã hóa yêu cầu **cả hai**: + +1. **Cụm mật khẩu** (`PICOCLAW_KEY_PASSPHRASE`) +2. **Tệp khóa riêng SSH** + +Điều này có nghĩa là chỉ rò rỉ tệp cấu hình không đủ để khôi phục khóa API, ngay cả khi cụm mật khẩu yếu. Khóa SSH đóng góp 256 bit entropy (Ed25519) bất kể độ mạnh của cụm mật khẩu. + +### Mô hình Mối đe dọa + +| Kẻ tấn công có | Có thể giải mã? | +|----------------|-----------------| +| Chỉ tệp cấu hình | Không — cần cụm mật khẩu + khóa SSH | +| Chỉ khóa SSH | Không — cần cụm mật khẩu | +| Chỉ cụm mật khẩu | Không — cần khóa SSH | +| Tệp cấu hình + khóa SSH + cụm mật khẩu | Có — xâm phạm hoàn toàn | + +--- + +## Biến Môi trường + +| Biến | Bắt buộc | Mô tả | +|------|----------|-------| +| `PICOCLAW_KEY_PASSPHRASE` | Có (cho `enc://`) | Cụm mật khẩu dùng để dẫn xuất khóa | +| `PICOCLAW_SSH_KEY_PATH` | Không | Đường dẫn đến khóa riêng SSH. Nếu không đặt, tự động phát hiện từ `~/.ssh/picoclaw_ed25519.key` | + +### Tự động Phát hiện Khóa SSH + +Nếu `PICOCLAW_SSH_KEY_PATH` không được đặt, PicoClaw tìm khóa chuyên dụng: + +``` +~/.ssh/picoclaw_ed25519.key +``` + +Tệp chuyên dụng này tránh xung đột với các khóa SSH hiện có của người dùng. +Chạy `picoclaw onboard` để tạo tự động. + +`os.UserHomeDir()` được sử dụng để phân giải thư mục home đa nền tảng (đọc `USERPROFILE` trên Windows, `HOME` trên Unix/macOS). + +> **Lưu ý:** Tệp khóa SSH là bắt buộc cho mã hóa thông tin xác thực. Nếu không tìm thấy khóa và `PICOCLAW_SSH_KEY_PATH` không được đặt, mã hóa/giải mã sẽ thất bại. Chạy `picoclaw onboard` để tạo khóa tự động. + +--- + +## Di chuyển + +Vì tài liệu bí mật duy nhất là `PICOCLAW_KEY_PASSPHRASE` và tệp khóa riêng SSH, việc di chuyển rất đơn giản: + +1. Sao chép tệp cấu hình sang máy mới. +2. Đặt `PICOCLAW_KEY_PASSPHRASE` với cùng giá trị. +3. Sao chép tệp khóa riêng SSH đến cùng đường dẫn (hoặc đặt `PICOCLAW_SSH_KEY_PATH` đến vị trí mới). + +Không cần mã hóa lại. + +--- + +## Lưu ý về Bảo mật + +- **Cả cụm mật khẩu và khóa SSH đều bắt buộc.** Khóa SSH đóng vai trò yếu tố thứ hai — không có nó, mã hóa/giải mã sẽ thất bại. Chạy `picoclaw onboard` để tạo khóa nếu chưa tồn tại. +- **Khóa SSH chỉ đọc khi chạy.** PicoClaw không bao giờ ghi hoặc sửa đổi tệp khóa SSH. +- **Khóa văn bản thuần vẫn được hỗ trợ.** Các cấu hình hiện có không dùng `enc://` không bị ảnh hưởng. +- **Định dạng `enc://` được quản lý phiên bản** thông qua trường `info` của HKDF (`picoclaw-credential-v1`), cho phép nâng cấp thuật toán trong tương lai mà không làm hỏng các giá trị đã mã hóa hiện có. diff --git a/docs/security/credential_encryption.zh.md b/docs/security/credential_encryption.zh.md new file mode 100644 index 000000000..5083eee18 --- /dev/null +++ b/docs/security/credential_encryption.zh.md @@ -0,0 +1,158 @@ +> 返回 [README](../project/README.zh.md) + +# 凭据加密 + +PicoClaw 支持对 `model_list` 配置条目中的 `api_key` 值进行加密。 +加密后的密钥以 `enc://` 字符串形式存储,并在启动时自动解密。 + +--- + +## 快速开始 + +**1. 设置密码短语** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. 加密 API 密钥** + +运行 `picoclaw onboard` — 它会提示你输入密码短语并生成 SSH 密钥, +然后在下一次 `SaveConfig` 调用时自动重新加密配置中所有明文 `api_key` 条目。生成的 `enc://` 值如下所示: + +``` +enc://AAAA...base64... +``` + +**3. 将输出粘贴到你的配置中** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "model": "openai/gpt-4o", + "api_key": "enc://AAAA...base64...", + "api_base": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## 支持的 `api_key` 格式 + +| 格式 | 示例 | 行为 | +|------|------|------| +| 明文 | `sk-abc123` | 直接使用 | +| 文件引用 | `file://openai.key` | 从配置文件所在目录读取内容 | +| 加密 | `enc://` | 启动时使用 `PICOCLAW_KEY_PASSPHRASE` 解密 | +| 空值 | `""` | 原样传递(用于 `auth_method: oauth`) | + +--- + +## 加密设计 + +### 密钥派生 + +加密使用 **HKDF-SHA256**,并以 SSH 私钥作为第二因子。 + +``` +sshHash = SHA256(ssh_private_key_file_bytes) +ikm = HMAC-SHA256(key=sshHash, message=passphrase) +aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### 加密 + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### 传输格式 + +``` +enc:// +``` + +| 字段 | 大小 | 描述 | +|------|------|------| +| `salt` | 16 字节 | 每次加密随机生成;输入 HKDF | +| `nonce` | 12 字节 | 每次加密随机生成;AES-GCM IV | +| `ciphertext` | 可变 | AES-256-GCM 密文 + 16 字节认证标签 | + +GCM 认证标签会自动附加到密文之后。任何篡改都会导致解密失败并报错,而不是返回损坏的明文。 + +### 性能 + +| 操作 | 耗时 (ARM Cortex-A) | +|------|---------------------| +| 密钥派生 (HKDF) | < 1 ms | +| AES-256-GCM 解密 | < 1 ms | +| **启动总开销** | **每个密钥 < 2 ms** | + +--- + +## 使用 SSH 密钥的双因子安全 + +当提供 SSH 私钥时,破解加密需要**同时具备**: + +1. **密码短语** (`PICOCLAW_KEY_PASSPHRASE`) +2. **SSH 私钥文件** + +这意味着仅泄露配置文件不足以恢复 API 密钥,即使密码短语较弱也是如此。SSH 密钥贡献 256 位熵(Ed25519),与密码短语强度无关。 + +### 威胁模型 + +| 攻击者拥有 | 能否解密? | +|------------|-----------| +| 仅配置文件 | 否 — 需要密码短语 + SSH 密钥 | +| 仅 SSH 密钥 | 否 — 需要密码短语 | +| 仅密码短语 | 否 — 需要 SSH 密钥 | +| 配置文件 + SSH 密钥 + 密码短语 | 是 — 完全泄露 | + +--- + +## 环境变量 + +| 变量 | 是否必需 | 描述 | +|------|----------|------| +| `PICOCLAW_KEY_PASSPHRASE` | 是(用于 `enc://`) | 用于密钥派生的密码短语 | +| `PICOCLAW_SSH_KEY_PATH` | 否 | SSH 私钥路径。如未设置,自动从 `~/.ssh/picoclaw_ed25519.key` 检测 | + +### SSH 密钥自动检测 + +如果未设置 `PICOCLAW_SSH_KEY_PATH`,PicoClaw 会查找专用密钥: + +``` +~/.ssh/picoclaw_ed25519.key +``` + +此专用文件避免与用户现有的 SSH 密钥冲突。 +运行 `picoclaw onboard` 可自动生成该密钥。 + +`os.UserHomeDir()` 用于跨平台主目录解析(在 Windows 上读取 `USERPROFILE`,在 Unix/macOS 上读取 `HOME`)。 + +> **注意:** SSH 密钥文件是凭据加密的必要条件。如果未找到密钥且未设置 `PICOCLAW_SSH_KEY_PATH`,加密/解密将失败。运行 `picoclaw onboard` 可自动生成密钥。 + +--- + +## 迁移 + +由于唯一的密钥材料是 `PICOCLAW_KEY_PASSPHRASE` 和 SSH 私钥文件,迁移非常简单: + +1. 将配置文件复制到新机器。 +2. 将 `PICOCLAW_KEY_PASSPHRASE` 设置为相同的值。 +3. 将 SSH 私钥文件复制到相同路径(或将 `PICOCLAW_SSH_KEY_PATH` 设置为新位置)。 + +无需重新加密。 + +--- + +## 安全注意事项 + +- **密码短语和 SSH 密钥都是必需的。** SSH 密钥作为第二因子 — 没有它,加密/解密将失败。如果密钥不存在,运行 `picoclaw onboard` 生成。 +- **SSH 密钥在运行时为只读。** PicoClaw 不会写入或修改 SSH 密钥文件。 +- **仍然支持明文密钥。** 不使用 `enc://` 的现有配置不受影响。 +- **`enc://` 格式通过版本控制**,通过 HKDF `info` 字段(`picoclaw-credential-v1`)实现,允许未来升级算法而不破坏现有加密值。 diff --git a/docs/security/security_configuration.md b/docs/security/security_configuration.md new file mode 100644 index 000000000..065eb1e76 --- /dev/null +++ b/docs/security/security_configuration.md @@ -0,0 +1,651 @@ +# Security Configuration + +## Overview + +PicoClaw supports separating sensitive data (API keys, tokens, secrets, passwords) from the main configuration by storing them in a `.security.yml` file. This improves security by: + +1. **Separation of concerns**: Configuration settings and secrets are in separate files +2. **Easier sharing**: The main config can be shared without exposing sensitive data +3. **Better version control**: `.security.yml` should be added to `.gitignore` +4. **Flexible deployment**: Different environments can use different security files + +## File Structure + +``` +~/.picoclaw/ +├── config.json # Main configuration (safe to share) +└── .security.yml # Security data (never share) +``` + +## How It Works + +The security configuration works through **direct field mapping**, NOT through `ref:` string references. The system automatically loads values from `.security.yml` and applies them to the corresponding fields in `config.json`. + +### Key Points: + +- Values in `.security.yml` are automatically mapped to corresponding fields in the config +- The mapping is based on field names and structure, not on reference strings +- If a value exists in `.security.yml`, it **overrides** the value in `config.json` +- You can omit sensitive fields from `config.json` entirely (recommended) + +## Security Configuration Structure + +### Complete Example: .security.yml + +```yaml +# Model API Keys +# All models MUST use `api_keys` (plural) array format +# Even a single key must be provided as an array with one element +model_list: + gpt-5.4: + api_keys: + - "sk-proj-your-actual-openai-key-1" + - "sk-proj-your-actual-openai-key-2" # Optional: Multiple keys for failover + claude-sonnet-4.6: + api_keys: + - "sk-ant-your-actual-anthropic-key" # Single key in array format + +# Channel Tokens +channels: + telegram: + token: "your-telegram-bot-token" + feishu: + app_secret: "your-feishu-app-secret" + encrypt_key: "your-feishu-encrypt-key" + verification_token: "your-feishu-verification-token" + discord: + token: "your-discord-bot-token" + weixin: + token: "your-weixin-token" + qq: + app_secret: "your-qq-app-secret" + dingtalk: + client_secret: "your-dingtalk-client-secret" + slack: + bot_token: "your-slack-bot-token" + app_token: "your-slack-app-token" + matrix: + access_token: "your-matrix-access-token" + line: + channel_secret: "your-line-channel-secret" + channel_access_token: "your-line-channel-access-token" + onebot: + access_token: "your-onebot-access-token" + wecom: + token: "your-wecom-token" + encoding_aes_key: "your-wecom-encoding-aes-key" + wecom_app: + corp_secret: "your-wecom-app-corp-secret" + token: "your-wecom-app-token" + encoding_aes_key: "your-wecom-app-encoding-aes-key" + wecom_aibot: + secret: "your-wecom-aibot-secret" + token: "your-wecom-aibot-token" + encoding_aes_key: "your-wecom-aibot-encoding-aes-key" + pico: + token: "your-pico-token" + irc: + password: "your-irc-password" + nickserv_password: "your-irc-nickserv-password" + sasl_password: "your-irc-sasl-password" + +# Web Tool API Keys +web: + brave: + api_keys: + - "BSAyour-brave-api-key-1" + - "BSAyour-brave-api-key-2" # Optional: Multiple keys for failover + tavily: + api_keys: + - "tvly-your-tavily-api-key" # Single key in array format + perplexity: + api_keys: + - "pplx-your-perplexity-api-key" # Single key in array format + glm_search: + api_key: "your-glm-search-api-key" # GLMSearch uses single key format (not array) + baidu_search: + api_key: "your-baidu-search-api-key" + +# Skills Registry Tokens +skills: + github: + token: "your-github-token" + clawhub: + auth_token: "your-clawhub-auth-token" +``` + +## Usage + +### Step 1: Create .security.yml + +Create or copy the security file: +```bash +cp security.example.yml ~/.picoclaw/.security.yml +``` + +### Step 2: Fill in your actual values + +Edit `~/.picoclaw/.security.yml` and replace placeholder values with your actual API keys and tokens. + +### Step 3: Set proper permissions + +```bash +chmod 600 ~/.picoclaw/.security.yml +``` + +### Step 4: Simplify config.json (Recommended) + +You can now remove sensitive fields from `config.json` since they're loaded from `.security.yml`: + +**Before:** +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api.openai.com/v1", + "api_key": "sk-your-actual-api-key-here" + } + ], + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "token": "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" + } + } +} +``` + +**After:** +```json +{ + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api.openai.com/v1" + // api_key is now loaded from .security.yml + } + ], + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram" + // token is now loaded from .security.yml + } + } +} +``` + +### Step 5: Verify + +Restart PicoClaw and verify it loads correctly: +```bash +picoclaw --version +``` + +## Field Mapping Rules + +### Models + +**In .security.yml:** +```yaml +model_list: + : + api_keys: + - "key-1" + - "key-2" +``` + +**Mapping:** +- Field `api_keys` (array) maps to the model's API keys +- The `` must match the `model_name` field in `config.json` +- Supports indexed names (e.g., "gpt-5.4:0") - the system will also try the base name ("gpt-5.4") + +### Channels + +Each channel maps its fields directly: + +**In .security.yml:** +```yaml +channels: + telegram: + token: "value" + feishu: + app_secret: "value" + encrypt_key: "value" + verification_token: "value" + discord: + token: "value" +``` + +**Mapping:** +- `channels.telegram.token` → `config.channels.telegram.token` +- `channels.feishu.app_secret` → `config.channels.feishu.app_secret` +- etc. + +### Web Tools + +**Brave, Tavily, Perplexity:** +```yaml +web: + brave: + api_keys: + - "key-1" + - "key-2" +``` +- Use `api_keys` (plural) array format + +**GLMSearch:** +```yaml +web: + glm_search: + api_key: "single-key-here" +``` +- Use `api_key` (singular) single string format + +**BaiduSearch:** +```yaml +web: + baidu_search: + api_key: "your-key" +``` +- Use `api_key` (singular) single string format + +### Skills + +**In .security.yml:** +```yaml +skills: + github: + token: "value" + clawhub: + auth_token: "value" +``` + +## API Key Formats + +### Models - Single key + +Use array format with one element: +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-your-key" +``` + +### Models - Multiple keys (Load Balancing & Failover) + +Use array format with multiple elements: +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-your-key-1" + - "sk-your-key-2" + - "sk-your-key-3" +``` + +**Benefits:** +- **Load balancing**: Requests are distributed across multiple keys +- **Failover**: Automatic switching to another key if one fails +- **Rate limit management**: Distribute usage across multiple keys +- **High availability**: Reduce downtime during API provider issues + +### Web Tools (Brave/Tavily/Perplexity) - Single key + +```yaml +web: + brave: + api_keys: + - "BSA-your-key" +``` + +### Web Tools (Brave/Tavily/Perplexity) - Multiple keys + +```yaml +web: + brave: + api_keys: + - "BSA-key-1" + - "BSA-key-2" +``` + +### Web Tool (GLMSearch/BaiduSearch) - Single key only + +```yaml +web: + glm_search: + api_key: "your-glm-key" # Single string (NOT array) + baidu_search: + api_key: "your-baidu-key" # Single string (NOT array) +``` + +## Model Name Matching + +The system supports intelligent model name matching in `.security.yml`: + +### Example 1: Exact Match + +**config.json:** +```json +{ + "model_name": "gpt-5.4:0" +} +``` + +**.security.yml (exact match with index):** +```yaml +model_list: + gpt-5.4:0: + api_keys: ["key-1"] +``` + +### Example 2: Base Name Match + +**config.json:** +```json +{ + "model_name": "gpt-5.4:0" +} +``` + +**.security.yml (base name without index):** +```yaml +model_list: + gpt-5.4: + api_keys: ["key-1", "key-2"] +``` + +Both methods work. The base name match allows you to use simpler keys in `.security.yml` even when your config uses indexed model names for load balancing. + +## Backward Compatibility + +The system maintains full backward compatibility: + +1. **Direct values**: You can still use direct values in `config.json` (not recommended for production) +2. **Mixed usage**: You can have some fields in `.security.yml` and others in `config.json` +3. **Optional security file**: If `.security.yml` doesn't exist, the system will only use values from `config.json` +4. **Override behavior**: If a field exists in both files, `.security.yml` value takes precedence + +## Environment Variables + +You can override any security value using environment variables: + +**For models:** +```bash +export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env" +``` + +**For channels:** +```bash +export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env" +export PICOCLAW_CHANNELS_FEISHU_APP_SECRET="secret-from-env" +``` + +**For web tools:** +```bash +export PICOCLAW_TOOLS_WEB_BRAVE_API_KEY="key-from-env" +export PICOCLAW_TOOLS_WEB_BAIDU_API_KEY="baidu-key-from-env" +``` + +Environment variables have the highest priority and will override both `config.json` and `.security.yml` values. + +The pattern is: `PICOCLAW_
__` with underscores separating path segments and converted to uppercase. + +## Security Best Practices + +1. **Never commit `.security.yml`** to version control +2. **Add to .gitignore**: Ensure `.security.yml` is in your `.gitignore` file +3. **Set file permissions**: `chmod 600 ~/.picoclaw/.security.yml` +4. **Use different keys** for different environments (dev, staging, production) +5. **Rotate keys regularly** and update `.security.yml` +6. **Backup securely**: Encrypt backups containing `.security.yml`. Note that config migrations automatically create date-stamped backups (e.g., `config.json.20260330.bak` and `.security.yml.20260330.bak`) +7. **Review access**: Ensure only authorized users have read access to the file + +## API + +### loadSecurityConfig + +```go +func loadSecurityConfig(securityPath string) (*SecurityConfig, error) +``` + +Loads the security configuration from `.security.yml`. Returns an empty `SecurityConfig` if the file doesn't exist. + +### saveSecurityConfig + +```go +func saveSecurityConfig(securityPath string, sec *SecurityConfig) error +``` + +Saves the security configuration to `.security.yml` with `0o600` permissions. + +### applySecurityConfig + +```go +func applySecurityConfig(cfg *Config, sec *SecurityConfig) error +``` + +Applies security configuration to the main config by copying values from `.security.yml` to the corresponding fields in the config. + +### securityPath + +```go +func securityPath(configPath string) string +``` + +Returns the path to `.security.yml` relative to the config file. + +## Example: Complete Configuration + +### config.json + +```json +{ + "version": 3, + "agents": { + "defaults": { + "workspace": "~/picoclaw-workspace", + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api.openai.com/v1" + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_base": "https://api.anthropic.com/v1" + } + ], + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram" + } + }, + "tools": { + "web": { + "brave": { + "enabled": true + } + } + } +} +``` + +### .security.yml + +```yaml +model_list: + gpt-5.4: + api_keys: + - "sk-proj-actual-openai-key-1" + - "sk-proj-actual-openai-key-2" + claude-sonnet-4.6: + api_keys: + - "sk-ant-actual-anthropic-key" + +channels: + telegram: + token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" + +web: + brave: + api_keys: + - "BSAactualbravekey-1" + - "BSAactualbravekey-2" + tavily: + api_keys: + - "tvly-your-tavily-key" + glm_search: + api_key: "your-glm-key" + baidu_search: + api_key: "your-baidu-key" +``` + +## Testing + +Run the security configuration tests: + +```bash +go test ./pkg/config -run TestSecurityConfig +``` + +## Troubleshooting + +### Error: "failed to load security config" + +- Verify `.security.yml` exists in the same directory as `config.json` +- Check the YAML syntax is valid (use a YAML validator) +- Ensure file permissions allow reading + +### Error: "model security entry not found" + +- Ensure the model name in `config.json` matches exactly in `.security.yml` +- Check that the `model_list` section exists in `.security.yml` +- For models with indexed names (e.g., "gpt-5.4:0"), ensure the exact name is used or check the base name without index +- Verify the YAML structure is correct (proper indentation) + +### Multiple API Keys Not Working + +- Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch/BaiduSearch) +- Check that the array format is correct in YAML (proper indentation with dashes) +- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format) +- GLMSearch and BaiduSearch MUST use `api_key` (single string format) + +### Load Balancing/Failover Issues + +- Verify all API keys in the `api_keys` array are valid +- Check that all keys have the same rate limits and permissions +- Monitor logs to see which keys are being used and failing +- Ensure the `api_keys` array is properly formatted in YAML + +### Keys Not Being Applied + +- Check that `.security.yml` is in the same directory as `config.json` +- Verify the file permissions allow reading (`chmod 600 ~/.picoclaw/.security.yml`) +- Ensure the YAML structure matches the expected format +- Check for typos in field names (case-sensitive) +- Verify the model/channel names match exactly (case-sensitive) + +## Migration Guide + +### Step 1: Backup your config + +The system automatically creates a date-stamped backup before saving a migrated config (e.g., `config.json.20260330.bak` and `.security.yml.20260330.bak`). If you prefer a manual backup: + +```bash +cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup +``` + +### Step 2: Create .security.yml + +```bash +cp security.example.yml ~/.picoclaw/.security.yml +``` + +### Step 3: Fill in your API keys + +Edit `~/.picoclaw/.security.yml` and replace placeholder values with your actual keys. + +### Step 4: Remove sensitive fields from config.json + +Remove or comment out sensitive fields from `config.json`: +- `api_key` fields from `model_list` entries +- `token` fields from `channels` +- `api_key` fields from `tools.web` +- `token`/`auth_token` fields from `tools.skills` + +### Step 5: Set proper permissions + +```bash +chmod 600 ~/.picoclaw/.security.yml +``` + +### Step 6: Test + +```bash +picoclaw --version +``` + +### Step 7: Verify functionality + +Test your models and channels to ensure everything works correctly. + +### Step 8: Clean up (optional) + +If everything works, you can delete the backups: +```bash +rm ~/.picoclaw/config.json.backup +# Also remove auto-generated date-stamped backups if desired: +rm ~/.picoclaw/config.json.20*.bak ~/.picoclaw/.security.yml.20*.bak +``` + +## Advanced: Encrypted API Keys + +PicoClaw supports encrypting API keys in the security file for additional protection. + +### Setup + +1. Set a passphrase via environment variable: +```bash +export PICOCLAW_CREDENTIAL_PASSPHRASE="your-secure-passphrase" +``` + +2. When saving config, API keys will be encrypted automatically: +```go +SaveConfig(path, config) +``` + +### Encrypted Format + +Encrypted keys are stored as: +```yaml +model_list: + gpt-5.4: + api_keys: + - "enc://encrypted-base64-string" +``` + +The system automatically decrypts keys at runtime when loading the configuration. + +### Benefits + +- Additional layer of security +- Keys are encrypted at rest +- Passphrase can be managed separately from the config file + +### Important Notes + +- Always backup your passphrase securely +- If you lose the passphrase, you'll lose access to encrypted keys +- Use a strong, unique passphrase +- Never commit the passphrase to version control diff --git a/docs/security/sensitive_data_filtering.md b/docs/security/sensitive_data_filtering.md new file mode 100644 index 000000000..e2d9de427 --- /dev/null +++ b/docs/security/sensitive_data_filtering.md @@ -0,0 +1,107 @@ +# Sensitive Data Filtering + +PicoClaw can filter sensitive values (API keys, tokens, secrets, passwords) from tool call results before they are sent to the LLM. This prevents the LLM from seeing its own credentials, which could otherwise leak through tool output or cause confusing behavior. + +--- + +## Overview + +When the LLM uses a tool that returns its own credentials (e.g., a tool that echoes the API key being used), those values are automatically replaced with `[FILTERED]` in the message sent to the LLM. + +Sensitive values are collected from [`.security.yml`](./credential_encryption.md) — the centralized storage for all sensitive configuration (API keys, tokens, secrets stored alongside `config.json`). This includes: + +- Model API keys +- Channel tokens (Telegram, Discord, Slack, Matrix, etc.) +- Web tool API keys (Brave, Tavily, Perplexity, etc.) +- Skills tokens (GitHub, ClawHub) + +--- + +## Configuration + +Sensitive data filtering is configured in the `tools` section of `config.json`: + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `filter_sensitive_data` | bool | `true` | Enable/disable filtering. When `false`, no filtering is performed. | +| `filter_min_length` | int | `8` | Minimum content length to trigger filtering. Short content is skipped for performance. | + +```json +{ + "tools": { + "filter_sensitive_data": true, + "filter_min_length": 8 + } +} +``` + +### Environment Variable + +| Variable | Description | +|----------|-------------| +| `PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA` | Set to `true` or `false` to override the config value | + +--- + +## How It Works + +1. **On startup**: All sensitive values are collected from `.security.yml` using reflection and compiled into a `strings.Replacer` (O(n+m) performance, computed once). + +2. **Per tool result**: Before sending any tool result content to the LLM: + - If `filter_sensitive_data` is `false`, content is passed through unchanged + - If content length < `filter_min_length`, content is passed through unchanged (fast path) + - Otherwise, all sensitive values are replaced with `[FILTERED]` + +3. **Replacement**: Uses `strings.Replacer` for efficient O(n+m) string substitution, where n = content length and m = total sensitive value length. + +--- + +## Example + +Given the following `.security.yml`: + +```yaml +model_list: + my-model: + api_keys: + - sk-secret-key-12345 + +channels: + telegram: + token: "123456:ABC-DEF" +``` + +And a tool result containing: + +``` +The model is using API key sk-secret-key-12345 and Telegram bot 123456:ABC-DEF +``` + +The LLM will receive: + +``` +The model is using API key [FILTERED] and Telegram bot [FILTERED] +``` + +--- + +## Performance + +- **Fast path**: Content shorter than `filter_min_length` (default 8) is returned unchanged without any string scanning +- **Efficient replacement**: Uses `strings.Replacer` with O(n+m) complexity instead of regex +- **Lazy initialization**: The replacement map is built once on first access via `sync.Once` + +--- + +## Security Considerations + +- **Credential exposure prevention**: Without filtering, tools that echo credentials could cause the LLM to see its own API keys, potentially leading to confusion or credential leakage in logs +- **Defense in depth**: Filtering complements (but does not replace) credential encryption — both features should be used together +- **No false positives**: Only values explicitly stored in `.security.yml` are filtered; the LLM's general knowledge is unaffected + +--- + +## Related + +- [Credential Encryption](./credential_encryption.md) — encrypting API keys in config +- [Tools Configuration](../reference/tools_configuration.md) diff --git a/docs/security/sensitive_data_filtering.zh.md b/docs/security/sensitive_data_filtering.zh.md new file mode 100644 index 000000000..6ff1acc20 --- /dev/null +++ b/docs/security/sensitive_data_filtering.zh.md @@ -0,0 +1,107 @@ +# 敏感数据过滤 + +PicoClaw 可以从工具调用结果中过滤敏感值(API 密钥、令牌、密码等),然后再发送给 LLM。这可以防止 LLM 看到自己的凭据,避免通过工具输出泄露或产生混淆行为。 + +--- + +## 概述 + +当 LLM 使用的工具返回其自身的凭据时(例如,一个回显正在使用的 API 密钥的工具),这些值会自动替换为 `[FILTERED]` 再发送给 LLM。 + +敏感值从 `.security.yml` 中收集 —— 这是所有敏感配置的集中存储,包括: + +- 模型 API 密钥 +- 频道令牌(Telegram、Discord、Slack、Matrix 等) +- Web 工具 API 密钥(Brave、Tavily、Perplexity 等) +- 技能令牌(GitHub、ClawHub) + +--- + +## 配置 + +敏感数据过滤在 `config.json` 的 `tools` 部分配置: + +| 配置 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `filter_sensitive_data` | bool | `true` | 启用/禁用过滤。为 `false` 时,不进行任何过滤。 | +| `filter_min_length` | int | `8` | 触发过滤的最小内容长度。短内容会被跳过以提高性能。 | + +```json +{ + "tools": { + "filter_sensitive_data": true, + "filter_min_length": 8 + } +} +``` + +### 环境变量 + +| 变量 | 说明 | +|------|------| +| `PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA` | 设置为 `true` 或 `false` 以覆盖配置值 | + +--- + +## 工作原理 + +1. **启动时**:使用反射从 `.security.yml` 中收集所有敏感值,并编译成 `strings.Replacer`(O(n+m) 性能,仅计算一次)。 + +2. **每个工具结果**:在将任何工具结果发送给 LLM 之前: + - 如果 `filter_sensitive_data` 为 `false`,内容原样传递 + - 如果内容长度 < `filter_min_length`,内容原样传递(快速路径) + - 否则,所有敏感值都会被替换为 `[FILTERED]` + +3. **替换**:使用 `strings.Replacer` 进行高效的 O(n+m) 字符串替换,其中 n = 内容长度,m = 敏感值总长度。 + +--- + +## 示例 + +给定以下 `.security.yml`: + +```yaml +model_list: + my-model: + api_keys: + - sk-secret-key-12345 + +channels: + telegram: + token: "123456:ABC-DEF" +``` + +以及包含以下内容的工具结果: + +``` +The model is using API key sk-secret-key-12345 and Telegram bot 123456:ABC-DEF +``` + +LLM 将收到: + +``` +The model is using API key [FILTERED] and Telegram bot [FILTERED] +``` + +--- + +## 性能 + +- **快速路径**:短于 `filter_min_length`(默认 8)的内容会直接返回,不进行任何字符串扫描 +- **高效替换**:使用 `strings.Replacer`,复杂度为 O(n+m),而非正则表达式 +- **延迟初始化**:替换映射通过 `sync.Once` 在首次访问时构建一次 + +--- + +## 安全注意事项 + +- **凭据泄露防护**:如果没有过滤,返回凭据的工具可能导致 LLM 看到自己的 API 密钥,可能导致日志中泄露凭据或产生混淆 +- **纵深防御**:过滤是对凭据加密的补充(而非替代)—— 应同时使用这两个功能 +- **无误报**:只有明确存储在 `.security.yml` 中的值才会被过滤;LLM 的通用知识不受影响 + +--- + +## 相关文档 + +- [凭据加密](./credential_encryption.zh.md) — 配置中 API 密钥的加密 +- [工具配置](../reference/tools_configuration.zh.md) diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md deleted file mode 100644 index e64a3a107..000000000 --- a/docs/tools_configuration.md +++ /dev/null @@ -1,220 +0,0 @@ -# Tools Configuration - -PicoClaw's tools configuration is located in the `tools` field of `config.json`. - -## Directory Structure - -```json -{ - "tools": { - "web": { ... }, - "mcp": { ... }, - "exec": { ... }, - "cron": { ... }, - "skills": { ... } - } -} -``` - -## Web Tools - -Web tools are used for web search and fetching. - -### Brave - -| Config | Type | Default | Description | -| ------------- | ------ | ------- | ------------------------- | -| `enabled` | bool | false | Enable Brave search | -| `api_key` | string | - | Brave Search API key | -| `max_results` | int | 5 | Maximum number of results | - -### DuckDuckGo - -| Config | Type | Default | Description | -| ------------- | ---- | ------- | ------------------------- | -| `enabled` | bool | true | Enable DuckDuckGo search | -| `max_results` | int | 5 | Maximum number of results | - -### Perplexity - -| Config | Type | Default | Description | -| ------------- | ------ | ------- | ------------------------- | -| `enabled` | bool | false | Enable Perplexity search | -| `api_key` | string | - | Perplexity API key | -| `max_results` | int | 5 | Maximum number of results | - -## Exec Tool - -The exec tool is used to execute shell commands. - -| Config | Type | Default | Description | -| ---------------------- | ----- | ------- | ------------------------------------------ | -| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | -| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | - -### Functionality - -- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns -- **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked - -### Default Blocked Command Patterns - -By default, PicoClaw blocks the following dangerous commands: - -- Delete commands: `rm -rf`, `del /f/q`, `rmdir /s` -- Disk operations: `format`, `mkfs`, `diskpart`, `dd if=`, writing to `/dev/sd*` -- System operations: `shutdown`, `reboot`, `poweroff` -- Command substitution: `$()`, `${}`, backticks -- Pipe to shell: `| sh`, `| bash` -- Privilege escalation: `sudo`, `chmod`, `chown` -- Process control: `pkill`, `killall`, `kill -9` -- Remote operations: `curl | sh`, `wget | sh`, `ssh` -- Package management: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` -- Containers: `docker run`, `docker exec` -- Git: `git push`, `git force` -- Other: `eval`, `source *.sh` - -### Configuration Example - -```json -{ - "tools": { - "exec": { - "enable_deny_patterns": true, - "custom_deny_patterns": ["\\brm\\s+-r\\b", "\\bkillall\\s+python"] - } - } -} -``` - -## Cron Tool - -The cron tool is used for scheduling periodic tasks. - -| Config | Type | Default | Description | -| ---------------------- | ---- | ------- | ---------------------------------------------- | -| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | - -## MCP Tool - -The MCP tool enables integration with external Model Context Protocol servers. - -### Global Config - -| Config | Type | Default | Description | -| --------- | ------ | ------- | ----------------------------------- | -| `enabled` | bool | false | Enable MCP integration globally | -| `servers` | object | `{}` | Map of server name to server config | - -### Per-Server Config - -| Config | Type | Required | Description | -| ---------- | ------ | -------- | ------------------------------------------ | -| `enabled` | bool | yes | Enable this MCP server | -| `type` | string | no | Transport type: `stdio`, `sse`, `http` | -| `command` | string | stdio | Executable command for stdio transport | -| `args` | array | no | Command arguments for stdio transport | -| `env` | object | no | Environment variables for stdio process | -| `env_file` | string | no | Path to environment file for stdio process | -| `url` | string | sse/http | Endpoint URL for `sse`/`http` transport | -| `headers` | object | no | HTTP headers for `sse`/`http` transport | - -### Transport Behavior - -- If `type` is omitted, transport is auto-detected: - - `url` is set → `sse` - - `command` is set → `stdio` -- `http` and `sse` both use `url` + optional `headers`. -- `env` and `env_file` are only applied to `stdio` servers. - -### Configuration Examples - -#### 1) Stdio MCP server - -```json -{ - "tools": { - "mcp": { - "enabled": true, - "servers": { - "filesystem": { - "enabled": true, - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] - } - } - } - } -} -``` - -#### 2) Remote SSE/HTTP MCP server - -```json -{ - "tools": { - "mcp": { - "enabled": true, - "servers": { - "remote-mcp": { - "enabled": true, - "type": "sse", - "url": "https://example.com/mcp", - "headers": { - "Authorization": "Bearer YOUR_TOKEN" - } - } - } - } - } -} -``` - -## Skills Tool - -The skills tool configures skill discovery and installation via registries like ClawHub. - -### Registries - -| Config | Type | Default | Description | -| ---------------------------------- | ------ | -------------------- | ----------------------- | -| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | -| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | -| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits | -| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path | -| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path | -| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path | - -### Configuration Example - -```json -{ - "tools": { - "skills": { - "registries": { - "clawhub": { - "enabled": true, - "base_url": "https://clawhub.ai", - "auth_token": "", - "search_path": "/api/v1/search", - "skills_path": "/api/v1/skills", - "download_path": "/api/v1/download" - } - } - } - } -} -``` - -## Environment Variables - -All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_
_`: - -For example: - -- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` -- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` -- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` -- `PICOCLAW_TOOLS_MCP_ENABLED=true` - -Note: Nested map-style config (for example `tools.mcp.servers..*`) is configured in `config.json` rather than environment variables. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md deleted file mode 100644 index 219d2c6e3..000000000 --- a/docs/troubleshooting.md +++ /dev/null @@ -1,43 +0,0 @@ -# Troubleshooting - -## "model ... not found in model_list" or OpenRouter "free is not a valid model ID" - -**Symptom:** You see either: - -- `Error creating provider: model "openrouter/free" not found in model_list` -- OpenRouter returns 400: `"free is not a valid model ID"` - -**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. - -- **Wrong:** `"model": "free"` → OpenRouter receives `free` and rejects it. -- **Right:** `"model": "openrouter/free"` → OpenRouter receives `openrouter/free` (auto free-tier routing). - -**Fix:** In `~/.picoclaw/config.json` (or your config path): - -1. **agents.defaults.model** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`). -2. That entry’s **model** must be a valid OpenRouter model ID, for example: - - `"openrouter/free"` – auto free-tier - - `"google/gemini-2.0-flash-exp:free"` - - `"meta-llama/llama-3.1-8b-instruct:free"` - -Example snippet: - -```json -{ - "agents": { - "defaults": { - "model": "openrouter-free" - } - }, - "model_list": [ - { - "model_name": "openrouter-free", - "model": "openrouter/free", - "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", - "api_base": "https://openrouter.ai/api/v1" - } - ] -} -``` - -Get your key at [OpenRouter Keys](https://openrouter.ai/keys). diff --git a/examples/pico-echo-server/README.md b/examples/pico-echo-server/README.md new file mode 100644 index 000000000..f6b5d8020 --- /dev/null +++ b/examples/pico-echo-server/README.md @@ -0,0 +1,47 @@ +# pico-echo-server + +Minimal Pico Protocol WebSocket server for testing the `pico_client` channel. + +## Usage + +```bash +go run ./examples/pico-echo-server -addr :9090 -token secret +``` + +### Flags + +| Flag | Default | Description | +|----------|---------|------------------------------------| +| `-addr` | `:9090` | Listen address | +| `-token` | (none) | Auth token; empty disables auth | + +## How it works + +- Listens for WebSocket connections at `/ws` +- Authenticates via `Authorization: Bearer ` header or `?token=` query param +- Prints received `message.send` content to stdout +- Responds to `ping` with `pong` +- Lines typed into stdin are broadcast as `message.create` to all connected clients + +## Testing with pico_client + +1. Start the server: + ```bash + go run ./examples/pico-echo-server -token mytoken + ``` + +2. Configure `pico_client` in your `config.json`: + ```json + { + "channels": { + "pico_client": { + "enabled": true, + "url": "ws://localhost:9090/ws", + "token": "mytoken", + "session_id": "test-session" + } + } + } + ``` + +3. Start picoclaw — the client connects and you can exchange messages interactively via stdin/stdout. diff --git a/examples/pico-echo-server/main.go b/examples/pico-echo-server/main.go new file mode 100644 index 000000000..46970fb34 --- /dev/null +++ b/examples/pico-echo-server/main.go @@ -0,0 +1,160 @@ +// pico-echo-server is a minimal Pico Protocol WebSocket server for testing +// the pico_client channel. It accepts connections, prints received messages +// to stdout, and forwards stdin lines as message.create to all connected clients. +// +// Usage: +// +// go run ./examples/pico-echo-server -addr :9090 -token secret +// +// Then configure pico_client with url=ws://localhost:9090/ws&token=secret. +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +type picoMessage struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + SessionID string `json:"session_id,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + Payload map[string]any `json:"payload,omitempty"` +} + +var upgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + +type server struct { + token string + mu sync.Mutex + conns map[*websocket.Conn]string // conn → sessionID +} + +func (s *server) handleWS(w http.ResponseWriter, r *http.Request) { + if s.token != "" { + auth := r.Header.Get("Authorization") + if auth != "Bearer "+s.token { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("upgrade: %v", err) + return + } + + sessionID := r.URL.Query().Get("session_id") + if sessionID == "" { + sessionID = fmt.Sprintf("sess-%d", time.Now().UnixMilli()) + } + + s.mu.Lock() + s.conns[conn] = sessionID + s.mu.Unlock() + + log.Printf("[+] client connected (session=%s)", sessionID) + + defer func() { + s.mu.Lock() + delete(s.conns, conn) + s.mu.Unlock() + conn.Close() + log.Printf("[-] client disconnected (session=%s)", sessionID) + }() + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + log.Printf("read error: %v", err) + } + return + } + + var msg picoMessage + if err := json.Unmarshal(raw, &msg); err != nil { + log.Printf("bad json: %v", err) + continue + } + + switch msg.Type { + case "ping": + pong := picoMessage{Type: "pong", ID: msg.ID, Timestamp: time.Now().UnixMilli()} + conn.WriteJSON(pong) + + case "message.send": + content, _ := msg.Payload["content"].(string) + fmt.Printf("[%s] %s\n", sessionID, content) + + case "typing.start": + log.Printf("[%s] typing...", sessionID) + + case "typing.stop": + log.Printf("[%s] stopped typing", sessionID) + + default: + log.Printf("[%s] unknown type: %s", sessionID, msg.Type) + } + } +} + +func (s *server) broadcast(content string) { + msg := picoMessage{ + Type: "message.create", + Timestamp: time.Now().UnixMilli(), + Payload: map[string]any{"content": content}, + } + + s.mu.Lock() + defer s.mu.Unlock() + + for conn, sid := range s.conns { + msg.SessionID = sid + if err := conn.WriteJSON(msg); err != nil { + log.Printf("write to %s failed: %v", sid, err) + } + } +} + +func main() { + addr := flag.String("addr", ":9090", "listen address") + token := flag.String("token", "", "auth token (empty = no auth)") + flag.Parse() + + s := &server{ + token: *token, + conns: make(map[*websocket.Conn]string), + } + + http.HandleFunc("/ws", s.handleWS) + + log.Printf("listening on %s", *addr) + log.Printf("connect with: ws://localhost%s/ws", *addr) + fmt.Println("Type messages to send to connected clients (Ctrl+C to quit):") + + go func() { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + s.broadcast(line) + log.Printf("[server] sent: %s", line) + } + }() + + log.Fatal(http.ListenAndServe(*addr, nil)) +} diff --git a/go.mod b/go.mod index f60be046f..f52e328cf 100644 --- a/go.mod +++ b/go.mod @@ -1,69 +1,112 @@ module github.com/sipeed/picoclaw -go 1.25.7 +go 1.25.9 require ( + fyne.io/systray v1.12.0 + github.com/SevereCloud/vksdk/v3 v3.3.1 github.com/adhocore/gronx v1.19.6 - github.com/anthropics/anthropic-sdk-go v1.22.1 + github.com/anthropics/anthropic-sdk-go v1.26.0 + github.com/atc0005/go-teams-notify/v2 v2.14.0 + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6 github.com/bwmarrin/discordgo v0.29.0 - github.com/caarlos0/env/v11 v11.3.1 - github.com/chzyer/readline v1.5.1 - github.com/ergochat/irc-go v0.5.0 - github.com/gdamore/tcell/v2 v2.13.8 + github.com/caarlos0/env/v11 v11.4.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/creack/pty v1.1.24 + github.com/ergochat/irc-go v0.6.0 + github.com/ergochat/readline v0.1.3 + github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/h2non/filetype v1.1.3 - github.com/larksuite/oapi-sdk-go/v3 v3.5.3 + github.com/larksuite/oapi-sdk-go/v3 v3.6.1 github.com/mdp/qrterminal/v3 v3.2.1 - github.com/modelcontextprotocol/go-sdk v1.3.1 - github.com/mymmrac/telego v1.6.0 + github.com/minio/selfupdate v0.6.0 + github.com/modelcontextprotocol/go-sdk v1.5.0 + github.com/muesli/termenv v0.16.0 + github.com/mymmrac/telego v1.8.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/openai/openai-go/v3 v3.22.0 - github.com/rivo/tview v0.42.0 + github.com/pion/rtp v1.10.1 + github.com/pion/webrtc/v3 v3.3.6 + github.com/rs/zerolog v1.35.1 github.com/slack-go/slack v0.17.3 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 + go.mau.fi/util v0.9.8 go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 - golang.org/x/oauth2 v0.35.0 - golang.org/x/time v0.14.0 + golang.org/x/oauth2 v0.36.0 + golang.org/x/term v0.42.0 + golang.org/x/time v0.15.0 google.golang.org/protobuf v1.36.11 - maunium.net/go/mautrix v0.26.3 - modernc.org/sqlite v1.46.1 + gopkg.in/yaml.v3 v3.0.1 + maunium.net/go/mautrix v0.27.0 + modernc.org/sqlite v1.48.2 + rsc.io/qr v0.2.0 ) require ( - filippo.io/edwards25519 v1.1.1 // indirect + aead.dev/minisign v0.2.0 // indirect + filippo.io/edwards25519 v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect + github.com/aws/smithy-go v1.25.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beeper/argo-go v1.1.2 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect - github.com/gdamore/encoding v1.0.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-sqlite3 v1.14.42 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect + github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect + github.com/pion/randutil v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rs/zerolog v1.34.0 // indirect github.com/segmentio/asm v1.1.3 // indirect - github.com/segmentio/encoding v0.5.3 // indirect - github.com/spf13/pflag v1.0.10 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.mau.fi/libsignal v0.2.1 // indirect - go.mau.fi/util v0.9.6 // indirect - golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect - golang.org/x/term v0.40.0 // indirect - golang.org/x/text v0.34.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.67.6 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/text v0.36.0 // indirect + modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - rsc.io/qr v0.2.0 // indirect ) require ( @@ -72,10 +115,10 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/github/copilot-sdk/go v0.1.23 + github.com/github/copilot-sdk/go v0.2.0 github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/jsonschema-go v0.4.2 github.com/grbit/go-json v0.11.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -86,11 +129,13 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect - github.com/valyala/fastjson v1.6.7 // indirect + github.com/valyala/fastjson v1.6.10 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/arch v0.24.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/crypto v0.50.0 + golang.org/x/net v0.53.0 + golang.org/x/sync v0.20.0 + golang.org/x/sys v0.43.0 ) + +replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532 diff --git a/go.sum b/go.sum index 4060997f8..d43e48f5b 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,14 @@ +aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk= +aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= -filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= -filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= +fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg= +github.com/SevereCloud/vksdk/v3 v3.3.1/go.mod h1:c6WaA5aocUYsXfkcUbg2qy45V9M1VDcqHHmHIN14NAw= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= @@ -11,53 +17,99 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0= -github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= +github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= +github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo= +github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6 h1:Wbo1WlWyGaAXlr6C7OGXq9avbdJhIV9cQ4M6E34b5x8= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6/go.mod h1:uY1fJe6m3I3w/m8UAkQ89Cm/ZAt/um6LW+AOZU33LDI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= -github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= -github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= -github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc= +github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= -github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= -github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= -github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= -github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= -github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= -github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw= -github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= +github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28= +github.com/ergochat/irc-go v0.6.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= +github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo= +github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= -github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= -github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= -github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= -github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= +github.com/github/copilot-sdk/go v0.2.0 h1:RnrIIirmtp4wGgqSQFJ2k9phbeveIxOtYZqDogoNEa0= +github.com/github/copilot-sdk/go v0.2.0/go.mod h1:uGWkjVYcp2DV9DgtqYihh5tEoJjNqxIFaUNnrwY4FxM= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= @@ -65,11 +117,12 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -79,6 +132,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 h1:p7t34F7K4OCRQblcDhNJnP46Uaarz3z2cLcvOZYxWn8= +github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -115,31 +170,35 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= -github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/larksuite/oapi-sdk-go/v3 v3.6.1 h1:vAdu+sX9yXNkKnKnYQeIv6yBkjP37Q1JEJHmMa2eCjQ= +github.com/larksuite/oapi-sdk-go/v3 v3.6.1/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= -github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= -github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= -github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= -github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= -github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= +github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= +github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= +github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= +github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow= +github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -154,29 +213,33 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys= github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= -github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= -github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA= +github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE= +github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= -github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= -github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= -github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= @@ -218,12 +281,20 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= -github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= -github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532 h1:gxFHYeUDGziRb0zXYEqBFohC+NJbIW9L0tddaXMWr2o= +github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532/go.mod h1:A0FcMFJKJ9fRjgSuZ2o+pIQ6mPS81SVuiLN2vYTa7Ao= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -231,10 +302,18 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= -go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts= -go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI= +go.mau.fi/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8= +go.mau.fi/util v0.9.8/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -243,19 +322,20 @@ golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= -golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -265,56 +345,55 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -322,10 +401,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -333,8 +412,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -359,26 +438,27 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk= -maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38= +maunium.net/go/mautrix v0.27.0 h1:yfEYwoIluVWkofUgbZl9gP4i5nQTF+QNsxtb+r5bKlM= +maunium.net/go/mautrix v0.27.0/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= -modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= -modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= -modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -387,8 +467,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= -modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c= +modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/pkg/agent/adapters/channelmanager.go b/pkg/agent/adapters/channelmanager.go new file mode 100644 index 000000000..ad0840e86 --- /dev/null +++ b/pkg/agent/adapters/channelmanager.go @@ -0,0 +1,51 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package adapters + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" +) + +// channelManagerAdapter wraps *channels.Manager to implement interfaces.ChannelManager. +type channelManagerAdapter struct { + inner *channels.Manager +} + +// NewChannelManager creates an adapter for *channels.Manager. +func NewChannelManager(inner *channels.Manager) interfaces.ChannelManager { + return &channelManagerAdapter{inner: inner} +} + +func (a *channelManagerAdapter) GetChannel(name string) (channels.Channel, bool) { + return a.inner.GetChannel(name) +} + +func (a *channelManagerAdapter) GetEnabledChannels() []string { + return a.inner.GetEnabledChannels() +} + +func (a *channelManagerAdapter) InvokeTypingStop(channel, chatID string) { + a.inner.InvokeTypingStop(channel, chatID) +} + +func (a *channelManagerAdapter) SendMessage(ctx context.Context, msg bus.OutboundMessage) error { + return a.inner.SendMessage(ctx, msg) +} + +func (a *channelManagerAdapter) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + return a.inner.SendMedia(ctx, msg) +} + +func (a *channelManagerAdapter) SendPlaceholder(ctx context.Context, channel, chatID string) bool { + return a.inner.SendPlaceholder(ctx, channel, chatID) +} + +func (a *channelManagerAdapter) DismissToolFeedback( + ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext, +) { + a.inner.DismissToolFeedback(ctx, channel, chatID, outboundCtx) +} diff --git a/pkg/agent/adapters/messagebus.go b/pkg/agent/adapters/messagebus.go new file mode 100644 index 000000000..ccae7e8bc --- /dev/null +++ b/pkg/agent/adapters/messagebus.go @@ -0,0 +1,36 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package adapters + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/bus" +) + +// messageBusAdapter wraps *bus.MessageBus to implement interfaces.MessageBus. +type messageBusAdapter struct { + inner *bus.MessageBus +} + +// NewMessageBus creates an adapter for *bus.MessageBus. +func NewMessageBus(inner *bus.MessageBus) interfaces.MessageBus { + return &messageBusAdapter{inner: inner} +} + +func (a *messageBusAdapter) PublishInbound(ctx context.Context, msg bus.InboundMessage) error { + return a.inner.PublishInbound(ctx, msg) +} + +func (a *messageBusAdapter) PublishOutbound(ctx context.Context, msg bus.OutboundMessage) error { + return a.inner.PublishOutbound(ctx, msg) +} + +func (a *messageBusAdapter) PublishOutboundMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + return a.inner.PublishOutboundMedia(ctx, msg) +} + +func (a *messageBusAdapter) InboundChan() <-chan bus.InboundMessage { + return a.inner.InboundChan() +} diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go new file mode 100644 index 000000000..97ee4fe7d --- /dev/null +++ b/pkg/agent/agent.go @@ -0,0 +1,638 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package agent + +import ( + "context" + "fmt" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/audio/asr" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/constants" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/state" + "github.com/sipeed/picoclaw/pkg/utils" +) + +type AgentLoop struct { + // Core dependencies + bus interfaces.MessageBus + cfg *config.Config + registry *AgentRegistry + state *state.Manager + + // Runtime event system + runtimeEvents runtimeevents.Bus + ownsRuntimeEvents bool + runtimeEventLogMu sync.RWMutex + runtimeEventLogger *runtimeEventLogger + runtimeEventLogSub runtimeevents.Subscription + hooks *HookManager + + // Runtime state + running atomic.Bool + contextManager ContextManager + fallback *providers.FallbackChain + channelManager interfaces.ChannelManager + mediaStore media.MediaStore + transcriber asr.Transcriber + cmdRegistry *commands.Registry + mcp mcpRuntime + hookRuntime hookRuntime + steering *steeringQueue + pendingSkills sync.Map + pendingStops sync.Map + mu sync.RWMutex + + // workerSem limits concurrent turn processing workers. + workerSem chan struct{} + + // activeTurnStates tracks active turns per session to prevent duplicates. + activeTurnStates sync.Map + subTurnCounter atomic.Int64 + + turnSeq atomic.Uint64 + activeRequests sync.WaitGroup + + reloadFunc func() error + + providerFactory func(*config.ModelConfig) (providers.LLMProvider, string, error) +} + +// processOptions configures how a message is processed +type processOptions struct { + Dispatch DispatchRequest // Normalized routed request boundary for this turn + SessionKey string // Session identifier for history/context + SessionAliases []string // Compatibility aliases for the session key + Channel string // Target channel for tool execution + ChatID string // Target chat ID for tool execution + MessageID string // Current inbound platform message ID + ReplyToMessageID string // Current inbound reply target message ID + SenderID string // Current sender ID for dynamic context + SenderDisplayName string // Current sender display name for dynamic context + UserMessage string // User message content (may include prefix) + ForcedSkills []string // Skills explicitly requested for this message + SystemPromptOverride string // Override the default system prompt (Used by SubTurns) + Media []string // media:// refs from inbound message + InitialSteeringMessages []providers.Message // Steering messages from refactor/agent + DefaultResponse string // Response when LLM returns empty + EnableSummary bool // Whether to trigger summarization + SendResponse bool // Whether to send response via bus + AllowInterimPicoPublish bool // Whether pico tool-call interim text can be published when SendResponse is false + SuppressToolFeedback bool // Whether to suppress inline tool feedback messages + NoHistory bool // If true, don't load session history (for heartbeat) + SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue) + InboundContext *bus.InboundContext // Normalized inbound facts for events/hooks + RouteResult *routing.ResolvedRoute // Route decision snapshot for events/hooks + SessionScope *session.SessionScope // Session scope snapshot for events/hooks +} + +type continuationTarget struct { + SessionKey string + Channel string + ChatID string +} + +const ( + defaultResponse = "The model returned an empty response. This may indicate a provider error or token limit." + toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps." + handledToolResponseSummary = "Requested output delivered via tool attachment." + sessionKeyAgentPrefix = "agent:" + pendingTurnPrefix = "pending-" + metadataKeyMessageKind = "message_kind" + metadataKeyToolCalls = "tool_calls" + messageKindThought = "thought" + messageKindToolFeedback = "tool_feedback" + messageKindToolCalls = "tool_calls" + metadataKeyAccountID = "account_id" + metadataKeyGuildID = "guild_id" + metadataKeyTeamID = "team_id" + metadataKeyReplyToMessage = "reply_to_message_id" + metadataKeyParentPeerKind = "parent_peer_kind" + metadataKeyParentPeerID = "parent_peer_id" +) + +// registerSharedTools registers tools that are shared across all agents (web, message, spawn). + +func (al *AgentLoop) Run(ctx context.Context) error { + al.running.Store(true) + + if err := al.ensureHooksInitialized(ctx); err != nil { + return err + } + if err := al.ensureMCPInitialized(ctx); err != nil { + return err + } + + idleTicker := time.NewTicker(100 * time.Millisecond) + defer idleTicker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case <-idleTicker.C: + if !al.running.Load() { + return nil + } + case msg, ok := <-al.bus.InboundChan(): + if !ok { + return nil + } + + // Resolve the session key for this message + sessionKey, agentID, ok := al.resolveSteeringTarget(msg) + if !ok { + // Non-routable message (e.g., system) — process immediately. + // Note: system messages are processed in the main goroutine, + // so they block the receive loop but guarantee session serialization. + al.processMessageSync(ctx, msg) + continue + } + + // Atomically claim the session key with a unique placeholder sentinel + // to prevent a TOCTOU race where multiple messages for the same session + // pass the Load check before either registers. + // The placeholder ensures GetActiveTurnBySession() never returns nil + // during turn setup. Each placeholder has a unique turnID to prevent + // cross-worker cleanup issues. + placeholder := &turnState{ + turnID: makePendingTurnID(sessionKey, al.turnSeq.Add(1)), + phase: TurnPhaseSetup, + } + if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded { + if al.tryHandleStopCommand(ctx, msg, sessionKey) { + continue + } + + // Another turn is already active (or reserved) for this session — enqueue + if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{ + Role: "user", + Content: msg.Content, + Media: append([]string(nil), msg.Media...), + }); err != nil { + logger.WarnCF("agent", "Failed to enqueue steering message", + map[string]any{ + "error": err.Error(), + "channel": msg.Channel, + "chat_id": msg.ChatID, + "session_key": sessionKey, + }) + } + continue + } + + // Session claimed — spawn a worker goroutine that acquires a semaphore + // slot. The goroutine is spawned immediately so the main loop keeps + // draining the inbound channel. The goroutine blocks on the semaphore. + go func(m bus.InboundMessage) { + // Acquire semaphore slot (blocks if at capacity) + select { + case al.workerSem <- struct{}{}: + // Got slot, start worker + case <-ctx.Done(): + // Context canceled while waiting for a slot — clean up the + // placeholder to prevent session-level deadlock. + al.activeTurnStates.Delete(sessionKey) + return + } + + // Safety-net cleanup: if the placeholder was never replaced by a real + // turnState (e.g., error before runTurn), delete it here. When runTurn + // completes normally, clearActiveTurn deletes the real turnState and + // this becomes a no-op (the key is already gone). + defer func() { + if actual, ok := al.activeTurnStates.Load(sessionKey); ok { + if ts, ok := actual.(*turnState); ok && strings.HasPrefix(ts.turnID, pendingTurnPrefix) { + // Placeholder still present — runTurn never replaced it. + al.activeTurnStates.Delete(sessionKey) + } + } + }() + + defer func() { + if r := recover(); r != nil { + logger.RecoverPanicNoExit(r) + logger.ErrorCF("agent", "Worker goroutine panicked", + map[string]any{ + "session_key": sessionKey, + "channel": m.Channel, + "chat_id": m.ChatID, + "panic": fmt.Sprintf("%v", r), + }) + } + }() + defer func() { <-al.workerSem }() // Release slot + + if al.channelManager != nil { + defer al.channelManager.InvokeTypingStop(m.Channel, m.ChatID) + } + + if al.takePendingStop(sessionKey) { + al.activeTurnStates.Delete(sessionKey) + target := &continuationTarget{ + SessionKey: sessionKey, + Channel: m.Channel, + ChatID: m.ChatID, + } + continued, continueErr := al.drainQueuedSteeringContinuations(ctx, target) + if continueErr != nil { + al.maybePublishError(ctx, m.Channel, m.ChatID, sessionKey, continueErr) + return + } + if continued != "" { + al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, continued) + } + return + } + + al.runTurnWithSteering(ctx, m) + }(msg) + + // TODO: Re-enable media cleanup after inbound media is properly consumed by the agent. + // Currently disabled because files are deleted before the LLM can access their content. + // defer func() { + // if al.mediaStore != nil && msg.MediaScope != "" { + // if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil { + // logger.WarnCF("agent", "Failed to release media", map[string]any{ + // "scope": msg.MediaScope, + // "error": releaseErr.Error(), + // }) + // } + // } + // }() + } + } +} + +// processMessageSync processes a message synchronously (for non-routable/system messages). + +// runTurnWithSteering runs a complete turn for a message and drains its steering queue. + +// maybePublishError publishes an error response unless the error is context.Canceled. +// Returns true if processing should continue (non-cancellation error or no error), +// false if context was canceled and the caller should return. + +// publishResponseOrError publishes the response, or an error message if processing failed. + +func (al *AgentLoop) Stop() { + al.running.Store(false) +} + +// Close releases resources held by agent session stores. Call after Stop. +func (al *AgentLoop) Close() { + mcpManager := al.mcp.takeManager() + + if mcpManager != nil { + if err := mcpManager.Close(); err != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": err.Error(), + }) + } + } + + al.GetRegistry().Close() + if al.hooks != nil { + al.hooks.Close() + } + al.closeRuntimeEventLogger() + if al.runtimeEvents != nil && al.ownsRuntimeEvents { + if err := al.runtimeEvents.Close(); err != nil { + logger.ErrorCF("agent", "Failed to close runtime event bus", + map[string]any{ + "error": err.Error(), + }) + } + } +} + +// MountHook registers an in-process hook on the agent loop. + +// UnmountHook removes a previously registered in-process hook. + +type turnEventScope struct { + agentID string + sessionKey string + turnID string + context *TurnContext +} + +// ReloadProviderAndConfig atomically swaps the provider and config with proper synchronization. +// It uses a context to allow timeout control from the caller. +// Returns an error if the reload fails or context is canceled. +func (al *AgentLoop) ReloadProviderAndConfig( + ctx context.Context, + provider providers.LLMProvider, + cfg *config.Config, +) error { + // Validate inputs + if provider == nil { + return fmt.Errorf("provider cannot be nil") + } + if cfg == nil { + return fmt.Errorf("config cannot be nil") + } + + // Create new registry with updated config and provider + // Wrap in defer/recover to handle any panics gracefully + var registry *AgentRegistry + var panicErr error + done := make(chan struct{}, 1) + + go func() { + defer func() { + if r := recover(); r != nil { + logger.RecoverPanicNoExit(r) + panicErr = fmt.Errorf("panic during registry creation: %v", r) + logger.ErrorCF("agent", "Panic during registry creation", + map[string]any{"panic": r}) + } + close(done) + }() + + registry = NewAgentRegistry(cfg, provider) + }() + + // Wait for completion or context cancellation + select { + case <-done: + if registry == nil { + if panicErr != nil { + return fmt.Errorf("registry creation failed: %w", panicErr) + } + return fmt.Errorf("registry creation failed (nil result)") + } + case <-ctx.Done(): + return fmt.Errorf("context canceled during registry creation: %w", ctx.Err()) + } + + // Check context again before proceeding + if err := ctx.Err(); err != nil { + return fmt.Errorf("context canceled after registry creation: %w", err) + } + + // Ensure shared tools are re-registered on the new registry + registerSharedTools(al, cfg, al.bus, registry, provider) + + // Atomically swap the config and registry under write lock + // This ensures readers see a consistent pair + al.mu.Lock() + oldRegistry := al.registry + + // Store new values + al.cfg = cfg + al.registry = registry + + // Also update fallback chain with new config; rebuild rate limiter registry. + newRL := providers.NewRateLimiterRegistry() + for _, agentID := range registry.ListAgentIDs() { + if agent, ok := registry.GetAgent(agentID); ok { + newRL.RegisterCandidates(agent.Candidates) + newRL.RegisterCandidates(agent.LightCandidates) + } + } + al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(), newRL) + + al.mu.Unlock() + al.refreshRuntimeEventLogger(cfg) + + oldMCPManager := al.mcp.reset() + al.hookRuntime.reset(al) + configureHookManagerFromConfig(al.hooks, cfg) + if err := al.ensureHooksInitialized(ctx); err != nil { + logger.WarnCF("agent", "Configured hooks failed to reinitialize after reload", + map[string]any{"error": err.Error()}) + } + if oldMCPManager != nil { + if err := oldMCPManager.Close(); err != nil { + logger.WarnCF("agent", "Failed to close previous MCP manager during reload", + map[string]any{"error": err.Error()}) + } + } + if err := al.ensureMCPInitialized(ctx); err != nil { + logger.WarnCF("agent", "MCP failed to reinitialize after reload", + map[string]any{"error": err.Error()}) + } + + // Close old provider after releasing the lock + // This prevents blocking readers while closing + if oldProvider, ok := extractProvider(oldRegistry); ok { + if stateful, ok := oldProvider.(providers.StatefulProvider); ok { + // Give in-flight requests a moment to complete + // Use a reasonable timeout that balances cleanup vs resource usage + select { + case <-time.After(100 * time.Millisecond): + stateful.Close() + case <-ctx.Done(): + // Context canceled, close immediately but log warning + logger.WarnCF("agent", "Context canceled during provider cleanup, forcing close", + map[string]any{"error": ctx.Err()}) + stateful.Close() + } + } + } + + logger.InfoCF("agent", "Provider and config reloaded successfully", + map[string]any{ + "model": cfg.Agents.Defaults.GetModelName(), + }) + + return nil +} + +// GetRegistry returns the current registry (thread-safe) + +// GetConfig returns the current config (thread-safe) + +// SetMediaStore injects a MediaStore for media lifecycle management. + +// SetTranscriber injects a voice transcriber for agent-level audio transcription. + +// SetReloadFunc sets the callback function for triggering config reload. + +var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) + +// transcribeAudioInMessage resolves audio media refs, transcribes them, and +// replaces audio annotations in msg.Content with the transcribed text. +// Returns the (possibly modified) message and true if audio was transcribed. + +// sendTranscriptionFeedback sends feedback to the user with the result of +// audio transcription if the option is enabled. It uses Manager.SendMessage +// which executes synchronously (rate limiting, splitting, retry) so that +// ordering with the subsequent placeholder is guaranteed. + +// inferMediaType determines the media type ("image", "audio", "video", "file") +// from a filename and MIME content type. + +// RecordLastChannel records the last active channel for this workspace. +// This uses the atomic state save mechanism to prevent data loss on crash. + +// RecordLastChatID records the last active chat ID for this workspace. +// This uses the atomic state save mechanism to prevent data loss on crash. + +// ProcessHeartbeat processes a heartbeat request without session history. +// Each heartbeat is independent and doesn't accumulate context. + +// runAgentLoop remains the top-level shell that starts a turn and publishes +// any post-turn work. runTurn owns the full turn lifecycle. +func (al *AgentLoop) runAgentLoop( + ctx context.Context, + agent *AgentInstance, + opts processOptions, +) (string, error) { + opts = normalizeProcessOptions(opts) + + // Record last channel for heartbeat notifications (skip internal channels and cli) + if opts.Dispatch.Channel() != "" && + opts.Dispatch.ChatID() != "" && + !constants.IsInternalChannel(opts.Dispatch.Channel()) { + channelKey := fmt.Sprintf("%s:%s", opts.Dispatch.Channel(), opts.Dispatch.ChatID()) + if err := al.RecordLastChannel(channelKey); err != nil { + logger.WarnCF( + "agent", + "Failed to record last channel", + map[string]any{"error": err.Error()}, + ) + } + } + + ensureSessionMetadata( + agent.Sessions, + opts.Dispatch.SessionKey, + opts.Dispatch.SessionScope, + opts.Dispatch.SessionAliases, + ) + + turnScope := al.newTurnEventScope( + agent.ID, + opts.Dispatch.SessionKey, + newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope), + ) + ts := newTurnState(agent, opts, turnScope) + pipeline := NewPipeline(al) + result, err := al.runTurn(ctx, ts, pipeline) + if err != nil { + return "", err + } + if result.status == TurnEndStatusAborted { + return "", nil + } + + for _, followUp := range result.followUps { + if pubErr := al.bus.PublishInbound(ctx, followUp); pubErr != nil { + logger.WarnCF("agent", "Failed to publish follow-up after turn", + map[string]any{ + "turn_id": ts.turnID, + "error": pubErr.Error(), + }) + } + } + + if opts.SendResponse && result.finalContent != "" { + agentID, sessionKey, scope := outboundTurnMetadata( + agent.ID, + opts.Dispatch.SessionKey, + opts.Dispatch.SessionScope, + ) + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Context: outboundContextFromInbound( + opts.Dispatch.InboundContext, + opts.Dispatch.Channel(), + opts.Dispatch.ChatID(), + opts.Dispatch.ReplyToMessageID(), + ), + AgentID: agentID, + SessionKey: sessionKey, + Scope: scope, + Content: result.finalContent, + ContextUsage: computeContextUsage(agent, opts.Dispatch.SessionKey), + }) + } + + if result.finalContent != "" { + responsePreview := utils.Truncate(result.finalContent, 120) + logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), + map[string]any{ + "agent_id": agent.ID, + "session_key": opts.Dispatch.SessionKey, + "iterations": ts.currentIteration(), + "final_length": len(result.finalContent), + }) + } + + return result.finalContent, nil +} + +// selectCandidates returns the model candidates and resolved model name to use +// for a conversation turn. When model routing is configured and the incoming +// message scores below the complexity threshold, it returns the light model +// candidates instead of the primary ones. +// +// The returned (candidates, model) pair is used for all LLM calls within one +// turn — tool follow-up iterations use the same tier as the initial call so +// that a multi-step tool chain doesn't switch models mid-way. + +// resolveContextManager selects the ContextManager implementation based on config. + +// GetStartupInfo returns information about loaded tools and skills for logging. + +// formatMessagesForLog formats messages for logging + +// formatToolsForLog formats tool definitions for logging + +// summarizeSession summarizes the conversation history for a session. +// findNearestUserMessage finds the nearest user message to the given index. +// It searches backward first, then forward if no user message is found. +// retryLLMCall calls the LLM with retry logic. +// summarizeBatch summarizes a batch of messages. +// estimateTokens estimates the number of tokens in a message list. +// Counts Content, ToolCalls arguments, and ToolCallID metadata so that +// tool-heavy conversations are not systematically undercounted. + +// askSideQuestion handles /btw commands by creating an isolated provider instance +// that doesn't share state with the main conversation provider. + +// shallowCloneLLMOptions creates a shallow copy of LLM options map. +// Note: This is a shallow copy - nested maps/slices are shared. + +// hasMediaRefs checks if any message has media references. + +// isolatedSideQuestionProvider creates a separate provider instance for /btw commands +// to avoid sharing state with the main conversation provider. + +// sideQuestionModelConfig resolves the model config for side questions. + +// sideQuestionModelName determines which model name to use for side questions. + +// modelNameFromIdentityKey extracts the model name from an identity key. + +// closeProviderIfStateful closes a provider if it implements StatefulProvider. + +// makePendingTurnID generates a unique turn ID for placeholder turns. +// Format: "pending-{sessionKey}-{sequence}" + +// isNativeSearchProvider reports whether the given LLM provider implements +// NativeSearchCapable and returns true for SupportsNativeSearch. + +// filterClientWebSearch returns a copy of tools with the client-side +// web_search tool removed. Used when native provider search is preferred. + +// Helper to extract provider from registry for cleanup diff --git a/pkg/agent/agent_command.go b/pkg/agent/agent_command.go new file mode 100644 index 000000000..ae0293d71 --- /dev/null +++ b/pkg/agent/agent_command.go @@ -0,0 +1,498 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func (al *AgentLoop) handleCommand( + ctx context.Context, + msg bus.InboundMessage, + agent *AgentInstance, + opts *processOptions, +) (string, bool) { + normalizeProcessOptionsInPlace(opts) + + if !commands.HasCommandPrefix(msg.Content) { + return "", false + } + + if matched, handled, reply := al.applyExplicitSkillCommand(msg.Content, agent, opts); matched { + return reply, handled + } + + if al.cmdRegistry == nil { + return "", false + } + + rt := al.buildCommandsRuntime(ctx, agent, opts) + executor := commands.NewExecutor(al.cmdRegistry, rt) + + var commandReply string + result := executor.Execute(ctx, commands.Request{ + Channel: msg.Channel, + ChatID: msg.ChatID, + SenderID: msg.SenderID, + Text: msg.Content, + Reply: func(text string) error { + commandReply = text + return nil + }, + }) + + switch result.Outcome { + case commands.OutcomeHandled: + if result.Err != nil { + return mapCommandError(result), true + } + if commandReply != "" { + return commandReply, true + } + return "", true + default: // OutcomePassthrough — let the message fall through to LLM + return "", false + } +} + +func (al *AgentLoop) applyExplicitSkillCommand( + raw string, + agent *AgentInstance, + opts *processOptions, +) (matched bool, handled bool, reply string) { + normalizeProcessOptionsInPlace(opts) + + cmdName, ok := commands.CommandName(raw) + if !ok || cmdName != "use" { + return false, false, "" + } + + if agent == nil || agent.ContextBuilder == nil { + return true, true, commandsUnavailableSkillMessage() + } + + parts := strings.Fields(strings.TrimSpace(raw)) + if len(parts) < 2 { + return true, true, buildUseCommandHelp(agent) + } + + arg := strings.TrimSpace(parts[1]) + if strings.EqualFold(arg, "clear") || strings.EqualFold(arg, "off") { + if opts != nil { + al.clearPendingSkills(opts.Dispatch.SessionKey) + } + return true, true, "Cleared pending skill override." + } + + skillName, ok := agent.ContextBuilder.ResolveSkillName(arg) + if !ok { + return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", arg) + } + + if len(parts) < 3 { + if opts == nil || strings.TrimSpace(opts.Dispatch.SessionKey) == "" { + return true, true, commandsUnavailableSkillMessage() + } + al.setPendingSkills(opts.Dispatch.SessionKey, []string{skillName}) + return true, true, fmt.Sprintf( + "Skill %q is armed for your next message. Send your next prompt normally, or use /use clear to cancel.", + skillName, + ) + } + + message := strings.TrimSpace(strings.Join(parts[2:], " ")) + if message == "" { + return true, true, buildUseCommandHelp(agent) + } + + if opts != nil { + opts.ForcedSkills = append(opts.ForcedSkills, skillName) + opts.Dispatch.UserMessage = message + opts.UserMessage = message + } + + return true, false, "" +} + +func (al *AgentLoop) buildCommandsRuntime( + ctx context.Context, + agent *AgentInstance, + opts *processOptions, +) *commands.Runtime { + normalizeProcessOptionsInPlace(opts) + + registry := al.GetRegistry() + cfg := al.GetConfig() + rt := &commands.Runtime{ + Config: cfg, + ListAgentIDs: registry.ListAgentIDs, + ListDefinitions: al.cmdRegistry.Definitions, + ListMCPServers: func(ctx context.Context) []commands.MCPServerInfo { + if cfg == nil { + return nil + } + + if len(cfg.Tools.MCP.Servers) == 0 { + return nil + } + + if err := al.ensureMCPInitialized(ctx); err != nil { + logger.WarnCF("agent", "Failed to refresh MCP status for command", + map[string]any{ + "error": err.Error(), + }) + } + + connected := make(map[string]int) + if manager := al.mcp.getManager(); manager != nil { + for serverName, conn := range manager.GetServers() { + connected[serverName] = len(conn.Tools) + } + } + + servers := make([]commands.MCPServerInfo, 0, len(cfg.Tools.MCP.Servers)) + for serverName, serverCfg := range cfg.Tools.MCP.Servers { + toolCount, isConnected := connected[serverName] + servers = append(servers, commands.MCPServerInfo{ + Name: serverName, + Enabled: serverCfg.Enabled, + Deferred: serverIsDeferred(cfg.Tools.MCP.Discovery.Enabled, serverCfg), + Connected: isConnected, + ToolCount: toolCount, + }) + } + + sort.Slice(servers, func(i, j int) bool { + return strings.ToLower(servers[i].Name) < strings.ToLower(servers[j].Name) + }) + + return servers + }, + ListMCPTools: func(ctx context.Context, serverName string) ([]commands.MCPToolInfo, error) { + if cfg == nil { + return nil, fmt.Errorf("command unavailable: config not loaded") + } + + serverName = strings.TrimSpace(serverName) + if serverName == "" { + return nil, fmt.Errorf("server name is required") + } + + resolvedName := "" + var serverCfg config.MCPServerConfig + for name, candidate := range cfg.Tools.MCP.Servers { + if strings.EqualFold(name, serverName) { + resolvedName = name + serverCfg = candidate + break + } + } + if resolvedName == "" { + return nil, fmt.Errorf("MCP server '%s' is not configured", serverName) + } + if !serverCfg.Enabled { + return nil, fmt.Errorf("MCP server '%s' is configured but disabled", resolvedName) + } + if !cfg.Tools.IsToolEnabled("mcp") { + return nil, fmt.Errorf("MCP integration is disabled") + } + + if err := al.ensureMCPInitialized(ctx); err != nil { + logger.WarnCF("agent", "Failed to initialize MCP runtime for command", + map[string]any{ + "server": resolvedName, + "error": err.Error(), + }) + } + + manager := al.mcp.getManager() + if manager == nil { + return nil, fmt.Errorf("MCP server '%s' is configured but not connected", resolvedName) + } + + conn, ok := manager.GetServer(resolvedName) + if !ok { + return nil, fmt.Errorf("MCP server '%s' is configured but not connected", resolvedName) + } + + toolInfos := make([]commands.MCPToolInfo, 0, len(conn.Tools)) + for _, tool := range conn.Tools { + if tool == nil { + continue + } + name := strings.TrimSpace(tool.Name) + if name == "" { + continue + } + + description := strings.TrimSpace(tool.Description) + if description == "" { + description = fmt.Sprintf("MCP tool from %s server", resolvedName) + } + + toolInfos = append(toolInfos, commands.MCPToolInfo{ + Name: name, + Description: description, + Parameters: summarizeMCPToolParameters(tool.InputSchema), + }) + } + sort.Slice(toolInfos, func(i, j int) bool { + return toolInfos[i].Name < toolInfos[j].Name + }) + return toolInfos, nil + }, + GetEnabledChannels: func() []string { + if al.channelManager == nil { + return nil + } + return al.channelManager.GetEnabledChannels() + }, + GetActiveTurn: func() any { + info := al.GetActiveTurn() + if info == nil { + return nil + } + return info + }, + SwitchChannel: func(value string) error { + if al.channelManager == nil { + return fmt.Errorf("channel manager not initialized") + } + if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { + return fmt.Errorf("channel '%s' not found or not enabled", value) + } + return nil + }, + } + rt.StopActiveTurn = func() (commands.StopResult, error) { + if opts == nil { + return commands.StopResult{}, fmt.Errorf("process options not available") + } + return al.stopActiveTurnForSession(opts.Dispatch.SessionKey) + } + if agent != nil && agent.ContextBuilder != nil { + rt.ListSkillNames = agent.ContextBuilder.ListSkillNames + } + rt.ReloadConfig = func() error { + if al.reloadFunc == nil { + return fmt.Errorf("reload not configured") + } + return al.reloadFunc() + } + if agent != nil { + if agent.ContextBuilder != nil { + rt.ListSkillNames = agent.ContextBuilder.ListSkillNames + } + rt.GetModelInfo = func() (string, string) { + return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider) + } + rt.SwitchModel = func(value string) (string, error) { + value = strings.TrimSpace(value) + modelCfg, err := resolvedModelConfig(cfg, value, agent.Workspace) + if err != nil { + return "", err + } + + nextProvider, _, err := providers.CreateProviderFromConfig(modelCfg) + if err != nil { + return "", fmt.Errorf("failed to initialize model %q: %w", value, err) + } + + nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, value, agent.Fallbacks) + if len(nextCandidates) == 0 { + return "", fmt.Errorf("model %q did not resolve to any provider candidates", value) + } + + oldModel := agent.Model + oldProvider := agent.Provider + agent.Model = value + agent.Provider = nextProvider + agent.Candidates = nextCandidates + agent.ThinkingLevel = parseThinkingLevel(modelCfg.ThinkingLevel) + + if oldProvider != nil && oldProvider != nextProvider { + if stateful, ok := oldProvider.(providers.StatefulProvider); ok { + stateful.Close() + } + } + return oldModel, nil + } + + rt.ClearHistory = func() error { + if opts == nil { + return fmt.Errorf("process options not available") + } + return al.contextManager.Clear(ctx, opts.SessionKey) + } + + rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) { + return al.askSideQuestion(ctx, agent, opts, question) + } + + rt.GetContextStats = func() *commands.ContextStats { + if opts == nil || agent.Sessions == nil { + return nil + } + usage := computeContextUsage(agent, opts.SessionKey) + if usage == nil { + return nil + } + history := agent.Sessions.GetHistory(opts.SessionKey) + return &commands.ContextStats{ + UsedTokens: usage.UsedTokens, + TotalTokens: usage.TotalTokens, + CompressAtTokens: usage.CompressAtTokens, + UsedPercent: usage.UsedPercent, + MessageCount: len(history), + } + } + } + return rt +} + +func summarizeMCPToolParameters(schema any) []commands.MCPToolParameterInfo { + schemaMap := normalizeMCPSchema(schema) + properties, ok := schemaMap["properties"].(map[string]any) + if !ok || len(properties) == 0 { + return nil + } + + required := make(map[string]struct{}) + switch raw := schemaMap["required"].(type) { + case []string: + for _, name := range raw { + required[name] = struct{}{} + } + case []any: + for _, value := range raw { + name, ok := value.(string) + if ok { + required[name] = struct{}{} + } + } + } + + names := make([]string, 0, len(properties)) + for name := range properties { + names = append(names, name) + } + sort.Strings(names) + + params := make([]commands.MCPToolParameterInfo, 0, len(names)) + for _, name := range names { + param := commands.MCPToolParameterInfo{Name: name} + if propMap, ok := properties[name].(map[string]any); ok { + if typeName, ok := propMap["type"].(string); ok { + param.Type = strings.TrimSpace(typeName) + } + if desc, ok := propMap["description"].(string); ok { + param.Description = strings.TrimSpace(desc) + } + } + _, param.Required = required[name] + params = append(params, param) + } + return params +} + +func normalizeMCPSchema(schema any) map[string]any { + if schema == nil { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + + if schemaMap, ok := schema.(map[string]any); ok { + return schemaMap + } + + var jsonData []byte + switch raw := schema.(type) { + case json.RawMessage: + jsonData = raw + case []byte: + jsonData = raw + } + + if jsonData == nil { + var err error + jsonData, err = json.Marshal(schema) + if err != nil { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + } + + var result map[string]any + if err := json.Unmarshal(jsonData, &result); err != nil { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + + return result +} + +func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" || len(skillNames) == 0 { + return + } + + filtered := make([]string, 0, len(skillNames)) + for _, name := range skillNames { + name = strings.TrimSpace(name) + if name != "" { + filtered = append(filtered, name) + } + } + if len(filtered) == 0 { + return + } + + al.pendingSkills.Store(sessionKey, filtered) +} + +func (al *AgentLoop) takePendingSkills(sessionKey string) []string { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return nil + } + + value, ok := al.pendingSkills.LoadAndDelete(sessionKey) + if !ok { + return nil + } + + skills, ok := value.([]string) + if !ok { + return nil + } + + return append([]string(nil), skills...) +} + +func (al *AgentLoop) clearPendingSkills(sessionKey string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return + } + al.pendingSkills.Delete(sessionKey) +} diff --git a/pkg/agent/agent_event.go b/pkg/agent/agent_event.go new file mode 100644 index 000000000..99ea2a18e --- /dev/null +++ b/pkg/agent/agent_event.go @@ -0,0 +1,91 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "fmt" + + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +func (al *AgentLoop) newTurnEventScope(agentID, sessionKey string, turnCtx *TurnContext) turnEventScope { + seq := al.turnSeq.Add(1) + return turnEventScope{ + agentID: agentID, + sessionKey: sessionKey, + turnID: fmt.Sprintf("%s-turn-%d", agentID, seq), + context: cloneTurnContext(turnCtx), + } +} + +func (ts turnEventScope) meta(iteration int, source, tracePath string) HookMeta { + return HookMeta{ + AgentID: ts.agentID, + TurnID: ts.turnID, + SessionKey: ts.sessionKey, + Iteration: iteration, + Source: source, + TracePath: tracePath, + turnContext: cloneTurnContext(ts.context), + } +} + +func (al *AgentLoop) emitEvent(kind runtimeevents.Kind, meta HookMeta, payload any) { + clonedMeta := cloneHookMeta(meta) + eventCtx := cloneTurnContext(clonedMeta.turnContext) + evt := runtimeevents.Event{ + Kind: kind, + Source: runtimeevents.Source{Component: "agent", Name: clonedMeta.AgentID}, + Scope: runtimeScopeFromHookMeta(clonedMeta, eventCtx), + Correlation: runtimeCorrelationFromHookMeta(clonedMeta), + Severity: runtimeSeverityForAgentEvent(kind, payload), + Payload: payload, + Attrs: runtimeAttrsFromHookMeta(clonedMeta), + } + + if al == nil { + return + } + + al.publishRuntimeEvent(evt) +} + +// MountHook registers an in-process hook on the agent loop. +func (al *AgentLoop) MountHook(reg HookRegistration) error { + if al == nil || al.hooks == nil { + return fmt.Errorf("hook manager is not initialized") + } + return al.hooks.Mount(reg) +} + +// UnmountHook removes a previously registered in-process hook. +func (al *AgentLoop) UnmountHook(name string) { + if al == nil || al.hooks == nil { + return + } + al.hooks.Unmount(name) +} + +// RuntimeEvents returns the root runtime event channel. +func (al *AgentLoop) RuntimeEvents() runtimeevents.EventChannel { + if al == nil || al.runtimeEvents == nil { + return nil + } + return al.runtimeEvents.Channel() +} + +// RuntimeEventStats returns runtime event bus counters. +func (al *AgentLoop) RuntimeEventStats() runtimeevents.Stats { + if al == nil || al.runtimeEvents == nil { + return runtimeevents.Stats{Closed: true} + } + return al.runtimeEvents.Stats() +} + +// RuntimeEventBus returns the runtime event bus used by the agent loop. +func (al *AgentLoop) RuntimeEventBus() runtimeevents.Bus { + if al == nil { + return nil + } + return al.runtimeEvents +} diff --git a/pkg/agent/agent_init.go b/pkg/agent/agent_init.go new file mode 100644 index 000000000..e95fbe7f8 --- /dev/null +++ b/pkg/agent/agent_init.go @@ -0,0 +1,356 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "fmt" + "time" + + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/state" + "github.com/sipeed/picoclaw/pkg/tools" +) + +func NewAgentLoop( + cfg *config.Config, + msgBus *bus.MessageBus, + provider providers.LLMProvider, + opts ...AgentLoopOption, +) *AgentLoop { + registry := NewAgentRegistry(cfg, provider) + + // Set up shared fallback chain with rate limiting. + cooldown := providers.NewCooldownTracker() + rl := providers.NewRateLimiterRegistry() + // Register rate limiters for all agents' candidates so that RPM limits + // configured in ModelConfig are enforced before each LLM call. + for _, agentID := range registry.ListAgentIDs() { + if agent, ok := registry.GetAgent(agentID); ok { + rl.RegisterCandidates(agent.Candidates) + rl.RegisterCandidates(agent.LightCandidates) + } + } + fallbackChain := providers.NewFallbackChain(cooldown, rl) + + // Create state manager using default agent's workspace for channel recording + defaultAgent := registry.GetDefaultAgent() + var stateManager *state.Manager + if defaultAgent != nil { + stateManager = state.NewManager(defaultAgent.Workspace) + } + + // Determine worker pool size from config (default: 1 = sequential) + workerPoolSize := cfg.Agents.Defaults.MaxParallelTurns + if workerPoolSize <= 0 { + workerPoolSize = 1 + } + + al := &AgentLoop{ + bus: msgBus, + cfg: cfg, + registry: registry, + state: stateManager, + fallback: fallbackChain, + cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), + steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)), + workerSem: make(chan struct{}, workerPoolSize), + ownsRuntimeEvents: true, + } + for _, opt := range opts { + if opt != nil { + opt(al) + } + } + if al.runtimeEvents == nil { + al.runtimeEvents = runtimeevents.NewBus() + al.ownsRuntimeEvents = true + } + al.refreshRuntimeEventLogger(cfg) + al.providerFactory = providers.CreateProviderFromConfig + al.hooks = NewHookManager(al.runtimeEvents.Channel()) + configureHookManagerFromConfig(al.hooks, cfg) + al.contextManager = al.resolveContextManager() + + // Register shared tools to all agents (now that al is created) + registerSharedTools(al, cfg, msgBus, registry, provider) + + return al +} + +func registerSharedTools( + al *AgentLoop, + cfg *config.Config, + msgBus interfaces.MessageBus, + registry *AgentRegistry, + provider providers.LLMProvider, +) { + allowReadPaths := buildAllowReadPatterns(cfg) + var ttsProvider tts.TTSProvider + if cfg.Tools.IsToolEnabled("send_tts") { + ttsProvider = tts.DetectTTS(cfg) + if ttsProvider == nil { + logger.WarnCF("voice-tts", "send_tts enabled but no TTS provider configured", nil) + } + } + + for _, agentID := range registry.ListAgentIDs() { + agent, ok := registry.GetAgent(agentID) + if !ok { + continue + } + + if cfg.Tools.IsToolEnabled("web") { + searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptionsFromConfig(cfg)) + if err != nil { + logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) + } else if searchTool != nil { + agent.Tools.Register(searchTool) + } + } + if cfg.Tools.IsToolEnabled("web_fetch") { + fetchTool, err := tools.NewWebFetchToolWithProxy( + 50000, + cfg.Tools.Web.Proxy, + cfg.Tools.Web.Format, + cfg.Tools.Web.FetchLimitBytes, + cfg.Tools.Web.PrivateHostWhitelist) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } else { + agent.Tools.Register(fetchTool) + } + } + + // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms + if cfg.Tools.IsToolEnabled("i2c") { + agent.Tools.Register(tools.NewI2CTool()) + } + if cfg.Tools.IsToolEnabled("spi") { + agent.Tools.Register(tools.NewSPITool()) + } + if cfg.Tools.IsToolEnabled("serial") { + agent.Tools.Register(tools.NewSerialTool()) + } + + // Message tool + if cfg.Tools.IsToolEnabled("message") { + messageTool := tools.NewMessageTool() + messageTool.SetSendCallback(func( + ctx context.Context, + channel, chatID, content, replyToMessageID string, + ) error { + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID) + outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata( + tools.ToolAgentID(ctx), + tools.ToolSessionKey(ctx), + tools.ToolSessionScope(ctx), + ) + return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Context: outboundCtx, + AgentID: outboundAgentID, + SessionKey: outboundSessionKey, + Scope: outboundScope, + Content: content, + ReplyToMessageID: replyToMessageID, + }) + }) + agent.Tools.Register(messageTool) + } + if cfg.Tools.IsToolEnabled("reaction") { + reactionTool := tools.NewReactionTool() + reactionTool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { + if al.channelManager == nil { + return fmt.Errorf("channel manager not configured") + } + ch, ok := al.channelManager.GetChannel(channel) + if !ok { + return fmt.Errorf("channel %s not found", channel) + } + rc, ok := ch.(channels.ReactionCapable) + if !ok { + return fmt.Errorf("channel %s does not support reactions", channel) + } + _, err := rc.ReactToMessage(ctx, chatID, messageID) + return err + }) + agent.Tools.Register(reactionTool) + } + + // Send file tool (outbound media via MediaStore — store injected later by SetMediaStore) + if cfg.Tools.IsToolEnabled("send_file") { + sendFileTool := tools.NewSendFileTool( + agent.Workspace, + cfg.Agents.Defaults.RestrictToWorkspace, + cfg.Agents.Defaults.GetMaxMediaSize(), + nil, + allowReadPaths, + ) + agent.Tools.Register(sendFileTool) + } + + if ttsProvider != nil { + agent.Tools.Register(tools.NewSendTTSTool(ttsProvider, nil)) + } + + if cfg.Tools.IsToolEnabled("load_image") { + loadImageTool := tools.NewLoadImageTool( + agent.Workspace, + cfg.Agents.Defaults.RestrictToWorkspace, + cfg.Agents.Defaults.GetMaxMediaSize(), + nil, + allowReadPaths, + ) + agent.Tools.Register(loadImageTool) + } + + // Skill discovery and installation tools + skills_enabled := cfg.Tools.IsToolEnabled("skills") + find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") + install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") + if skills_enabled && (find_skills_enable || install_skills_enable) { + registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills) + + if find_skills_enable { + searchCache := skills.NewSearchCache( + cfg.Tools.Skills.SearchCache.MaxSize, + time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, + ) + agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) + } + + if install_skills_enable { + agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) + } + } + + // Spawn and spawn_status tools share a SubagentManager. + // Construct it when either tool is enabled (both require subagent). + spawnEnabled := cfg.Tools.IsToolEnabled("spawn") + spawnStatusEnabled := cfg.Tools.IsToolEnabled("spawn_status") + if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") { + subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) + subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) + + // Inject a media resolver so the legacy RunToolLoop fallback path can + // resolve media:// refs in the same way the main AgentLoop does. + // This keeps subagent vision support working even when the optimized + // sub-turn spawner path is unavailable. + subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { + return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize()) + }) + + // Set the spawner that links into AgentLoop's turnState + subagentManager.SetSpawner(func( + ctx context.Context, + task, label, targetAgentID string, + tls *tools.ToolRegistry, + maxTokens int, + temperature float64, + hasMaxTokens, hasTemperature bool, + ) (*tools.ToolResult, error) { + // 1. Recover parent Turn State from Context + parentTS := turnStateFromContext(ctx) + if parentTS == nil { + // Fallback: If no turnState exists in context, create an isolated ad-hoc root turn state + // so that the tool can still function outside of an agent loop (e.g. tests, raw invocations). + parentTS = &turnState{ + ctx: ctx, + turnID: "adhoc-root", + depth: 0, + session: nil, // Ephemeral session not needed for adhoc spawn + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, 5), + } + } + + // 2. Build Tools slice from registry + var tlSlice []tools.Tool + for _, name := range tls.List() { + if t, ok := tls.Get(name); ok { + tlSlice = append(tlSlice, t) + } + } + + // 3. System Prompt + systemPrompt := "You are a subagent. Complete the given task independently and report the result.\n" + + "You have access to tools - use them as needed to complete your task.\n" + + "After completing the task, provide a clear summary of what was done.\n\n" + + "Task: " + task + + // 4. Resolve Model + modelToUse := agent.Model + if targetAgentID != "" { + if targetAgent, ok := al.GetRegistry().GetAgent(targetAgentID); ok { + modelToUse = targetAgent.Model + } + } + + // 5. Build SubTurnConfig + cfg := SubTurnConfig{ + Model: modelToUse, + Tools: tlSlice, + SystemPrompt: systemPrompt, + } + if hasMaxTokens { + cfg.MaxTokens = maxTokens + } + + // 6. Spawn SubTurn + return spawnSubTurn(ctx, al, parentTS, cfg) + }) + + // Clone the parent's tool registry so subagents can use all + // tools registered so far (file, web, etc.) but NOT spawn/ + // spawn_status which are added below — preventing recursive + // subagent spawning. + subagentManager.SetTools(agent.Tools.Clone()) + if spawnEnabled { + spawnTool := tools.NewSpawnTool(subagentManager) + spawnTool.SetSpawner(NewSubTurnSpawner(al)) + currentAgentID := agentID + spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { + return registry.CanSpawnSubagent(currentAgentID, targetAgentID) + }) + + agent.Tools.Register(spawnTool) + + // Also register the synchronous subagent tool + subagentTool := tools.NewSubagentTool(subagentManager) + subagentTool.SetSpawner(NewSubTurnSpawner(al)) + agent.Tools.Register(subagentTool) + } + if spawnStatusEnabled { + agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager)) + } + } else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") { + logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil) + } + + // Register delegate tool for multi-agent setups. + // Auto-enabled when multiple agents exist. Delegation uses the SubTurn + // mechanism directly (not SubagentManager) and is independent of the + // subagent tool. + if len(registry.ListAgentIDs()) > 1 { + delegateTool := tools.NewDelegateTool() + delegateTool.SetSpawner(NewSubTurnSpawner(al)) + currentAgentID := agentID + delegateTool.SetSelfAgentID(currentAgentID) + delegateTool.SetAllowlistChecker(func(targetAgentID string) bool { + return registry.CanSpawnSubagent(currentAgentID, targetAgentID) + }) + agent.Tools.Register(delegateTool) + } + } +} diff --git a/pkg/agent/agent_inject.go b/pkg/agent/agent_inject.go new file mode 100644 index 000000000..6c0ad10da --- /dev/null +++ b/pkg/agent/agent_inject.go @@ -0,0 +1,103 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "github.com/sipeed/picoclaw/pkg/audio/asr" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/tools" +) + +func (al *AgentLoop) RegisterTool(tool tools.Tool) { + registry := al.GetRegistry() + for _, agentID := range registry.ListAgentIDs() { + if agent, ok := registry.GetAgent(agentID); ok { + agent.Tools.Register(tool) + } + } +} + +func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { + al.channelManager = cm +} + +func (al *AgentLoop) GetRegistry() *AgentRegistry { + al.mu.RLock() + defer al.mu.RUnlock() + return al.registry +} + +func (al *AgentLoop) GetConfig() *config.Config { + al.mu.RLock() + defer al.mu.RUnlock() + return al.cfg +} + +func (al *AgentLoop) SetMediaStore(s media.MediaStore) { + al.mediaStore = s + + // Propagate store to all registered tools that can emit media. + registry := al.GetRegistry() + for _, agentID := range registry.ListAgentIDs() { + if agent, ok := registry.GetAgent(agentID); ok { + agent.Tools.SetMediaStore(s) + } + } + registry.ForEachTool("send_tts", func(t tools.Tool) { + if st, ok := t.(*tools.SendTTSTool); ok { + st.SetMediaStore(s) + } + }) +} + +func (al *AgentLoop) SetTranscriber(t asr.Transcriber) { + al.transcriber = t +} + +func (al *AgentLoop) SetReloadFunc(fn func() error) { + al.reloadFunc = fn +} + +func (al *AgentLoop) RecordLastChannel(channel string) error { + if al.state == nil { + return nil + } + return al.state.SetLastChannel(channel) +} + +func (al *AgentLoop) RecordLastChatID(chatID string) error { + if al.state == nil { + return nil + } + return al.state.SetLastChatID(chatID) +} + +func (al *AgentLoop) GetStartupInfo() map[string]any { + info := make(map[string]any) + + registry := al.GetRegistry() + agent := registry.GetDefaultAgent() + if agent == nil { + return info + } + + // Tools info + toolsList := agent.Tools.List() + info["tools"] = map[string]any{ + "count": len(toolsList), + "names": toolsList, + } + + // Skills info + info["skills"] = agent.ContextBuilder.GetSkillsInfo() + + // Agents info + info["agents"] = map[string]any{ + "count": len(registry.ListAgentIDs()), + "ids": registry.ListAgentIDs(), + } + + return info +} diff --git a/pkg/agent/agent_mcp.go b/pkg/agent/agent_mcp.go new file mode 100644 index 000000000..b3c69504b --- /dev/null +++ b/pkg/agent/agent_mcp.go @@ -0,0 +1,262 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package agent + +import ( + "context" + "fmt" + "sync" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/mcp" + "github.com/sipeed/picoclaw/pkg/tools" +) + +type mcpRuntime struct { + initOnce sync.Once + mu sync.Mutex + manager *mcp.Manager + initErr error +} + +func (r *mcpRuntime) reset() *mcp.Manager { + r.mu.Lock() + manager := r.manager + r.manager = nil + r.initErr = nil + r.initOnce = sync.Once{} + r.mu.Unlock() + return manager +} + +func (r *mcpRuntime) setManager(manager *mcp.Manager) { + r.mu.Lock() + r.manager = manager + r.initErr = nil + r.mu.Unlock() +} + +func (r *mcpRuntime) setInitErr(err error) { + r.mu.Lock() + r.initErr = err + r.mu.Unlock() +} + +func (r *mcpRuntime) getInitErr() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.initErr +} + +func (r *mcpRuntime) takeManager() *mcp.Manager { + r.mu.Lock() + defer r.mu.Unlock() + manager := r.manager + r.manager = nil + return manager +} + +func (r *mcpRuntime) hasManager() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.manager != nil +} + +func (r *mcpRuntime) getManager() *mcp.Manager { + r.mu.Lock() + defer r.mu.Unlock() + return r.manager +} + +// ensureMCPInitialized loads MCP servers/tools once so both Run() and direct +// agent mode share the same initialization path. +func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { + if !al.cfg.Tools.IsToolEnabled("mcp") { + return nil + } + + if al.cfg.Tools.MCP.Servers == nil || len(al.cfg.Tools.MCP.Servers) == 0 { + logger.WarnCF("agent", "MCP is enabled but no servers are configured, skipping MCP initialization", nil) + return nil + } + + findValidServer := false + for _, serverCfg := range al.cfg.Tools.MCP.Servers { + if serverCfg.Enabled { + findValidServer = true + } + } + if !findValidServer { + logger.WarnCF("agent", "MCP is enabled but no valid servers are configured, skipping MCP initialization", nil) + return nil + } + + al.mcp.initOnce.Do(func() { + mcpManager := mcp.NewManager(mcp.WithRuntimeEvents(al.runtimeEvents)) + + defaultAgent := al.registry.GetDefaultAgent() + workspacePath := al.cfg.WorkspacePath() + if defaultAgent != nil && defaultAgent.Workspace != "" { + workspacePath = defaultAgent.Workspace + } + + if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { + al.mcp.setInitErr(fmt.Errorf("failed to load MCP servers: %w", err)) + logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", + map[string]any{ + "error": err.Error(), + }) + if closeErr := mcpManager.Close(); closeErr != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": closeErr.Error(), + }) + } + return + } + + // Register MCP tools for all agents + servers := mcpManager.GetServers() + uniqueTools := 0 + totalRegistrations := 0 + agentIDs := al.registry.ListAgentIDs() + agentCount := len(agentIDs) + + for serverName, conn := range servers { + uniqueTools += len(conn.Tools) + + // Determine whether this server's tools should be deferred (hidden). + // Per-server "deferred" field takes precedence over the global Discovery.Enabled. + serverCfg := al.cfg.Tools.MCP.Servers[serverName] + registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg) + + for _, agentID := range agentIDs { + agent, ok := al.registry.GetAgent(agentID) + if !ok || agent.ContextBuilder == nil { + continue + } + if err := agent.ContextBuilder.RegisterPromptContributor(mcpServerPromptContributor{ + serverName: serverName, + toolCount: len(conn.Tools), + deferred: registerAsHidden, + }); err != nil { + logger.WarnCF("agent", "Failed to register MCP prompt contributor", + map[string]any{ + "agent_id": agentID, + "server": serverName, + "error": err.Error(), + }) + } + } + + for _, tool := range conn.Tools { + for _, agentID := range agentIDs { + agent, ok := al.registry.GetAgent(agentID) + if !ok { + continue + } + + mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) + mcpTool.SetWorkspace(agent.Workspace) + mcpTool.SetMaxInlineTextRunes(al.cfg.Tools.MCP.GetMaxInlineTextChars()) + mcpTool.SetEventPublisher(al.runtimeEvents) + + if registerAsHidden { + agent.Tools.RegisterHidden(mcpTool) + } else { + agent.Tools.Register(mcpTool) + } + + totalRegistrations++ + logger.DebugCF("agent", "Registered MCP tool", + map[string]any{ + "agent_id": agentID, + "server": serverName, + "tool": tool.Name, + "name": mcpTool.Name(), + "deferred": registerAsHidden, + }) + } + } + } + logger.InfoCF("agent", "MCP tools registered successfully", + map[string]any{ + "server_count": len(servers), + "unique_tools": uniqueTools, + "total_registrations": totalRegistrations, + "agent_count": agentCount, + }) + + // Initializes Discovery Tools only if enabled by configuration + if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled { + useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25 + useRegex := al.cfg.Tools.MCP.Discovery.UseRegex + + // Fail fast: If discovery is enabled but no search method is turned on + if !useBM25 && !useRegex { + al.mcp.setInitErr(fmt.Errorf( + "tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration", + )) + if closeErr := mcpManager.Close(); closeErr != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": closeErr.Error(), + }) + } + return + } + + ttl := al.cfg.Tools.MCP.Discovery.TTL + if ttl <= 0 { + ttl = 5 // Default value + } + + maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults + if maxSearchResults <= 0 { + maxSearchResults = 5 // Default value + } + + logger.InfoCF("agent", "Initializing tool discovery", map[string]any{ + "bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults, + }) + + for _, agentID := range agentIDs { + agent, ok := al.registry.GetAgent(agentID) + if !ok { + continue + } + + if useRegex { + agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults)) + } + if useBM25 { + agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults)) + } + } + } + + al.mcp.setManager(mcpManager) + }) + + return al.mcp.getInitErr() +} + +// serverIsDeferred reports whether an MCP server's tools should be registered +// as hidden (deferred/discovery mode). +// +// The per-server Deferred field takes precedence over the global discoveryEnabled +// default. When Deferred is nil, discoveryEnabled is used as the fallback. +func serverIsDeferred(discoveryEnabled bool, serverCfg config.MCPServerConfig) bool { + if !discoveryEnabled { + return false + } + if serverCfg.Deferred != nil { + return *serverCfg.Deferred + } + return true +} diff --git a/pkg/agent/agent_mcp_test.go b/pkg/agent/agent_mcp_test.go new file mode 100644 index 000000000..b68fcc2c1 --- /dev/null +++ b/pkg/agent/agent_mcp_test.go @@ -0,0 +1,181 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package agent + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/mcp" +) + +func boolPtr(b bool) *bool { return &b } + +func TestMCPRuntimeResetClearsState(t *testing.T) { + var rt mcpRuntime + manager := mcp.NewManager() + rt.setManager(manager) + rt.setInitErr(errors.New("stale init error")) + rt.initOnce.Do(func() {}) + + got := rt.reset() + if got != manager { + t.Fatalf("reset() manager = %p, want %p", got, manager) + } + if rt.hasManager() { + t.Fatal("expected manager to be cleared after reset") + } + if err := rt.getInitErr(); err != nil { + t.Fatalf("getInitErr() = %v, want nil", err) + } + + reran := false + rt.initOnce.Do(func() { reran = true }) + if !reran { + t.Fatal("expected initOnce to be reset") + } +} + +func TestReloadProviderAndConfig_ResetsMCPRuntime(t *testing.T) { + al, cfg, _, _, cleanup := newTestAgentLoop(t) + defer cleanup() + defer al.Close() + + manager := mcp.NewManager() + al.mcp.setManager(manager) + al.mcp.setInitErr(errors.New("stale init error")) + al.mcp.initOnce.Do(func() {}) + + if !al.mcp.hasManager() { + t.Fatal("expected MCP manager to exist before reload") + } + + if err := al.ReloadProviderAndConfig(context.Background(), &mockProvider{}, cfg); err != nil { + t.Fatalf("ReloadProviderAndConfig() error = %v", err) + } + + if al.mcp.hasManager() { + t.Fatal("expected MCP manager to be cleared when reloaded config has MCP disabled") + } + if err := al.mcp.getInitErr(); err != nil { + t.Fatalf("getInitErr() = %v, want nil", err) + } + + reran := false + al.mcp.initOnce.Do(func() { reran = true }) + if !reran { + t.Fatal("expected MCP initOnce to be reset after reload") + } +} + +func TestServerIsDeferred(t *testing.T) { + tests := []struct { + name string + discoveryEnabled bool + serverDeferred *bool + want bool + }{ + // --- global false always wins: per-server deferred is ignored --- + { + name: "global false: per-server deferred=true is ignored", + discoveryEnabled: false, + serverDeferred: boolPtr(true), + want: false, + }, + { + name: "global false: per-server deferred=false stays false", + discoveryEnabled: false, + serverDeferred: boolPtr(false), + want: false, + }, + // --- global true: per-server override applies --- + { + name: "global true: per-server deferred=false opts out", + discoveryEnabled: true, + serverDeferred: boolPtr(false), + want: false, + }, + { + name: "global true: per-server deferred=true stays true", + discoveryEnabled: true, + serverDeferred: boolPtr(true), + want: true, + }, + // --- no per-server override: fall back to global --- + { + name: "no per-server field, global discovery enabled", + discoveryEnabled: true, + serverDeferred: nil, + want: true, + }, + { + name: "no per-server field, global discovery disabled", + discoveryEnabled: false, + serverDeferred: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + serverCfg := config.MCPServerConfig{Deferred: tt.serverDeferred} + got := serverIsDeferred(tt.discoveryEnabled, serverCfg) + if got != tt.want { + t.Errorf("serverIsDeferred(discoveryEnabled=%v, deferred=%v) = %v, want %v", + tt.discoveryEnabled, tt.serverDeferred, got, tt.want) + } + }) + } +} + +func TestEnsureMCPInitialized_LoadFailureSetsInitErr(t *testing.T) { + al, cfg, _, _, cleanup := newTestAgentLoop(t) + defer cleanup() + defer al.Close() + + cfg.Tools = config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "broken": { + Enabled: true, + Command: "picoclaw-command-that-does-not-exist-for-mcp-tests", + }, + }, + }, + } + + err := al.ensureMCPInitialized(context.Background()) + if err == nil { + t.Fatal("ensureMCPInitialized() error = nil, want load failure") + } + if !strings.Contains(err.Error(), "failed to load MCP servers") { + t.Fatalf("ensureMCPInitialized() error = %q, want wrapped load failure", err.Error()) + } + + initErr := al.mcp.getInitErr() + if initErr == nil { + t.Fatal("getInitErr() = nil, want cached load failure") + } + if !strings.Contains(initErr.Error(), "failed to load MCP servers") { + t.Fatalf("getInitErr() = %q, want wrapped load failure", initErr.Error()) + } + if al.mcp.getManager() != nil { + t.Fatal("expected MCP manager to remain nil after load failure") + } + + err = al.ensureMCPInitialized(context.Background()) + if err == nil { + t.Fatal("second ensureMCPInitialized() error = nil, want cached load failure") + } + if !strings.Contains(err.Error(), "failed to load MCP servers") { + t.Fatalf("second ensureMCPInitialized() error = %q, want wrapped load failure", err.Error()) + } +} diff --git a/pkg/agent/agent_media.go b/pkg/agent/agent_media.go new file mode 100644 index 000000000..c02c7392c --- /dev/null +++ b/pkg/agent/agent_media.go @@ -0,0 +1,288 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package agent + +import ( + "bytes" + "encoding/base64" + "io" + "os" + "regexp" + "strings" + + "github.com/h2non/filetype" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// genericPlaceholderRegex matches generic media placeholders emitted by various +// channels: [image], [image: photo], [image: filename.jpg] — but NOT path tags +// like [image:/path/to/file] (path tags have no space after the colon). +var ( + imagePlaceholderRegex = regexp.MustCompile(`\[image(:\s+[^\]]*)?\]`) + audioPlaceholderRegex = regexp.MustCompile(`\[audio(:\s+[^\]]*)?\]`) + videoPlaceholderRegex = regexp.MustCompile(`\[video(:\s+[^\]]*)?\]`) + filePlaceholderRegex = regexp.MustCompile(`\[file(:\s+[^\]]*)?\]`) +) + +// resolveMediaRefs resolves media:// refs in messages. +// For user messages: images get path tags only ([image:/path]) so the LLM +// can decide whether to view them via load_image or operate on the file. +// For tool messages: images are base64-encoded and appended as a synthetic +// user message only after the contiguous tool-message block ends, so we don't +// break the tool-results-must-immediately-follow-assistant constraint that +// LLM APIs enforce. +// Non-image files always get path tags regardless of role. +// Returns a new slice; original messages are not mutated. +func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message { + if store == nil { + return messages + } + + result := make([]providers.Message, 0, len(messages)) + var pendingToolImages []string + + for idx, m := range messages { + // When leaving a tool-message block, flush any accumulated images + // as a synthetic user message. + if m.Role != "tool" && len(pendingToolImages) > 0 { + result = append(result, providers.Message{ + Role: "user", + Content: "[Loaded image from tool result above]", + Media: pendingToolImages, + }) + pendingToolImages = nil + } + + if len(m.Media) == 0 { + result = append(result, m) + if idx == len(messages)-1 && len(pendingToolImages) > 0 { + result = append(result, providers.Message{ + Role: "user", + Content: "[Loaded image from tool result above]", + Media: pendingToolImages, + }) + pendingToolImages = nil + } + continue + } + + msg := m + resolved := make([]string, 0, len(m.Media)) + var pathTags []string + + for _, ref := range m.Media { + if !strings.HasPrefix(ref, "media://") { + resolved = append(resolved, ref) + continue + } + + localPath, meta, err := store.ResolveWithMeta(ref) + if err != nil { + logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{ + "ref": ref, + "error": err.Error(), + }) + continue + } + + info, err := os.Stat(localPath) + if err != nil { + logger.WarnCF("agent", "Failed to stat media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + continue + } + + mime := detectMIME(localPath, meta) + pathTags = append(pathTags, buildPathTag(mime, localPath)) + + if m.Role == "tool" && strings.HasPrefix(mime, "image/") { + dataURL := encodeImageToDataURL(localPath, mime, info, maxSize) + if dataURL != "" { + pendingToolImages = append(pendingToolImages, dataURL) + } + } + } + + msg.Media = resolved + if len(pathTags) > 0 { + msg.Content = injectPathTags(msg.Content, pathTags) + } + result = append(result, msg) + + // If this is the last message and we have pending images, flush them. + if idx == len(messages)-1 && len(pendingToolImages) > 0 { + result = append(result, providers.Message{ + Role: "user", + Content: "[Loaded image from tool result above]", + Media: pendingToolImages, + }) + pendingToolImages = nil + } + } + + return result +} + +// encodeImageToDataURL base64-encodes an image file into a data URL. +// Returns empty string if the file exceeds maxSize or encoding fails. +func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string { + if info.Size() > int64(maxSize) { + logger.WarnCF("agent", "Media file too large, skipping", map[string]any{ + "path": localPath, + "size": info.Size(), + "max_size": maxSize, + }) + return "" + } + + f, err := os.Open(localPath) + if err != nil { + logger.WarnCF("agent", "Failed to open media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + return "" + } + defer f.Close() + + prefix := "data:" + mime + ";base64," + encodedLen := base64.StdEncoding.EncodedLen(int(info.Size())) + var buf bytes.Buffer + buf.Grow(len(prefix) + encodedLen) + buf.WriteString(prefix) + + encoder := base64.NewEncoder(base64.StdEncoding, &buf) + if _, err := io.Copy(encoder, f); err != nil { + logger.WarnCF("agent", "Failed to encode media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + return "" + } + encoder.Close() + + return buf.String() +} + +func buildArtifactTags(store media.MediaStore, refs []string) []string { + if store == nil || len(refs) == 0 { + return nil + } + + tags := make([]string, 0, len(refs)) + for _, ref := range refs { + localPath, meta, err := store.ResolveWithMeta(ref) + if err != nil { + continue + } + mime := detectMIME(localPath, meta) + tags = append(tags, buildPathTag(mime, localPath)) + } + + return tags +} + +func buildProviderAttachments(store media.MediaStore, refs []string) []providers.Attachment { + if store == nil || len(refs) == 0 { + return nil + } + + attachments := make([]providers.Attachment, 0, len(refs)) + for _, ref := range refs { + attachment := providers.Attachment{Ref: ref} + if _, meta, err := store.ResolveWithMeta(ref); err == nil { + attachment.Filename = meta.Filename + attachment.ContentType = meta.ContentType + attachment.Type = inferMediaType(meta.Filename, meta.ContentType) + } + attachments = append(attachments, attachment) + } + + return attachments +} + +// detectMIME determines the MIME type from metadata or magic-bytes detection. +// Returns empty string if detection fails. +func detectMIME(localPath string, meta media.MediaMeta) string { + if meta.ContentType != "" { + return meta.ContentType + } + kind, err := filetype.MatchFile(localPath) + if err != nil || kind == filetype.Unknown { + return "" + } + return kind.MIME.Value +} + +// buildPathTag creates a structured tag exposing the local file path. +// Tag type is derived from MIME: [image:/path], [audio:/path], [video:/path], or [file:/path]. +func buildPathTag(mime, localPath string) string { + switch { + case strings.HasPrefix(mime, "image/"): + return "[image:" + localPath + "]" + case strings.HasPrefix(mime, "audio/"): + return "[audio:" + localPath + "]" + case strings.HasPrefix(mime, "video/"): + return "[video:" + localPath + "]" + default: + return "[file:" + localPath + "]" + } +} + +// injectPathTags replaces generic media tags in content with path-bearing versions, +// or appends if no matching generic tag is found. Channels emit a few different +// placeholder formats — [image], [image: photo], [image: filename.jpg] — so we +// match all of them via regex while leaving path tags ([image:/path]) untouched. +// +// When content is structured data (e.g., JSON from Feishu interactive cards or +// post messages), tags are only injected via placeholder replacement — never +// appended — to avoid corrupting the payload. +func injectPathTags(content string, tags []string) string { + isStructured := looksLikeJSON(content) + for _, tag := range tags { + var pattern *regexp.Regexp + switch { + case strings.HasPrefix(tag, "[image:"): + pattern = imagePlaceholderRegex + case strings.HasPrefix(tag, "[audio:"): + pattern = audioPlaceholderRegex + case strings.HasPrefix(tag, "[video:"): + pattern = videoPlaceholderRegex + case strings.HasPrefix(tag, "[file:"): + pattern = filePlaceholderRegex + } + + if pattern != nil { + if loc := pattern.FindStringIndex(content); loc != nil { + content = content[:loc[0]] + tag + content[loc[1]:] + continue + } + } + + if isStructured { + content = tag + "\n" + content + continue + } + + if content == "" { + content = tag + } else { + content += " " + tag + } + } + return content +} + +func looksLikeJSON(s string) bool { + s = strings.TrimSpace(s) + return len(s) > 1 && s[0] == '{' +} diff --git a/pkg/agent/agent_message.go b/pkg/agent/agent_message.go new file mode 100644 index 000000000..96b0b0817 --- /dev/null +++ b/pkg/agent/agent_message.go @@ -0,0 +1,302 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/utils" +) + +func (al *AgentLoop) buildContinuationTarget(msg bus.InboundMessage) (*continuationTarget, error) { + if msg.Channel == "system" { + return nil, nil + } + + route, _, err := al.resolveMessageRoute(msg) + if err != nil { + return nil, err + } + allocation := al.allocateRouteSession(route, msg) + + return &continuationTarget{ + SessionKey: resolveScopeKey(allocation.SessionKey, msg.SessionKey), + Channel: msg.Channel, + ChatID: msg.ChatID, + }, nil +} + +func (al *AgentLoop) ProcessDirect( + ctx context.Context, + content, sessionKey string, +) (string, error) { + return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") +} + +func (al *AgentLoop) ProcessDirectWithChannel( + ctx context.Context, + content, sessionKey, channel, chatID string, +) (string, error) { + if err := al.ensureHooksInitialized(ctx); err != nil { + return "", err + } + if err := al.ensureMCPInitialized(ctx); err != nil { + return "", err + } + + msg := bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: channel, + ChatID: chatID, + ChatType: "direct", + SenderID: "cron", + }, + Content: content, + SessionKey: sessionKey, + } + + return al.processMessage(ctx, msg) +} + +func (al *AgentLoop) ProcessHeartbeat( + ctx context.Context, + content, channel, chatID string, +) (string, error) { + if err := al.ensureHooksInitialized(ctx); err != nil { + return "", err + } + if err := al.ensureMCPInitialized(ctx); err != nil { + return "", err + } + + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + return "", fmt.Errorf("no default agent for heartbeat") + } + dispatch := DispatchRequest{ + SessionKey: "heartbeat", + UserMessage: content, + } + if channel != "" || chatID != "" { + dispatch.InboundContext = &bus.InboundContext{ + Channel: channel, + ChatID: chatID, + ChatType: "direct", + SenderID: "heartbeat", + } + } + return al.runAgentLoop(ctx, agent, processOptions{ + Dispatch: dispatch, + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + SuppressToolFeedback: true, + NoHistory: true, // Don't load session history for heartbeat + }) +} + +func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { + msg = bus.NormalizeInboundMessage(msg) + + // Add message preview to log (show full content for error messages) + var logContent string + if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { + logContent = msg.Content // Full content for errors + } else { + logContent = utils.Truncate(msg.Content, 80) + } + logger.InfoCF( + "agent", + fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), + map[string]any{ + "channel": msg.Channel, + "chat_id": msg.ChatID, + "sender_id": msg.SenderID, + "session_key": msg.SessionKey, + }, + ) + + var hadAudio bool + msg, hadAudio = al.transcribeAudioInMessage(ctx, msg) + + // For audio messages the placeholder was deferred by the channel. + // Now that transcription (and optional feedback) is done, send it. + if hadAudio && al.channelManager != nil { + al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID) + } + + // Route system messages to processSystemMessage + if msg.Channel == "system" { + return al.processSystemMessage(ctx, msg) + } + + route, agent, routeErr := al.resolveMessageRoute(msg) + if routeErr != nil { + return "", routeErr + } + + allocation := al.allocateRouteSession(route, msg) + + // Resolve session key from the route allocation, while preserving explicit + // agent-scoped keys supplied by the caller. + scopeKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey) + sessionKey := scopeKey + + // Reset message-tool state for this round so we don't skip publishing due to a previous round. + if tool, ok := agent.Tools.Get("message"); ok { + if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok { + resetter.ResetSentInRound(sessionKey) + } + } + + logger.InfoCF("agent", "Routed message", + map[string]any{ + "agent_id": agent.ID, + "scope_key": scopeKey, + "session_key": sessionKey, + "matched_by": route.MatchedBy, + "route_agent": route.AgentID, + "route_channel": route.Channel, + "route_main_session": allocation.MainSessionKey, + }) + + opts := processOptions{ + Dispatch: DispatchRequest{ + SessionKey: sessionKey, + SessionAliases: buildSessionAliases(sessionKey, append(allocation.SessionAliases, msg.SessionKey)...), + InboundContext: cloneInboundContext(&msg.Context), + RouteResult: cloneResolvedRoute(&route), + SessionScope: session.CloneScope(&allocation.Scope), + UserMessage: msg.Content, + Media: append([]string(nil), msg.Media...), + }, + SenderID: msg.SenderID, + SenderDisplayName: msg.Sender.DisplayName, + DefaultResponse: defaultResponse, + EnableSummary: true, + SendResponse: false, + AllowInterimPicoPublish: true, + } + + // context-dependent commands check their own Runtime fields and report + // "unavailable" when the required capability is nil. + if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled { + return response, nil + } + + if pending := al.takePendingSkills(opts.Dispatch.SessionKey); len(pending) > 0 { + opts.ForcedSkills = append(opts.ForcedSkills, pending...) + logger.InfoCF("agent", "Applying pending skill override", + map[string]any{ + "session_key": opts.Dispatch.SessionKey, + "skills": strings.Join(pending, ","), + }) + } + + return al.runAgentLoop(ctx, agent, opts) +} + +func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { + registry := al.GetRegistry() + inboundCtx := normalizedInboundContext(msg) + route := registry.ResolveRoute(inboundCtx) + + agent, ok := registry.GetAgent(route.AgentID) + if !ok { + agent = registry.GetDefaultAgent() + } + if agent == nil { + return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) + } + + return route, agent, nil +} + +func (al *AgentLoop) allocateRouteSession(route routing.ResolvedRoute, msg bus.InboundMessage) session.Allocation { + return session.AllocateRouteSession(session.AllocationInput{ + AgentID: route.AgentID, + Context: normalizedInboundContext(msg), + SessionPolicy: route.SessionPolicy, + }) +} + +func (al *AgentLoop) processSystemMessage( + ctx context.Context, + msg bus.InboundMessage, +) (string, error) { + if msg.Channel != "system" { + return "", fmt.Errorf( + "processSystemMessage called with non-system message channel: %s", + msg.Channel, + ) + } + + logger.InfoCF("agent", "Processing system message", + map[string]any{ + "sender_id": msg.SenderID, + "chat_id": msg.ChatID, + }) + + // Parse origin channel from chat_id (format: "channel:chat_id") + var originChannel, originChatID string + if idx := strings.Index(msg.ChatID, ":"); idx > 0 { + originChannel = msg.ChatID[:idx] + originChatID = msg.ChatID[idx+1:] + } else { + originChannel = "cli" + originChatID = msg.ChatID + } + + // Extract subagent result from message content + // Format: "Task 'label' completed.\n\nResult:\n" + content := msg.Content + if idx := strings.Index(content, "Result:\n"); idx >= 0 { + content = content[idx+8:] // Extract just the result part + } + + // Skip internal channels - only log, don't send to user + if constants.IsInternalChannel(originChannel) { + logger.InfoCF("agent", "Subagent completed (internal channel)", + map[string]any{ + "sender_id": msg.SenderID, + "content_len": len(content), + "channel": originChannel, + }) + return "", nil + } + + // Use default agent for system messages + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + return "", fmt.Errorf("no default agent for system message") + } + + // Use the origin session for context + sessionKey := session.BuildMainSessionKey(agent.ID) + dispatch := DispatchRequest{ + SessionKey: sessionKey, + UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content), + } + if originChannel != "" || originChatID != "" { + dispatch.InboundContext = &bus.InboundContext{ + Channel: originChannel, + ChatID: originChatID, + ChatType: "direct", + SenderID: msg.SenderID, + } + } + + return al.runAgentLoop(ctx, agent, processOptions{ + Dispatch: dispatch, + DefaultResponse: "Background task completed.", + EnableSummary: false, + SendResponse: true, + }) +} diff --git a/pkg/agent/agent_options.go b/pkg/agent/agent_options.go new file mode 100644 index 000000000..224062a3f --- /dev/null +++ b/pkg/agent/agent_options.go @@ -0,0 +1,20 @@ +package agent + +import runtimeevents "github.com/sipeed/picoclaw/pkg/events" + +// AgentLoopOption configures an AgentLoop at construction time. +type AgentLoopOption func(*AgentLoop) + +// WithRuntimeEvents injects the runtime event bus used for new observation APIs. +// +// The injected bus is treated as externally owned and will not be closed by +// AgentLoop.Close. Passing nil leaves the default owned runtime bus enabled. +func WithRuntimeEvents(bus runtimeevents.Bus) AgentLoopOption { + return func(al *AgentLoop) { + if bus == nil { + return + } + al.runtimeEvents = bus + al.ownsRuntimeEvents = false + } +} diff --git a/pkg/agent/agent_outbound.go b/pkg/agent/agent_outbound.go new file mode 100644 index 000000000..1728f6f79 --- /dev/null +++ b/pkg/agent/agent_outbound.go @@ -0,0 +1,262 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" +) + +func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, sessionKey string, err error) bool { + if errors.Is(err, context.Canceled) { + return false + } + al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, fmt.Sprintf("Error processing message: %v", err)) + return true +} + +func (al *AgentLoop) publishResponseOrError( + ctx context.Context, + channel, chatID, sessionKey string, + response string, + err error, +) { + if err != nil { + if !al.maybePublishError(ctx, channel, chatID, sessionKey, err) { + return + } + response = "" + } + al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, response) +} + +func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) { + if response == "" { + return + } + + alreadySentToSameChat := false + defaultAgent := al.GetRegistry().GetDefaultAgent() + if defaultAgent != nil { + if tool, ok := defaultAgent.Tools.Get("message"); ok { + if mt, ok := tool.(*tools.MessageTool); ok { + alreadySentToSameChat = mt.HasSentTo(sessionKey, channel, chatID) + } + } + } + + if alreadySentToSameChat { + logger.DebugCF( + "agent", + "Skipped outbound (message tool already sent to same chat)", + map[string]any{"channel": channel, "chat_id": chatID}, + ) + return + } + + msg := bus.OutboundMessage{ + Context: bus.NewOutboundContext(channel, chatID, ""), + Content: response, + } + if sessionKey != "" { + msg.ContextUsage = computeContextUsage(al.agentForSession(sessionKey), sessionKey) + } + al.bus.PublishOutbound(ctx, msg) + logger.InfoCF("agent", "Published outbound response", + map[string]any{ + "channel": channel, + "chat_id": chatID, + "content_len": len(response), + }) +} + +func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) { + if al.channelManager == nil { + return "" + } + if ch, ok := al.channelManager.GetChannel(channelName); ok { + return ch.ReasoningChannelID() + } + return "" +} + +func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, chatID string) { + if reasoningContent == "" || chatID == "" { + return + } + + if ctx.Err() != nil { + return + } + + pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) + defer pubCancel() + + if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Context: bus.InboundContext{ + Channel: "pico", + ChatID: chatID, + Raw: map[string]string{ + metadataKeyMessageKind: messageKindThought, + }, + }, + Content: reasoningContent, + }); err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || + errors.Is(err, bus.ErrBusClosed) { + logger.DebugCF("agent", "Pico reasoning publish skipped (timeout/cancel)", map[string]any{ + "channel": "pico", + "error": err.Error(), + }) + } else { + logger.WarnCF("agent", "Failed to publish pico reasoning (best-effort)", map[string]any{ + "channel": "pico", + "error": err.Error(), + }) + } + } +} + +func (al *AgentLoop) publishPicoToolCallInterim( + ctx context.Context, + ts *turnState, + reasoningContent string, + content string, + toolCalls []providers.ToolCall, +) { + if ts == nil || ts.chatID == "" || al == nil || al.bus == nil { + return + } + + if strings.TrimSpace(reasoningContent) != "" { + pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second) + err := al.bus.PublishOutbound( + pubCtx, + outboundMessageForTurnWithKind(ts, reasoningContent, messageKindThought), + ) + pubCancel() + if err != nil && !errors.Is(err, context.DeadlineExceeded) && + !errors.Is(err, context.Canceled) && + !errors.Is(err, bus.ErrBusClosed) { + logger.WarnCF("agent", "Failed to publish pico reasoning", map[string]any{ + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + } + } + + if !ts.opts.AllowInterimPicoPublish { + return + } + + visibleToolCalls := utils.BuildVisibleToolCalls( + toolCalls, + al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), + ) + duplicateToolCallContent := len(visibleToolCalls) > 0 && + utils.ToolCallExplanationDuplicatesContent(content, toolCalls) + + if strings.TrimSpace(content) != "" && !duplicateToolCallContent { + pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second) + err := al.bus.PublishOutbound(pubCtx, outboundMessageForTurn(ts, content)) + pubCancel() + if err != nil && !errors.Is(err, context.DeadlineExceeded) && + !errors.Is(err, context.Canceled) && + !errors.Is(err, bus.ErrBusClosed) { + logger.WarnCF("agent", "Failed to publish pico interim assistant content", map[string]any{ + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + } + } + + if len(visibleToolCalls) == 0 { + return + } + + rawToolCalls, err := json.Marshal(visibleToolCalls) + if err != nil { + logger.WarnCF("agent", "Failed to serialize pico tool calls", map[string]any{ + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + return + } + + msg := outboundMessageForTurnWithKind(ts, "", messageKindToolCalls) + if msg.Context.Raw == nil { + msg.Context.Raw = map[string]string{} + } + msg.Context.Raw[metadataKeyToolCalls] = string(rawToolCalls) + + pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second) + err = al.bus.PublishOutbound(pubCtx, msg) + pubCancel() + if err != nil && !errors.Is(err, context.DeadlineExceeded) && + !errors.Is(err, context.Canceled) && + !errors.Is(err, bus.ErrBusClosed) { + logger.WarnCF("agent", "Failed to publish pico tool calls", map[string]any{ + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + } +} + +func (al *AgentLoop) handleReasoning( + ctx context.Context, + reasoningContent, channelName, channelID string, +) { + if reasoningContent == "" || channelName == "" || channelID == "" { + return + } + + // Check context cancellation before attempting to publish, + // since PublishOutbound's select may race between send and ctx.Done(). + if ctx.Err() != nil { + return + } + + // Use a short timeout so the goroutine does not block indefinitely when + // the outbound bus is full. Reasoning output is best-effort; dropping it + // is acceptable to avoid goroutine accumulation. + pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) + defer pubCancel() + + if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Context: bus.NewOutboundContext(channelName, channelID, ""), + Content: reasoningContent, + }); err != nil { + // Treat context.DeadlineExceeded / context.Canceled as expected + // (bus full under load, or parent canceled). Check the error + // itself rather than ctx.Err(), because pubCtx may time out + // (5 s) while the parent ctx is still active. + // Also treat ErrBusClosed as expected — it occurs during normal + // shutdown when the bus is closed before all goroutines finish. + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || + errors.Is(err, bus.ErrBusClosed) { + logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{ + "channel": channelName, + "error": err.Error(), + }) + } else { + logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ + "channel": channelName, + "error": err.Error(), + }) + } + } +} diff --git a/pkg/agent/agent_steering.go b/pkg/agent/agent_steering.go new file mode 100644 index 000000000..9b136e7cd --- /dev/null +++ b/pkg/agent/agent_steering.go @@ -0,0 +1,112 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" +) + +func (al *AgentLoop) processMessageSync(ctx context.Context, msg bus.InboundMessage) { + if al.channelManager != nil { + defer al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID) + } + + response, err := al.processMessage(ctx, msg) + al.publishResponseOrError(ctx, msg.Channel, msg.ChatID, msg.SessionKey, response, err) +} + +func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.InboundMessage) { + // Process the initial message + response, err := al.processMessage(ctx, initialMsg) + if err != nil { + if !al.maybePublishError(ctx, initialMsg.Channel, initialMsg.ChatID, initialMsg.SessionKey, err) { + return // context canceled + } + response = "" + } + finalResponse := response + + // Build continuation target + target, targetErr := al.buildContinuationTarget(initialMsg) + if targetErr != nil { + logger.WarnCF("agent", "Failed to build steering continuation target", + map[string]any{ + "channel": initialMsg.Channel, + "error": targetErr.Error(), + }) + return + } + if target == nil { + // System message or non-routable, response already published + return + } + + continued, continueErr := al.drainQueuedSteeringContinuations(ctx, target) + if continueErr != nil { + logger.WarnCF("agent", "Failed to continue queued steering", + map[string]any{ + "channel": target.Channel, + "chat_id": target.ChatID, + "error": continueErr.Error(), + }) + } else if continued != "" { + finalResponse = continued + } + + // Publish final response + if finalResponse != "" { + al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse) + } +} + +func (al *AgentLoop) drainQueuedSteeringContinuations( + ctx context.Context, + target *continuationTarget, +) (string, error) { + if target == nil { + return "", nil + } + + finalResponse := "" + for al.pendingSteeringCountForScope(target.SessionKey) > 0 { + if err := ctx.Err(); err != nil { + return finalResponse, err + } + + logger.InfoCF("agent", "Continuing queued steering after turn end", + map[string]any{ + "channel": target.Channel, + "chat_id": target.ChatID, + "session_key": target.SessionKey, + "queue_depth": al.pendingSteeringCountForScope(target.SessionKey), + }) + + continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) + if continueErr != nil { + return finalResponse, continueErr + } + if continued == "" { + break + } + finalResponse = continued + } + + return finalResponse, nil +} + +func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) { + if msg.Channel == "system" { + return "", "", false + } + + route, agent, err := al.resolveMessageRoute(msg) + if err != nil || agent == nil { + return "", "", false + } + allocation := al.allocateRouteSession(route, msg) + + return resolveScopeKey(allocation.SessionKey, msg.SessionKey), agent.ID, true +} diff --git a/pkg/agent/agent_stop.go b/pkg/agent/agent_stop.go new file mode 100644 index 000000000..54cd51477 --- /dev/null +++ b/pkg/agent/agent_stop.go @@ -0,0 +1,122 @@ +package agent + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/commands" +) + +func (al *AgentLoop) tryHandleStopCommand( + ctx context.Context, + msg bus.InboundMessage, + sessionKey string, +) bool { + cmdName, ok := commands.CommandName(msg.Content) + if !ok || cmdName != "stop" { + return false + } + + result, err := al.stopActiveTurnForSession(sessionKey) + + // This function is only called when loaded=true (another turn already + // claimed this session). If stopActiveTurnForSession found a pending + // placeholder but didn't stop it, that placeholder belongs to the other + // message's worker which hasn't started yet — arm a pending stop so the + // worker will bail when it checks before running. + if err == nil && !result.Stopped { + if ts := al.getActiveTurnState(sessionKey); ts != nil { + snap := ts.snapshot() + if strings.HasPrefix(snap.TurnID, pendingTurnPrefix) { + al.markPendingStop(sessionKey) + result.Stopped = true + } + } + } + + reply := commands.FormatStopReply(result) + if err != nil { + reply = "Failed to stop task: " + err.Error() + } + + if al.channelManager != nil { + al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID) + } + al.resetMessageToolRound(sessionKey) + al.PublishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, sessionKey, reply) + return true +} + +func (al *AgentLoop) stopActiveTurnForSession(sessionKey string) (commands.StopResult, error) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return commands.StopResult{}, fmt.Errorf("session key is required") + } + + result := commands.StopResult{} + cleared := al.clearSteeringMessagesForScope(sessionKey) + al.clearPendingSkills(sessionKey) + + ts := al.getActiveTurnState(sessionKey) + if ts == nil { + result.Stopped = cleared > 0 + return result, nil + } + + snap := ts.snapshot() + result.TaskName = snap.UserMessage + + if strings.HasPrefix(snap.TurnID, pendingTurnPrefix) { + // A pending placeholder means this session is either idle (our own + // placeholder from the /stop command) or another message is queued but + // hasn't started yet. In both cases, we don't arm a pending stop here; + // the caller (tryHandleStopCommand) handles the "another message queued" + // case explicitly, since it knows loaded=true. + return result, nil + } + + if err := al.HardAbort(sessionKey); err != nil { + if al.getActiveTurnState(sessionKey) == nil { + result.Stopped = cleared > 0 + return result, nil + } + return commands.StopResult{}, err + } + + result.Stopped = true + return result, nil +} + +func (al *AgentLoop) markPendingStop(sessionKey string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return + } + al.pendingStops.Store(sessionKey, struct{}{}) +} + +func (al *AgentLoop) takePendingStop(sessionKey string) bool { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return false + } + _, ok := al.pendingStops.LoadAndDelete(sessionKey) + return ok +} + +func (al *AgentLoop) resetMessageToolRound(sessionKey string) { + if strings.TrimSpace(sessionKey) == "" { + return + } + if registry := al.GetRegistry(); registry != nil { + if agent := registry.GetDefaultAgent(); agent != nil { + if tool, ok := agent.Tools.Get("message"); ok { + if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok { + resetter.ResetSentInRound(sessionKey) + } + } + } + } +} diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go new file mode 100644 index 000000000..a75919912 --- /dev/null +++ b/pkg/agent/agent_test.go @@ -0,0 +1,5593 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" +) + +type fakeChannel struct{ id string } + +func (f *fakeChannel) Name() string { return "fake" } +func (f *fakeChannel) Start(ctx context.Context) error { return nil } +func (f *fakeChannel) Stop(ctx context.Context) error { return nil } +func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + return nil, nil +} +func (f *fakeChannel) IsRunning() bool { return true } +func (f *fakeChannel) IsAllowed(string) bool { return true } +func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } +func (f *fakeChannel) ReasoningChannelID() string { return f.id } + +type fakeMediaChannel struct { + fakeChannel + sentMessages []bus.OutboundMessage + sentMedia []bus.OutboundMediaMessage +} + +func (f *fakeMediaChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + f.sentMessages = append(f.sentMessages, msg) + return nil, nil +} + +func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { + f.sentMedia = append(f.sentMedia, msg) + return nil, nil +} + +func newStartedTestChannelManager( + t *testing.T, + msgBus *bus.MessageBus, + store media.MediaStore, + name string, + ch channels.Channel, +) *channels.Manager { + t.Helper() + + cm, err := channels.NewManager(&config.Config{}, msgBus, store) + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + cm.RegisterChannel(name, ch) + if err := cm.StartAll(context.Background()); err != nil { + t.Fatalf("StartAll() error = %v", err) + } + t.Cleanup(func() { + if err := cm.StopAll(context.Background()); err != nil { + t.Fatalf("StopAll() error = %v", err) + } + }) + return cm +} + +type recordingProvider struct { + lastMessages []providers.Message + lastModel string +} + +func (r *recordingProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + r.lastMessages = append([]providers.Message(nil), messages...) + r.lastModel = model + return &providers.LLMResponse{ + Content: "Mock response", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (r *recordingProvider) GetDefaultModel() string { + return "mock-model" +} + +type modelRewriteHook struct { + model string +} + +func (h modelRewriteHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + next := req.Clone() + next.Model = h.model + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h modelRewriteHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + return resp.Clone(), HookDecision{Action: HookActionContinue}, nil +} + +func useTestSideQuestionProvider(al *AgentLoop, provider providers.LLMProvider) { + al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) { + model := provider.GetDefaultModel() + if mc != nil { + if _, modelID := providers.ExtractProtocol(mc); modelID != "" { + model = modelID + } + } + return provider, model, nil + } +} + +func newTestAgentLoop( + t *testing.T, +) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + cfg = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus = bus.NewMessageBus() + provider = &mockProvider{} + al = NewAgentLoop(cfg, msgBus, provider) + return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) } +} + +func TestNewAgentLoop_RegistersWebSearchTool(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = t.TempDir() + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + if _, ok := agent.Tools.Get("web_search"); !ok { + t.Fatal("expected web_search tool to be registered") + } +} + +func TestNewAgentLoop_RegistersWebSearchTool_WhenExplicitProviderUnavailable(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = t.TempDir() + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Sogou.Enabled = true + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + if _, ok := agent.Tools.Get("web_search"); !ok { + t.Fatal("expected web_search tool to fall back to auto provider selection") + } +} + +func TestNewAgentLoop_DoesNotRegisterWebSearchTool_WhenNoReadyProviders(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = t.TempDir() + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Sogou.Enabled = false + cfg.Tools.Web.DuckDuckGo.Enabled = false + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + if _, ok := agent.Tools.Get("web_search"); ok { + t.Fatal("expected web_search tool to be absent when no providers are ready") + } +} + +func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "discord", + SenderID: "discord:123", + Sender: bus.SenderInfo{ + DisplayName: "Alice", + }, + ChatID: "group-1", + Content: "hello", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages") + } + + systemPrompt := provider.lastMessages[0].Content + wantSender := "## Current Sender\nCurrent sender: Alice (ID: discord:123)" + if !strings.Contains(systemPrompt, wantSender) { + t.Fatalf("system prompt missing sender context %q:\n%s", wantSender, systemPrompt) + } + + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + if lastMessage.Role != "user" || lastMessage.Content != "hello" { + t.Fatalf("last provider message = %+v, want unchanged user message", lastMessage) + } +} + +func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) { + tmpDir := t.TempDir() + skillDir := filepath.Join(tmpDir, "skills", "shell") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("mkdir skill dir: %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("# shell\n\nPrefer concise shell commands and explain them briefly."), + 0o644, + ); err != nil { + t.Fatalf("write skill file: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/use shell explain how to list files", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages") + } + + systemPrompt := provider.lastMessages[0].Content + if !strings.Contains(systemPrompt, "# Active Skills") { + t.Fatalf("system prompt missing active skills section:\n%s", systemPrompt) + } + if !strings.Contains(systemPrompt, "### Skill: shell") { + t.Fatalf("system prompt missing requested skill content:\n%s", systemPrompt) + } + + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + if lastMessage.Role != "user" || lastMessage.Content != "explain how to list files" { + t.Fatalf("last provider message = %+v, want rewritten user message", lastMessage) + } +} + +func TestProcessMessage_BtwCommandRunsWithoutPersistingHistory(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + // Add model list so isolated provider can resolve the model + ModelList: []*config.ModelConfig{ + {ModelName: "test-model", Model: "openai/test-model"}, + }, + } + + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + useTestSideQuestionProvider(al, provider) + defaultAgent := al.GetRegistry().GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + msg := bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/btw explain side effects", + } + route, _, err := al.resolveMessageRoute(msg) + if err != nil { + t.Fatalf("resolveMessageRoute() error = %v", err) + } + allocation := al.allocateRouteSession(route, msg) + sessionKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey) + initialHistory := []providers.Message{ + {Role: "user", Content: "We decided to avoid global state."}, + {Role: "assistant", Content: "Right, keep it request-scoped."}, + } + defaultAgent.Sessions.SetHistory(sessionKey, initialHistory) + defaultAgent.Sessions.SetSummary(sessionKey, "The team decided to keep state request-scoped.") + + response, err := al.processMessage(context.Background(), msg) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages") + } + if len(provider.lastMessages) != 4 { + t.Fatalf("provider messages len = %d, want 4 (system + prior history + user)", len(provider.lastMessages)) + } + + if !reflect.DeepEqual(provider.lastMessages[1:3], initialHistory) { + t.Fatalf("provider history = %#v, want %#v", provider.lastMessages[1:3], initialHistory) + } + + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + if lastMessage.Role != "user" || lastMessage.Content != "explain side effects" { + t.Fatalf("last provider message = %+v, want stripped /btw question", lastMessage) + } + + history := al.GetRegistry().GetDefaultAgent().Sessions.GetHistory(sessionKey) + if !reflect.DeepEqual(history, initialHistory) { + t.Fatalf("session history = %#v, want %#v", history, initialHistory) + } +} + +func TestProcessMessage_BtwCommandIncludesRequestContextAndMedia(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + useTestSideQuestionProvider(al, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "discord", + SenderID: "discord:123", + Sender: bus.SenderInfo{ + DisplayName: "Alice", + }, + ChatID: "group-1", + Content: "/btw describe this image", + Media: []string{"media://image-1"}, + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages") + } + + systemPrompt := provider.lastMessages[0].Content + if !strings.Contains(systemPrompt, "## Current Session\nChannel: discord\nChat ID: group-1") { + t.Fatalf("system prompt missing current session context:\n%s", systemPrompt) + } + if !strings.Contains(systemPrompt, "## Current Sender\nCurrent sender: Alice (ID: discord:123)") { + t.Fatalf("system prompt missing current sender context:\n%s", systemPrompt) + } + + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + if lastMessage.Role != "user" || lastMessage.Content != "describe this image" { + t.Fatalf("last provider message = %+v, want stripped /btw question", lastMessage) + } + if !reflect.DeepEqual(lastMessage.Media, []string{"media://image-1"}) { + t.Fatalf("last provider media = %#v, want media ref", lastMessage.Media) + } +} + +func TestProcessMessage_BtwCommandUsesIsolatedProvider(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + // Add model list so isolated provider can resolve the model + ModelList: []*config.ModelConfig{ + {ModelName: "test-model", Model: "openai/test-model"}, + }, + } + + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + useTestSideQuestionProvider(al, provider) + defaultAgent := al.GetRegistry().GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + // Set up initial history for the main session + mainSessionKey := "telegram:123:chat-1" + initialHistory := []providers.Message{ + {Role: "user", Content: "We decided to avoid global state."}, + {Role: "assistant", Content: "Right, keep it request-scoped."}, + } + defaultAgent.Sessions.SetHistory(mainSessionKey, initialHistory) + + // Process a /btw command + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + SessionKey: mainSessionKey, + Content: "/btw explain isolation", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + + // Verify the provider received the side question + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages for /btw command") + } + + // Verify the question was stripped of /btw prefix + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + if lastMessage.Role != "user" || lastMessage.Content != "explain isolation" { + t.Fatalf("last provider message = %+v, want stripped /btw question", lastMessage) + } + + // Verify main session history was NOT modified + currentHistory := defaultAgent.Sessions.GetHistory(mainSessionKey) + if !reflect.DeepEqual(currentHistory, initialHistory) { + t.Fatalf("main session history was modified:\ngot %#v\nwant %#v", currentHistory, initialHistory) + } +} + +func TestProcessMessage_BtwCommandRetriesWithoutMediaOnVisionUnsupported(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + // Add model list so isolated provider can resolve the model + ModelList: []*config.ModelConfig{ + {ModelName: "test-model", Model: "openai/test-model"}, + }, + } + + msgBus := bus.NewMessageBus() + provider := &visionUnsupportedMediaProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + useTestSideQuestionProvider(al, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/btw describe this image", + Media: []string{"data:image/png;base64,abc123"}, + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "ok" { + t.Fatalf("processMessage() response = %q, want %q", response, "ok") + } + // Note: With isolated providers, each /btw creates a new provider instance, + // so we can't track calls across retries in the same way. + // The retry logic happens within askSideQuestion, creating separate isolated providers. + // For now, we just verify the command succeeds. + if provider.calls < 1 { + t.Fatalf("provider was not called for /btw command") + } +} + +func TestProcessMessage_BtwCommandUsesProviderFactoryModel(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "lb-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{ + {ModelName: "lb-model", Model: "openai/lb-model-a"}, + {ModelName: "lb-model", Model: "openai/lb-model-b"}, + }, + } + + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + useTestSideQuestionProvider(al, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/btw explain load balancing", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + + // Verify that /btw used the configured model from ModelList + // The provider should have been called with one of the lb-model variants + if provider.lastModel == "" { + t.Fatal("provider was not called for /btw command") + } + if !strings.HasPrefix(provider.lastModel, "lb-model") { + t.Fatalf("/btw used model %q, expected lb-model variant", provider.lastModel) + } +} + +func TestProcessMessage_BtwCommandHookModelBypassesFallbackCandidates(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "primary-model", + ModelFallbacks: []string{"fallback-model"}, + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + useTestSideQuestionProvider(al, provider) + if err := al.MountHook(NamedHook("rewrite-model", modelRewriteHook{model: "hook-model"})); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/btw explain hook routing", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if provider.lastModel != "hook-model" { + t.Fatalf("/btw model = %q, want hook-selected model", provider.lastModel) + } +} + +func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + agent := al.GetRegistry().GetDefaultAgent() + + opts := processOptions{} + reply, handled := al.handleCommand(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/use missing explain how to list files", + }, agent, &opts) + if !handled { + t.Fatal("expected /use with unknown skill to be handled") + } + if !strings.Contains(reply, "Unknown skill: missing") { + t.Fatalf("reply = %q, want unknown skill error", reply) + } +} + +func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) { + tmpDir := t.TempDir() + skillDir := filepath.Join(tmpDir, "skills", "shell") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("mkdir skill dir: %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("# shell\n\nPrefer concise shell commands and explain them briefly."), + 0o644, + ); err != nil { + t.Fatalf("write skill file: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/use shell", + })) + if err != nil { + t.Fatalf("processMessage() arm error = %v", err) + } + if !strings.Contains(response, `Skill "shell" is armed for your next message.`) { + t.Fatalf("arm response = %q, want armed confirmation", response) + } + + response, err = al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "explain how to list files", + })) + if err != nil { + t.Fatalf("processMessage() follow-up error = %v", err) + } + if response != "Mock response" { + t.Fatalf("follow-up response = %q, want %q", response, "Mock response") + } + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages") + } + + systemPrompt := provider.lastMessages[0].Content + if !strings.Contains(systemPrompt, "### Skill: shell") { + t.Fatalf("system prompt missing pending skill content:\n%s", systemPrompt) + } + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + if lastMessage.Role != "user" || lastMessage.Content != "explain how to list files" { + t.Fatalf("last provider message = %+v, want unchanged follow-up user message", lastMessage) + } +} + +func TestApplyExplicitSkillCommand_ArmsSkillForNextMessage(t *testing.T) { + al, cfg, _, _, cleanup := newTestAgentLoop(t) + defer cleanup() + + if err := os.MkdirAll(filepath.Join(cfg.Agents.Defaults.Workspace, "skills", "finance-news"), 0o755); err != nil { + t.Fatalf("MkdirAll(skill) error = %v", err) + } + if err := os.WriteFile( + filepath.Join(cfg.Agents.Defaults.Workspace, "skills", "finance-news", "SKILL.md"), + []byte("# Finance News\n\nUse web tools for current finance updates.\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(SKILL.md) error = %v", err) + } + + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + opts := &processOptions{SessionKey: "agent:main:test"} + matched, handled, reply := al.applyExplicitSkillCommand("/use finance-news", agent, opts) + if !matched { + t.Fatal("expected /use command to match") + } + if !handled { + t.Fatal("expected /use without inline message to be handled immediately") + } + if !strings.Contains(reply, `Skill "finance-news" is armed for your next message`) { + t.Fatalf("unexpected reply: %q", reply) + } + + pending := al.takePendingSkills(opts.SessionKey) + if len(pending) != 1 || pending[0] != "finance-news" { + t.Fatalf("pending skills = %#v, want [finance-news]", pending) + } +} + +func TestApplyExplicitSkillCommand_InlineMessageMutatesOptions(t *testing.T) { + al, cfg, _, _, cleanup := newTestAgentLoop(t) + defer cleanup() + + if err := os.MkdirAll(filepath.Join(cfg.Agents.Defaults.Workspace, "skills", "finance-news"), 0o755); err != nil { + t.Fatalf("MkdirAll(skill) error = %v", err) + } + if err := os.WriteFile( + filepath.Join(cfg.Agents.Defaults.Workspace, "skills", "finance-news", "SKILL.md"), + []byte("# Finance News\n\nUse web tools for current finance updates.\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(SKILL.md) error = %v", err) + } + + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + opts := &processOptions{ + SessionKey: "agent:main:test", + UserMessage: "/use finance-news dammi le ultime news", + } + matched, handled, reply := al.applyExplicitSkillCommand(opts.UserMessage, agent, opts) + if !matched { + t.Fatal("expected /use command to match") + } + if handled { + t.Fatal("expected /use with inline message to fall through into normal agent execution") + } + if reply != "" { + t.Fatalf("unexpected reply: %q", reply) + } + if opts.UserMessage != "dammi le ultime news" { + t.Fatalf("opts.UserMessage = %q, want %q", opts.UserMessage, "dammi le ultime news") + } + if len(opts.ForcedSkills) != 1 || opts.ForcedSkills[0] != "finance-news" { + t.Fatalf("opts.ForcedSkills = %#v, want [finance-news]", opts.ForcedSkills) + } +} + +func TestRecordLastChannel(t *testing.T) { + al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) + defer cleanup() + + testChannel := "test-channel" + if err := al.RecordLastChannel(testChannel); err != nil { + t.Fatalf("RecordLastChannel failed: %v", err) + } + if got := al.state.GetLastChannel(); got != testChannel { + t.Errorf("Expected channel '%s', got '%s'", testChannel, got) + } + al2 := NewAgentLoop(cfg, msgBus, provider) + if got := al2.state.GetLastChannel(); got != testChannel { + t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, got) + } +} + +func TestRecordLastChatID(t *testing.T) { + al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) + defer cleanup() + + testChatID := "test-chat-id-123" + if err := al.RecordLastChatID(testChatID); err != nil { + t.Fatalf("RecordLastChatID failed: %v", err) + } + if got := al.state.GetLastChatID(); got != testChatID { + t.Errorf("Expected chat ID '%s', got '%s'", testChatID, got) + } + al2 := NewAgentLoop(cfg, msgBus, provider) + if got := al2.state.GetLastChatID(); got != testChatID { + t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, got) + } +} + +func TestNewAgentLoop_StateInitialized(t *testing.T) { + // Create temp workspace + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create test config + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + // Create agent loop + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + // Verify state manager is initialized + if al.state == nil { + t.Error("Expected state manager to be initialized") + } + + // Verify state directory was created + stateDir := filepath.Join(tmpDir, "state") + if _, err := os.Stat(stateDir); os.IsNotExist(err) { + t.Error("Expected state directory to exist") + } +} + +// TestToolRegistry_ToolRegistration verifies tools can be registered and retrieved +func TestToolRegistry_ToolRegistration(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + // Register a custom tool + customTool := &mockCustomTool{} + al.RegisterTool(customTool) + + // Verify tool is registered by checking it doesn't panic on GetStartupInfo + // (actual tool retrieval is tested in tools package tests) + info := al.GetStartupInfo() + toolsInfo := info["tools"].(map[string]any) + toolsList := toolsInfo["names"].([]string) + + // Check that our custom tool name is in the list + found := slices.Contains(toolsList, "mock_custom") + if !found { + t.Error("Expected custom tool to be registered") + } +} + +// TestToolContext_Updates verifies tool context helpers work correctly +func TestToolContext_Updates(t *testing.T) { + ctx := tools.WithToolContext(context.Background(), "telegram", "chat-42") + + if got := tools.ToolChannel(ctx); got != "telegram" { + t.Errorf("expected channel 'telegram', got %q", got) + } + if got := tools.ToolChatID(ctx); got != "chat-42" { + t.Errorf("expected chatID 'chat-42', got %q", got) + } + + // Empty context returns empty strings + if got := tools.ToolChannel(context.Background()); got != "" { + t.Errorf("expected empty channel from bare context, got %q", got) + } + + inboundCtx := tools.WithToolInboundContext( + context.Background(), + "telegram", + "chat-42", + "msg-123", + "msg-100", + ) + if got := tools.ToolMessageID(inboundCtx); got != "msg-123" { + t.Errorf("expected messageID 'msg-123', got %q", got) + } + if got := tools.ToolReplyToMessageID(inboundCtx); got != "msg-100" { + t.Errorf("expected replyToMessageID 'msg-100', got %q", got) + } +} + +// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved +func TestToolRegistry_GetDefinitions(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + // Register a test tool and verify it shows up in startup info + testTool := &mockCustomTool{} + al.RegisterTool(testTool) + + info := al.GetStartupInfo() + toolsInfo := info["tools"].(map[string]any) + toolsList := toolsInfo["names"].([]string) + + // Check that our custom tool name is in the list + found := slices.Contains(toolsList, "mock_custom") + if !found { + t.Error("Expected custom tool to be registered") + } +} + +func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &handledMediaProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + store := media.NewFileMediaStore() + al.SetMediaStore(store) + telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} + al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel)) + + imagePath := filepath.Join(tmpDir, "screen.png") + if err := os.WriteFile(imagePath, []byte("fake screenshot"), 0o644); err != nil { + t.Fatalf("WriteFile(imagePath) error = %v", err) + } + + al.RegisterTool(&handledMediaTool{ + store: store, + path: imagePath, + }) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + ChatID: "chat1", + SenderID: "user1", + Content: "take a screenshot of the screen and send it to me", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "" { + t.Fatalf("expected no final response when media tool already handled delivery, got %q", response) + } + if provider.calls != 1 { + t.Fatalf("expected exactly 1 LLM call, got %d", provider.calls) + } + if len(provider.toolCounts) != 1 { + t.Fatalf("expected tool counts for 1 provider call, got %d", len(provider.toolCounts)) + } + if provider.toolCounts[0] == 0 { + t.Fatal("expected tools to be available on the first LLM call") + } + + if len(telegramChannel.sentMedia) != 1 { + t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia)) + } + if telegramChannel.sentMedia[0].Channel != "telegram" || telegramChannel.sentMedia[0].ChatID != "chat1" { + t.Fatalf("unexpected sent media target: %+v", telegramChannel.sentMedia[0]) + } + if len(telegramChannel.sentMedia[0].Parts) != 1 { + t.Fatalf("expected exactly 1 sent media part, got %d", len(telegramChannel.sentMedia[0].Parts)) + } + + select { + case extra := <-msgBus.OutboundMediaChan(): + t.Fatalf("expected handled media to bypass async queue, got %+v", extra) + default: + } + + defaultAgent := al.GetRegistry().GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + route, _, err := al.resolveMessageRoute(testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + ChatID: "chat1", + SenderID: "user1", + Content: "take a screenshot of the screen and send it to me", + })) + if err != nil { + t.Fatalf("resolveMessageRoute() error = %v", err) + } + sessionKey := resolveScopeKey(al.allocateRouteSession(route, testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + ChatID: "chat1", + SenderID: "user1", + Content: "take a screenshot of the screen and send it to me", + })).SessionKey, "") + history := defaultAgent.Sessions.GetHistory(sessionKey) + if len(history) == 0 { + t.Fatal("expected session history to be saved") + } + last := history[len(history)-1] + if last.Role != "assistant" || last.Content != "Requested output delivered via tool attachment." { + t.Fatalf("expected handled assistant summary in history, got %+v", last) + } + if len(last.Attachments) != 1 { + t.Fatalf("expected handled assistant summary attachments in history, got %+v", last.Attachments) + } +} + +func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &handledMediaWithSteeringProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + store := media.NewFileMediaStore() + al.SetMediaStore(store) + telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} + al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel)) + + imagePath := filepath.Join(tmpDir, "screen-steering.png") + if err := os.WriteFile(imagePath, []byte("fake screenshot"), 0o644); err != nil { + t.Fatalf("WriteFile(imagePath) error = %v", err) + } + + al.RegisterTool(&handledMediaWithSteeringTool{ + store: store, + path: imagePath, + loop: al, + }) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + ChatID: "chat1", + SenderID: "user1", + Content: "take a screenshot of the screen and send it to me", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Handled the queued steering message." { + t.Fatalf("response = %q, want queued steering response", response) + } + if provider.calls != 2 { + t.Fatalf("expected 2 LLM calls after queued steering, got %d", provider.calls) + } + if len(telegramChannel.sentMedia) != 1 { + t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia)) + } +} + +func TestRunAgentLoop_ResponseHandledToolPublishesForUserWhenSendResponseDisabled(t *testing.T) { + tmpDir := t.TempDir() + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = tmpDir + cfg.Agents.Defaults.ModelName = "test-model" + cfg.Agents.Defaults.MaxTokens = 4096 + cfg.Agents.Defaults.MaxToolIterations = 10 + + msgBus := bus.NewMessageBus() + provider := &handledUserProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + store := media.NewFileMediaStore() + al.SetMediaStore(store) + telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} + al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel)) + al.RegisterTool(&handledUserTool{}) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + response, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{ + Dispatch: DispatchRequest{ + SessionKey: "session-1", + UserMessage: "take a screenshot of the screen and send it to me", + SessionScope: &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: defaultAgent.ID, + Channel: "telegram", + Dimensions: []string{"chat"}, + Values: map[string]string{ + "chat": "direct:chat1", + }, + }, + InboundContext: &bus.InboundContext{ + Channel: "telegram", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + }, + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if response != "" { + t.Fatalf("expected no final response when tool already handled delivery, got %q", response) + } + + deadline := time.Now().Add(2 * time.Second) + for len(telegramChannel.sentMessages) == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if len(telegramChannel.sentMessages) != 1 { + t.Fatalf("expected exactly 1 sent text message, got %d", len(telegramChannel.sentMessages)) + } + if telegramChannel.sentMessages[0].Content != "Handled user output from tool." { + t.Fatalf("unexpected sent text message: %+v", telegramChannel.sentMessages[0]) + } + if telegramChannel.sentMessages[0].AgentID != defaultAgent.ID { + t.Fatalf("sent text agent_id = %q, want %q", telegramChannel.sentMessages[0].AgentID, defaultAgent.ID) + } + if telegramChannel.sentMessages[0].SessionKey != "session-1" { + t.Fatalf("sent text session_key = %q, want session-1", telegramChannel.sentMessages[0].SessionKey) + } + if telegramChannel.sentMessages[0].Scope == nil || + telegramChannel.sentMessages[0].Scope.Values["chat"] != "direct:chat1" { + t.Fatalf("unexpected sent text scope: %+v", telegramChannel.sentMessages[0].Scope) + } +} + +func TestAppendEventContextFields_IncludesInboundRouteAndScope(t *testing.T) { + fields := map[string]any{} + + appendEventContextFields(fields, &TurnContext{ + Inbound: &bus.InboundContext{ + Channel: "slack", + Account: "workspace-a", + ChatID: "C123", + ChatType: "channel", + TopicID: "thread-42", + SpaceType: "workspace", + SpaceID: "T001", + SenderID: "U123", + Mentioned: true, + }, + Route: &routing.ResolvedRoute{ + AgentID: "support", + Channel: "slack", + AccountID: "workspace-a", + MatchedBy: "default", + SessionPolicy: routing.SessionPolicy{ + Dimensions: []string{"chat", "sender"}, + IdentityLinks: map[string][]string{ + "canonical-user": {"slack:U123"}, + }, + }, + }, + Scope: &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "support", + Channel: "slack", + Account: "workspace-a", + Dimensions: []string{"chat", "sender"}, + Values: map[string]string{ + "chat": "channel:c123", + "sender": "u123", + }, + }, + }) + + if fields["inbound_channel"] != "slack" { + t.Fatalf("inbound_channel = %v, want slack", fields["inbound_channel"]) + } + if fields["inbound_topic_id"] != "thread-42" { + t.Fatalf("inbound_topic_id = %v, want thread-42", fields["inbound_topic_id"]) + } + if fields["route_matched_by"] != "default" { + t.Fatalf("route_matched_by = %v, want default", fields["route_matched_by"]) + } + if fields["route_dimensions"] != "chat,sender" { + t.Fatalf("route_dimensions = %v, want chat,sender", fields["route_dimensions"]) + } + if fields["route_identity_link_count"] != 1 { + t.Fatalf("route_identity_link_count = %v, want 1", fields["route_identity_link_count"]) + } + if fields["scope_dimensions"] != "chat,sender" { + t.Fatalf("scope_dimensions = %v, want chat,sender", fields["scope_dimensions"]) + } + if fields["scope_chat"] != "channel:c123" { + t.Fatalf("scope_chat = %v, want channel:c123", fields["scope_chat"]) + } + if fields["scope_sender"] != "u123" { + t.Fatalf("scope_sender = %v, want u123", fields["scope_sender"]) + } +} + +func TestResolveMessageRoute_UsesInboundContextAccount(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + }, + List: []config.AgentConfig{ + {ID: "main", Default: true}, + {ID: "work"}, + }, + }, + Session: config.SessionConfig{ + Dimensions: []string{"sender"}, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "ok"}) + + route, _, err := al.resolveMessageRoute(testInboundMessage(bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "slack", + Account: "workspace-a", + ChatID: "C123", + ChatType: "channel", + SenderID: "U123", + SpaceID: "T001", + SpaceType: "workspace", + }, + Content: "hello", + })) + if err != nil { + t.Fatalf("resolveMessageRoute() error = %v", err) + } + if route.AgentID != "main" { + t.Fatalf("AgentID = %q, want main", route.AgentID) + } + if route.MatchedBy != "default" { + t.Fatalf("MatchedBy = %q, want default", route.MatchedBy) + } + if route.AccountID != "workspace-a" { + t.Fatalf("AccountID = %q, want workspace-a", route.AccountID) + } +} + +func TestResolveMessageRoute_UsesDispatchRulesInOrder(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + }, + List: []config.AgentConfig{ + {ID: "main", Default: true}, + {ID: "support"}, + {ID: "sales"}, + }, + Dispatch: &config.DispatchConfig{ + Rules: []config.DispatchRule{ + { + Name: "support-group", + Agent: "support", + When: config.DispatchSelector{ + Channel: "telegram", + Chat: "group:-100123", + }, + SessionDimensions: []string{"chat"}, + }, + { + Name: "vip-in-group", + Agent: "sales", + When: config.DispatchSelector{ + Channel: "telegram", + Chat: "group:-100123", + Sender: "12345", + }, + SessionDimensions: []string{"chat", "sender"}, + }, + }, + }, + }, + Session: config.SessionConfig{ + Dimensions: []string{"sender"}, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "ok"}) + + route, _, err := al.resolveMessageRoute(testInboundMessage(bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "-100123", + ChatType: "group", + SenderID: "12345", + }, + Content: "hello", + })) + if err != nil { + t.Fatalf("resolveMessageRoute() error = %v", err) + } + if route.AgentID != "support" { + t.Fatalf("AgentID = %q, want support", route.AgentID) + } + if route.MatchedBy != "dispatch.rule:support-group" { + t.Fatalf("MatchedBy = %q, want dispatch.rule:support-group", route.MatchedBy) + } + if got := route.SessionPolicy.Dimensions; len(got) != 1 || got[0] != "chat" { + t.Fatalf("SessionPolicy.Dimensions = %v, want [chat]", got) + } +} + +func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) { + tmpDir := t.TempDir() + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = tmpDir + cfg.Agents.Defaults.ModelName = "test-model" + cfg.Agents.Defaults.MaxTokens = 4096 + cfg.Agents.Defaults.MaxToolIterations = 10 + + msgBus := bus.NewMessageBus() + provider := &artifactThenSendProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + store := media.NewFileMediaStore() + al.SetMediaStore(store) + telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} + al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel)) + + mediaDir := media.TempDir() + if err := os.MkdirAll(mediaDir, 0o700); err != nil { + t.Fatalf("MkdirAll(mediaDir) error = %v", err) + } + imagePath := filepath.Join(mediaDir, "artifact-screen.png") + if err := os.WriteFile(imagePath, []byte("fake screenshot"), 0o644); err != nil { + t.Fatalf("WriteFile(imagePath) error = %v", err) + } + + al.RegisterTool(&mediaArtifactTool{ + store: store, + path: imagePath, + }) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + ChatID: "chat1", + SenderID: "user1", + Content: "take a screenshot of the screen and send it to me", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "" { + t.Fatalf("expected no final response after send_file handled delivery, got %q", response) + } + if provider.calls != 2 { + t.Fatalf("expected 2 LLM calls (artifact + send_file), got %d", provider.calls) + } + + if len(telegramChannel.sentMedia) != 1 { + t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia)) + } + if telegramChannel.sentMedia[0].Channel != "telegram" || telegramChannel.sentMedia[0].ChatID != "chat1" { + t.Fatalf("unexpected sent media target: %+v", telegramChannel.sentMedia[0]) + } + if len(telegramChannel.sentMedia[0].Parts) != 1 { + t.Fatalf("expected exactly 1 sent media part, got %d", len(telegramChannel.sentMedia[0].Parts)) + } + + select { + case extra := <-msgBus.OutboundMediaChan(): + t.Fatalf("expected synchronous send_file delivery to bypass async queue, got %+v", extra) + default: + } +} + +// TestAgentLoop_GetStartupInfo verifies startup info contains tools +func TestAgentLoop_GetStartupInfo(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = tmpDir + cfg.Agents.Defaults.ModelName = "test-model" + cfg.Agents.Defaults.MaxTokens = 4096 + cfg.Agents.Defaults.MaxToolIterations = 10 + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + info := al.GetStartupInfo() + + // Verify tools info exists + toolsInfo, ok := info["tools"] + if !ok { + t.Fatal("Expected 'tools' key in startup info") + } + + toolsMap, ok := toolsInfo.(map[string]any) + if !ok { + t.Fatal("Expected 'tools' to be a map") + } + + count, ok := toolsMap["count"] + if !ok { + t.Fatal("Expected 'count' in tools info") + } + + // Should have default tools registered + if count.(int) == 0 { + t.Error("Expected at least some tools to be registered") + } +} + +// TestAgentLoop_Stop verifies Stop() sets running to false +func TestAgentLoop_Stop(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + // Note: running is only set to true when Run() is called + // We can't test that without starting the event loop + // Instead, verify the Stop method can be called safely + al.Stop() + + // Verify running is false (initial state or after Stop) + if al.running.Load() { + t.Error("Expected agent to be stopped (or never started)") + } +} + +// Mock implementations for testing + +type simpleMockProvider struct { + response string +} + +func (m *simpleMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{ + Content: m.response, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *simpleMockProvider) GetDefaultModel() string { + return "mock-model" +} + +type reasoningContentProvider struct { + response string + reasoningContent string +} + +func (m *reasoningContentProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{ + Content: m.response, + ReasoningContent: m.reasoningContent, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *reasoningContentProvider) GetDefaultModel() string { + return "reasoning-content-model" +} + +type countingMockProvider struct { + response string + calls int +} + +func (m *countingMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + return &providers.LLMResponse{ + Content: m.response, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *countingMockProvider) GetDefaultModel() string { + return "counting-mock-model" +} + +type handledMediaProvider struct { + calls int + toolCounts []int +} + +func (m *handledMediaProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + m.toolCounts = append(m.toolCounts, len(tools)) + if m.calls == 1 { + return &providers.LLMResponse{ + Content: "Taking the screenshot now.", + ToolCalls: []providers.ToolCall{{ + ID: "call_handled_media", + Type: "function", + Name: "handled_media_tool", + Arguments: map[string]any{}, + }}, + }, nil + } + return &providers.LLMResponse{}, nil +} + +func (m *handledMediaProvider) GetDefaultModel() string { + return "handled-media-model" +} + +type handledUserProvider struct { + calls int +} + +func (m *handledUserProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + Content: "Delivering the result now.", + ToolCalls: []providers.ToolCall{{ + ID: "call_handled_user", + Type: "function", + Name: "handled_user_tool", + Arguments: map[string]any{}, + }}, + }, nil + } + return &providers.LLMResponse{}, nil +} + +func (m *handledUserProvider) GetDefaultModel() string { + return "handled-user-model" +} + +type messageToolProvider struct { + calls int +} + +func (m *messageToolProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + Content: "", + ToolCalls: []providers.ToolCall{{ + ID: "call_message", + Type: "function", + Name: "message", + Arguments: map[string]any{"content": "direct tool message"}, + }}, + }, nil + } + return &providers.LLMResponse{}, nil +} + +func (m *messageToolProvider) GetDefaultModel() string { + return "message-tool-model" +} + +type reasoningVisibleToolProvider struct { + filePath string + calls int +} + +func (m *reasoningVisibleToolProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + Content: "I'll inspect that file now.", + ReasoningContent: "Read the file before answering.", + ToolCalls: []providers.ToolCall{{ + ID: "call_read_file", + Type: "function", + Name: "read_file", + Arguments: map[string]any{"path": m.filePath}, + }}, + }, nil + } + return &providers.LLMResponse{Content: "DONE"}, nil +} + +func (m *reasoningVisibleToolProvider) GetDefaultModel() string { + return "reasoning-visible-tool-model" +} + +type artifactThenSendProvider struct { + calls int +} + +func (m *artifactThenSendProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + Content: "Taking the screenshot now.", + ToolCalls: []providers.ToolCall{{ + ID: "call_artifact_media", + Type: "function", + Name: "media_artifact_tool", + Arguments: map[string]any{}, + }}, + }, nil + } + + var artifactPath string + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role != "tool" { + continue + } + for _, prefix := range []string{"[image:", "[file:", "[audio:", "[video:"} { + start := strings.Index(messages[i].Content, prefix) + if start < 0 { + continue + } + rest := messages[i].Content[start+len(prefix):] + end := strings.Index(rest, "]") + if end < 0 { + continue + } + artifactPath = rest[:end] + break + } + if artifactPath != "" { + break + } + } + if artifactPath == "" { + return nil, fmt.Errorf("provider did not receive artifact path in tool result") + } + + return &providers.LLMResponse{ + Content: "", + ToolCalls: []providers.ToolCall{{ + ID: "call_send_file", + Type: "function", + Name: "send_file", + Arguments: map[string]any{"path": artifactPath}, + }}, + }, nil +} + +func (m *artifactThenSendProvider) GetDefaultModel() string { + return "artifact-then-send-model" +} + +type toolFeedbackProvider struct { + filePath string + calls int +} + +func (m *toolFeedbackProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{{ + ID: "call_heartbeat_read_file", + Type: "function", + Name: "read_file", + Arguments: map[string]any{"path": m.filePath}, + }}, + }, nil + } + + return &providers.LLMResponse{ + Content: "HEARTBEAT_OK", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *toolFeedbackProvider) GetDefaultModel() string { + return "heartbeat-tool-feedback-model" +} + +type toolFeedbackReasoningProvider struct { + filePath string + calls int +} + +func (m *toolFeedbackReasoningProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + ReasoningContent: "Read README.md first to confirm the context that needs to be changed.", + ToolCalls: []providers.ToolCall{{ + ID: "call_reasoning_read_file", + Type: "function", + Name: "read_file", + Arguments: map[string]any{"path": m.filePath}, + }}, + }, nil + } + + return &providers.LLMResponse{ + Content: "DONE", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *toolFeedbackReasoningProvider) GetDefaultModel() string { + return "tool-feedback-reasoning-model" +} + +func TestToolFeedbackExplanationFromResponse_UsesCurrentContentFirst(t *testing.T) { + response := &providers.LLMResponse{ + Content: "Read README.md first", + ReasoningContent: "current reasoning fallback", + } + messages := []providers.Message{ + {Role: "user", Content: "check file"}, + {Role: "assistant", Content: "Previous turn explanation"}, + {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, + } + + got := toolFeedbackExplanationFromResponse(response, messages) + if got != "Read README.md first" { + t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want current content", got) + } +} + +func TestSideQuestionResponseContent_FallsBackWhenContentIsWhitespace(t *testing.T) { + response := &providers.LLMResponse{ + Content: " \n\t ", + ReasoningContent: "reasoning fallback", + } + + if got := sideQuestionResponseContent(response); got != "reasoning fallback" { + t.Fatalf("sideQuestionResponseContent() = %q, want %q", got, "reasoning fallback") + } +} + +func TestResponseReasoningContent_FallsBackWhenReasoningIsWhitespace(t *testing.T) { + response := &providers.LLMResponse{ + Reasoning: " \n\t ", + ReasoningContent: "structured reasoning fallback", + } + + if got := responseReasoningContent(response); got != "structured reasoning fallback" { + t.Fatalf("responseReasoningContent() = %q, want %q", got, "structured reasoning fallback") + } +} + +func TestToolFeedbackExplanationFromResponse_UsesExplicitToolCallExtraContent(t *testing.T) { + response := &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{{ + ID: "call_1", + Name: "read_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read README.md first to confirm the current project structure.", + }, + }}, + } + messages := []providers.Message{ + {Role: "user", Content: "check file"}, + {Role: "assistant", Content: ""}, + {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, + } + + got := toolFeedbackExplanationFromResponse(response, messages) + if got != "Read README.md first to confirm the current project structure." { + t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want explicit tool feedback explanation", got) + } +} + +func TestToolFeedbackExplanationForToolCall_PrefersToolSpecificExtraContent(t *testing.T) { + response := &providers.LLMResponse{ + Content: "Shared explanation", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Name: "read_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read README.md first.", + }, + }, + { + ID: "call_2", + Name: "edit_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Update config example after reading it.", + }, + }, + }, + } + + got1 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil) + got2 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[1], nil) + if got1 != "Read README.md first." { + t.Fatalf("toolFeedbackExplanationForToolCall() first = %q, want tool-specific explanation", got1) + } + if got2 != "Update config example after reading it." { + t.Fatalf("toolFeedbackExplanationForToolCall() second = %q, want tool-specific explanation", got2) + } +} + +func TestToolFeedbackExplanationForToolCall_DoesNotReuseAnotherToolCallExplanation(t *testing.T) { + response := &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Name: "read_file", + }, + { + ID: "call_2", + Name: "edit_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Update config example after reading it.", + }, + }, + }, + } + messages := []providers.Message{ + {Role: "user", Content: "inspect the config and update the example"}, + } + + got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], messages) + want := utils.ToolFeedbackContinuationHint + ": inspect the config and update the example" + if got != want { + t.Fatalf("toolFeedbackExplanationForToolCall() = %q, want %q", got, want) + } +} + +func TestToolFeedbackExplanationFromResponse_DoesNotUseReasoningContent(t *testing.T) { + response := &providers.LLMResponse{ + Content: "", + ReasoningContent: "hidden reasoning should not be shown", + } + messages := []providers.Message{ + {Role: "user", Content: "check file"}, + {Role: "assistant", Content: "Previous turn explanation"}, + {Role: "user", Content: "Inspect README.md and update the config example."}, + {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, + } + + got := toolFeedbackExplanationFromResponse(response, messages) + want := utils.ToolFeedbackContinuationHint + ": Inspect README.md and update the config example." + if got != want { + t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want latest user content fallback", got) + } +} + +func TestToolFeedbackExplanationForToolCall_DoesNotTruncateLongExplanation(t *testing.T) { + explanation := "Read README.md first to confirm the current project structure before editing the config example." + response := &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{{ + ID: "call_1", + Name: "read_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: explanation, + }, + }}, + } + + got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil) + if got != explanation { + t.Fatalf("toolFeedbackExplanationForToolCall() = %q, want full explanation", got) + } +} + +func TestToolFeedbackArgsPreview_UsesJSONAndTruncates(t *testing.T) { + got := toolFeedbackArgsPreview(map[string]any{ + "path": "README.md", + "limit": 42, + }, 128) + want := "{\n \"limit\": 42,\n \"path\": \"README.md\"\n}" + if got != want { + t.Fatalf("toolFeedbackArgsPreview() = %q, want %q", got, want) + } +} + +type picoInterleavedContentProvider struct { + calls int +} + +func (m *picoInterleavedContentProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + Content: "intermediate model text", + ToolCalls: []providers.ToolCall{{ + ID: "call_tool_limit_test", + Type: "function", + Name: "tool_limit_test_tool", + Arguments: map[string]any{"value": "x"}, + }}, + }, nil + } + + return &providers.LLMResponse{ + Content: "final model text", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *picoInterleavedContentProvider) GetDefaultModel() string { + return "pico-interleaved-content-model" +} + +type picoDistinctToolCallContentProvider struct { + calls int +} + +func (m *picoDistinctToolCallContentProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + Content: "intermediate model text", + ToolCalls: []providers.ToolCall{{ + ID: "call_tool_limit_test", + Type: "function", + Name: "tool_limit_test_tool", + Arguments: map[string]any{"value": "x"}, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }}, + }, nil + } + + return &providers.LLMResponse{ + Content: "final model text", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *picoDistinctToolCallContentProvider) GetDefaultModel() string { + return "pico-distinct-tool-call-content-model" +} + +type toolLimitOnlyProvider struct{} + +func (m *toolLimitOnlyProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{{ + ID: "call_tool_limit_test", + Type: "function", + Name: "tool_limit_test_tool", + Arguments: map[string]any{"value": "x"}, + }}, + }, nil +} + +func (m *toolLimitOnlyProvider) GetDefaultModel() string { + return "tool-limit-only-model" +} + +// mockCustomTool is a simple mock tool for registration testing +type mockCustomTool struct{} + +func (m *mockCustomTool) Name() string { + return "mock_custom" +} + +func (m *mockCustomTool) Description() string { + return "Mock custom tool for testing" +} + +func (m *mockCustomTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "additionalProperties": true, + } +} + +func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + return tools.SilentResult("Custom tool executed") +} + +type handledMediaTool struct { + store media.MediaStore + path string +} + +func (m *handledMediaTool) Name() string { return "handled_media_tool" } +func (m *handledMediaTool) Description() string { + return "Returns a media attachment and fully handles the user response" +} + +func (m *handledMediaTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + } +} + +func (m *handledMediaTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + ref, err := m.store.Store(m.path, media.MediaMeta{ + Filename: filepath.Base(m.path), + ContentType: "image/png", + Source: "test:handled_media_tool", + }, "test:handled_media") + if err != nil { + return tools.ErrorResult(err.Error()).WithError(err) + } + return tools.MediaResult("Attachment delivered by tool.", []string{ref}).WithResponseHandled() +} + +type handledUserTool struct{} + +func (m *handledUserTool) Name() string { return "handled_user_tool" } +func (m *handledUserTool) Description() string { + return "Returns a user-visible result and marks delivery as handled" +} + +func (m *handledUserTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + } +} + +func (m *handledUserTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + return tools.UserResult("Handled user output from tool.").WithResponseHandled() +} + +type handledMediaWithSteeringProvider struct { + calls int +} + +func (m *handledMediaWithSteeringProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + Content: "Taking the screenshot now.", + ToolCalls: []providers.ToolCall{{ + ID: "call_handled_media_steering", + Type: "function", + Name: "handled_media_with_steering_tool", + Arguments: map[string]any{}, + }}, + }, nil + } + + for _, msg := range messages { + if msg.Role == "user" && msg.Content == "what about this instead?" { + return &providers.LLMResponse{Content: "Handled the queued steering message."}, nil + } + } + + return nil, fmt.Errorf("provider did not receive queued steering message") +} + +func (m *handledMediaWithSteeringProvider) GetDefaultModel() string { + return "handled-media-with-steering-model" +} + +type handledMediaWithSteeringTool struct { + store media.MediaStore + path string + loop *AgentLoop +} + +func (m *handledMediaWithSteeringTool) Name() string { return "handled_media_with_steering_tool" } +func (m *handledMediaWithSteeringTool) Description() string { + return "Returns handled media and enqueues a steering message during execution" +} + +func (m *handledMediaWithSteeringTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + } +} + +func (m *handledMediaWithSteeringTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + if err := m.loop.Steer(providers.Message{Role: "user", Content: "what about this instead?"}); err != nil { + return tools.ErrorResult(err.Error()).WithError(err) + } + + ref, err := m.store.Store(m.path, media.MediaMeta{ + Filename: filepath.Base(m.path), + ContentType: "image/png", + Source: "test:handled_media_with_steering_tool", + }, "test:handled_media_with_steering") + if err != nil { + return tools.ErrorResult(err.Error()).WithError(err) + } + return tools.MediaResult("Attachment delivered by tool.", []string{ref}).WithResponseHandled() +} + +type mediaArtifactTool struct { + store media.MediaStore + path string +} + +func (m *mediaArtifactTool) Name() string { return "media_artifact_tool" } +func (m *mediaArtifactTool) Description() string { + return "Returns a media artifact that the agent can forward or save later" +} + +func (m *mediaArtifactTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + } +} + +func (m *mediaArtifactTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + ref, err := m.store.Store(m.path, media.MediaMeta{ + Filename: filepath.Base(m.path), + ContentType: "image/png", + Source: "test:media_artifact_tool", + }, "test:media_artifact") + if err != nil { + return tools.ErrorResult(err.Error()).WithError(err) + } + return tools.MediaResult("Artifact created.", []string{ref}) +} + +type toolLimitTestTool struct{} + +func (m *toolLimitTestTool) Name() string { + return "tool_limit_test_tool" +} + +func (m *toolLimitTestTool) Description() string { + return "Tool used to exhaust the iteration budget in tests" +} + +func (m *toolLimitTestTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "value": map[string]any{"type": "string"}, + }, + } +} + +func (m *toolLimitTestTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + return tools.SilentResult("tool limit test result") +} + +// testHelper executes a message and returns the response +type testHelper struct { + al *AgentLoop +} + +func newChatCompletionTestServer( + t *testing.T, + label string, + response string, + calls *int, + model *string, +) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("%s server path = %q, want /chat/completions", label, r.URL.Path) + } + *calls = *calls + 1 + defer r.Body.Close() + + var req struct { + Model string `json:"model"` + } + decodeErr := json.NewDecoder(r.Body).Decode(&req) + if decodeErr != nil { + t.Fatalf("decode %s request: %v", label, decodeErr) + } + *model = req.Model + + w.Header().Set("Content-Type", "application/json") + encodeErr := json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": response}, + "finish_reason": "stop", + }, + }, + }) + if encodeErr != nil { + t.Fatalf("encode %s response: %v", label, encodeErr) + } + })) +} + +func newStrictChatCompletionTestServer( + t *testing.T, + label string, + expectedModel string, + response string, + calls *int, +) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("%s server path = %q, want /chat/completions", label, r.URL.Path) + } + *calls = *calls + 1 + defer r.Body.Close() + + var req struct { + Model string `json:"model"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode %s request: %v", label, err) + } + if req.Model != expectedModel { + t.Fatalf("%s server model = %q, want %q", label, req.Model, expectedModel) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": response}, + "finish_reason": "stop", + }, + }, + }); err != nil { + t.Fatalf("encode %s response: %v", label, err) + } + })) +} + +func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string { + // Use a short timeout to avoid hanging + timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout) + defer cancel() + + response, err := h.al.processMessage(timeoutCtx, testInboundMessage(msg)) + if err != nil { + tb.Fatalf("processMessage failed: %v", err) + } + return response +} + +func testInboundMessage(msg bus.InboundMessage) bus.InboundMessage { + if msg.Context.Channel == "" && + msg.Context.Account == "" && + msg.Context.ChatID == "" && + msg.Context.ChatType == "" && + msg.Context.TopicID == "" && + msg.Context.SpaceID == "" && + msg.Context.SpaceType == "" && + msg.Context.SenderID == "" && + msg.Context.MessageID == "" && + !msg.Context.Mentioned && + msg.Context.ReplyToMessageID == "" && + msg.Context.ReplyToSenderID == "" && + len(msg.Context.ReplyHandles) == 0 && + len(msg.Context.Raw) == 0 { + msg.Context = bus.InboundContext{ + Channel: msg.Channel, + ChatID: msg.ChatID, + ChatType: "direct", + SenderID: msg.SenderID, + MessageID: msg.MessageID, + } + } + return bus.NormalizeInboundMessage(msg) +} + +const responseTimeout = 3 * time.Second + +func TestProcessMessage_UsesRouteSessionKey(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &simpleMockProvider{response: "ok"} + al := NewAgentLoop(cfg, msgBus, provider) + + msg := bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Content: "hello", + } + + route := al.registry.ResolveRoute(bus.NormalizeInboundMessage(msg).Context) + sessionKey := al.allocateRouteSession(route, msg).SessionKey + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("No default agent found") + } + + helper := testHelper{al: al} + _ = helper.executeAndGetResponse(t, context.Background(), msg) + + history := defaultAgent.Sessions.GetHistory(sessionKey) + if len(history) != 2 { + t.Fatalf("expected session history len=2, got %d", len(history)) + } + if history[0].Role != "user" || history[0].Content != "hello" { + t.Fatalf("unexpected first message in session: %+v", history[0]) + } +} + +func TestProcessMessage_CommandOutcomes(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Session: config.SessionConfig{ + Dimensions: []string{"chat"}, + }, + } + + msgBus := bus.NewMessageBus() + provider := &countingMockProvider{response: "LLM reply"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + baseMsg := bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "whatsapp", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + } + + showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: baseMsg.Context.Channel, + ChatID: baseMsg.Context.ChatID, + ChatType: baseMsg.Context.ChatType, + SenderID: baseMsg.Context.SenderID, + }, + Content: "/show channel", + }) + if showResp != "Current Channel: whatsapp" { + t.Fatalf("unexpected /show reply: %q", showResp) + } + if provider.calls != 0 { + t.Fatalf("LLM should not be called for handled command, calls=%d", provider.calls) + } + + fooResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: baseMsg.Context.Channel, + ChatID: baseMsg.Context.ChatID, + ChatType: baseMsg.Context.ChatType, + SenderID: baseMsg.Context.SenderID, + }, + Content: "/foo", + }) + if fooResp != "LLM reply" { + t.Fatalf("unexpected /foo reply: %q", fooResp) + } + if provider.calls != 1 { + t.Fatalf("LLM should be called exactly once after /foo passthrough, calls=%d", provider.calls) + } + + newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: baseMsg.Context.Channel, + ChatID: baseMsg.Context.ChatID, + ChatType: baseMsg.Context.ChatType, + SenderID: baseMsg.Context.SenderID, + }, + Content: "/new", + }) + if newResp != "LLM reply" { + t.Fatalf("unexpected /new reply: %q", newResp) + } + if provider.calls != 2 { + t.Fatalf("LLM should be called for passthrough /new command, calls=%d", provider.calls) + } +} + +func TestProcessMessage_MCPCommandsHandledWithoutLLMCall(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + deferred := true + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Session: config.SessionConfig{ + Dimensions: []string{"chat"}, + }, + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Discovery: config.ToolDiscoveryConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "github": { + Enabled: true, + Deferred: &deferred, + }, + }, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &countingMockProvider{response: "LLM reply"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + baseContext := bus.InboundContext{ + Channel: "whatsapp", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + } + + listResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Context: baseContext, + Content: "/list mcp", + }) + if !strings.Contains(listResp, "- `github`") || !strings.Contains(listResp, "Deferred: yes") { + t.Fatalf("unexpected /list mcp reply: %q", listResp) + } + if provider.calls != 0 { + t.Fatalf("LLM should not be called for /list mcp, calls=%d", provider.calls) + } + + showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Context: baseContext, + Content: "/show mcp github", + }) + if showResp != "MCP server 'github' is configured but not connected" { + t.Fatalf("unexpected /show mcp reply: %q", showResp) + } + if provider.calls != 0 { + t.Fatalf("LLM should not be called for /show mcp, calls=%d", provider.calls) + } +} + +func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Provider: "openai", + ModelName: "local", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "local", + Model: "openai/local-model", + APIBase: "https://local.example.invalid/v1", + APIKeys: config.SimpleSecureStrings("test-key"), + }, + { + ModelName: "deepseek", + Model: "openrouter/deepseek/deepseek-v3.2", + APIBase: "https://openrouter.ai/api/v1", + APIKeys: config.SimpleSecureStrings("test-key"), + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &countingMockProvider{response: "LLM reply"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/switch model to deepseek", + }) + if !strings.Contains(switchResp, "Switched model from local to deepseek") { + t.Fatalf("unexpected /switch reply: %q", switchResp) + } + + showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/show model", + }) + if !strings.Contains(showResp, "Current Model: deepseek (Provider: openrouter)") { + t.Fatalf("unexpected /show model reply after switch: %q", showResp) + } + + if provider.calls != 0 { + t.Fatalf("LLM should not be called for /switch and /show, calls=%d", provider.calls) + } +} + +func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Provider: "openai", + ModelName: "local", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "local", + Model: "openai/local-model", + APIBase: "https://local.example.invalid/v1", + APIKeys: config.SimpleSecureStrings("test-key"), + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &countingMockProvider{response: "LLM reply"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/switch model to missing", + }) + if switchResp != `model "missing" not found in model_list or providers` { + t.Fatalf("unexpected /switch error reply: %q", switchResp) + } + + showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/show model", + }) + if !strings.Contains(showResp, "Current Model: local (Provider: openai)") { + t.Fatalf("unexpected /show model reply after rejected switch: %q", showResp) + } + + if provider.calls != 0 { + t.Fatalf("LLM should not be called for rejected /switch and /show, calls=%d", provider.calls) + } +} + +func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + localCalls := 0 + localModel := "" + localServer := newChatCompletionTestServer(t, "local", "local reply", &localCalls, &localModel) + defer localServer.Close() + + remoteCalls := 0 + remoteModel := "" + remoteServer := newChatCompletionTestServer(t, "remote", "remote reply", &remoteCalls, &remoteModel) + defer remoteServer.Close() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Provider: "openai", + ModelName: "local", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "local", + Model: "openai/Qwen3.5-35B-A3B", + APIBase: localServer.URL, + APIKeys: config.SimpleSecureStrings("local-key"), + }, + { + ModelName: "deepseek", + Model: "openrouter/deepseek/deepseek-v3.2", + APIBase: remoteServer.URL, + APIKeys: config.SimpleSecureStrings("remote-key"), + }, + }, + } + + msgBus := bus.NewMessageBus() + provider, _, err := providers.CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + firstResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hello before switch", + }) + if firstResp != "local reply" { + t.Fatalf("unexpected response before switch: %q", firstResp) + } + if localCalls != 1 { + t.Fatalf("local calls before switch = %d, want 1", localCalls) + } + if remoteCalls != 0 { + t.Fatalf("remote calls before switch = %d, want 0", remoteCalls) + } + if localModel != "Qwen3.5-35B-A3B" { + t.Fatalf("local model before switch = %q, want %q", localModel, "Qwen3.5-35B-A3B") + } + + switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/switch model to deepseek", + }) + if !strings.Contains(switchResp, "Switched model from local to deepseek") { + t.Fatalf("unexpected /switch reply: %q", switchResp) + } + + secondResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hello after switch", + }) + if secondResp != "remote reply" { + t.Fatalf("unexpected response after switch: %q", secondResp) + } + if localCalls != 1 { + t.Fatalf("local calls after switch = %d, want 1", localCalls) + } + if remoteCalls != 1 { + t.Fatalf("remote calls after switch = %d, want 1", remoteCalls) + } + if remoteModel != "deepseek-v3.2" { + t.Fatalf( + "remote model after switch = %q, want %q", + remoteModel, + "deepseek-v3.2", + ) + } +} + +func TestProcessMessage_ModelRoutingUsesLightProvider(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + heavyCalls := 0 + heavyServer := newStrictChatCompletionTestServer( + t, + "heavy", + "gemini-2.5-flash", + "heavy reply", + &heavyCalls, + ) + defer heavyServer.Close() + + lightCalls := 0 + lightServer := newStrictChatCompletionTestServer( + t, + "light", + "qwen2.5:0.5b", + "light reply", + &lightCalls, + ) + defer lightServer.Close() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "gemini-main", + MaxTokens: 4096, + MaxToolIterations: 10, + Routing: &config.RoutingConfig{ + Enabled: true, + LightModel: "qwen-light", + Threshold: 0.99, + }, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "gemini-main", + Model: "gemini/gemini-2.5-flash", + APIBase: heavyServer.URL, + APIKeys: config.SimpleSecureStrings("heavy-key"), + }, + { + ModelName: "qwen-light", + Model: "ollama/qwen2.5:0.5b", + APIBase: lightServer.URL, + APIKeys: config.SimpleSecureStrings("light-key"), + }, + }, + } + + msgBus := bus.NewMessageBus() + provider, _, err := providers.CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hi", + }) + if resp != "light reply" { + t.Fatalf("response = %q, want %q", resp, "light reply") + } + if heavyCalls != 0 { + t.Fatalf("heavy calls = %d, want 0", heavyCalls) + } + if lightCalls != 1 { + t.Fatalf("light calls = %d, want 1", lightCalls) + } +} + +// TestProcessMessage_FallbackUsesPerCandidateProvider is the loop-level test for +// bug #2140. It verifies that when the primary model returns a rate-limit error +// the fallback closure routes the retry to the fallback model's own provider +// (its own api_base), not back to the primary provider's endpoint. +func TestProcessMessage_FallbackUsesPerCandidateProvider(t *testing.T) { + workspace := t.TempDir() + + primaryCalls := 0 + primaryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + primaryCalls++ + // Return 429 so FallbackChain classifies this as retriable and moves on. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "message": "rate limit exceeded", + "type": "rate_limit_error", + }, + }) + })) + defer primaryServer.Close() + + fallbackCalls := 0 + fallbackServer := newStrictChatCompletionTestServer( + t, "fallback", "gemma-3-27b-it", "fallback reply", &fallbackCalls, + ) + defer fallbackServer.Close() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "mistral-primary", + ModelFallbacks: []string{"gemma-fallback"}, + MaxTokens: 4096, + MaxToolIterations: 3, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "mistral-primary", + Model: "openrouter/mistralai/mistral-small-3.1", + APIBase: primaryServer.URL, + APIKeys: config.SimpleSecureStrings("primary-key"), + Workspace: workspace, + }, + { + ModelName: "gemma-fallback", + Model: "openrouter/gemma-3-27b-it", + APIBase: fallbackServer.URL, + APIKeys: config.SimpleSecureStrings("fallback-key"), + Workspace: workspace, + }, + }, + } + + provider, _, err := providers.CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hi", + }) + + if resp != "fallback reply" { + t.Fatalf("response = %q, want %q (fallback provider)", resp, "fallback reply") + } + if primaryCalls == 0 { + t.Fatal("primary server was never called; expected at least one attempt") + } + if fallbackCalls != 1 { + t.Fatalf("fallback server calls = %d, want 1", fallbackCalls) + } +} + +// TestProcessMessage_FallbackUsesActiveProviderWhenCandidateNotRegistered verifies +// that when a candidate has no model_list entry it is absent from CandidateProviders +// and the fallback closure falls back to activeProvider instead of panicking. +func TestProcessMessage_FallbackUsesActiveProviderWhenCandidateNotRegistered(t *testing.T) { + workspace := t.TempDir() + + // Primary server: returns 429 on first call, succeeds on second. + // Both the primary and the unregistered fallback share this server + // (same api_base) so activeProvider routes both calls here. + callCount := 0 + primaryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + if callCount == 1 { + w.WriteHeader(http.StatusTooManyRequests) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"message": "rate limit", "type": "rate_limit_error"}, + }) + return + } + // Second call (fallback via activeProvider) succeeds. + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"content": "active provider reply"}, "finish_reason": "stop"}, + }, + }) + })) + defer primaryServer.Close() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "primary-model", + MaxTokens: 4096, + MaxToolIterations: 3, + // No model_list entry for this alias — absent from CandidateProviders. + ModelFallbacks: []string{"openrouter/fallback-model"}, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "primary-model", + Model: "openrouter/primary-model", + APIBase: primaryServer.URL, + APIKeys: config.SimpleSecureStrings("primary-key"), + Workspace: workspace, + }, + }, + } + + provider, _, err := providers.CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + + helper := testHelper{al: al} + resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hi", + }) + + if resp != "active provider reply" { + t.Fatalf("response = %q, want %q", resp, "active provider reply") + } + if callCount < 2 { + t.Fatalf("primary server calls = %d, want >= 2 (one 429 + one success via activeProvider)", callCount) + } +} + +// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound +func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &simpleMockProvider{response: "File operation complete"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + // ReadFileTool returns SilentResult, which should not send user message + ctx := context.Background() + msg := bus.InboundMessage{ + Channel: "test", + SenderID: "user1", + ChatID: "chat1", + Content: "read test.txt", + SessionKey: "test-session", + } + + response := helper.executeAndGetResponse(t, ctx, msg) + + // Silent tool should return the LLM's response directly + if response != "File operation complete" { + t.Errorf("Expected 'File operation complete', got: %s", response) + } +} + +// TestToolResult_UserFacingToolDoesSendMessage verifies user-facing tools trigger outbound +func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &simpleMockProvider{response: "Command output: hello world"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + // ExecTool returns UserResult, which should send user message + ctx := context.Background() + msg := bus.InboundMessage{ + Channel: "test", + SenderID: "user1", + ChatID: "chat1", + Content: "run hello", + SessionKey: "test-session", + } + + response := helper.executeAndGetResponse(t, ctx, msg) + + // User-facing tool should include the output in final response + if response != "Command output: hello world" { + t.Errorf("Expected 'Command output: hello world', got: %s", response) + } +} + +// failFirstMockProvider fails on the first N calls with a specific error +type failFirstMockProvider struct { + failures int + currentCall int + failError error + successResp string +} + +func (m *failFirstMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.currentCall++ + if m.currentCall <= m.failures { + return nil, m.failError + } + return &providers.LLMResponse{ + Content: m.successResp, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *failFirstMockProvider) GetDefaultModel() string { + return "mock-fail-model" +} + +// TestAgentLoop_ContextExhaustionRetry verify that the agent retries on context errors +func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + + // Create a provider that fails once with a context error + contextErr := fmt.Errorf("InvalidParameter: Total tokens of image and text exceed max message tokens") + provider := &failFirstMockProvider{ + failures: 1, + failError: contextErr, + successResp: "Recovered from context error", + } + + al := NewAgentLoop(cfg, msgBus, provider) + + // Inject some history to simulate a full context. + // Session history only stores user/assistant/tool messages — the system + // prompt is built dynamically by BuildMessages and is NOT stored here. + sessionKey := "test-session-context" + history := []providers.Message{ + {Role: "user", Content: "Old message 1"}, + {Role: "assistant", Content: "Old response 1"}, + {Role: "user", Content: "Old message 2"}, + {Role: "assistant", Content: "Old response 2"}, + {Role: "user", Content: "Trigger message"}, + } + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("No default agent found") + } + defaultAgent.Sessions.SetHistory(sessionKey, history) + + // Call ProcessDirectWithChannel + // Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration + response, err := al.ProcessDirectWithChannel( + context.Background(), + "Trigger message", + sessionKey, + "test", + "test-chat", + ) + if err != nil { + t.Fatalf("Expected success after retry, got error: %v", err) + } + + if response != "Recovered from context error" { + t.Errorf("Expected 'Recovered from context error', got '%s'", response) + } + + // We expect 2 calls: 1st failed, 2nd succeeded + if provider.currentCall != 2 { + t.Errorf("Expected 2 calls (1 fail + 1 success), got %d", provider.currentCall) + } + + // Check final history length + finalHistory := defaultAgent.Sessions.GetHistory(sessionKey) + // We verify that the history has been modified (compressed) + // Original length: 5 + // Expected behavior: compression drops ~50% of Turns + // Without compression: 5 + 1 (new user msg) + 1 (assistant msg) = 7 + if len(finalHistory) >= 7 { + t.Errorf("Expected history to be compressed (len < 7), got %d", len(finalHistory)) + } +} + +type visionUnsupportedMediaProvider struct { + calls int + mediaSeen []bool +} + +func (p *visionUnsupportedMediaProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.calls++ + + hasMedia := false + for _, msg := range messages { + for _, ref := range msg.Media { + if strings.TrimSpace(ref) != "" { + hasMedia = true + break + } + } + if hasMedia { + break + } + } + p.mediaSeen = append(p.mediaSeen, hasMedia) + + if hasMedia { + return nil, fmt.Errorf("API request failed: " + + "Status: 404 Body: {\"error\":{\"message\":\"No endpoints found that support image input\"}}") + } + + return &providers.LLMResponse{ + Content: "ok", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (p *visionUnsupportedMediaProvider) GetDefaultModel() string { + return "mock-fail-model" +} + +func TestAgentLoop_VisionUnsupportedErrorStripsSessionMedia(t *testing.T) { + workspace := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 3, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &visionUnsupportedMediaProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + sessionKey := "agent:main:telegram:direct:user1" + + timeoutCtx, cancel := context.WithTimeout(context.Background(), responseTimeout) + defer cancel() + + resp, err := al.processMessage(timeoutCtx, testInboundMessage(bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + MessageID: "m1", + }, + Content: "describe this", + Media: []string{"data:image/png;base64,abc123"}, + SessionKey: sessionKey, + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if resp != "ok" { + t.Fatalf("response = %q, want %q", resp, "ok") + } + if provider.calls != 2 { + t.Fatalf("calls = %d, want %d (fail with media, then retry without media)", provider.calls, 2) + } + if !slices.Equal(provider.mediaSeen, []bool{true, false}) { + t.Fatalf("mediaSeen = %v, want %v", provider.mediaSeen, []bool{true, false}) + } + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + history := agent.Sessions.GetHistory(sessionKey) + for i, msg := range history { + if len(msg.Media) > 0 { + t.Fatalf("history[%d].Media = %v, want no media after stripping", i, msg.Media) + } + } + + timeoutCtx2, cancel2 := context.WithTimeout(context.Background(), responseTimeout) + defer cancel2() + + resp2, err := al.processMessage(timeoutCtx2, testInboundMessage(bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + MessageID: "m2", + }, + Content: "hello again", + SessionKey: sessionKey, + })) + if err != nil { + t.Fatalf("processMessage() second call error = %v", err) + } + if resp2 != "ok" { + t.Fatalf("second response = %q, want %q", resp2, "ok") + } + if provider.calls != 3 { + t.Fatalf("calls after second turn = %d, want %d", provider.calls, 3) + } + if !slices.Equal(provider.mediaSeen, []bool{true, false, false}) { + t.Fatalf("mediaSeen = %v, want %v", provider.mediaSeen, []bool{true, false, false}) + } +} + +func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 3, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &simpleMockProvider{response: ""} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "empty-response", "test", "chat1") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + if response != defaultResponse { + t.Fatalf("response = %q, want %q", response, defaultResponse) + } +} + +func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 1, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolLimitOnlyProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + al.RegisterTool(&toolLimitTestTool{}) + + response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "tool-limit", "test", "chat1") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + if response != toolLimitResponse { + t.Fatalf("response = %q, want %q", response, toolLimitResponse) + } + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("No default agent found") + } + route := al.registry.ResolveRoute(bus.InboundContext{ + Channel: "test", + ChatType: "direct", + SenderID: "cron", + }) + history := defaultAgent.Sessions.GetHistory(al.allocateRouteSession(route, testInboundMessage(bus.InboundMessage{ + Channel: "test", + SenderID: "cron", + ChatID: "chat1", + })).SessionKey) + if len(history) != 4 { + t.Fatalf("history len = %d, want 4", len(history)) + } + assertRoles(t, history, "user", "assistant", "tool", "assistant") + if history[3].Content != toolLimitResponse { + t.Fatalf("final assistant content = %q, want %q", history[3].Content, toolLimitResponse) + } +} + +// TestProcessDirectWithChannel_TriggersMCPInitialization verifies that +// ProcessDirectWithChannel triggers MCP initialization when MCP is enabled. +// Note: Manager is only initialized when at least one MCP server is configured +// and successfully connected. +func TestProcessDirectWithChannel_TriggersMCPInitialization(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Test with MCP enabled but no servers - should not initialize manager + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{ + Enabled: true, + }, + // No servers configured - manager should not be initialized + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + defer al.Close() + + if al.mcp.hasManager() { + t.Fatal("expected MCP manager to be nil before first direct processing") + } + + _, err = al.ProcessDirectWithChannel( + context.Background(), + "hello", + "session-1", + "cli", + "direct", + ) + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + // Manager should not be initialized when no servers are configured + if al.mcp.hasManager() { + t.Fatal("expected MCP manager to be nil when no servers are configured") + } +} + +func TestTargetReasoningChannelID_AllChannels(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil) + if err != nil { + t.Fatalf("Failed to create channel manager: %v", err) + } + for name, id := range map[string]string{ + "whatsapp": "rid-whatsapp", + "telegram": "rid-telegram", + "feishu": "rid-feishu", + "discord": "rid-discord", + "maixcam": "rid-maixcam", + "qq": "rid-qq", + "dingtalk": "rid-dingtalk", + "slack": "rid-slack", + "line": "rid-line", + "onebot": "rid-onebot", + "wecom": "rid-wecom", + } { + chManager.RegisterChannel(name, &fakeChannel{id: id}) + } + al.SetChannelManager(chManager) + tests := []struct { + channel string + wantID string + }{ + {channel: "whatsapp", wantID: "rid-whatsapp"}, + {channel: "telegram", wantID: "rid-telegram"}, + {channel: "feishu", wantID: "rid-feishu"}, + {channel: "discord", wantID: "rid-discord"}, + {channel: "maixcam", wantID: "rid-maixcam"}, + {channel: "qq", wantID: "rid-qq"}, + {channel: "dingtalk", wantID: "rid-dingtalk"}, + {channel: "slack", wantID: "rid-slack"}, + {channel: "line", wantID: "rid-line"}, + {channel: "onebot", wantID: "rid-onebot"}, + {channel: "wecom", wantID: "rid-wecom"}, + {channel: "unknown", wantID: ""}, + } + + for _, tt := range tests { + t.Run(tt.channel, func(t *testing.T) { + got := al.targetReasoningChannelID(tt.channel) + if got != tt.wantID { + t.Fatalf("targetReasoningChannelID(%q) = %q, want %q", tt.channel, got, tt.wantID) + } + }) + } +} + +func TestHandleReasoning(t *testing.T) { + newLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus + } + + t.Run("skips when any required field is empty", func(t *testing.T) { + al, msgBus := newLoop(t) + al.handleReasoning(context.Background(), "reasoning", "telegram", "") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + for { + select { + case msg, ok := <-msgBus.OutboundChan(): + if !ok { + t.Fatalf("expected no outbound message, got %+v", msg) + } + if msg.Content == "reasoning" { + t.Fatalf("expected no message for empty chatID, got %+v", msg) + } + return + case <-ctx.Done(): + t.Log("expected an outbound message, got none within timeout") + return + default: + // Continue to check for message + time.Sleep(5 * time.Millisecond) // Avoid busy loop + } + } + }) + + t.Run("publishes one message for non telegram", func(t *testing.T) { + al, msgBus := newLoop(t) + al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1") + + msg, ok := <-msgBus.OutboundChan() + if !ok { + t.Fatal("expected an outbound message") + } + if msg.Channel != "slack" || msg.ChatID != "channel-1" || msg.Content != "hello reasoning" { + t.Fatalf("unexpected outbound message: %+v", msg) + } + }) + + t.Run("publishes one message for telegram", func(t *testing.T) { + al, msgBus := newLoop(t) + reasoning := "hello telegram reasoning" + al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + for { + select { + case <-ctx.Done(): + t.Fatal("expected an outbound message, got none within timeout") + return + case msg, ok := <-msgBus.OutboundChan(): + if !ok { + t.Fatal("expected outbound message") + } + + if msg.Channel != "telegram" { + t.Fatalf("expected telegram channel message, got %+v", msg) + } + if msg.ChatID != "tg-chat" { + t.Fatalf("expected chatID tg-chat, got %+v", msg) + } + if msg.Content != reasoning { + t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning) + } + return + } + } + }) + t.Run("expired ctx", func(t *testing.T) { + al, msgBus := newLoop(t) + reasoning := "hello telegram reasoning" + + al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat") + + consumeCtx, consumeCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer consumeCancel() + + for { + select { + case msg, ok := <-msgBus.OutboundChan(): + if !ok { + t.Fatalf("expected no outbound message, but received: %+v", msg) + } + t.Logf("Received unexpected outbound message: %+v", msg) + return + case <-consumeCtx.Done(): + t.Fatalf("failed: no message received within timeout") + return + } + } + }) + + t.Run("returns promptly when bus is full", func(t *testing.T) { + al, msgBus := newLoop(t) + + // Fill the outbound bus buffer until a publish would block. + // Use a short timeout to detect when the buffer is full, + // rather than hardcoding the buffer size. + for i := 0; ; i++ { + fillCtx, fillCancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + err := msgBus.PublishOutbound(fillCtx, bus.OutboundMessage{ + Context: bus.NewOutboundContext("filler", "filler", ""), + Content: fmt.Sprintf("filler-%d", i), + }) + fillCancel() + if err != nil { + // Buffer is full (timed out trying to send). + break + } + } + + // Use a short-deadline parent context to bound the test. + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + start := time.Now() + al.handleReasoning(ctx, "should timeout", "slack", "channel-full") + elapsed := time.Since(start) + + // handleReasoning uses a 5s internal timeout, but the parent ctx + // expires in 500ms. It should return within ~500ms, not 5s. + if elapsed > 2*time.Second { + t.Fatalf("handleReasoning blocked too long (%v); expected prompt return", elapsed) + } + + // Drain the bus and verify the reasoning message was NOT published + // (it should have been dropped due to timeout). + timeer := time.After(1 * time.Second) + for { + select { + case <-timeer: + t.Logf( + "no reasoning message received after draining bus for 1s, as expected,length=%d", + len(msgBus.OutboundChan()), + ) + return + case msg, ok := <-msgBus.OutboundChan(): + if !ok { + break + } + if msg.Content == "should timeout" { + t.Fatal("expected reasoning message to be dropped when bus is full, but it was published") + } + } + } + }) +} + +func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &reasoningContentProvider{ + response: "final answer", + reasoningContent: "thinking trace", + } + al := NewAgentLoop(cfg, msgBus, provider) + + chManager, err := channels.NewManager(&config.Config{}, msgBus, nil) + if err != nil { + t.Fatalf("Failed to create channel manager: %v", err) + } + chManager.RegisterChannel("telegram", &fakeChannel{id: "reason-chat"}) + al.SetChannelManager(chManager) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hello", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "final answer" { + t.Fatalf("processMessage() response = %q, want %q", response, "final answer") + } + + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.Channel != "telegram" { + t.Fatalf("reasoning channel = %q, want %q", outbound.Channel, "telegram") + } + if outbound.ChatID != "reason-chat" { + t.Fatalf("reasoning chatID = %q, want %q", outbound.ChatID, "reason-chat") + } + if outbound.Context.Channel != "telegram" || outbound.Context.ChatID != "reason-chat" { + t.Fatalf("unexpected reasoning context: %+v", outbound.Context) + } + if outbound.Content != "thinking trace" { + t.Fatalf("reasoning content = %q, want %q", outbound.Content, "thinking trace") + } + case <-time.After(3 * time.Second): + t.Fatal("expected reasoning content to be published to reasoning channel") + } +} + +func TestProcessMessage_PicoPublishesReasoningAsThoughtMessage(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &reasoningContentProvider{ + response: "final answer", + reasoningContent: "thinking trace", + } + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "pico", + SenderID: "user1", + ChatID: "pico:test-session", + Content: "hello", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "final answer" { + t.Fatalf("processMessage() response = %q, want %q", response, "final answer") + } + + var thoughtMsg *bus.OutboundMessage + deadline := time.After(3 * time.Second) + + for thoughtMsg == nil { + select { + case outbound := <-msgBus.OutboundChan(): + msg := outbound + if msg.Content == "thinking trace" { + thoughtMsg = &msg + } + case <-deadline: + t.Fatal("expected thought outbound message for pico") + } + } + + if thoughtMsg.Channel != "pico" || thoughtMsg.ChatID != "pico:test-session" { + t.Fatalf("thought message route = %s/%s, want pico/pico:test-session", thoughtMsg.Channel, thoughtMsg.ChatID) + } + if thoughtMsg.Context.Raw[metadataKeyMessageKind] != messageKindThought { + t.Fatalf( + "thought metadata kind = %q, want %q", + thoughtMsg.Context.Raw[metadataKeyMessageKind], + messageKindThought, + ) + } +} + +func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) { + tmpDir := t.TempDir() + heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt") + if err := os.WriteFile(heartbeatFile, []byte("heartbeat task"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + MaxArgsLength: 300, + }, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolFeedbackProvider{filePath: heartbeatFile} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.ProcessHeartbeat(context.Background(), "check heartbeat tasks", "telegram", "chat-1") + if err != nil { + t.Fatalf("ProcessHeartbeat() error = %v", err) + } + if response != "HEARTBEAT_OK" { + t.Fatalf("ProcessHeartbeat() response = %q, want %q", response, "HEARTBEAT_OK") + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("expected no outbound tool feedback during heartbeat, got %+v", outbound) + case <-time.After(200 * time.Millisecond): + } +} + +func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { + tmpDir := t.TempDir() + heartbeatFile := filepath.Join(tmpDir, "tool-feedback.txt") + if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + MaxArgsLength: 300, + }, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolFeedbackProvider{filePath: heartbeatFile} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + SenderID: "user-1", + ChatID: "chat-1", + Content: "check tool feedback", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "HEARTBEAT_OK" { + t.Fatalf("processMessage() response = %q, want %q", response, "HEARTBEAT_OK") + } + + select { + case outbound := <-msgBus.OutboundChan(): + escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`) + if outbound.Channel != "telegram" { + t.Fatalf("tool feedback channel = %q, want %q", outbound.Channel, "telegram") + } + if outbound.ChatID != "chat-1" { + t.Fatalf("tool feedback chatID = %q, want %q", outbound.ChatID, "chat-1") + } + if outbound.Context.Channel != "telegram" || outbound.Context.ChatID != "chat-1" { + t.Fatalf("unexpected tool feedback context: %+v", outbound.Context) + } + if !strings.Contains(outbound.Content, "`read_file`") { + t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content) + } + if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) { + t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content) + } + if !strings.Contains(outbound.Content, "check tool feedback") { + t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) + } + if !strings.Contains(outbound.Content, "\"path\":") { + t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content) + } + if !strings.Contains(outbound.Content, escapedHeartbeatFile) { + t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content) + } + if strings.Contains(outbound.Content, "Previous turn explanation") { + t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content) + } + if outbound.AgentID != "main" { + t.Fatalf("tool feedback agent_id = %q, want main", outbound.AgentID) + } + if outbound.SessionKey == "" { + t.Fatal("expected tool feedback to carry session_key") + } + if outbound.Scope == nil || outbound.Scope.AgentID != "main" || outbound.Scope.Channel != "telegram" { + t.Fatalf("expected tool feedback scope, got %+v", outbound.Scope) + } + case <-time.After(2 * time.Second): + t.Fatal("expected outbound tool feedback for regular messages") + } +} + +func TestProcessMessage_PersistsReasoningContentInSessionHistory(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &reasoningContentProvider{ + response: "final answer", + reasoningContent: "thinking trace", + } + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "pico", + SenderID: "user1", + ChatID: "pico:test-session", + Content: "hello", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "final answer" { + t.Fatalf("processMessage() response = %q, want %q", response, "final answer") + } + + store := al.GetRegistry().GetDefaultAgent().Sessions + sessionKeys := store.ListSessions() + if len(sessionKeys) != 1 { + t.Fatalf("session keys = %v, want exactly 1 active session", sessionKeys) + } + history := store.GetHistory(sessionKeys[0]) + if len(history) < 2 { + t.Fatalf("session history len = %d, want at least 2", len(history)) + } + + last := history[len(history)-1] + if last.Role != "assistant" { + t.Fatalf("last message role = %q, want assistant", last.Role) + } + if last.Content != "final answer" { + t.Fatalf("last message content = %q, want %q", last.Content, "final answer") + } + if last.ReasoningContent != "thinking trace" { + t.Fatalf("last message reasoning_content = %q, want %q", last.ReasoningContent, "thinking trace") + } +} + +func TestProcessMessage_PersistsReasoningToolResponseAsSingleAssistantRecord(t *testing.T) { + tmpDir := t.TempDir() + inspectPath := filepath.Join(tmpDir, "inspect.txt") + if err := os.WriteFile(inspectPath, []byte("inspect me"), 0o644); err != nil { + t.Fatalf("WriteFile(inspectPath) error = %v", err) + } + + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = tmpDir + cfg.Agents.Defaults.ModelName = "test-model" + cfg.Agents.Defaults.MaxTokens = 4096 + cfg.Agents.Defaults.MaxToolIterations = 10 + + msgBus := bus.NewMessageBus() + provider := &reasoningVisibleToolProvider{filePath: inspectPath} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hello", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "DONE" { + t.Fatalf("processMessage() response = %q, want %q", response, "DONE") + } + + store := al.GetRegistry().GetDefaultAgent().Sessions + sessionKeys := store.ListSessions() + if len(sessionKeys) != 1 { + t.Fatalf("session keys = %v, want exactly 1 active session", sessionKeys) + } + + history := store.GetHistory(sessionKeys[0]) + if len(history) < 3 { + t.Fatalf("session history len = %d, want at least 3", len(history)) + } + + var assistantWithToolCall *providers.Message + for i := range history { + msg := history[i] + if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { + assistantWithToolCall = &msg + break + } + } + if assistantWithToolCall == nil { + t.Fatal("expected assistant history record with tool_calls") + } + if assistantWithToolCall.Content != "I'll inspect that file now." { + t.Fatalf("assistant content = %q, want %q", assistantWithToolCall.Content, "I'll inspect that file now.") + } + if assistantWithToolCall.ReasoningContent != "Read the file before answering." { + t.Fatalf("assistant reasoning_content = %q, want preserved", assistantWithToolCall.ReasoningContent) + } + if len(assistantWithToolCall.ToolCalls) != 1 { + t.Fatalf("assistant tool calls = %+v, want single read_file tool", assistantWithToolCall.ToolCalls) + } + if got := providers.NormalizeToolCall(assistantWithToolCall.ToolCalls[0]).Name; got != "read_file" { + t.Fatalf("assistant tool calls = %+v, want single read_file tool", assistantWithToolCall.ToolCalls) + } + + sessionDir := filepath.Join(tmpDir, "sessions") + entries, err := os.ReadDir(sessionDir) + if err != nil { + t.Fatalf("ReadDir(%q) error = %v", sessionDir, err) + } + + var jsonlPath string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + jsonlPath = filepath.Join(sessionDir, entry.Name()) + break + } + if jsonlPath == "" { + t.Fatal("expected session jsonl file to be created") + } + + data, err := os.ReadFile(jsonlPath) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", jsonlPath, err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 3 { + t.Fatalf("jsonl lines = %d, want at least 3", len(lines)) + } + + matchingRecords := 0 + for _, line := range lines { + var msg providers.Message + if err := json.Unmarshal([]byte(line), &msg); err != nil { + t.Fatalf("Unmarshal(jsonl line) error = %v", err) + } + if msg.Role != "assistant" { + continue + } + if msg.Content == "I'll inspect that file now." || msg.ReasoningContent == "Read the file before answering." { + matchingRecords++ + toolName := "" + if len(msg.ToolCalls) == 1 { + toolName = providers.NormalizeToolCall(msg.ToolCalls[0]).Name + } + if msg.Content != "I'll inspect that file now." || + msg.ReasoningContent != "Read the file before answering." || + len(msg.ToolCalls) != 1 || + toolName != "read_file" { + t.Fatalf("assistant jsonl record = %+v, want content+reasoning+tool_calls in one line", msg) + } + } + } + if matchingRecords != 1 { + t.Fatalf("matching assistant jsonl records = %d, want exactly 1 canonical assistant record", matchingRecords) + } +} + +func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T) { + tmpDir := t.TempDir() + heartbeatFile := filepath.Join(tmpDir, "tool-feedback-reasoning.txt") + if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + MaxArgsLength: 300, + }, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolFeedbackReasoningProvider{filePath: heartbeatFile} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + SenderID: "user-1", + ChatID: "chat-1", + Content: "check reasoning fallback", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "DONE" { + t.Fatalf("processMessage() response = %q, want %q", response, "DONE") + } + + select { + case outbound := <-msgBus.OutboundChan(): + escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`) + if !strings.Contains(outbound.Content, "`read_file`") { + t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content) + } + if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) { + t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content) + } + if !strings.Contains(outbound.Content, "check reasoning fallback") { + t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) + } + if !strings.Contains(outbound.Content, "\"path\":") { + t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content) + } + if !strings.Contains(outbound.Content, escapedHeartbeatFile) { + t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content) + } + if strings.Contains(outbound.Content, "Read README.md first") { + t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content) + } + case <-time.After(2 * time.Second): + t.Fatal("expected outbound tool feedback without leaking reasoning") + } +} + +func TestProcessMessage_DoesNotPublishToolFeedbackForDiscordWhenDisabled(t *testing.T) { + assertToolFeedbackNotPublishedWhenDisabled(t, "discord") +} + +func assertToolFeedbackNotPublishedWhenDisabled(t *testing.T, channel string) { + t.Helper() + + tmpDir := t.TempDir() + heartbeatFile := filepath.Join(tmpDir, "tool-feedback-"+channel+".txt") + if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolFeedbackProvider{filePath: heartbeatFile} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: channel, + SenderID: "user-1", + ChatID: "chat-1", + Content: "check tool feedback", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "HEARTBEAT_OK" { + t.Fatalf("processMessage() response = %q, want %q", response, "HEARTBEAT_OK") + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("expected no outbound tool feedback for %s when disabled, got %+v", channel, outbound) + case <-time.After(200 * time.Millisecond): + } +} + +func TestProcessMessage_DoesNotPublishToolFeedbackForTelegramWhenDisabled(t *testing.T) { + assertToolFeedbackNotPublishedWhenDisabled(t, "telegram") +} + +func TestProcessMessage_DoesNotPublishToolFeedbackForFeishuWhenDisabled(t *testing.T) { + assertToolFeedbackNotPublishedWhenDisabled(t, "feishu") +} + +func TestProcessMessage_MessageToolPublishesOutboundWithTurnMetadata(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = t.TempDir() + cfg.Agents.Defaults.ModelName = "test-model" + cfg.Agents.Defaults.MaxTokens = 4096 + cfg.Agents.Defaults.MaxToolIterations = 10 + cfg.Session.Dimensions = []string{"chat"} + + msgBus := bus.NewMessageBus() + provider := &messageToolProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + SenderID: "user-1", + ChatID: "chat-1", + Content: "send a direct message", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response == "" { + t.Fatal("expected processMessage() to return a final loop response") + } + + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.Content != "direct tool message" { + t.Fatalf("outbound content = %q, want direct tool message", outbound.Content) + } + if outbound.AgentID != "main" { + t.Fatalf("outbound agent_id = %q, want main", outbound.AgentID) + } + if outbound.SessionKey == "" { + t.Fatal("expected message tool outbound to carry session_key") + } + if outbound.Scope == nil || outbound.Scope.Values["chat"] != "direct:chat-1" { + t.Fatalf("unexpected message tool outbound scope: %+v", outbound.Scope) + } + if outbound.Context.Channel != "telegram" || outbound.Context.ChatID != "chat-1" { + t.Fatalf("unexpected message tool outbound context: %+v", outbound.Context) + } + case <-time.After(2 * time.Second): + t.Fatal("expected message tool outbound") + } +} + +func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &picoDistinctToolCallContentProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + agent.Tools.Register(&toolLimitTestTool{}) + + runCtx, runCancel := context.WithCancel(context.Background()) + defer runCancel() + + runDone := make(chan error, 1) + go func() { + runDone <- al.Run(runCtx) + }() + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Channel: "pico", + SenderID: "user-1", + ChatID: "session-1", + Content: "run with tools", + }); err != nil { + t.Fatalf("PublishInbound() error = %v", err) + } + + outputs := make([]bus.OutboundMessage, 0, 3) + deadline := time.After(2 * time.Second) + for len(outputs) < 3 { + select { + case outbound := <-msgBus.OutboundChan(): + outputs = append(outputs, outbound) + case <-deadline: + t.Fatalf("timed out waiting for pico outputs, got %v", outputs) + } + } + + if outputs[0].Content != "intermediate model text" { + t.Fatalf("first outbound content = %q, want %q", outputs[0].Content, "intermediate model text") + } + if outputs[1].Context.Raw[metadataKeyMessageKind] != messageKindToolCalls { + t.Fatalf("second outbound = %+v, want tool_calls message", outputs[1]) + } + if !strings.Contains(outputs[1].Context.Raw[metadataKeyToolCalls], "tool_limit_test_tool") { + t.Fatalf("second outbound tool_calls = %q, want tool name", outputs[1].Context.Raw[metadataKeyToolCalls]) + } + if outputs[2].Content != "final model text" { + t.Fatalf("third outbound content = %q, want %q", outputs[2].Content, "final model text") + } + + runCancel() + select { + case err := <-runDone: + if err != nil { + t.Fatalf("Run() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for Run() to exit") + } + + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.Content == "final model text" { + t.Fatalf("unexpected duplicate final pico output: %+v", outbound) + } + case <-time.After(200 * time.Millisecond): + } +} + +func TestRunAgentLoop_PicoSkipsInterimPublishWhenNotAllowed(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &picoInterleavedContentProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + agent.Tools.Register(&toolLimitTestTool{}) + + response, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "agent:main:pico:session-1", + Channel: "pico", + ChatID: "session-1", + UserMessage: "run with tools", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + AllowInterimPicoPublish: false, + SuppressToolFeedback: true, + }) + if err != nil { + t.Fatalf("runAgentLoop() error = %v", err) + } + if response != "final model text" { + t.Fatalf("runAgentLoop() response = %q, want %q", response, "final model text") + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("unexpected outbound message when interim publish disabled: %+v", outbound) + case <-time.After(200 * time.Millisecond): + } +} + +func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + }, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &picoInterleavedContentProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + agent.Tools.Register(&toolLimitTestTool{}) + + runCtx, runCancel := context.WithCancel(context.Background()) + defer runCancel() + + runDone := make(chan error, 1) + go func() { + runDone <- al.Run(runCtx) + }() + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Channel: "pico", + SenderID: "user-1", + ChatID: "session-1", + Content: "run with tools", + }); err != nil { + t.Fatalf("PublishInbound() error = %v", err) + } + + outputs := make([]bus.OutboundMessage, 0, 3) + deadline := time.After(2 * time.Second) + for len(outputs) < 2 { + select { + case outbound := <-msgBus.OutboundChan(): + outputs = append(outputs, outbound) + case <-deadline: + t.Fatalf("timed out waiting for pico outputs, got %v", outputs) + } + } + + if outputs[0].Context.Raw[metadataKeyMessageKind] != messageKindToolCalls { + t.Fatalf("first outbound = %+v, want tool_calls message", outputs[0]) + } + if outputs[0].Content != "" { + t.Fatalf("first outbound content = %q, want empty tool_calls content", outputs[0].Content) + } + if !strings.Contains(outputs[0].Context.Raw[metadataKeyToolCalls], "tool_limit_test_tool") { + t.Fatalf("first outbound tool_calls = %q, want tool name", outputs[0].Context.Raw[metadataKeyToolCalls]) + } + if outputs[1].Content != "final model text" { + t.Fatalf("second outbound content = %q, want %q", outputs[1].Content, "final model text") + } + + runCancel() + select { + case err := <-runDone: + if err != nil { + t.Fatalf("Run() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for Run() to exit") + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("unexpected extra pico output after tool feedback + final reply: %+v", outbound) + case <-time.After(200 * time.Millisecond): + } +} + +func TestResolveMediaRefs_ImageInjectsPathTag(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + // Create a minimal valid PNG (8-byte header is enough for filetype detection) + pngPath := filepath.Join(dir, "test.png") + // PNG magic: 0x89 P N G \r \n 0x1A \n + minimal IHDR + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR length + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, // 1x1 RGB + 0x00, 0x00, 0x00, // no interlace + 0x90, 0x77, 0x53, 0xDE, // CRC + } + if err := os.WriteFile(pngPath, pngHeader, 0o644); err != nil { + t.Fatal(err) + } + ref, err := store.Store(pngPath, media.MediaMeta{}, "test") + if err != nil { + t.Fatal(err) + } + + messages := []providers.Message{ + {Role: "user", Content: "describe this", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (images use path tags), got %d", len(result[0].Media)) + } + localPath, _, _ := store.ResolveWithMeta(ref) + expectedContent := "describe this [image:" + localPath + "]" + if result[0].Content != expectedContent { + t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) + } +} + +func TestResolveMediaRefs_ToolRoleImageAppendedAsUserMessage(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + pngPath := filepath.Join(dir, "tool-result.png") + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR length + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, // 1x1 RGB + 0x00, 0x00, 0x00, // no interlace + 0x90, 0x77, 0x53, 0xDE, // CRC + } + if err := os.WriteFile(pngPath, pngHeader, 0o644); err != nil { + t.Fatal(err) + } + ref, _ := store.Store(pngPath, media.MediaMeta{}, "test") + + messages := []providers.Message{ + {Role: "tool", Content: "Image loaded", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + // Tool message should have path tag but no base64 + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media in tool message, got %d", len(result[0].Media)) + } + localPath, _, _ := store.ResolveWithMeta(ref) + if !strings.Contains(result[0].Content, "[image:"+localPath+"]") { + t.Fatalf("expected image path tag in tool content, got %q", result[0].Content) + } + + // A synthetic user message with base64 should follow + if len(result) != 2 { + t.Fatalf("expected 2 messages (tool + synthetic user), got %d", len(result)) + } + if result[1].Role != "user" { + t.Fatalf("expected synthetic message role=user, got %q", result[1].Role) + } + if len(result[1].Media) != 1 { + t.Fatalf("expected 1 base64 media in synthetic user message, got %d", len(result[1].Media)) + } + if !strings.HasPrefix(result[1].Media[0], "data:image/png;base64,") { + t.Fatalf("expected data:image/png;base64, prefix, got %q", result[1].Media[0][:40]) + } +} + +func TestResolveMediaRefs_MultiToolCallPreservesOrdering(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + // Create image for tool #1 + pngPath := filepath.Join(dir, "loaded.png") + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, + } + os.WriteFile(pngPath, pngHeader, 0o644) + imgRef, _ := store.Store(pngPath, media.MediaMeta{}, "test") + + // Simulate: assistant called load_image + read_file, two tool results follow + messages := []providers.Message{ + {Role: "assistant", Content: "Let me load the image and read the file."}, + {Role: "tool", Content: "Image loaded [image: photo]", Media: []string{imgRef}}, + {Role: "tool", Content: "file contents here"}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + // assistant, tool#1, tool#2 must remain contiguous — no user in between + if result[0].Role != "assistant" { + t.Fatalf("result[0] expected assistant, got %q", result[0].Role) + } + if result[1].Role != "tool" { + t.Fatalf("result[1] expected tool, got %q", result[1].Role) + } + if result[2].Role != "tool" { + t.Fatalf("result[2] expected tool, got %q", result[2].Role) + } + + // Synthetic user message should come AFTER the tool block + if len(result) != 4 { + t.Fatalf("expected 4 messages (assistant + 2 tool + synthetic user), got %d", len(result)) + } + if result[3].Role != "user" { + t.Fatalf("result[3] expected user, got %q", result[3].Role) + } + if len(result[3].Media) != 1 || !strings.HasPrefix(result[3].Media[0], "data:image/png;base64,") { + t.Fatal("expected synthetic user message to contain base64 image") + } +} + +func TestResolveMediaRefs_OversizedImageSkipsBase64KeepsPathTag(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + bigPath := filepath.Join(dir, "big.png") + // Write PNG header + padding to exceed limit + data := make([]byte, 1024+1) // 1KB + 1 byte + copy(data, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) + if err := os.WriteFile(bigPath, data, 0o644); err != nil { + t.Fatal(err) + } + ref, _ := store.Store(bigPath, media.MediaMeta{}, "test") + + messages := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{ref}}, + } + // Use a tiny limit (1KB) so the file is oversized + result := resolveMediaRefs(messages, store, 1024) + + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media)) + } + localPath, _, _ := store.ResolveWithMeta(ref) + expected := "hi [image:" + localPath + "]" + if result[0].Content != expected { + t.Fatalf("expected content %q, got %q", expected, result[0].Content) + } +} + +func TestResolveMediaRefs_UnknownTypeInjectsPath(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + txtPath := filepath.Join(dir, "readme.txt") + if err := os.WriteFile(txtPath, []byte("hello world"), 0o644); err != nil { + t.Fatal(err) + } + ref, _ := store.Store(txtPath, media.MediaMeta{}, "test") + + messages := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media entries, got %d", len(result[0].Media)) + } + expected := "hi [file:" + txtPath + "]" + if result[0].Content != expected { + t.Fatalf("expected content %q, got %q", expected, result[0].Content) + } +} + +func TestResolveMediaRefs_PassesThroughNonMediaRefs(t *testing.T) { + messages := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{"https://example.com/img.png"}}, + } + result := resolveMediaRefs(messages, nil, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 1 || result[0].Media[0] != "https://example.com/img.png" { + t.Fatalf("expected passthrough of non-media:// URL, got %v", result[0].Media) + } +} + +func TestResolveMediaRefs_DoesNotMutateOriginal(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + pngPath := filepath.Join(dir, "test.png") + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, + } + os.WriteFile(pngPath, pngHeader, 0o644) + ref, _ := store.Store(pngPath, media.MediaMeta{}, "test") + + original := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{ref}}, + } + originalRef := original[0].Media[0] + + resolveMediaRefs(original, store, config.DefaultMaxMediaSize) + + if original[0].Media[0] != originalRef { + t.Fatal("resolveMediaRefs mutated original message slice") + } +} + +func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + // File with JPEG content but stored with explicit content type + jpegPath := filepath.Join(dir, "photo") + jpegHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0} // JPEG magic bytes + os.WriteFile(jpegPath, jpegHeader, 0o644) + ref, _ := store.Store(jpegPath, media.MediaMeta{ContentType: "image/jpeg"}, "test") + + messages := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (images use path tags), got %d", len(result[0].Media)) + } + localPath, _, _ := store.ResolveWithMeta(ref) + expectedContent := "hi [image:" + localPath + "]" + if result[0].Content != expectedContent { + t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) + } +} + +func TestResolveMediaRefs_PDFInjectsFilePath(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + pdfPath := filepath.Join(dir, "report.pdf") + // PDF magic bytes + os.WriteFile(pdfPath, []byte("%PDF-1.4 test content"), 0o644) + ref, _ := store.Store(pdfPath, media.MediaMeta{ContentType: "application/pdf"}, "test") + + messages := []providers.Message{ + {Role: "user", Content: "report.pdf [file]", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (non-image), got %d", len(result[0].Media)) + } + expected := "report.pdf [file:" + pdfPath + "]" + if result[0].Content != expected { + t.Fatalf("expected content %q, got %q", expected, result[0].Content) + } +} + +func TestResolveMediaRefs_AudioInjectsAudioPath(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + oggPath := filepath.Join(dir, "voice.ogg") + os.WriteFile(oggPath, []byte("fake audio"), 0o644) + ref, _ := store.Store(oggPath, media.MediaMeta{ContentType: "audio/ogg"}, "test") + + messages := []providers.Message{ + {Role: "user", Content: "voice.ogg [audio]", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media, got %d", len(result[0].Media)) + } + expected := "voice.ogg [audio:" + oggPath + "]" + if result[0].Content != expected { + t.Fatalf("expected content %q, got %q", expected, result[0].Content) + } +} + +func TestResolveMediaRefs_VideoInjectsVideoPath(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + mp4Path := filepath.Join(dir, "clip.mp4") + os.WriteFile(mp4Path, []byte("fake video"), 0o644) + ref, _ := store.Store(mp4Path, media.MediaMeta{ContentType: "video/mp4"}, "test") + + messages := []providers.Message{ + {Role: "user", Content: "clip.mp4 [video]", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media, got %d", len(result[0].Media)) + } + expected := "clip.mp4 [video:" + mp4Path + "]" + if result[0].Content != expected { + t.Fatalf("expected content %q, got %q", expected, result[0].Content) + } +} + +func TestResolveMediaRefs_NoGenericTagAppendsPath(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + csvPath := filepath.Join(dir, "data.csv") + os.WriteFile(csvPath, []byte("a,b,c"), 0o644) + ref, _ := store.Store(csvPath, media.MediaMeta{ContentType: "text/csv"}, "test") + + messages := []providers.Message{ + {Role: "user", Content: "here is my data", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + expected := "here is my data [file:" + csvPath + "]" + if result[0].Content != expected { + t.Fatalf("expected content %q, got %q", expected, result[0].Content) + } +} + +func TestInjectPathTags_HandlesVariousChannelPlaceholders(t *testing.T) { + cases := []struct { + name string + content string + tag string + want string + }{ + // Telegram / Feishu format + {"image_photo", "[image: photo]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"}, + // WeCom / WeChat / Line format + {"bare_image", "[image]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"}, + // QQ / Discord format with filename + {"image_filename", "[image: pic.jpg]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"}, + {"audio_with_filename", "[audio: voice.m4a]", "[audio:/tmp/a.m4a]", "[audio:/tmp/a.m4a]"}, + {"bare_audio", "[audio]", "[audio:/tmp/a.m4a]", "[audio:/tmp/a.m4a]"}, + {"bare_video", "[video]", "[video:/tmp/v.mp4]", "[video:/tmp/v.mp4]"}, + {"bare_file", "[file]", "[file:/tmp/f.pdf]", "[file:/tmp/f.pdf]"}, + // Mixed surrounding text + { + "with_text", + "hello [image] world", + "[image:/tmp/p.png]", + "hello [image:/tmp/p.png] world", + }, + // No placeholder — append + {"no_placeholder", "hello world", "[image:/tmp/p.png]", "hello world [image:/tmp/p.png]"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := injectPathTags(tc.content, []string{tc.tag}) + if got != tc.want { + t.Errorf("expected %q, got %q", tc.want, got) + } + }) + } +} + +func TestInjectPathTags_DoesNotReplacePathTag(t *testing.T) { + // If content already contains a path tag, we must not touch it. + content := "see [image:/already/placed.png] thanks" + got := injectPathTags(content, []string{"[image:/new/path.png]"}) + want := "see [image:/already/placed.png] thanks [image:/new/path.png]" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestInjectPathTags_PrependsForJSONContent(t *testing.T) { + jsonContent := `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}` + got := injectPathTags(jsonContent, []string{"[image:/tmp/photo.png]"}) + want := "[image:/tmp/photo.png]\n" + jsonContent + if got != want { + t.Fatalf("expected tag prepended to JSON, got %q", got) + } +} + +func TestInjectPathTags_BracketTextNotTreatedAsJSON(t *testing.T) { + content := "[update] see attached report" + got := injectPathTags(content, []string{"[file:/tmp/report.pdf]"}) + want := "[update] see attached report [file:/tmp/report.pdf]" + if got != want { + t.Fatalf("expected tag appended to bracket text, got %q", got) + } +} + +func TestResolveMediaRefs_JSONContentPrependsPathTag(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + pngPath := filepath.Join(dir, "card_img.png") + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, + } + os.WriteFile(pngPath, pngHeader, 0o644) + ref, _ := store.Store(pngPath, media.MediaMeta{ContentType: "image/png"}, "test") + + jsonContent := `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}` + messages := []providers.Message{ + {Role: "user", Content: jsonContent, Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + want := "[image:" + pngPath + "]\n" + jsonContent + if result[0].Content != want { + t.Fatalf("expected path tag prepended to JSON content, got %q", result[0].Content) + } +} + +func TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + docPath := filepath.Join(dir, "doc.docx") + os.WriteFile(docPath, []byte("fake docx"), 0o644) + docxMIME := "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ref, _ := store.Store(docPath, media.MediaMeta{ContentType: docxMIME}, "test") + + messages := []providers.Message{ + {Role: "user", Content: "", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + expected := "[file:" + docPath + "]" + if result[0].Content != expected { + t.Fatalf("expected content %q, got %q", expected, result[0].Content) + } +} + +func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + pngPath := filepath.Join(dir, "photo.png") + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, + } + os.WriteFile(pngPath, pngHeader, 0o644) + imgRef, _ := store.Store(pngPath, media.MediaMeta{}, "test") + + pdfPath := filepath.Join(dir, "report.pdf") + os.WriteFile(pdfPath, []byte("%PDF-1.4 test"), 0o644) + fileRef, _ := store.Store(pdfPath, media.MediaMeta{ContentType: "application/pdf"}, "test") + + messages := []providers.Message{ + {Role: "user", Content: "check these [file]", Media: []string{imgRef, fileRef}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (all types use path tags), got %d", len(result[0].Media)) + } + imgLocalPath, _, _ := store.ResolveWithMeta(imgRef) + pdfLocalPath, _, _ := store.ResolveWithMeta(fileRef) + expectedContent := "check these [file:" + pdfLocalPath + "] [image:" + imgLocalPath + "]" + if result[0].Content != expectedContent { + t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) + } +} + +// --- Native search helper tests --- + +type nativeSearchProvider struct { + supported bool +} + +func (p *nativeSearchProvider) Chat( + ctx context.Context, msgs []providers.Message, tools []providers.ToolDefinition, + model string, opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{Content: "ok"}, nil +} + +func (p *nativeSearchProvider) GetDefaultModel() string { return "test-model" } + +func (p *nativeSearchProvider) SupportsNativeSearch() bool { return p.supported } + +type plainProvider struct{} + +func (p *plainProvider) Chat( + ctx context.Context, msgs []providers.Message, tools []providers.ToolDefinition, + model string, opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{Content: "ok"}, nil +} + +func (p *plainProvider) GetDefaultModel() string { return "test-model" } + +func TestIsNativeSearchProvider_Supported(t *testing.T) { + if !isNativeSearchProvider(&nativeSearchProvider{supported: true}) { + t.Fatal("expected true for provider that supports native search") + } +} + +func TestIsNativeSearchProvider_NotSupported(t *testing.T) { + if isNativeSearchProvider(&nativeSearchProvider{supported: false}) { + t.Fatal("expected false for provider that does not support native search") + } +} + +func TestIsNativeSearchProvider_NoInterface(t *testing.T) { + if isNativeSearchProvider(&plainProvider{}) { + t.Fatal("expected false for provider that does not implement NativeSearchCapable") + } +} + +func TestFilterClientWebSearch_RemovesWebSearch(t *testing.T) { + defs := []providers.ToolDefinition{ + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "web_search"}}, + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "read_file"}}, + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "exec"}}, + } + result := filterClientWebSearch(defs) + if len(result) != 2 { + t.Fatalf("len(result) = %d, want 2", len(result)) + } + for _, td := range result { + if td.Function.Name == "web_search" { + t.Fatal("web_search should be filtered out") + } + } +} + +func TestFilterClientWebSearch_NoWebSearch(t *testing.T) { + defs := []providers.ToolDefinition{ + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "read_file"}}, + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "exec"}}, + } + result := filterClientWebSearch(defs) + if len(result) != 2 { + t.Fatalf("len(result) = %d, want 2", len(result)) + } +} + +func TestFilterClientWebSearch_EmptyInput(t *testing.T) { + result := filterClientWebSearch(nil) + if len(result) != 0 { + t.Fatalf("len(result) = %d, want 0", len(result)) + } +} + +type overflowProvider struct { + calls int + lastMessages []providers.Message + chatFunc func(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]any) (*providers.LLMResponse, error) +} + +func (p *overflowProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.calls++ + p.lastMessages = append([]providers.Message(nil), messages...) + + if p.chatFunc != nil { + return p.chatFunc(ctx, messages, tools, model, opts) + } + + if p.calls == 1 { + return nil, errors.New("context_window_exceeded") + } + + return &providers.LLMResponse{ + Content: "Recovered from overflow", + }, nil +} + +func (p *overflowProvider) GetDefaultModel() string { + return "test-model" +} + +func TestProcessMessage_ContextOverflowRecovery(t *testing.T) { + al, cfg, _, _, cleanup := newTestAgentLoop(t) + defer cleanup() + _ = cfg + + provider := &overflowProvider{} + al.registry = NewAgentRegistry(al.cfg, provider) + + sessionKey := "agent:main:test-session" + agent := al.GetRegistry().GetDefaultAgent() + + for i := 0; i < 5; i++ { + agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: "heavy message"}) + agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "assistant", Content: "response"}) + } + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "test", + ChatID: "chat1", + SenderID: "user1", + SessionKey: "test-session", + Content: "trigger recovery", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Recovered from overflow" { + t.Fatalf("response = %q, want %q", response, "Recovered from overflow") + } + + if provider.calls != 2 { + t.Fatalf("expected 2 calls, got %d", provider.calls) + } +} + +func TestProcessMessage_ContextOverflow_AnthropicStyle(t *testing.T) { + al, cfg, _, _, cleanup := newTestAgentLoop(t) + defer cleanup() + _ = cfg + + provider := &overflowProvider{} + al.registry = NewAgentRegistry(al.cfg, provider) + + recoveryMsg := "error: status 400: context_window_exceeded" + + provider.chatFunc = func( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, + ) (*providers.LLMResponse, error) { + if provider.calls == 1 { + return nil, errors.New(recoveryMsg) + } + return &providers.LLMResponse{Content: "Anthropic recovery success"}, nil + } + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "test", + ChatID: "chat1", + SenderID: "user1", + Content: "hello", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if !strings.Contains(response, "Anthropic recovery success") { + t.Fatalf("response = %q, want success message", response) + } + if provider.calls != 2 { + t.Fatalf("expected 2 calls for retry, got %d", provider.calls) + } +} + +func TestParallelMessageProcessing_DifferentSessionsProcessedConcurrently(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Track concurrent executions using a unique ID per turn + var mu sync.Mutex + activeTurns := make(map[string]bool) + maxConcurrent := 0 + turnCounter := 0 + var wg sync.WaitGroup + wg.Add(3) // Wait for 3 turns to complete + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + MaxParallelTurns: 3, // Allow up to 3 concurrent turns + }, + }, + Session: config.SessionConfig{ + Dimensions: []string{"chat"}, + }, + } + + msgBus := bus.NewMessageBus() + defer msgBus.Close() + + // Create a slow mock provider that tracks concurrency + provider := &concurrentMockProvider{ + responseFunc: func(callID int) string { + mu.Lock() + turnCounter++ + turnID := fmt.Sprintf("turn-%d", turnCounter) + activeTurns[turnID] = true + currentActive := len(activeTurns) + if currentActive > maxConcurrent { + maxConcurrent = currentActive + } + mu.Unlock() + + // Simulate some processing time + time.Sleep(100 * time.Millisecond) + + mu.Lock() + delete(activeTurns, turnID) + mu.Unlock() + + wg.Done() + return fmt.Sprintf("Response %s", turnID) + }, + } + + al := NewAgentLoop(cfg, msgBus, provider) + defer al.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start the agent loop + go func() { + if err := al.Run(ctx); err != nil { + t.Logf("Agent loop error: %v", err) + } + }() + + // Give the loop time to start + time.Sleep(50 * time.Millisecond) + + // Send 3 messages from different sessions + sessions := []string{"user1", "user2", "user3"} + for i, session := range sessions { + msg := bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: fmt.Sprintf("chat%d", i), + ChatType: "direct", + SenderID: session, + }, + Channel: "telegram", + ChatID: fmt.Sprintf("chat%d", i), + SenderID: session, + Content: fmt.Sprintf("Hello from %s", session), + } + if err := msgBus.PublishInbound(context.Background(), msg); err != nil { + t.Fatalf("PublishInbound failed: %v", err) + } + } + + // Wait for all turns to complete with timeout + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + // All turns completed successfully + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for turns to complete") + } + + // Verify that we had concurrent executions + mu.Lock() + defer mu.Unlock() + + if maxConcurrent < 2 { + t.Errorf("Expected at least 2 concurrent executions, got max %d", maxConcurrent) + } + + t.Logf("Maximum concurrent executions: %d", maxConcurrent) +} + +func TestParallelMessageProcessing_SameSessionProcessedSequentially(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + var mu sync.Mutex + turnIDs := make(map[string]bool) + var wg sync.WaitGroup + var firstResponse sync.Once + wg.Add(1) // Only 1 turn should be created for same session + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + MaxParallelTurns: 3, + }, + }, + Session: config.SessionConfig{ + Dimensions: []string{"chat"}, + }, + } + + msgBus := bus.NewMessageBus() + defer msgBus.Close() + + al := NewAgentLoop(cfg, msgBus, &concurrentMockProvider{ + responseFunc: func(callID int) string { + firstResponse.Do(func() { + wg.Done() + }) + return "ok" + }, + }) + defer al.Close() + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 64, + runtimeevents.KindAgentTurnStart, + ) + defer closeRuntimeEvents() + + go func() { + for evt := range runtimeCh { + if evt.Kind == runtimeevents.KindAgentTurnStart { + mu.Lock() + turnIDs[evt.Scope.TurnID] = true + mu.Unlock() + } + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + if err := al.Run(ctx); err != nil { + t.Logf("Agent loop error: %v", err) + } + }() + + time.Sleep(50 * time.Millisecond) + + // Send 3 messages from the SAME session - only one turn should be created; + // subsequent messages should be enqueued to the steering queue and processed + // within the same turn (not as separate concurrent turns). + for i := 0; i < 3; i++ { + msg := bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: fmt.Sprintf("Message %d", i+1), + } + if err := msgBus.PublishInbound(context.Background(), msg); err != nil { + t.Fatalf("PublishInbound failed: %v", err) + } + } + + // Wait for turn to complete with timeout + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + // Turn completed successfully + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for turn to complete") + } + + mu.Lock() + defer mu.Unlock() + + // Only 1 turn ID should have been created — proving messages were + // serialized into a single turn rather than spawning concurrent turns. + if len(turnIDs) != 1 { + t.Errorf("Expected 1 turn (others queued to steering), got %d: %v", len(turnIDs), turnIDs) + } +} + +// concurrentMockProvider is a mock provider that allows tracking concurrency +type concurrentMockProvider struct { + responseFunc func(callID int) string +} + +func (p *concurrentMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + // Use an atomic counter to assign unique call IDs for concurrency tracking. + // This avoids relying on sessionKey derivation from message content, which + // is not deterministic across concurrent calls. + response := "Mock response" + if p.responseFunc != nil { + response = p.responseFunc(len(messages)) + } + + return &providers.LLMResponse{ + Content: response, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (p *concurrentMockProvider) GetDefaultModel() string { + return "test-model" +} diff --git a/pkg/agent/agent_transcribe.go b/pkg/agent/agent_transcribe.go new file mode 100644 index 000000000..0ab328f36 --- /dev/null +++ b/pkg/agent/agent_transcribe.go @@ -0,0 +1,109 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) { + if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 { + return msg, false + } + + // Transcribe each audio media ref in order. + var transcriptions []string + var keptMedia []string + for _, ref := range msg.Media { + path, meta, err := al.mediaStore.ResolveWithMeta(ref) + if err != nil { + logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) + keptMedia = append(keptMedia, ref) + continue + } + if !utils.IsAudioFile(meta.Filename, meta.ContentType) { + keptMedia = append(keptMedia, ref) + continue + } + result, err := al.transcriber.Transcribe(ctx, path) + if err != nil { + logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err}) + transcriptions = append(transcriptions, "") + keptMedia = append(keptMedia, ref) + continue + } + transcriptions = append(transcriptions, result.Text) + } + + if len(transcriptions) == 0 { + return msg, false + } + + al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions) + + // Replace audio annotations sequentially with transcriptions. + idx := 0 + newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string { + if idx >= len(transcriptions) { + return match + } + text := transcriptions[idx] + idx++ + if text == "" { + return match + } + return "[voice: " + text + "]" + }) + + // Append any remaining transcriptions not matched by an annotation. + for ; idx < len(transcriptions); idx++ { + if transcriptions[idx] != "" { + newContent += "\n[voice: " + transcriptions[idx] + "]" + } + } + + msg.Content = newContent + msg.Media = keptMedia + return msg, true +} + +func (al *AgentLoop) sendTranscriptionFeedback( + ctx context.Context, + channel, chatID, messageID string, + validTexts []string, +) { + if !al.cfg.Voice.EchoTranscription { + return + } + if al.channelManager == nil { + return + } + + var nonEmpty []string + for _, t := range validTexts { + if t != "" { + nonEmpty = append(nonEmpty, t) + } + } + + var feedbackMsg string + if len(nonEmpty) > 0 { + feedbackMsg = "Transcript: " + strings.Join(nonEmpty, "\n") + } else { + feedbackMsg = "No voice detected in the audio" + } + + err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{ + Context: bus.NewOutboundContext(channel, chatID, messageID), + Content: feedbackMsg, + ReplyToMessageID: messageID, + }) + if err != nil { + logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()}) + } +} diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go new file mode 100644 index 000000000..9228b6d55 --- /dev/null +++ b/pkg/agent/agent_utils.go @@ -0,0 +1,596 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "fmt" + "maps" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/utils" +) + +func outboundContextFromInbound( + inbound *bus.InboundContext, + channel, chatID, replyToMessageID string, +) bus.InboundContext { + if inbound == nil { + return bus.NewOutboundContext(channel, chatID, replyToMessageID) + } + + outboundCtx := *cloneInboundContext(inbound) + if outboundCtx.Channel == "" { + outboundCtx.Channel = channel + } + if outboundCtx.ChatID == "" { + outboundCtx.ChatID = chatID + } + if outboundCtx.ReplyToMessageID == "" { + outboundCtx.ReplyToMessageID = replyToMessageID + } + return outboundCtx +} + +func outboundScopeFromSessionScope(scope *session.SessionScope) *bus.OutboundScope { + if scope == nil { + return nil + } + outboundScope := &bus.OutboundScope{ + Version: scope.Version, + AgentID: scope.AgentID, + Channel: scope.Channel, + Account: scope.Account, + } + if len(scope.Dimensions) > 0 { + outboundScope.Dimensions = append([]string(nil), scope.Dimensions...) + } + if len(scope.Values) > 0 { + outboundScope.Values = make(map[string]string, len(scope.Values)) + for key, value := range scope.Values { + outboundScope.Values[key] = value + } + } + return outboundScope +} + +func outboundTurnMetadata( + agentID, sessionKey string, + scope *session.SessionScope, +) (string, string, *bus.OutboundScope) { + return agentID, sessionKey, outboundScopeFromSessionScope(scope) +} + +func outboundMessageForTurn(ts *turnState, content string) bus.OutboundMessage { + agentID, sessionKey, scope := outboundTurnMetadata(ts.agent.ID, ts.sessionKey, ts.opts.Dispatch.SessionScope) + return bus.OutboundMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Context: outboundContextFromInbound( + ts.opts.Dispatch.InboundContext, + ts.channel, + ts.chatID, + ts.opts.Dispatch.ReplyToMessageID(), + ), + AgentID: agentID, + SessionKey: sessionKey, + Scope: scope, + Content: content, + } +} + +func outboundMessageForTurnWithKind(ts *turnState, content, kind string) bus.OutboundMessage { + msg := outboundMessageForTurn(ts, content) + if strings.TrimSpace(kind) == "" { + return msg + } + if msg.Context.Raw == nil { + msg.Context.Raw = make(map[string]string, 1) + } + msg.Context.Raw[metadataKeyMessageKind] = kind + return msg +} + +func latestUserContent(messages []providers.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + if msg.Role != "user" { + continue + } + if content := strings.TrimSpace(msg.Content); content != "" { + return content + } + } + return "" +} + +func toolFeedbackExplanationFromResponse( + response *providers.LLMResponse, + messages []providers.Message, +) string { + if response == nil { + return "" + } + explanation := strings.TrimSpace(response.Content) + if explanation == "" { + explanation = toolFeedbackExplanationFromToolCalls(response.ToolCalls) + } + if explanation == "" { + explanation = toolFeedbackExplanationFromMessages(messages) + } + return explanation +} + +func toolFeedbackExplanationFromToolCalls(toolCalls []providers.ToolCall) string { + for _, tc := range toolCalls { + if tc.ExtraContent == nil { + continue + } + if explanation := strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation); explanation != "" { + return explanation + } + } + return "" +} + +func toolFeedbackExplanationForToolCall( + response *providers.LLMResponse, + toolCall providers.ToolCall, + messages []providers.Message, +) string { + if toolCall.ExtraContent != nil { + if explanation := strings.TrimSpace(toolCall.ExtraContent.ToolFeedbackExplanation); explanation != "" { + return explanation + } + } + if response == nil { + return toolFeedbackExplanationFromMessages(messages) + } + + explanation := strings.TrimSpace(response.Content) + if explanation == "" { + explanation = toolFeedbackExplanationFromMessages(messages) + } + return explanation +} + +func toolFeedbackExplanationFromMessages(messages []providers.Message) string { + explanation := latestUserContent(messages) + if explanation != "" { + return utils.ToolFeedbackContinuationHint + ": " + explanation + } + return "" +} + +func toolFeedbackArgsPreview(args map[string]any, maxLen int) string { + argsJSON := utils.FormatArgsJSON(args, true, false) + return utils.Truncate(argsJSON, maxLen) +} + +func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool { + if ts == nil || ts.channel == "" || ts.opts.SuppressToolFeedback { + return false + } + return cfg != nil && cfg.Agents.Defaults.IsToolFeedbackEnabled() +} + +func cloneEventArguments(args map[string]any) map[string]any { + if len(args) == 0 { + return nil + } + + cloned := make(map[string]any, len(args)) + for k, v := range args { + cloned[k] = v + } + return cloned +} + +func hookDeniedToolContent(prefix, reason string) string { + if reason == "" { + return prefix + } + return prefix + ": " + reason +} + +func appendEventContextFields(fields map[string]any, turnCtx *TurnContext) { + if turnCtx == nil { + return + } + + if inbound := turnCtx.Inbound; inbound != nil { + if inbound.Channel != "" { + fields["inbound_channel"] = inbound.Channel + } + if inbound.Account != "" { + fields["inbound_account"] = inbound.Account + } + if inbound.ChatID != "" { + fields["inbound_chat_id"] = inbound.ChatID + } + if inbound.ChatType != "" { + fields["inbound_chat_type"] = inbound.ChatType + } + if inbound.TopicID != "" { + fields["inbound_topic_id"] = inbound.TopicID + } + if inbound.SpaceType != "" { + fields["inbound_space_type"] = inbound.SpaceType + } + if inbound.SpaceID != "" { + fields["inbound_space_id"] = inbound.SpaceID + } + if inbound.SenderID != "" { + fields["inbound_sender_id"] = inbound.SenderID + } + if inbound.Mentioned { + fields["inbound_mentioned"] = true + } + } + + if route := turnCtx.Route; route != nil { + if route.AgentID != "" { + fields["route_agent_id"] = route.AgentID + } + if route.Channel != "" { + fields["route_channel"] = route.Channel + } + if route.AccountID != "" { + fields["route_account_id"] = route.AccountID + } + if route.MatchedBy != "" { + fields["route_matched_by"] = route.MatchedBy + } + if len(route.SessionPolicy.Dimensions) > 0 { + fields["route_dimensions"] = strings.Join(route.SessionPolicy.Dimensions, ",") + } + if count := len(route.SessionPolicy.IdentityLinks); count > 0 { + fields["route_identity_link_count"] = count + } + } + + if scope := turnCtx.Scope; scope != nil { + if scope.Version > 0 { + fields["scope_version"] = scope.Version + } + if scope.AgentID != "" { + fields["scope_agent_id"] = scope.AgentID + } + if scope.Channel != "" { + fields["scope_channel"] = scope.Channel + } + if scope.Account != "" { + fields["scope_account"] = scope.Account + } + if len(scope.Dimensions) > 0 { + fields["scope_dimensions"] = strings.Join(scope.Dimensions, ",") + } + for dim, value := range scope.Values { + if dim == "" || value == "" { + continue + } + fields["scope_"+dim] = value + } + } +} + +func inferMediaType(filename, contentType string) string { + ct := strings.ToLower(contentType) + fn := strings.ToLower(filename) + + // SVG is an image MIME type, but raster-only delivery endpoints such as + // Telegram SendPhoto reject it. Treat it as a file/document instead. + if strings.HasPrefix(ct, "image/svg") || filepath.Ext(fn) == ".svg" { + return "file" + } + + if strings.HasPrefix(ct, "image/") { + return "image" + } + if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" { + return "audio" + } + if strings.HasPrefix(ct, "video/") { + return "video" + } + + // Fallback: infer from extension + ext := filepath.Ext(fn) + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp": + return "image" + case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": + return "audio" + case ".mp4", ".avi", ".mov", ".webm", ".mkv": + return "video" + } + + return "file" +} + +func normalizedInboundContext(msg bus.InboundMessage) bus.InboundContext { + return bus.NormalizeInboundMessage(msg).Context +} + +func resolveScopeKey(routeSessionKey, msgSessionKey string) string { + if isExplicitSessionKey(msgSessionKey) { + return msgSessionKey + } + return routeSessionKey +} + +func isExplicitSessionKey(sessionKey string) bool { + return session.IsExplicitSessionKey(sessionKey) +} + +func buildSessionAliases(canonicalKey string, keys ...string) []string { + if len(keys) == 0 { + return nil + } + aliases := make([]string, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + canonicalKey = strings.TrimSpace(canonicalKey) + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" || key == canonicalKey { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + aliases = append(aliases, key) + } + if len(aliases) == 0 { + return nil + } + return aliases +} + +func ensureSessionMetadata(store session.SessionStore, key string, scope *session.SessionScope, aliases []string) { + if key == "" || scope == nil { + return + } + metaStore, ok := store.(interface { + EnsureSessionMetadata(sessionKey string, scope *session.SessionScope, aliases []string) + }) + if !ok { + return + } + metaStore.EnsureSessionMetadata(key, scope, aliases) +} + +func sleepWithContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func formatMessagesForLog(messages []providers.Message) string { + if len(messages) == 0 { + return "[]" + } + + var sb strings.Builder + sb.WriteString("[\n") + for i, msg := range messages { + fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role) + if len(msg.ToolCalls) > 0 { + sb.WriteString(" ToolCalls:\n") + for _, tc := range msg.ToolCalls { + fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) + if tc.Function != nil { + fmt.Fprintf( + &sb, + " Arguments: %s\n", + utils.Truncate(tc.Function.Arguments, 200), + ) + } + } + } + if msg.Content != "" { + content := utils.Truncate(msg.Content, 200) + fmt.Fprintf(&sb, " Content: %s\n", content) + } + if msg.ToolCallID != "" { + fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID) + } + sb.WriteString("\n") + } + sb.WriteString("]") + return sb.String() +} + +func formatToolsForLog(toolDefs []providers.ToolDefinition) string { + if len(toolDefs) == 0 { + return "[]" + } + + var sb strings.Builder + sb.WriteString("[\n") + for i, tool := range toolDefs { + fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) + fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) + if len(tool.Function.Parameters) > 0 { + fmt.Fprintf( + &sb, + " Parameters: %s\n", + utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200), + ) + } + } + sb.WriteString("]") + return sb.String() +} + +func activeSkillNames(agent *AgentInstance, opts processOptions) []string { + if agent == nil { + return nil + } + + combined := make([]string, 0, len(agent.SkillsFilter)+len(opts.ForcedSkills)) + combined = append(combined, agent.SkillsFilter...) + combined = append(combined, opts.ForcedSkills...) + if len(combined) == 0 { + return nil + } + + var resolved []string + seen := make(map[string]struct{}, len(combined)) + for _, name := range combined { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if agent.ContextBuilder != nil { + if canonical, ok := agent.ContextBuilder.ResolveSkillName(name); ok { + name = canonical + } + } + key := strings.ToLower(name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + resolved = append(resolved, name) + } + + return resolved +} + +func sideQuestionResponseContent(response *providers.LLMResponse) string { + if response == nil { + return "" + } + if strings.TrimSpace(response.Content) != "" { + return response.Content + } + return responseReasoningContent(response) +} + +func responseReasoningContent(response *providers.LLMResponse) string { + if response == nil { + return "" + } + if strings.TrimSpace(response.Reasoning) != "" { + return response.Reasoning + } + if strings.TrimSpace(response.ReasoningContent) != "" { + return response.ReasoningContent + } + return "" +} + +func shallowCloneLLMOptions(opts map[string]any) map[string]any { + clone := make(map[string]any, len(opts)) + maps.Copy(clone, opts) + return clone +} + +func hasMediaRefs(messages []providers.Message) bool { + for _, msg := range messages { + if len(msg.Media) > 0 { + return true + } + } + return false +} + +func sideQuestionModelName(agent *AgentInstance, usedLight bool) string { + if usedLight && len(agent.LightCandidates) > 0 { + // Use the first light candidate's model + return agent.LightCandidates[0].Model + } + return agent.Model +} + +func modelNameFromIdentityKey(identityKey string) string { + if identityKey == "" { + return "" + } + parts := strings.SplitN(identityKey, "/", 2) + if len(parts) == 2 { + return parts[1] + } + return identityKey +} + +func closeProviderIfStateful(provider providers.LLMProvider) { + if stateful, ok := provider.(providers.StatefulProvider); ok { + stateful.Close() + } +} + +func makePendingTurnID(sessionKey string, seq uint64) string { + return pendingTurnPrefix + sessionKey + "-" + fmt.Sprintf("%d", seq) +} + +func commandsUnavailableSkillMessage() string { + return "Skill selection is unavailable in the current context." +} + +func buildUseCommandHelp(agent *AgentInstance) string { + if agent == nil || agent.ContextBuilder == nil { + return "Usage: /use [message]" + } + + names := agent.ContextBuilder.ListSkillNames() + if len(names) == 0 { + return "Usage: /use [message]\nNo installed skills found." + } + + return fmt.Sprintf( + "Usage: /use [message]\n\nInstalled Skills:\n- %s\n\nUse /use to apply a skill to your next message, or /use to force it immediately.", + strings.Join(names, "\n- "), + ) +} + +func mapCommandError(result commands.ExecuteResult) string { + if result.Command == "" { + return fmt.Sprintf("Failed to execute command: %v", result.Err) + } + return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err) +} + +func isNativeSearchProvider(p providers.LLMProvider) bool { + if ns, ok := p.(providers.NativeSearchCapable); ok { + return ns.SupportsNativeSearch() + } + return false +} + +func filterClientWebSearch(tools []providers.ToolDefinition) []providers.ToolDefinition { + result := make([]providers.ToolDefinition, 0, len(tools)) + for _, t := range tools { + if strings.EqualFold(t.Function.Name, "web_search") { + continue + } + result = append(result, t) + } + return result +} + +func extractProvider(registry *AgentRegistry) (providers.LLMProvider, bool) { + if registry == nil { + return nil, false + } + // Get any agent to access the provider + defaultAgent := registry.GetDefaultAgent() + if defaultAgent == nil { + return nil, false + } + return defaultAgent.Provider, true +} diff --git a/pkg/agent/agent_utils_test.go b/pkg/agent/agent_utils_test.go new file mode 100644 index 000000000..6612a60b3 --- /dev/null +++ b/pkg/agent/agent_utils_test.go @@ -0,0 +1,76 @@ +package agent + +import "testing" + +func TestInferMediaType(t *testing.T) { + tests := []struct { + name string + filename string + contentType string + want string + }{ + { + name: "png content type", + filename: "diagram", + contentType: "image/png", + want: "image", + }, + { + name: "jpeg extension fallback", + filename: "photo.JPG", + contentType: "", + want: "image", + }, + { + name: "svg content type is file", + filename: "diagram", + contentType: "image/svg+xml", + want: "file", + }, + { + name: "svg content type with parameters is file", + filename: "diagram", + contentType: "image/svg+xml; charset=utf-8", + want: "file", + }, + { + name: "svg extension fallback is file", + filename: "diagram.SVG", + contentType: "", + want: "file", + }, + { + name: "audio content type", + filename: "voice", + contentType: "audio/ogg", + want: "audio", + }, + { + name: "ogg application content type", + filename: "voice.ogg", + contentType: "application/ogg", + want: "audio", + }, + { + name: "video extension fallback", + filename: "clip.MP4", + contentType: "", + want: "video", + }, + { + name: "unknown type", + filename: "archive.bin", + contentType: "application/octet-stream", + want: "file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := inferMediaType(tt.filename, tt.contentType) + if got != tt.want { + t.Fatalf("inferMediaType(%q, %q) = %q, want %q", tt.filename, tt.contentType, got, tt.want) + } + }) + } +} diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 24ab3a58c..1de44af43 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -1,6 +1,7 @@ package agent import ( + "context" "errors" "fmt" "io/fs" @@ -11,16 +12,21 @@ import ( "strings" "sync" "time" + "unicode/utf8" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/utils" ) type ContextBuilder struct { - workspace string - skillsLoader *skills.SkillsLoader - memory *MemoryStore + workspace string + skillsLoader *skills.SkillsLoader + memory *MemoryStore + splitOnMarker bool + promptRegistry *PromptRegistry // Cache for system prompt to avoid rebuilding on every call. // This fixes issue #607: repeated reprocessing of the entire context. @@ -89,21 +95,33 @@ func (cb *ContextBuilder) buildFilteredSkillsSummary() string { return skills.FormatSkillsSummary(filtered) } +func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder { + if useBM25 || useRegex { + if err := cb.RegisterPromptContributor(toolDiscoveryPromptContributor{ + useBM25: useBM25, + useRegex: useRegex, + }); err != nil { + logger.WarnCF("agent", "Failed to register tool discovery prompt contributor", map[string]any{ + "error": err.Error(), + }) + } + } + return cb +} + +func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder { + cb.splitOnMarker = enabled + return cb +} + func getGlobalConfigDir() string { - if home := os.Getenv("PICOCLAW_HOME"); home != "" { - return home - } - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".picoclaw") + return config.GetHome() } func NewContextBuilder(workspace string) *ContextBuilder { // builtin skills: skills directory in current project // Use the skills/ directory under the current working directory - builtinSkillsDir := strings.TrimSpace(os.Getenv("PICOCLAW_BUILTIN_SKILLS")) + builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills)) if builtinSkillsDir == "" { wd, _ := os.Getwd() builtinSkillsDir = filepath.Join(wd, "skills") @@ -111,16 +129,42 @@ func NewContextBuilder(workspace string) *ContextBuilder { globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills") return &ContextBuilder{ - workspace: workspace, - skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), - memory: NewMemoryStore(workspace), + workspace: workspace, + skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), + memory: NewMemoryStore(workspace), + promptRegistry: NewPromptRegistry(), } } +func (cb *ContextBuilder) RegisterPromptSource(desc PromptSourceDescriptor) error { + err := cb.promptRegistryOrDefault().RegisterSource(desc) + if err == nil { + cb.InvalidateCache() + } + return err +} + +func (cb *ContextBuilder) RegisterPromptContributor(contributor PromptContributor) error { + err := cb.promptRegistryOrDefault().RegisterContributor(contributor) + if err == nil { + cb.InvalidateCache() + } + return err +} + +func (cb *ContextBuilder) promptRegistryOrDefault() *PromptRegistry { + if cb.promptRegistry == nil { + cb.promptRegistry = NewPromptRegistry() + } + return cb.promptRegistry +} + func (cb *ContextBuilder) getIdentity() string { workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) + version := config.FormatVersion() - return fmt.Sprintf(`# picoclaw 🦞 + return fmt.Sprintf( + `# picoclaw 🦞 (%s) You are picoclaw, a helpful AI assistant. @@ -139,39 +183,126 @@ Your workspace is at: %s 3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md 4. **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.`, - workspacePath, workspacePath, workspacePath, workspacePath, workspacePath) + version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath) +} + +func formatToolDiscoveryRule(useBM25, useRegex bool) string { + if !useBM25 && !useRegex { + return "" + } + + var toolNames []string + if useBM25 { + toolNames = append(toolNames, `"tool_search_tool_bm25"`) + } + if useRegex { + toolNames = append(toolNames, `"tool_search_tool_regex"`) + } + + return fmt.Sprintf( + `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.`, + strings.Join(toolNames, " or "), + ) } func (cb *ContextBuilder) BuildSystemPrompt() string { - parts := []string{} + return renderPromptPartsLegacy(cb.BuildSystemPromptParts()) +} + +func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart { + stack := NewPromptStack(cb.promptRegistryOrDefault()) + add := func(part PromptPart) { + if err := stack.Add(part); err != nil { + logger.WarnCF("agent", "Skipping invalid prompt part", map[string]any{ + "id": part.ID, + "layer": part.Layer, + "slot": part.Slot, + "source": part.Source.ID, + "error": err.Error(), + }) + } + } // Core identity section - parts = append(parts, cb.getIdentity()) + add(PromptPart{ + ID: "kernel.identity", + Layer: PromptLayerKernel, + Slot: PromptSlotIdentity, + Source: PromptSource{ID: PromptSourceKernel, Name: "identity"}, + Title: "picoclaw identity", + Content: cb.getIdentity(), + Stable: true, + Cache: PromptCacheEphemeral, + }) // Bootstrap files bootstrapContent := cb.LoadBootstrapFiles() if bootstrapContent != "" { - parts = append(parts, bootstrapContent) + add(PromptPart{ + ID: "instruction.workspace", + Layer: PromptLayerInstruction, + Slot: PromptSlotWorkspace, + Source: PromptSource{ID: PromptSourceWorkspace, Name: "workspace"}, + Title: "workspace instructions", + Content: bootstrapContent, + Stable: true, + Cache: PromptCacheEphemeral, + }) } // Skills - show summary, AI can read full content with read_file tool skillsSummary := cb.buildFilteredSkillsSummary() if skillsSummary != "" { - parts = append(parts, fmt.Sprintf(`# Skills + add(PromptPart{ + ID: "capability.skill_catalog", + Layer: PromptLayerCapability, + Slot: PromptSlotSkillCatalog, + Source: PromptSource{ID: PromptSourceSkillCatalog, Name: "skill:index"}, + Title: "skill catalog", + Content: fmt.Sprintf(`# Skills The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. -%s`, skillsSummary)) +%s`, skillsSummary), + Stable: true, + Cache: PromptCacheEphemeral, + }) } // Memory context memoryContext := cb.memory.GetMemoryContext() if memoryContext != "" { - parts = append(parts, "# Memory\n\n"+memoryContext) + add(PromptPart{ + ID: "context.memory", + Layer: PromptLayerContext, + Slot: PromptSlotMemory, + Source: PromptSource{ID: PromptSourceMemory, Name: "memory:workspace"}, + Title: "memory", + Content: "# Memory\n\n" + memoryContext, + Stable: true, + Cache: PromptCacheEphemeral, + }) } - // Join with "---" separator - return strings.Join(parts, "\n\n---\n\n") + // Multi-Message Sending (if enabled) + if cb.splitOnMarker { + add(PromptPart{ + ID: "context.output_policy.split_on_marker", + Layer: PromptLayerContext, + Slot: PromptSlotOutput, + Source: PromptSource{ID: PromptSourceOutputPolicy, Name: "split_on_marker"}, + Title: "multi-message output policy", + Content: `# MULTI-MESSAGE OUTPUT +You MUST frequently use <|[SPLIT]|> to break your responses into multiple short messages. NEVER output a single long wall of text. Actively split distinct concepts or parts. Example: Message part 1<|[SPLIT]|>Message part 2<|[SPLIT]|>Message part 3 + +Each part separated by the marker will be sent as an independent message.`, + Stable: true, + Cache: PromptCacheEphemeral, + }) + } + + stack.Seal() + return stack.Parts() } // BuildSystemPromptWithCache returns the cached system prompt if available @@ -217,6 +348,49 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string { return prompt } +// EstimateSystemTokens estimates the token count of the full system message +// that would be sent to the LLM, mirroring the composition logic in BuildMessages. +// It includes: static prompt, dynamic context, active skills, and summary with +// wrapping prefixes and separators. This avoids needing all per-request parameters +// that BuildMessages requires (media, channel, chatID, sender, etc.). +func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []string) int { + staticPrompt := cb.BuildSystemPromptWithCache() + + // Dynamic context is small and varies per request; use a representative estimate. + // Actual buildDynamicContext produces ~200-400 chars of time/runtime/session info. + const dynamicContextChars = 300 + + totalChars := utf8.RuneCountInString(staticPrompt) + dynamicContextChars + + if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" { + totalChars += utf8.RuneCountInString(skillsText) + totalChars += 7 // separator \n\n---\n\n + } + + if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), PromptBuildRequest{ + Summary: summary, + ActiveSkills: append([]string(nil), activeSkills...), + }); err == nil { + for _, part := range contributedParts { + if strings.TrimSpace(part.Content) == "" { + continue + } + totalChars += utf8.RuneCountInString(part.Content) + totalChars += 7 // separator + } + } + + if summary != "" { + // Matches the CONTEXT_SUMMARY: prefix added in BuildMessages + const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " + + "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n" + totalChars += utf8.RuneCountInString(summaryPrefix) + utf8.RuneCountInString(summary) + totalChars += 7 // separator + } + + return totalChars * 2 / 5 // same heuristic as tokenizer.EstimateMessageTokens +} + // InvalidateCache clears the cached system prompt. // Normally not needed because the cache auto-invalidates via mtime checks, // but this is useful for tests or explicit reload commands. @@ -236,13 +410,10 @@ func (cb *ContextBuilder) InvalidateCache() { // invalidation (bootstrap files + memory). Skill roots are handled separately // because they require both directory-level and recursive file-level checks. func (cb *ContextBuilder) sourcePaths() []string { - return []string{ - filepath.Join(cb.workspace, "AGENTS.md"), - filepath.Join(cb.workspace, "SOUL.md"), - filepath.Join(cb.workspace, "USER.md"), - filepath.Join(cb.workspace, "IDENTITY.md"), - filepath.Join(cb.workspace, "memory", "MEMORY.md"), - } + agentDefinition := cb.LoadAgentDefinition() + paths := agentDefinition.trackedPaths(cb.workspace) + paths = append(paths, filepath.Join(cb.workspace, "memory", "MEMORY.md")) + return uniquePaths(paths) } // skillRoots returns all skill root directories that can affect @@ -446,18 +617,32 @@ func skillFilesChangedSince(skillRoots []string, filesAtCache map[string]time.Ti } func (cb *ContextBuilder) LoadBootstrapFiles() string { - bootstrapFiles := []string{ - "AGENTS.md", - "SOUL.md", - "USER.md", - "IDENTITY.md", + var sb strings.Builder + + agentDefinition := cb.LoadAgentDefinition() + if agentDefinition.Agent != nil { + label := string(agentDefinition.Source) + if label == "" { + label = relativeWorkspacePath(cb.workspace, agentDefinition.Agent.Path) + } + fmt.Fprintf(&sb, "## %s\n\n%s\n\n", label, agentDefinition.Agent.Body) + } + if agentDefinition.Soul != nil { + fmt.Fprintf( + &sb, + "## %s\n\n%s\n\n", + relativeWorkspacePath(cb.workspace, agentDefinition.Soul.Path), + agentDefinition.Soul.Content, + ) + } + if agentDefinition.User != nil { + fmt.Fprintf(&sb, "## %s\n\n%s\n\n", "USER.md", agentDefinition.User.Content) } - var sb strings.Builder - for _, filename := range bootstrapFiles { - filePath := filepath.Join(cb.workspace, filename) + if agentDefinition.Source != AgentDefinitionSourceAgent { + filePath := filepath.Join(cb.workspace, "IDENTITY.md") if data, err := os.ReadFile(filePath); err == nil { - fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data) + fmt.Fprintf(&sb, "## %s\n\n%s\n\n", "IDENTITY.md", data) } } @@ -472,7 +657,23 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { // // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching // See: https://platform.openai.com/docs/guides/prompt-caching -func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { +func formatCurrentSenderLine(senderID, senderDisplayName string) string { + senderID = strings.TrimSpace(senderID) + senderDisplayName = strings.TrimSpace(senderDisplayName) + + switch { + case senderDisplayName != "" && senderID != "": + return fmt.Sprintf("Current sender: %s (ID: %s)", senderDisplayName, senderID) + case senderDisplayName != "": + return fmt.Sprintf("Current sender: %s", senderDisplayName) + case senderID != "": + return fmt.Sprintf("Current sender: %s", senderID) + default: + return "" + } +} + +func (cb *ContextBuilder) buildDynamicContext(channel, chatID, senderID, senderDisplayName string) string { now := time.Now().Format("2006-01-02 15:04 (Monday)") rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) @@ -482,6 +683,9 @@ func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { if channel != "" && chatID != "" { fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID) } + if senderLine := formatCurrentSenderLine(senderID, senderDisplayName); senderLine != "" { + fmt.Fprintf(&sb, "\n\n## Current Sender\n%s", senderLine) + } return sb.String() } @@ -491,8 +695,23 @@ func (cb *ContextBuilder) BuildMessages( summary string, currentMessage string, media []string, - channel, chatID string, + channel, chatID, senderID, senderDisplayName string, + activeSkills ...string, ) []providers.Message { + return cb.BuildMessagesFromPrompt(PromptBuildRequest{ + History: history, + Summary: summary, + CurrentMessage: currentMessage, + Media: media, + Channel: channel, + ChatID: chatID, + SenderID: senderID, + SenderDisplayName: senderDisplayName, + ActiveSkills: append([]string(nil), activeSkills...), + }) +} + +func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []providers.Message { messages := []providers.Message{} // The static part (identity, bootstrap, skills, memory) is cached locally to @@ -507,7 +726,7 @@ func (cb *ContextBuilder) BuildMessages( staticPrompt := cb.BuildSystemPromptWithCache() // Build short dynamic context (time, runtime, session) — changes per request - dynamicCtx := cb.buildDynamicContext(channel, chatID) + dynamicCtx := cb.buildDynamicContext(req.Channel, req.ChatID, req.SenderID, req.SenderDisplayName) // Compose a single system message: static (cached) + dynamic + optional summary. // Keeping all system content in one message ensures every provider adapter can @@ -518,20 +737,77 @@ func (cb *ContextBuilder) BuildMessages( // cache-aware adapters (Anthropic) can set per-block cache_control. // The static block is marked "ephemeral" — its prefix hash is stable // across requests, enabling LLM-side KV cache reuse. - stringParts := []string{staticPrompt, dynamicCtx} + stringParts := []string{staticPrompt} contentBlocks := []providers.ContentBlock{ - {Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}}, - {Type: "text", Text: dynamicCtx}, + promptContentBlock(PromptPart{ + ID: "kernel.static", + Layer: PromptLayerKernel, + Slot: PromptSlotIdentity, + Source: PromptSource{ID: PromptSourceKernel, Name: "static"}, + Content: staticPrompt, + }, &providers.CacheControl{Type: "ephemeral"}), } - if summary != "" { - summaryText := fmt.Sprintf( - "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ - "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s", - summary) - stringParts = append(stringParts, summaryText) - contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText}) + promptParts := append([]PromptPart(nil), req.Overlays...) + promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(req.ActiveSkills)...) + if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), req); err != nil { + logger.WarnCF("agent", "Prompt contributor collection failed", map[string]any{ + "error": err.Error(), + }) + } else { + promptParts = append(promptParts, contributedParts...) + } + + if len(promptParts) > 0 { + for _, overlay := range sortPromptParts(promptParts) { + if strings.TrimSpace(overlay.Content) == "" { + continue + } + if err := cb.promptRegistryOrDefault().ValidatePart(overlay); err != nil { + logger.WarnCF("agent", "Skipping invalid prompt overlay", map[string]any{ + "id": overlay.ID, + "layer": overlay.Layer, + "slot": overlay.Slot, + "source": overlay.Source.ID, + "error": err.Error(), + }) + continue + } + stringParts = append(stringParts, overlay.Content) + contentBlocks = append(contentBlocks, promptContentBlock(overlay, nil)) + } + } + + runtimePart := PromptPart{ + ID: "context.runtime", + Layer: PromptLayerContext, + Slot: PromptSlotRuntime, + Source: PromptSource{ID: PromptSourceRuntime, Name: "runtime"}, + Title: "runtime context", + Content: dynamicCtx, + Stable: false, + Cache: PromptCacheNone, + } + stringParts = append(stringParts, dynamicCtx) + contentBlocks = append(contentBlocks, promptContentBlock(runtimePart, nil)) + + if req.Summary != "" { + summaryPart := PromptPart{ + ID: "context.summary", + Layer: PromptLayerContext, + Slot: PromptSlotSummary, + Source: PromptSource{ID: PromptSourceSummary, Name: "context.summary"}, + Title: "context summary", + Content: fmt.Sprintf( + "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ + "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s", + req.Summary), + Stable: false, + Cache: PromptCacheNone, + } + stringParts = append(stringParts, summaryPart.Content) + contentBlocks = append(contentBlocks, promptContentBlock(summaryPart, nil)) } fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n") @@ -548,21 +824,19 @@ func (cb *ContextBuilder) BuildMessages( "static_chars": len(staticPrompt), "dynamic_chars": len(dynamicCtx), "total_chars": len(fullSystemPrompt), - "has_summary": summary != "", + "has_summary": req.Summary != "", + "overlays": len(req.Overlays), "cached": isCached, }) // Log preview of system prompt (avoid logging huge content) - preview := fullSystemPrompt - if len(preview) > 500 { - preview = preview[:500] + "... (truncated)" - } + preview := utils.Truncate(fullSystemPrompt, 500) logger.DebugCF("agent", "System prompt preview", map[string]any{ "preview": preview, }) - history = sanitizeHistoryForProvider(history) + history := sanitizeHistoryForProvider(req.History) // Single system message containing all context — compatible with all providers. // SystemParts enables cache-aware adapters to set per-block cache_control; @@ -576,16 +850,11 @@ func (cb *ContextBuilder) BuildMessages( // Add conversation history messages = append(messages, history...) - // Add current user message - if strings.TrimSpace(currentMessage) != "" { - msg := providers.Message{ - Role: "user", - Content: currentMessage, - } - if len(media) > 0 { - msg.Media = media - } - messages = append(messages, msg) + // Add current user message. Media-only turns must still be preserved so + // multimodal providers receive the uploaded image even when the user sends + // no accompanying text. + if strings.TrimSpace(req.CurrentMessage) != "" || len(req.Media) > 0 { + messages = append(messages, userPromptMessage(req.CurrentMessage, req.Media)) } return messages @@ -657,30 +926,60 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message // tool result messages following it. This is required by strict providers // like DeepSeek that enforce: "An assistant message with 'tool_calls' must // be followed by tool messages responding to each 'tool_call_id'." + // + // Deduplication is scoped to the contiguous tool-result block that follows a + // single assistant tool-call message. Some providers legitimately reuse call + // IDs across separate turns (for example "call_0"), so global deduplication + // would incorrectly delete later valid tool results and leave an + // assistant(tool_calls) -> assistant sequence behind. final := make([]providers.Message, 0, len(sanitized)) for i := 0; i < len(sanitized); i++ { msg := sanitized[i] + if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { - // Collect expected tool_call IDs expected := make(map[string]bool, len(msg.ToolCalls)) + invalidToolCallID := false for _, tc := range msg.ToolCalls { + if tc.ID == "" { + invalidToolCallID = true + continue + } expected[tc.ID] = false } - // Check following messages for matching tool results - toolMsgCount := 0 - for j := i + 1; j < len(sanitized); j++ { - if sanitized[j].Role != "tool" { + block := make([]providers.Message, 0, len(expected)) + seenInBlock := make(map[string]bool, len(expected)) + j := i + 1 + for ; j < len(sanitized); j++ { + next := sanitized[j] + if next.Role != "tool" { break } - toolMsgCount++ - if _, exists := expected[sanitized[j].ToolCallID]; exists { - expected[sanitized[j].ToolCallID] = true + if next.ToolCallID == "" { + logger.DebugCF("agent", "Dropping tool result without tool_call_id", map[string]any{}) + continue } + if _, ok := expected[next.ToolCallID]; !ok { + logger.DebugCF("agent", "Dropping unexpected tool result", map[string]any{ + "tool_call_id": next.ToolCallID, + }) + continue + } + if seenInBlock[next.ToolCallID] { + logger.DebugCF("agent", "Dropping duplicate tool result in tool block", map[string]any{ + "tool_call_id": next.ToolCallID, + }) + continue + } + seenInBlock[next.ToolCallID] = true + expected[next.ToolCallID] = true + block = append(block, next) } - // If any tool_call_id is missing, drop this assistant message and its partial tool messages - allFound := true + allFound := !invalidToolCallID + if invalidToolCallID { + logger.DebugCF("agent", "Dropping assistant message with empty tool_call_id", map[string]any{}) + } for toolCallID, found := range expected { if !found { allFound = false @@ -690,7 +989,7 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message map[string]any{ "missing_tool_call_id": toolCallID, "expected_count": len(expected), - "found_count": toolMsgCount, + "found_count": len(block), }, ) break @@ -698,11 +997,23 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message } if !allFound { - // Skip this assistant message and its tool messages - i += toolMsgCount + i = j - 1 continue } + + final = append(final, msg) + final = append(final, block...) + i = j - 1 + continue } + + if msg.Role == "tool" { + logger.DebugCF("agent", "Dropping orphaned tool message after validation", map[string]any{ + "tool_call_id": msg.ToolCallID, + }) + continue + } + final = append(final, msg) } @@ -735,6 +1046,88 @@ func (cb *ContextBuilder) AddAssistantMessage( return messages } +func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string { + if cb.skillsLoader == nil || len(skillNames) == 0 { + return "" + } + + var ordered []string + seen := make(map[string]struct{}, len(skillNames)) + for _, name := range skillNames { + canonical, ok := cb.ResolveSkillName(name) + if !ok { + continue + } + if _, exists := seen[canonical]; exists { + continue + } + seen[canonical] = struct{}{} + ordered = append(ordered, canonical) + } + if len(ordered) == 0 { + return "" + } + + content := cb.skillsLoader.LoadSkillsForContext(ordered) + if strings.TrimSpace(content) == "" { + return "" + } + + return fmt.Sprintf(`# Active Skills + +The following skills are active for this request. Follow them when relevant. + +%s`, content) +} + +func (cb *ContextBuilder) buildActiveSkillsPromptParts(skillNames []string) []PromptPart { + skillsText := cb.buildActiveSkillsContext(skillNames) + if strings.TrimSpace(skillsText) == "" { + return nil + } + + return []PromptPart{ + { + ID: "capability.active_skills", + Layer: PromptLayerCapability, + Slot: PromptSlotActiveSkill, + Source: PromptSource{ID: PromptSourceActiveSkills, Name: "skill:active"}, + Title: "active skills", + Content: skillsText, + Stable: false, + Cache: PromptCacheNone, + }, + } +} + +func (cb *ContextBuilder) ListSkillNames() []string { + if cb.skillsLoader == nil { + return nil + } + + allSkills := cb.skillsLoader.ListSkills() + names := make([]string, 0, len(allSkills)) + for _, skill := range allSkills { + names = append(names, skill.Name) + } + return names +} + +func (cb *ContextBuilder) ResolveSkillName(name string) (string, bool) { + name = strings.TrimSpace(name) + if name == "" || cb.skillsLoader == nil { + return "", false + } + + for _, skill := range cb.skillsLoader.ListSkills() { + if strings.EqualFold(skill.Name, name) { + return skill.Name, true + } + } + + return "", false +} + // GetSkillsInfo returns information about loaded skills. func (cb *ContextBuilder) GetSkillsInfo() map[string]any { allSkills := cb.skillsLoader.ListSkills() diff --git a/pkg/agent/context_budget.go b/pkg/agent/context_budget.go new file mode 100644 index 000000000..72f80382a --- /dev/null +++ b/pkg/agent/context_budget.go @@ -0,0 +1,117 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package agent + +import ( + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tokenizer" +) + +// parseTurnBoundaries returns the starting index of each Turn in the history. +// A Turn is a complete "user input → LLM iterations → final response" cycle +// (as defined in #1316). Each Turn begins at a user message and extends +// through all subsequent assistant/tool messages until the next user message. +// +// Cutting at a Turn boundary guarantees that no tool-call sequence +// (assistant+ToolCalls → tool results) is split across the cut. +func parseTurnBoundaries(history []providers.Message) []int { + var starts []int + for i, msg := range history { + if msg.Role == "user" { + starts = append(starts, i) + } + } + return starts +} + +// isSafeBoundary reports whether index is a valid Turn boundary — i.e., +// a position where the kept portion (history[index:]) begins at a user +// message, so no tool-call sequence is torn apart. +func isSafeBoundary(history []providers.Message, index int) bool { + if index <= 0 || index >= len(history) { + return true + } + return history[index].Role == "user" +} + +// findSafeBoundary locates the nearest Turn boundary to targetIndex. +// It prefers the boundary at or before targetIndex (preserving more recent +// context). Falls back to the nearest boundary after targetIndex, and +// returns targetIndex unchanged only when no Turn boundary exists at all. +func findSafeBoundary(history []providers.Message, targetIndex int) int { + if len(history) == 0 { + return 0 + } + if targetIndex <= 0 { + return 0 + } + if targetIndex >= len(history) { + return len(history) + } + + turns := parseTurnBoundaries(history) + if len(turns) == 0 { + return targetIndex + } + + // Find the last Turn boundary at or before targetIndex. + // Prefer backward: keeps more recent messages. + backward := -1 + for _, t := range turns { + if t <= targetIndex { + backward = t + } + } + if backward > 0 { + return backward + } + + // No valid Turn boundary before target (or only at index 0 which + // would keep everything). Use the first Turn after targetIndex. + for _, t := range turns { + if t > targetIndex { + return t + } + } + + // No Turn boundary after targetIndex either. The only boundary is at + // index 0, meaning the entire history is a single Turn. Return 0 to + // signal that safe compression is not possible — callers check for + // mid <= 0 and skip compression in that case. + return 0 +} + +// EstimateMessageTokens estimates the token count for a single message. +// Delegates to the shared tokenizer package for consistency across agent and seahorse. +func EstimateMessageTokens(msg providers.Message) int { + return tokenizer.EstimateMessageTokens(msg) +} + +// EstimateToolDefsTokens estimates the total token cost of tool definitions +// as they appear in the LLM request. Delegates to the shared tokenizer package. +func EstimateToolDefsTokens(defs []providers.ToolDefinition) int { + return tokenizer.EstimateToolDefsTokens(defs) +} + +// isOverContextBudget checks whether the assembled messages plus tool definitions +// and output reserve would exceed the model's context window. This enables +// proactive compression before calling the LLM, rather than reacting to 400 errors. +func isOverContextBudget( + contextWindow int, + messages []providers.Message, + toolDefs []providers.ToolDefinition, + maxTokens int, +) bool { + msgTokens := 0 + for _, m := range messages { + msgTokens += EstimateMessageTokens(m) + } + + toolTokens := EstimateToolDefsTokens(toolDefs) + total := msgTokens + toolTokens + maxTokens + + return total > contextWindow +} diff --git a/pkg/agent/context_budget_test.go b/pkg/agent/context_budget_test.go new file mode 100644 index 000000000..9de1707ec --- /dev/null +++ b/pkg/agent/context_budget_test.go @@ -0,0 +1,846 @@ +package agent + +import ( + "fmt" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +// msgUser creates a user message. +func msgUser(content string) providers.Message { + return providers.Message{Role: "user", Content: content} +} + +// msgAssistant creates a plain assistant message (no tool calls). +func msgAssistant(content string) providers.Message { + return providers.Message{Role: "assistant", Content: content} +} + +// msgAssistantTC creates an assistant message with tool calls. +func msgAssistantTC(toolIDs ...string) providers.Message { + tcs := make([]providers.ToolCall, len(toolIDs)) + for i, id := range toolIDs { + tcs[i] = providers.ToolCall{ + ID: id, + Type: "function", + Name: "tool_" + id, + Function: &providers.FunctionCall{ + Name: "tool_" + id, + Arguments: `{"key":"value"}`, + }, + } + } + return providers.Message{Role: "assistant", ToolCalls: tcs} +} + +// msgTool creates a tool result message. +func msgTool(callID, content string) providers.Message { + return providers.Message{Role: "tool", ToolCallID: callID, Content: content} +} + +func TestParseTurnBoundaries(t *testing.T) { + tests := []struct { + name string + history []providers.Message + want []int + }{ + { + name: "empty history", + history: nil, + want: nil, + }, + { + name: "simple exchange", + history: []providers.Message{ + msgUser("q1"), + msgAssistant("a1"), + msgUser("q2"), + msgAssistant("a2"), + }, + want: []int{0, 2}, + }, + { + name: "tool-call Turn", + history: []providers.Message{ + msgUser("search"), + msgAssistantTC("tc1"), + msgTool("tc1", "result"), + msgAssistant("found it"), + msgUser("thanks"), + msgAssistant("welcome"), + }, + want: []int{0, 4}, + }, + { + name: "chained tool calls in single Turn", + history: []providers.Message{ + msgUser("save and notify"), + msgAssistantTC("tc_save"), + msgTool("tc_save", "saved"), + msgAssistantTC("tc_notify"), + msgTool("tc_notify", "notified"), + msgAssistant("done"), + }, + want: []int{0}, + }, + { + name: "no user messages", + history: []providers.Message{ + msgAssistant("a1"), + msgAssistant("a2"), + }, + want: nil, + }, + { + name: "leading non-user messages", + history: []providers.Message{ + msgAssistantTC("tc1"), + msgTool("tc1", "r1"), + msgAssistant("greeting"), + msgUser("hello"), + msgAssistant("hi"), + }, + want: []int{3}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTurnBoundaries(tt.history) + if len(got) != len(tt.want) { + t.Errorf("parseTurnBoundaries() = %v, want %v", got, tt.want) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("parseTurnBoundaries()[%d] = %d, want %d", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestIsSafeBoundary(t *testing.T) { + tests := []struct { + name string + history []providers.Message + index int + want bool + }{ + { + name: "empty history, index 0", + history: nil, + index: 0, + want: true, + }, + { + name: "single user message, index 0", + history: []providers.Message{msgUser("hi")}, + index: 0, + want: true, + }, + { + name: "single user message, index 1 (end)", + history: []providers.Message{msgUser("hi")}, + index: 1, + want: true, + }, + { + name: "at user message", + history: []providers.Message{ + msgAssistant("hello"), + msgUser("how are you"), + msgAssistant("fine"), + }, + index: 1, + want: true, + }, + { + name: "at assistant without tool calls", + history: []providers.Message{ + msgUser("hello"), + msgAssistant("response"), + msgUser("follow up"), + }, + index: 1, + want: false, + }, + { + name: "at assistant with tool calls", + history: []providers.Message{ + msgUser("search something"), + msgAssistantTC("tc1"), + msgTool("tc1", "result"), + msgAssistant("here is what I found"), + }, + index: 1, + want: false, + }, + { + name: "at tool result", + history: []providers.Message{ + msgUser("do something"), + msgAssistantTC("tc1"), + msgTool("tc1", "done"), + msgAssistant("completed"), + }, + index: 2, + want: false, + }, + { + name: "negative index", + history: []providers.Message{ + msgUser("hello"), + }, + index: -1, + want: true, + }, + { + name: "index beyond length", + history: []providers.Message{ + msgUser("hello"), + }, + index: 5, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isSafeBoundary(tt.history, tt.index) + if got != tt.want { + t.Errorf("isSafeBoundary(history, %d) = %v, want %v", tt.index, got, tt.want) + } + }) + } +} + +func TestFindSafeBoundary(t *testing.T) { + tests := []struct { + name string + history []providers.Message + targetIndex int + want int + }{ + { + name: "empty history", + history: nil, + targetIndex: 0, + want: 0, + }, + { + name: "target at 0", + history: []providers.Message{msgUser("hi")}, + targetIndex: 0, + want: 0, + }, + { + name: "target beyond length", + history: []providers.Message{msgUser("hi")}, + targetIndex: 5, + want: 1, + }, + { + name: "target already at user message", + history: []providers.Message{ + msgUser("q1"), + msgAssistant("a1"), + msgUser("q2"), + msgAssistant("a2"), + }, + targetIndex: 2, + want: 2, + }, + { + name: "target at assistant, scan backward finds user", + history: []providers.Message{ + msgUser("q1"), + msgAssistant("a1"), + msgUser("q2"), + msgAssistant("a2"), + msgUser("q3"), + }, + targetIndex: 3, // assistant "a2" + want: 2, // backward to user "q2" + }, + { + name: "target inside tool sequence, scan backward finds user", + history: []providers.Message{ + msgUser("q1"), + msgAssistant("a1"), + msgUser("q2"), + msgAssistantTC("tc1", "tc2"), + msgTool("tc1", "r1"), + msgTool("tc2", "r2"), + msgAssistant("summary"), + msgUser("q3"), + }, + targetIndex: 4, // tool result "r1" + want: 2, // backward: 3=assistant+TC (not safe), 2=user → safe + }, + { + name: "target inside tool sequence, backward finds user before chain", + history: []providers.Message{ + msgUser("q1"), + msgAssistant("a1"), + msgUser("q2"), + msgAssistantTC("tc1", "tc2"), + msgTool("tc1", "r1"), + msgTool("tc2", "r2"), + msgAssistant("summary"), + msgUser("q3"), + }, + targetIndex: 5, // tool result "r2" + want: 2, // backward: 4=tool, 3=assistant+TC, 2=user → safe + }, + { + name: "no backward user, scan forward finds one", + history: []providers.Message{ + msgAssistantTC("tc1"), + msgTool("tc1", "r1"), + msgAssistant("a1"), + msgUser("q1"), + }, + targetIndex: 1, // tool result + want: 3, // forward to user "q1" + }, + { + name: "multi-step tool chain preserves atomicity", + history: []providers.Message{ + msgUser("q1"), + msgAssistant("a1"), + msgUser("q2"), + msgAssistantTC("tc1"), + msgTool("tc1", "r1"), + msgAssistantTC("tc2"), + msgTool("tc2", "r2"), + msgAssistant("final"), + msgUser("q3"), + msgAssistant("a3"), + }, + targetIndex: 5, // second assistant+TC + want: 2, // backward: 4=tool, 3=assistant+TC, 2=user → safe + }, + { + name: "all non-user messages returns target unchanged", + history: []providers.Message{ + msgAssistant("a1"), + msgAssistant("a2"), + msgAssistant("a3"), + }, + targetIndex: 1, + want: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findSafeBoundary(tt.history, tt.targetIndex) + if got != tt.want { + t.Errorf("findSafeBoundary(history, %d) = %d, want %d", + tt.targetIndex, got, tt.want) + } + }) + } +} + +func TestFindSafeBoundary_SingleTurnReturnsZero(t *testing.T) { + // A single Turn with no subsequent user message. The only Turn boundary + // is at index 0; cutting anywhere else would split the Turn's tool + // sequence. findSafeBoundary must return 0 so callers skip compression. + history := []providers.Message{ + msgUser("do everything"), // 0 ← only Turn boundary + msgAssistantTC("tc1"), // 1 + msgTool("tc1", "result"), // 2 + msgAssistant("all done"), // 3 + } + + got := findSafeBoundary(history, 2) + if got != 0 { + t.Errorf("findSafeBoundary(single_turn, 2) = %d, want 0 (cannot split single Turn)", got) + } +} + +func TestFindSafeBoundary_BackwardScanSkipsToolSequence(t *testing.T) { + // A long tool-call chain: user → assistant+TC → tool → tool → ... → assistant → user + // Target is inside the chain; boundary should skip the entire chain backward. + history := []providers.Message{ + msgUser("start"), // 0 + msgAssistant("before chain"), // 1 + msgUser("trigger"), // 2 ← expected safe boundary + msgAssistantTC("t1", "t2", "t3"), // 3 + msgTool("t1", "r1"), // 4 + msgTool("t2", "r2"), // 5 + msgTool("t3", "r3"), // 6 + msgAssistantTC("t4"), // 7 + msgTool("t4", "r4"), // 8 + msgAssistant("chain done"), // 9 + msgUser("next"), // 10 + } + + // Target at index 6 (middle of tool results) + got := findSafeBoundary(history, 6) + if got != 2 { + t.Errorf("findSafeBoundary(history, 6) = %d, want 2 (user before chain)", got) + } +} + +func TestEstimateMessageTokens(t *testing.T) { + tests := []struct { + name string + msg providers.Message + want int // minimum expected tokens (exact value depends on overhead) + }{ + { + name: "plain user message", + msg: msgUser("Hello, world!"), + want: 1, // at least some tokens + }, + { + name: "empty message still has overhead", + msg: providers.Message{Role: "user"}, + want: 1, // message overhead alone + }, + { + name: "assistant with tool calls", + msg: msgAssistantTC("tc_123"), + want: 1, + }, + { + name: "tool result with ID", + msg: msgTool("call_abc", "Here is the search result with lots of content"), + want: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := EstimateMessageTokens(tt.msg) + if got < tt.want { + t.Errorf("EstimateMessageTokens() = %d, want >= %d", got, tt.want) + } + }) + } +} + +func TestEstimateMessageTokens_ToolCallsContribute(t *testing.T) { + plain := msgAssistant("thinking") + withTC := providers.Message{ + Role: "assistant", + Content: "thinking", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Name: "web_search", + Function: &providers.FunctionCall{ + Name: "web_search", + Arguments: `{"query":"picoclaw agent framework","max_results":5}`, + }, + }, + }, + } + + plainTokens := EstimateMessageTokens(plain) + withTCTokens := EstimateMessageTokens(withTC) + + if withTCTokens <= plainTokens { + t.Errorf("message with ToolCalls (%d tokens) should exceed plain message (%d tokens)", + withTCTokens, plainTokens) + } +} + +func TestEstimateMessageTokens_MultibyteContent(t *testing.T) { + // Multi-byte characters (e.g. emoji, accented letters) are single runes + // but may map to different token counts. The heuristic should still produce + // reasonable estimates via RuneCountInString. + msg := msgUser("caf\u00e9 na\u00efve r\u00e9sum\u00e9 \u00fcber stra\u00dfe") + tokens := EstimateMessageTokens(msg) + if tokens <= 0 { + t.Errorf("multibyte message should produce positive token count, got %d", tokens) + } +} + +func TestEstimateMessageTokens_LargeArguments(t *testing.T) { + // Simulate a tool call with large JSON arguments. + largeArgs := fmt.Sprintf(`{"content":"%s"}`, strings.Repeat("x", 5000)) + msg := providers.Message{ + Role: "assistant", + ToolCalls: []providers.ToolCall{ + { + ID: "call_large", + Type: "function", + Name: "write_file", + Function: &providers.FunctionCall{ + Name: "write_file", + Arguments: largeArgs, + }, + }, + }, + } + + tokens := EstimateMessageTokens(msg) + // 5000+ chars → at least 2000 tokens with the 2.5 char/token heuristic + if tokens < 2000 { + t.Errorf("large tool call arguments should produce significant token count, got %d", tokens) + } +} + +func TestEstimateMessageTokens_ReasoningContent(t *testing.T) { + plain := msgAssistant("result") + withReasoning := providers.Message{ + Role: "assistant", + Content: "result", + ReasoningContent: strings.Repeat("thinking step ", 200), + } + + plainTokens := EstimateMessageTokens(plain) + reasoningTokens := EstimateMessageTokens(withReasoning) + + if reasoningTokens <= plainTokens { + t.Errorf("message with ReasoningContent (%d tokens) should exceed plain message (%d tokens)", + reasoningTokens, plainTokens) + } +} + +func TestEstimateMessageTokens_MediaItems(t *testing.T) { + plain := msgUser("describe this") + withMedia := providers.Message{ + Role: "user", + Content: "describe this", + Media: []string{"media://img1.png", "media://img2.png"}, + } + + plainTokens := EstimateMessageTokens(plain) + mediaTokens := EstimateMessageTokens(withMedia) + + if mediaTokens <= plainTokens { + t.Errorf("message with Media (%d tokens) should exceed plain message (%d tokens)", + mediaTokens, plainTokens) + } + + // Each media item should add exactly 256 tokens (not run through chars*2/5). + expectedDelta := 256 * 2 + actualDelta := mediaTokens - plainTokens + if actualDelta != expectedDelta { + t.Errorf("2 media items should add %d tokens, got delta %d", expectedDelta, actualDelta) + } +} + +func TestEstimateMessageTokens_SystemParts(t *testing.T) { + plain := providers.Message{Role: "system", Content: "instructions"} + withParts := providers.Message{ + Role: "system", + Content: "instructions", + SystemParts: []providers.ContentBlock{ + {Type: "text", Text: "some more system context"}, + {Type: "text", Text: "even more cached blocks"}, + }, + } + + plainTokens := EstimateMessageTokens(plain) + partsTokens := EstimateMessageTokens(withParts) + + if partsTokens <= plainTokens { + t.Errorf("system message with SystemParts (%d) should exceed plain message (%d)", + partsTokens, plainTokens) + } +} + +// --- EstimateToolDefsTokens tests --- + +func TestEstimateToolDefsTokens(t *testing.T) { + tests := []struct { + name string + defs []providers.ToolDefinition + want int // minimum expected tokens + }{ + { + name: "empty tool list", + defs: nil, + want: 0, + }, + { + name: "single tool with params", + defs: []providers.ToolDefinition{ + { + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: "web_search", + Description: "Search the web for information", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + }, + "required": []any{"query"}, + }, + }, + }, + }, + want: 1, + }, + { + name: "tool without params", + defs: []providers.ToolDefinition{ + { + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: "list_dir", + Description: "List directory contents", + }, + }, + }, + want: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := EstimateToolDefsTokens(tt.defs) + if got < tt.want { + t.Errorf("EstimateToolDefsTokens() = %d, want >= %d", got, tt.want) + } + }) + } +} + +func TestEstimateToolDefsTokens_ScalesWithCount(t *testing.T) { + makeTool := func(name string) providers.ToolDefinition { + return providers.ToolDefinition{ + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: name, + Description: "A test tool that does something useful", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "input": map[string]any{"type": "string", "description": "Input value"}, + }, + }, + }, + } + } + + one := EstimateToolDefsTokens([]providers.ToolDefinition{makeTool("tool_a")}) + three := EstimateToolDefsTokens([]providers.ToolDefinition{ + makeTool("tool_a"), makeTool("tool_b"), makeTool("tool_c"), + }) + + if three <= one { + t.Errorf("3 tools (%d tokens) should exceed 1 tool (%d tokens)", three, one) + } +} + +// --- isOverContextBudget tests --- + +func TestIsOverContextBudget(t *testing.T) { + systemMsg := providers.Message{Role: "system", Content: strings.Repeat("x", 1000)} + userMsg := msgUser("hello") + smallHistory := []providers.Message{systemMsg, msgUser("q1"), msgAssistant("a1"), userMsg} + + tools := []providers.ToolDefinition{ + { + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: "test_tool", + Description: "A test tool", + Parameters: map[string]any{"type": "object"}, + }, + }, + } + + tests := []struct { + name string + contextWindow int + messages []providers.Message + toolDefs []providers.ToolDefinition + maxTokens int + want bool + }{ + { + name: "within budget", + contextWindow: 100000, + messages: smallHistory, + toolDefs: tools, + maxTokens: 4096, + want: false, + }, + { + name: "over budget with small window", + contextWindow: 100, // very small window + messages: smallHistory, + toolDefs: tools, + maxTokens: 4096, + want: true, + }, + { + name: "large max_tokens eats budget", + contextWindow: 2000, + messages: smallHistory, + toolDefs: tools, + maxTokens: 1800, // leaves almost no room + want: true, + }, + { + name: "empty messages within budget", + contextWindow: 10000, + messages: nil, + toolDefs: nil, + maxTokens: 4096, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isOverContextBudget(tt.contextWindow, tt.messages, tt.toolDefs, tt.maxTokens) + if got != tt.want { + t.Errorf("isOverContextBudget() = %v, want %v", got, tt.want) + } + }) + } +} + +// --- Tests reflecting actual session data shape --- +// Session history never contains system messages. The system prompt is +// built dynamically by BuildMessages. These tests use realistic history +// shapes: user/assistant/tool only, with tool chains and reasoning content. + +func TestFindSafeBoundary_SessionHistoryNoSystem(t *testing.T) { + // Real session history starts with a user message, not a system message. + history := []providers.Message{ + msgUser("hello"), // 0 + msgAssistant("hi there"), // 1 + msgUser("search for X"), // 2 + msgAssistantTC("tc1"), // 3 + msgTool("tc1", "found X"), // 4 + msgAssistant("here is X"), // 5 + msgUser("thanks"), // 6 + msgAssistant("you're welcome"), // 7 + } + + // Mid-point is 4 (tool result). Should snap backward to 2 (user). + got := findSafeBoundary(history, 4) + if got != 2 { + t.Errorf("findSafeBoundary(session_history, 4) = %d, want 2", got) + } +} + +func TestFindSafeBoundary_SessionWithChainedTools(t *testing.T) { + // Session with chained tool calls (save then notify). + history := []providers.Message{ + msgUser("save and notify"), // 0 + msgAssistantTC("tc_save"), // 1 + msgTool("tc_save", "saved"), // 2 + msgAssistantTC("tc_notify"), // 3 + msgTool("tc_notify", "notified"), // 4 + msgAssistant("done"), // 5 + msgUser("check status"), // 6 + msgAssistant("all good"), // 7 + } + + // Target at 3 (inside chain). Should find user at 0, but backward + // scan stops at i>0, so forward scan finds user at 6. + // Actually: backward from 3: 2=tool (no), 1=assistantTC (no). Forward: 4=tool, 5=asst, 6=user ✓ + got := findSafeBoundary(history, 3) + if got != 6 { + t.Errorf("findSafeBoundary(chained_tools, 3) = %d, want 6", got) + } +} + +func TestEstimateMessageTokens_WithReasoningAndMedia(t *testing.T) { + // Message with all fields populated — mirrors what AddFullMessage stores. + msg := providers.Message{ + Role: "assistant", + Content: "Here is the analysis.", + ReasoningContent: strings.Repeat("Let me think about this carefully. ", 50), + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Name: "analyze", + Function: &providers.FunctionCall{ + Name: "analyze", + Arguments: `{"data":"sample","depth":3}`, + }, + }, + }, + } + + tokens := EstimateMessageTokens(msg) + + // ReasoningContent alone is ~1700 chars → ~680 tokens. + // Content + TC + overhead adds more. Should be well above 500. + if tokens < 500 { + t.Errorf("message with reasoning+toolcalls should have significant tokens, got %d", tokens) + } + + // Compare without reasoning to ensure it's counted. + msgNoReasoning := msg + msgNoReasoning.ReasoningContent = "" + tokensNoReasoning := EstimateMessageTokens(msgNoReasoning) + + if tokens <= tokensNoReasoning { + t.Errorf("reasoning content should add tokens: with=%d, without=%d", tokens, tokensNoReasoning) + } +} + +func TestIsOverContextBudget_RealisticSession(t *testing.T) { + // Simulate what BuildMessages produces: system + session history + current user. + // System message is built by BuildMessages, not stored in session. + systemMsg := providers.Message{ + Role: "system", + Content: strings.Repeat("system prompt content ", 100), + } + sessionHistory := []providers.Message{ + msgUser("first question"), + msgAssistant("first answer"), + msgUser("use tool X"), + { + Role: "assistant", + Content: "I'll use tool X", + ToolCalls: []providers.ToolCall{ + { + ID: "tc1", Type: "function", Name: "tool_x", + Function: &providers.FunctionCall{ + Name: "tool_x", + Arguments: `{"query":"test","verbose":true}`, + }, + }, + }, + }, + {Role: "tool", Content: strings.Repeat("result data ", 200), ToolCallID: "tc1"}, + msgAssistant("Here are the results from tool X."), + } + currentUser := msgUser("follow up question") + + // Assemble as BuildMessages would. + messages := make([]providers.Message, 0, 1+len(sessionHistory)+1) + messages = append(messages, systemMsg) + messages = append(messages, sessionHistory...) + messages = append(messages, currentUser) + + tools := []providers.ToolDefinition{ + { + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: "tool_x", + Description: "A useful tool", + Parameters: map[string]any{"type": "object"}, + }, + }, + } + + // With a large context window, should be within budget. + if isOverContextBudget(131072, messages, tools, 32768) { + t.Error("realistic session should be within 131072 context window") + } + + // With a tiny context window, should exceed budget. + if !isOverContextBudget(500, messages, tools, 32768) { + t.Error("realistic session should exceed 500 context window") + } +} diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index 707510820..ef5e6c5de 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -37,7 +37,7 @@ func setupWorkspace(t *testing.T, files map[string]string) string { // Codex (only reads last system message as instructions). func TestSingleSystemMessage(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ - "IDENTITY.md": "# Identity\nTest agent.", + "AGENT.md": "# Agent\nTest agent.", }) defer os.RemoveAll(tmpDir) @@ -82,7 +82,7 @@ func TestSingleSystemMessage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1") + msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1", "", "") systemCount := 0 for _, m := range msgs { @@ -126,6 +126,68 @@ func TestSingleSystemMessage(t *testing.T) { } } +func TestBuildMessages_CurrentSenderDynamicContext(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "IDENTITY.md": "# Identity\nTest agent.", + }) + defer os.RemoveAll(tmpDir) + + cb := NewContextBuilder(tmpDir) + + tests := []struct { + name string + senderID string + senderDisplayName string + wantLine string + wantSection bool + }{ + { + name: "both id and display name", + senderID: "feishu:ou_xxx", + senderDisplayName: "Zhang San", + wantLine: "Current sender: Zhang San (ID: feishu:ou_xxx)", + wantSection: true, + }, + { + name: "display name only", + senderDisplayName: "Alice", + wantLine: "Current sender: Alice", + wantSection: true, + }, + { + name: "id only", + senderID: "discord:123", + wantLine: "Current sender: discord:123", + wantSection: true, + }, + { + name: "no sender info", + wantSection: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msgs := cb.BuildMessages(nil, "", "hello", nil, "discord", "chat1", tt.senderID, tt.senderDisplayName) + sys := msgs[0].Content + + if tt.wantSection { + if !strings.Contains(sys, "## Current Sender") { + t.Fatalf("system prompt missing Current Sender section:\n%s", sys) + } + if !strings.Contains(sys, tt.wantLine) { + t.Fatalf("system prompt missing sender line %q:\n%s", tt.wantLine, sys) + } + return + } + + if strings.Contains(sys, "## Current Sender") { + t.Fatalf("system prompt should omit Current Sender section:\n%s", sys) + } + }) + } +} + // TestMtimeAutoInvalidation verifies that the cache detects source file changes // via mtime without requiring explicit InvalidateCache(). // Fix: original implementation had no auto-invalidation — edits to bootstrap files, @@ -140,10 +202,10 @@ func TestMtimeAutoInvalidation(t *testing.T) { }{ { name: "bootstrap file change", - file: "IDENTITY.md", - contentV1: "# Original Identity", - contentV2: "# Updated Identity", - checkField: "Updated Identity", + file: "AGENT.md", + contentV1: "# Original Agent", + contentV2: "# Updated Agent", + checkField: "Updated Agent", }, { name: "memory file change", @@ -218,7 +280,7 @@ func TestMtimeAutoInvalidation(t *testing.T) { // even when source files haven't changed (useful for tests and reload commands). func TestExplicitInvalidateCache(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ - "IDENTITY.md": "# Test Identity", + "AGENT.md": "# Test Agent", }) defer os.RemoveAll(tmpDir) @@ -245,8 +307,8 @@ func TestExplicitInvalidateCache(t *testing.T) { // when no files change (regression test for issue #607). func TestCacheStability(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ - "IDENTITY.md": "# Identity\nContent", - "SOUL.md": "# Soul\nContent", + "AGENT.md": "# Agent\nContent", + "SOUL.md": "# Soul\nContent", }) defer os.RemoveAll(tmpDir) @@ -545,7 +607,7 @@ description: delete-me-v1 // Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache func TestConcurrentBuildSystemPromptWithCache(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ - "IDENTITY.md": "# Identity\nConcurrency test agent.", + "AGENT.md": "# Agent\nConcurrency test agent.", "SOUL.md": "# Soul\nBe helpful.", "memory/MEMORY.md": "# Memory\nUser prefers Go.", "skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo", @@ -576,7 +638,7 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) { } // Also exercise BuildMessages concurrently - msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat") + msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat", "", "") if len(msgs) < 2 { errs <- "BuildMessages returned fewer than 2 messages" return @@ -645,6 +707,38 @@ func TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) { } } +func TestBuildMessages_IncludesMediaOnlyCurrentMessage(t *testing.T) { + tmpDir := setupWorkspace(t, nil) + defer os.RemoveAll(tmpDir) + + cb := NewContextBuilder(tmpDir) + msgs := cb.BuildMessages( + nil, + "", + "", + []string{"data:image/png;base64,abc123"}, + "pico", + "chat-1", + "", + "", + ) + + if len(msgs) != 2 { + t.Fatalf("len(msgs) = %d, want 2", len(msgs)) + } + + userMsg := msgs[1] + if userMsg.Role != "user" { + t.Fatalf("userMsg.Role = %q, want %q", userMsg.Role, "user") + } + if userMsg.Content != "" { + t.Fatalf("userMsg.Content = %q, want empty string", userMsg.Content) + } + if len(userMsg.Media) != 1 || userMsg.Media[0] != "data:image/png;base64,abc123" { + t.Fatalf("userMsg.Media = %#v, want image payload", userMsg.Media) + } +} + // BenchmarkBuildMessagesWithCache measures caching performance. func BenchmarkBuildMessagesWithCache(b *testing.B) { tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*") @@ -652,7 +746,7 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) { os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755) os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755) - for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} { + for _, name := range []string{"AGENT.md", "SOUL.md"} { os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644) } @@ -664,6 +758,6 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test") + _ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test", "", "") } } diff --git a/pkg/agent/context_legacy.go b/pkg/agent/context_legacy.go new file mode 100644 index 000000000..94ef5367d --- /dev/null +++ b/pkg/agent/context_legacy.go @@ -0,0 +1,390 @@ +package agent + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// legacyContextManager wraps the existing summarization/compression logic +// as a ContextManager implementation. It is the default when no other +// ContextManager is configured. +type legacyContextManager struct { + al *AgentLoop + summarizing sync.Map // dedup for async Compact (post-turn) +} + +func (m *legacyContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) { + // Legacy: read history from session, return as-is. + // Budget enforcement happens in BuildMessages caller via + // isOverContextBudget + forceCompression. + agent := m.al.registry.GetDefaultAgent() + if agent == nil { + return &AssembleResponse{}, nil + } + history := agent.Sessions.GetHistory(req.SessionKey) + summary := agent.Sessions.GetSummary(req.SessionKey) + return &AssembleResponse{ + History: history, + Summary: summary, + }, nil +} + +func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) error { + switch req.Reason { + case ContextCompressReasonProactive, ContextCompressReasonRetry: + // Sync emergency compression — budget exceeded. + if result, ok := m.forceCompression(req.SessionKey); ok { + m.al.emitEvent( + runtimeevents.KindAgentContextCompress, + m.al.newTurnEventScope("", req.SessionKey, nil).meta(0, "forceCompression", "turn.context.compress"), + ContextCompressPayload{ + Reason: req.Reason, + DroppedMessages: result.DroppedMessages, + RemainingMessages: result.RemainingMessages, + }, + ) + } + case ContextCompressReasonSummarize: + m.maybeSummarize(req.SessionKey) + } + return nil +} + +func (m *legacyContextManager) Ingest(_ context.Context, _ *IngestRequest) error { + // Legacy: no-op. Messages are persisted by Sessions JSONL. + return nil +} + +func (m *legacyContextManager) Clear(_ context.Context, sessionKey string) error { + agent := m.al.registry.GetDefaultAgent() + if agent == nil || agent.Sessions == nil { + return fmt.Errorf("sessions not initialized") + } + agent.Sessions.SetHistory(sessionKey, []providers.Message{}) + agent.Sessions.SetSummary(sessionKey, "") + return agent.Sessions.Save(sessionKey) +} + +// maybeSummarize triggers summarization if the session history exceeds thresholds. +// It runs asynchronously in a goroutine. +func (m *legacyContextManager) maybeSummarize(sessionKey string) { + agent := m.al.registry.GetDefaultAgent() + if agent == nil { + return + } + + newHistory := agent.Sessions.GetHistory(sessionKey) + tokenEstimate := m.estimateTokens(newHistory) + threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 + + if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold { + summarizeKey := agent.ID + ":" + sessionKey + if _, loading := m.summarizing.LoadOrStore(summarizeKey, true); !loading { + go func() { + defer m.summarizing.Delete(summarizeKey) + defer func() { + if r := recover(); r != nil { + logger.WarnCF("agent", "Summarization panic recovered", map[string]any{ + "session_key": sessionKey, + "panic": r, + }) + } + }() + logger.Debug("Memory threshold reached. Optimizing conversation history...") + m.summarizeSession(agent, sessionKey) + }() + } + } +} + +type compressionResult struct { + DroppedMessages int + RemainingMessages int +} + +// forceCompression aggressively reduces context when the limit is hit. +// It drops the oldest ~50% of Turns (a Turn is a complete user→LLM→response +// cycle, as defined in #1316), so tool-call sequences are never split. +func (m *legacyContextManager) forceCompression(sessionKey string) (compressionResult, bool) { + agent := m.al.registry.GetDefaultAgent() + if agent == nil { + return compressionResult{}, false + } + + history := agent.Sessions.GetHistory(sessionKey) + if len(history) <= 2 { + return compressionResult{}, false + } + + turns := parseTurnBoundaries(history) + var mid int + if len(turns) >= 2 { + mid = turns[len(turns)/2] + } else { + mid = findSafeBoundary(history, len(history)/2) + } + var keptHistory []providers.Message + if mid <= 0 { + for i := len(history) - 1; i >= 0; i-- { + if history[i].Role == "user" { + keptHistory = []providers.Message{history[i]} + break + } + } + } else { + keptHistory = history[mid:] + } + + droppedCount := len(history) - len(keptHistory) + + existingSummary := agent.Sessions.GetSummary(sessionKey) + compressionNote := fmt.Sprintf( + "[Emergency compression dropped %d oldest messages due to context limit]", + droppedCount, + ) + if existingSummary != "" { + compressionNote = existingSummary + "\n\n" + compressionNote + } + agent.Sessions.SetSummary(sessionKey, compressionNote) + + agent.Sessions.SetHistory(sessionKey, keptHistory) + agent.Sessions.Save(sessionKey) + + logger.WarnCF("agent", "Forced compression executed", map[string]any{ + "session_key": sessionKey, + "dropped_msgs": droppedCount, + "new_count": len(keptHistory), + }) + + return compressionResult{ + DroppedMessages: droppedCount, + RemainingMessages: len(keptHistory), + }, true +} + +func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey string) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + history := agent.Sessions.GetHistory(sessionKey) + summary := agent.Sessions.GetSummary(sessionKey) + + if len(history) <= 4 { + return + } + + safeCut := findSafeBoundary(history, len(history)-4) + if safeCut <= 0 { + return + } + keepCount := len(history) - safeCut + toSummarize := history[:safeCut] + + maxMessageTokens := agent.ContextWindow / 2 + validMessages := make([]providers.Message, 0) + omitted := false + + for _, msg := range toSummarize { + if msg.Role != "user" && msg.Role != "assistant" { + continue + } + msgTokens := len(msg.Content) / 2 + if msgTokens > maxMessageTokens { + omitted = true + continue + } + validMessages = append(validMessages, msg) + } + + if len(validMessages) == 0 { + return + } + + const ( + maxSummarizationMessages = 10 + llmMaxRetries = 3 + ) + + var finalSummary string + if len(validMessages) > maxSummarizationMessages { + mid := len(validMessages) / 2 + mid = m.findNearestUserMessage(validMessages, mid) + + part1 := validMessages[:mid] + part2 := validMessages[mid:] + + s1, _ := m.summarizeBatch(ctx, agent, part1, "") + s2, _ := m.summarizeBatch(ctx, agent, part2, "") + + mergePrompt := fmt.Sprintf( + "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", + s1, s2, + ) + + resp, err := m.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries) + if err == nil && resp.Content != "" { + finalSummary = resp.Content + } else { + finalSummary = s1 + " " + s2 + } + } else { + finalSummary, _ = m.summarizeBatch(ctx, agent, validMessages, summary) + } + + if omitted && finalSummary != "" { + finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]" + } + + if finalSummary != "" { + agent.Sessions.SetSummary(sessionKey, finalSummary) + agent.Sessions.TruncateHistory(sessionKey, keepCount) + agent.Sessions.Save(sessionKey) + m.al.emitEvent( + runtimeevents.KindAgentSessionSummarize, + m.al.newTurnEventScope(agent.ID, sessionKey, nil).meta(0, "summarizeSession", "turn.session.summarize"), + SessionSummarizePayload{ + SummarizedMessages: len(validMessages), + KeptMessages: keepCount, + SummaryLen: len(finalSummary), + OmittedOversized: omitted, + }, + ) + } +} + +func (m *legacyContextManager) findNearestUserMessage(messages []providers.Message, mid int) int { + originalMid := mid + + for mid > 0 && messages[mid].Role != "user" { + mid-- + } + + if messages[mid].Role == "user" { + return mid + } + + mid = originalMid + for mid < len(messages) && messages[mid].Role != "user" { + mid++ + } + + if mid < len(messages) { + return mid + } + + return originalMid +} + +func (m *legacyContextManager) retryLLMCall( + ctx context.Context, + agent *AgentInstance, + prompt string, + maxRetries int, +) (*providers.LLMResponse, error) { + const llmTemperature = 0.3 + + var resp *providers.LLMResponse + var err error + + for attempt := 0; attempt < maxRetries; attempt++ { + m.al.activeRequests.Add(1) + resp, err = func() (*providers.LLMResponse, error) { + defer m.al.activeRequests.Done() + return agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: prompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": agent.MaxTokens, + "temperature": llmTemperature, + "prompt_cache_key": agent.ID, + }, + ) + }() + + if err == nil && resp != nil && resp.Content != "" { + return resp, nil + } + if attempt < maxRetries-1 { + time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond) + } + } + + return resp, err +} + +func (m *legacyContextManager) summarizeBatch( + ctx context.Context, + agent *AgentInstance, + batch []providers.Message, + existingSummary string, +) (string, error) { + const ( + llmMaxRetries = 3 + fallbackMinContentLength = 200 + fallbackMaxContentPercent = 10 + ) + + var sb strings.Builder + sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n") + if existingSummary != "" { + sb.WriteString("Existing context: ") + sb.WriteString(existingSummary) + sb.WriteString("\n") + } + sb.WriteString("\nCONVERSATION:\n") + for _, msg := range batch { + fmt.Fprintf(&sb, "%s: %s\n", msg.Role, msg.Content) + } + prompt := sb.String() + + response, err := m.retryLLMCall(ctx, agent, prompt, llmMaxRetries) + if err == nil && response.Content != "" { + return strings.TrimSpace(response.Content), nil + } + + var fallback strings.Builder + fallback.WriteString("Conversation summary: ") + for i, msg := range batch { + if i > 0 { + fallback.WriteString(" | ") + } + content := strings.TrimSpace(msg.Content) + runes := []rune(content) + if len(runes) == 0 { + fallback.WriteString(fmt.Sprintf("%s: ", msg.Role)) + continue + } + + keepLength := len(runes) * fallbackMaxContentPercent / 100 + if keepLength < fallbackMinContentLength { + keepLength = fallbackMinContentLength + } + if keepLength > len(runes) { + keepLength = len(runes) + } + + content = string(runes[:keepLength]) + if keepLength < len(runes) { + content += "..." + } + fallback.WriteString(fmt.Sprintf("%s: %s", msg.Role, content)) + } + return fallback.String(), nil +} + +func (m *legacyContextManager) estimateTokens(messages []providers.Message) int { + total := 0 + for _, msg := range messages { + total += EstimateMessageTokens(msg) + } + return total +} diff --git a/pkg/agent/context_manager.go b/pkg/agent/context_manager.go new file mode 100644 index 000000000..5a5dfe97c --- /dev/null +++ b/pkg/agent/context_manager.go @@ -0,0 +1,94 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +// ContextManager manages conversation context via a pluggable strategy. +// Exactly ONE ContextManager is active per AgentLoop, selected by config. +// The default ("legacy") preserves current summarization behavior. +type ContextManager interface { + // Assemble builds budget-aware context from the ContextManager's own storage. + // Called before BuildMessages. Returns assembled messages ready for LLM. + Assemble(ctx context.Context, req *AssembleRequest) (*AssembleResponse, error) + + // Compact compresses conversation history. + // Called after turn completes (may be async internally) and on context overflow (sync). + Compact(ctx context.Context, req *CompactRequest) error + + // Ingest records a message into the ContextManager's own storage. + // Called after each message is persisted to session JSONL. + Ingest(ctx context.Context, req *IngestRequest) error + + // Clear removes all stored context for a session (messages, summaries, etc.). + // Called when the user issues /clear or /reset. + Clear(ctx context.Context, sessionKey string) error +} + +// AssembleRequest is the input to Assemble. +type AssembleRequest struct { + SessionKey string // session identifier + Budget int // context window in tokens + MaxTokens int // max response tokens +} + +// AssembleResponse is the output of Assemble. +type AssembleResponse struct { + History []providers.Message // assembled conversation history for BuildMessages + Summary string // conversation summary embedded into system prompt by BuildMessages +} + +// CompactRequest is the input to Compact. +type CompactRequest struct { + SessionKey string // session identifier + Reason ContextCompressReason // proactive_budget | llm_retry | summarize + Budget int // context window budget (used for retry aggressive compaction) +} + +// IngestRequest is the input to Ingest. +type IngestRequest struct { + SessionKey string // session identifier + Message providers.Message // the message just persisted +} + +// ContextManagerFactory constructs a ContextManager from config. +// al provides access to the AgentLoop's runtime resources (provider, model, workspace, etc.) +// cfg is the raw JSON configuration from config.json (may be nil). +type ContextManagerFactory func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) + +var ( + cmRegistryMu sync.RWMutex + cmRegistry = map[string]ContextManagerFactory{} +) + +// RegisterContextManager registers a named ContextManager factory. +func RegisterContextManager(name string, factory ContextManagerFactory) error { + if name == "" { + return fmt.Errorf("context manager name is required") + } + if factory == nil { + return fmt.Errorf("context manager %q factory is nil", name) + } + + cmRegistryMu.Lock() + defer cmRegistryMu.Unlock() + + if _, exists := cmRegistry[name]; exists { + return fmt.Errorf("context manager %q is already registered", name) + } + cmRegistry[name] = factory + return nil +} + +func lookupContextManager(name string) (ContextManagerFactory, bool) { + cmRegistryMu.RLock() + defer cmRegistryMu.RUnlock() + + f, ok := cmRegistry[name] + return f, ok +} diff --git a/pkg/agent/context_manager_test.go b/pkg/agent/context_manager_test.go new file mode 100644 index 000000000..46e521be4 --- /dev/null +++ b/pkg/agent/context_manager_test.go @@ -0,0 +1,782 @@ +package agent + +import ( + "context" + "encoding/json" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// --------------------------------------------------------------------------- +// Factory registry tests +// --------------------------------------------------------------------------- + +func TestRegisterContextManager_Success(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return &noopContextManager{}, nil + } + if err := RegisterContextManager("test_cm", factory); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + f, ok := lookupContextManager("test_cm") + if !ok { + t.Fatal("expected factory to be registered") + } + if f == nil { + t.Fatal("expected non-nil factory") + } +} + +func TestRegisterContextManager_EmptyName(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + err := RegisterContextManager("", func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return &noopContextManager{}, nil + }) + if err == nil { + t.Fatal("expected error for empty name") + } + if !strings.Contains(err.Error(), "name is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRegisterContextManager_NilFactory(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + err := RegisterContextManager("nil_factory", nil) + if err == nil { + t.Fatal("expected error for nil factory") + } + if !strings.Contains(err.Error(), "factory is nil") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRegisterContextManager_Duplicate(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return &noopContextManager{}, nil + } + if err := RegisterContextManager("dup_cm", factory); err != nil { + t.Fatalf("first registration failed: %v", err) + } + err := RegisterContextManager("dup_cm", factory) + if err == nil { + t.Fatal("expected error for duplicate registration") + } + if !strings.Contains(err.Error(), "already registered") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLookupContextManager_Unknown(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + _, ok := lookupContextManager("nonexistent") + if ok { + t.Fatal("expected lookup to fail for unknown name") + } +} + +// --------------------------------------------------------------------------- +// resolveContextManager tests +// --------------------------------------------------------------------------- + +func TestResolveContextManager_Default(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "", // default → legacy + }, + }, + } + al := newCMTestAgentLoop(cfg) + + cm := al.contextManager + if cm == nil { + t.Fatal("expected non-nil context manager") + } + if _, ok := cm.(*legacyContextManager); !ok { + t.Fatalf("expected *legacyContextManager, got %T", cm) + } +} + +func TestResolveContextManager_ExplicitLegacy(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "legacy", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + if _, ok := al.contextManager.(*legacyContextManager); !ok { + t.Fatalf("expected *legacyContextManager, got %T", al.contextManager) + } +} + +func TestResolveContextManager_UnknownFallsBackToLegacy(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "unknown_cm", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + if _, ok := al.contextManager.(*legacyContextManager); !ok { + t.Fatalf("expected fallback to *legacyContextManager, got %T", al.contextManager) + } +} + +func TestResolveContextManager_RegisteredFactory(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return &noopContextManager{}, nil + } + if err := RegisterContextManager("custom_cm", factory); err != nil { + t.Fatalf("register failed: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "custom_cm", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + if _, ok := al.contextManager.(*noopContextManager); !ok { + t.Fatalf("expected *noopContextManager, got %T", al.contextManager) + } +} + +func TestResolveContextManager_FactoryError(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return nil, os.ErrPermission + } + if err := RegisterContextManager("broken_cm", factory); err != nil { + t.Fatalf("register failed: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "broken_cm", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + // Should fall back to legacy when factory returns error + if _, ok := al.contextManager.(*legacyContextManager); !ok { + t.Fatalf("expected fallback to *legacyContextManager on factory error, got %T", al.contextManager) + } +} + +// --------------------------------------------------------------------------- +// Legacy Assemble tests +// --------------------------------------------------------------------------- + +func TestLegacyAssemble_Passthrough(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + history := []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi there"}, + } + agent.Sessions.SetHistory("test-session", history) + + resp, err := al.contextManager.Assemble(context.Background(), &AssembleRequest{ + SessionKey: "test-session", + Budget: 8000, + MaxTokens: 4096, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.History) != len(history) { + t.Fatalf("expected %d messages, got %d", len(history), len(resp.History)) + } + for i, msg := range resp.History { + if msg.Content != history[i].Content || msg.Role != history[i].Role { + t.Fatalf("message %d mismatch: want %+v, got %+v", i, history[i], msg) + } + } +} + +func TestLegacyAssemble_EmptyHistory(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + resp, err := al.contextManager.Assemble(context.Background(), &AssembleRequest{ + SessionKey: "test-session", + Budget: 8000, + MaxTokens: 4096, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.History) != 0 { + t.Fatalf("expected empty messages, got %d", len(resp.History)) + } +} + +// --------------------------------------------------------------------------- +// Legacy Compact overflow tests +// --------------------------------------------------------------------------- + +func TestLegacyCompact_Overflow(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + history := []providers.Message{ + {Role: "user", Content: "msg 1"}, + {Role: "assistant", Content: "resp 1"}, + {Role: "user", Content: "msg 2"}, + {Role: "assistant", Content: "resp 2"}, + {Role: "user", Content: "msg 3"}, + } + defaultAgent.Sessions.SetHistory("session-overflow", history) + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentContextCompress, + ) + defer closeRuntimeEvents() + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-overflow", + Reason: ContextCompressReasonRetry, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // After overflow compression, history should be shorter + newHistory := defaultAgent.Sessions.GetHistory("session-overflow") + if len(newHistory) >= len(history) { + t.Fatalf("expected compressed history, got %d messages (was %d)", len(newHistory), len(history)) + } + + // Summary should contain compression note + summary := defaultAgent.Sessions.GetSummary("session-overflow") + if !strings.Contains(summary, "Emergency compression") { + t.Fatalf("expected compression note in summary, got %q", summary) + } + + // Event should carry the proactive reason + events := collectRuntimeEventStream(runtimeCh) + compressEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentContextCompress) + if !ok { + t.Fatal("expected context compress event") + } + payload, ok := compressEvt.Payload.(ContextCompressPayload) + if !ok { + t.Fatalf("expected ContextCompressPayload, got %T", compressEvt.Payload) + } + if payload.Reason != ContextCompressReasonRetry { + t.Fatalf("expected retry reason, got %q", payload.Reason) + } +} + +func TestLegacyCompact_Overflow_ProactiveReason(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + history := []providers.Message{ + {Role: "user", Content: "msg 1"}, + {Role: "assistant", Content: "resp 1"}, + {Role: "user", Content: "msg 2"}, + {Role: "assistant", Content: "resp 2"}, + {Role: "user", Content: "msg 3"}, + } + defaultAgent.Sessions.SetHistory("session-proactive", history) + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentContextCompress, + ) + defer closeRuntimeEvents() + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-proactive", + Reason: ContextCompressReasonProactive, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + events := collectRuntimeEventStream(runtimeCh) + compressEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentContextCompress) + if !ok { + t.Fatal("expected context compress event") + } + payload, ok := compressEvt.Payload.(ContextCompressPayload) + if !ok { + t.Fatalf("expected ContextCompressPayload, got %T", compressEvt.Payload) + } + if payload.Reason != ContextCompressReasonProactive { + t.Fatalf("expected proactive reason, got %q", payload.Reason) + } +} + +func TestLegacyCompact_Overflow_TooShortToCompress(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + history := []providers.Message{ + {Role: "user", Content: "only one"}, + } + defaultAgent.Sessions.SetHistory("session-tiny", history) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-tiny", + Reason: ContextCompressReasonRetry, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // History should be unchanged (too short to compress) + newHistory := defaultAgent.Sessions.GetHistory("session-tiny") + if len(newHistory) != len(history) { + t.Fatalf("expected history unchanged, got %d messages (was %d)", len(newHistory), len(history)) + } +} + +// --------------------------------------------------------------------------- +// Legacy Compact post-turn tests +// --------------------------------------------------------------------------- + +func TestLegacyCompact_PostTurn_BelowThreshold(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + // Small history, below summarization thresholds + history := []providers.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "hello"}, + } + defaultAgent.Sessions.SetHistory("session-small", history) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-small", + Reason: ContextCompressReasonSummarize, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // History should remain unchanged + newHistory := defaultAgent.Sessions.GetHistory("session-small") + if len(newHistory) != len(history) { + t.Fatalf("expected unchanged history, got %d messages (was %d)", len(newHistory), len(history)) + } +} + +func TestLegacyCompact_PostTurn_ExceedsMessageThreshold(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextWindow: 8000, + SummarizeMessageThreshold: 2, + SummarizeTokenPercent: 75, + }, + }, + } + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "summary"}) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + // 6 messages > threshold of 2 + history := []providers.Message{ + {Role: "user", Content: "q1"}, + {Role: "assistant", Content: "a1"}, + {Role: "user", Content: "q2"}, + {Role: "assistant", Content: "a2"}, + {Role: "user", Content: "q3"}, + {Role: "assistant", Content: "a3"}, + } + defaultAgent.Sessions.SetHistory("session-threshold", history) + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentSessionSummarize, + ) + defer closeRuntimeEvents() + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-threshold", + Reason: ContextCompressReasonSummarize, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + waitForRuntimeEvent(t, runtimeCh, 5*time.Second, func(evt runtimeevents.Event) bool { + return evt.Kind == runtimeevents.KindAgentSessionSummarize + }) + + newHistory := defaultAgent.Sessions.GetHistory("session-threshold") + if len(newHistory) >= len(history) { + t.Fatalf("expected summarization to reduce history from %d messages, got %d", len(history), len(newHistory)) + } +} + +// --------------------------------------------------------------------------- +// Legacy Ingest tests +// --------------------------------------------------------------------------- + +func TestLegacyIngest_NoOp(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + err := al.contextManager.Ingest(context.Background(), &IngestRequest{ + SessionKey: "session-ingest", + Message: providers.Message{Role: "user", Content: "test"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Mock ContextManager — verifies dispatch through AgentLoop +// --------------------------------------------------------------------------- + +func TestAgentLoop_UsesCustomContextManager(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + mock := &trackingContextManager{} + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return mock, nil + } + if err := RegisterContextManager("tracking_cm", factory); err != nil { + t.Fatalf("register failed: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "tracking_cm", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + // Verify the mock was installed + if al.contextManager != mock { + t.Fatalf("expected mock context manager, got %T", al.contextManager) + } + + // Direct method calls + _, err := mock.Assemble(context.Background(), &AssembleRequest{ + SessionKey: "s1", + Budget: 8000, + MaxTokens: 4096, + }) + if err != nil { + t.Fatalf("Assemble error: %v", err) + } + if mock.assembleCalls.Load() != 1 { + t.Fatalf("expected 1 assemble call, got %d", mock.assembleCalls.Load()) + } + + err = mock.Compact(context.Background(), &CompactRequest{ + SessionKey: "s1", + Reason: ContextCompressReasonRetry, + }) + if err != nil { + t.Fatalf("Compact error: %v", err) + } + if mock.compactCalls.Load() != 1 { + t.Fatalf("expected 1 compact call, got %d", mock.compactCalls.Load()) + } + + err = mock.Ingest(context.Background(), &IngestRequest{ + SessionKey: "s1", + Message: providers.Message{Role: "user", Content: "test"}, + }) + if err != nil { + t.Fatalf("Ingest error: %v", err) + } + if mock.ingestCalls.Load() != 1 { + t.Fatalf("expected 1 ingest call, got %d", mock.ingestCalls.Load()) + } +} + +func TestIngestCalledDuringTurn(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + mock := &trackingContextManager{} + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return mock, nil + } + if err := RegisterContextManager("ingest_track_cm", factory); err != nil { + t.Fatalf("register failed: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "ingest_track_cm", + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "done"}) + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + // Run a turn — ingestMessage is called for user message and final assistant message + _, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{ + SessionKey: "session-ingest-turn", + Channel: "cli", + ChatID: "direct", + UserMessage: "test ingest", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + // Should have at least 2 ingest calls: user message + final assistant message + if mock.ingestCalls.Load() < 2 { + t.Fatalf("expected >= 2 ingest calls during turn, got %d", mock.ingestCalls.Load()) + } +} + +// --------------------------------------------------------------------------- +// forceCompression edge cases (via legacy Compact) +// --------------------------------------------------------------------------- + +func TestLegacyCompact_Overflow_SingleTurnKeepsLastUserMessage(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + // History with only 2 messages — forceCompression should still handle it + history := []providers.Message{ + {Role: "user", Content: "first question"}, + {Role: "assistant", Content: "first answer"}, + } + defaultAgent.Sessions.SetHistory("session-2msg", history) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-2msg", + Reason: ContextCompressReasonRetry, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + newHistory := defaultAgent.Sessions.GetHistory("session-2msg") + // With 2 messages, forceCompression returns false (len <= 2), so no compression + if len(newHistory) != len(history) { + t.Fatalf("expected no compression for 2-message history, got %d", len(newHistory)) + } +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// noopContextManager is a minimal ContextManager that does nothing. +type noopContextManager struct{} + +func (m *noopContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) { + return &AssembleResponse{}, nil +} +func (m *noopContextManager) Compact(_ context.Context, _ *CompactRequest) error { return nil } +func (m *noopContextManager) Ingest(_ context.Context, _ *IngestRequest) error { return nil } +func (m *noopContextManager) Clear(_ context.Context, _ string) error { return nil } + +// trackingContextManager tracks call counts for each method. +type trackingContextManager struct { + assembleCalls atomic.Int64 + compactCalls atomic.Int64 + ingestCalls atomic.Int64 + mu sync.Mutex + lastAssemble *AssembleRequest + lastCompact *CompactRequest + lastIngest *IngestRequest +} + +func (m *trackingContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) { + m.assembleCalls.Add(1) + m.mu.Lock() + m.lastAssemble = req + m.mu.Unlock() + return &AssembleResponse{}, nil +} + +func (m *trackingContextManager) Compact(_ context.Context, req *CompactRequest) error { + m.compactCalls.Add(1) + m.mu.Lock() + m.lastCompact = req + m.mu.Unlock() + return nil +} + +func (m *trackingContextManager) Ingest(_ context.Context, req *IngestRequest) error { + m.ingestCalls.Add(1) + m.mu.Lock() + m.lastIngest = req + m.mu.Unlock() + return nil +} + +func (m *trackingContextManager) Clear(_ context.Context, _ string) error { return nil } + +// resetCMRegistry clears the global factory registry and returns a cleanup +// function that restores the original state after the test. +func resetCMRegistry() func() { + cmRegistryMu.Lock() + original := make(map[string]ContextManagerFactory, len(cmRegistry)) + for k, v := range cmRegistry { + original[k] = v + } + cmRegistry = make(map[string]ContextManagerFactory) + cmRegistryMu.Unlock() + + return func() { + cmRegistryMu.Lock() + cmRegistry = original + cmRegistryMu.Unlock() + } +} + +func testConfig(t *testing.T) *config.Config { + t.Helper() + return &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } +} + +func newCMTestAgentLoop(cfg *config.Config) *AgentLoop { + msgBus := bus.NewMessageBus() + return NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "test"}) +} diff --git a/pkg/agent/context_seahorse.go b/pkg/agent/context_seahorse.go new file mode 100644 index 000000000..c6e5b30ac --- /dev/null +++ b/pkg/agent/context_seahorse.go @@ -0,0 +1,282 @@ +//go:build !mipsle && !netbsd && !(freebsd && arm) + +package agent + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" + "github.com/sipeed/picoclaw/pkg/seahorse" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/tokenizer" +) + +// seahorseContextManager adapts seahorse.Engine to agent.ContextManager. +type seahorseContextManager struct { + engine *seahorse.Engine + sessions session.SessionStore // for startup bootstrap +} + +// newSeahorseContextManager creates a seahorse-backed ContextManager. +func newSeahorseContextManager(_ json.RawMessage, al *AgentLoop) (ContextManager, error) { + if al == nil { + return nil, fmt.Errorf("seahorse: AgentLoop is required") + } + + // Resolve workspace for DB path + // DB stores session data, so it goes in sessions/ directory + agent := al.registry.GetDefaultAgent() + dbPath := agent.Workspace + "/sessions/seahorse.db" + + // Create CompleteFn from provider + completeFn := providerToCompleteFn(agent.Provider, agent.Model) + + // Create engine + engine, err := seahorse.NewEngine(seahorse.Config{ + DBPath: dbPath, + }, completeFn) + if err != nil { + return nil, fmt.Errorf("seahorse: create engine: %w", err) + } + + mgr := &seahorseContextManager{ + engine: engine, + sessions: agent.Sessions, + } + + // Register seahorse tools with the agent's tool registry + retrieval := mgr.engine.GetRetrieval() + al.RegisterTool(seahorse.NewGrepTool(retrieval)) + al.RegisterTool(seahorse.NewExpandTool(retrieval)) + + // Bootstrap all existing sessions at startup + if agent.Sessions != nil { + ctx := context.Background() + for _, sessionKey := range agent.Sessions.ListSessions() { + mgr.bootstrapSession(ctx, sessionKey) + } + } + + return mgr, nil +} + +// providerToCompleteFn wraps providers.LLMProvider as a seahorse.CompleteFn. +func providerToCompleteFn(provider providers.LLMProvider, model string) seahorse.CompleteFn { + return func(ctx context.Context, prompt string, opts seahorse.CompleteOptions) (string, error) { + resp, err := provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: prompt}}, + nil, // no tools for summarization + model, + map[string]any{ + "max_tokens": opts.MaxTokens, + "temperature": opts.Temperature, + "prompt_cache_key": "seahorse", + }, + ) + if err != nil { + return "", err + } + return resp.Content, nil + } +} + +// Assemble builds budget-aware context from seahorse SQLite. +func (m *seahorseContextManager) Assemble(ctx context.Context, req *AssembleRequest) (*AssembleResponse, error) { + if req == nil { + return nil, fmt.Errorf("seahorse assemble: nil request") + } + + budget := req.Budget + if budget <= 0 { + budget = 100000 + } + + // Reserve space for model response (spec lines 1400-1410) + effectiveBudget := budget - req.MaxTokens + if effectiveBudget <= 0 { + // MaxTokens >= budget is a configuration problem + // Use 50% as minimum to avoid guaranteed overflow + logger.WarnCF("agent", "MaxTokens >= budget, using 50% fallback", + map[string]any{"budget": budget, "max_tokens": req.MaxTokens}) + effectiveBudget = budget / 2 + } + + result, err := m.engine.Assemble(ctx, req.SessionKey, seahorse.AssembleInput{ + Budget: effectiveBudget, + }) + if err != nil { + return nil, fmt.Errorf("seahorse assemble: %w", err) + } + + history := seahorseToProviderMessages(result) + + // Summary is already formatted as XML with system prompt addition by assembler + return &AssembleResponse{ + History: history, + Summary: result.Summary, + }, nil +} + +// Compact compresses conversation history via seahorse summarization. +func (m *seahorseContextManager) Compact(ctx context.Context, req *CompactRequest) error { + if req == nil { + return nil + } + + // For retry (LLM overflow), use aggressive CompactUntilUnder to guarantee + // context shrinks below budget (spec lines ~1410). + if req.Reason == ContextCompressReasonRetry && req.Budget > 0 { + _, err := m.engine.CompactUntilUnder(ctx, req.SessionKey, req.Budget) + return err + } + + _, err := m.engine.Compact(ctx, req.SessionKey, seahorse.CompactInput{ + Force: req.Reason == ContextCompressReasonRetry, + Budget: &req.Budget, + }) + return err +} + +// Ingest records a message into seahorse SQLite. +// All existing sessions are bootstrapped at startup, so this only ingests new messages. +func (m *seahorseContextManager) Ingest(ctx context.Context, req *IngestRequest) error { + if req == nil { + return nil + } + + msg := providerToSeahorseMessage(req.Message) + _, err := m.engine.Ingest(ctx, req.SessionKey, []seahorse.Message{msg}) + return err +} + +// Clear removes all stored context for a session (seahorse DB + JSONL). +func (m *seahorseContextManager) Clear(ctx context.Context, sessionKey string) error { + if err := m.engine.ClearSession(ctx, sessionKey); err != nil { + return err + } + if m.sessions != nil { + m.sessions.SetHistory(sessionKey, []providers.Message{}) + m.sessions.SetSummary(sessionKey, "") + return m.sessions.Save(sessionKey) + } + return nil +} + +// bootstrapSession reconciles JSONL session history into seahorse SQLite. +func (m *seahorseContextManager) bootstrapSession(ctx context.Context, sessionKey string) { + if m.sessions == nil { + return + } + + history := m.sessions.GetHistory(sessionKey) + if len(history) == 0 { + return + } + + // Convert provider messages to seahorse messages + msgs := make([]seahorse.Message, len(history)) + for i, h := range history { + msgs[i] = providerToSeahorseMessage(h) + } + + if err := m.engine.Bootstrap(ctx, sessionKey, msgs); err != nil { + logger.WarnCF("seahorse", "bootstrap", map[string]any{ + "session": sessionKey, + "error": err.Error(), + }) + } +} + +// providerToSeahorseMessage converts a providers.Message to a seahorse.Message. +func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message { + result := seahorse.Message{ + Role: msg.Role, + Content: msg.Content, + ReasoningContent: msg.ReasoningContent, + TokenCount: tokenizer.EstimateMessageTokens(msg), + } + + // Convert ToolCalls → MessageParts + for _, tc := range msg.ToolCalls { + part := seahorse.MessagePart{ + Type: "tool_use", + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + ToolCallID: tc.ID, + } + result.Parts = append(result.Parts, part) + } + + // Convert tool result + if msg.ToolCallID != "" { + part := seahorse.MessagePart{ + Type: "tool_result", + ToolCallID: msg.ToolCallID, + Text: msg.Content, + } + result.Parts = append(result.Parts, part) + } + + // Convert media attachments + for _, mediaURI := range msg.Media { + part := seahorse.MessagePart{ + Type: "media", + MediaURI: mediaURI, + } + result.Parts = append(result.Parts, part) + } + + return result +} + +// seahorseToProviderMessages converts a seahorse.AssembleResult to []providers.Message. +func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes.Message { + messages := make([]protocoltypes.Message, 0, len(result.Messages)) + + // Convert assembled messages (which already include summary XML messages) + for _, msg := range result.Messages { + pm := protocoltypes.Message{ + Role: msg.Role, + Content: msg.Content, + ReasoningContent: msg.ReasoningContent, + } + + // Reconstruct ToolCalls from parts + for _, part := range msg.Parts { + if part.Type == "tool_use" { + pm.ToolCalls = append(pm.ToolCalls, protocoltypes.ToolCall{ + ID: part.ToolCallID, + Type: "function", // Required by OpenAI-compatible APIs (GLM, etc.) + Function: &protocoltypes.FunctionCall{ + Name: part.Name, + Arguments: part.Arguments, + }, + }) + } + if part.Type == "tool_result" { + pm.ToolCallID = part.ToolCallID + if pm.Content == "" && part.Text != "" { + pm.Content = part.Text + } + } + if part.Type == "media" && part.MediaURI != "" { + pm.Media = append(pm.Media, part.MediaURI) + } + } + + messages = append(messages, pm) + } + + return messages +} + +func init() { + if err := RegisterContextManager("seahorse", newSeahorseContextManager); err != nil { + panic(fmt.Sprintf("register seahorse context manager: %v", err)) + } +} diff --git a/pkg/agent/context_seahorse_test.go b/pkg/agent/context_seahorse_test.go new file mode 100644 index 000000000..e405ef944 --- /dev/null +++ b/pkg/agent/context_seahorse_test.go @@ -0,0 +1,1086 @@ +package agent + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" + "github.com/sipeed/picoclaw/pkg/seahorse" +) + +// seahorseTestProvider implements providers.LLMProvider for seahorse tests. +type seahorseTestProvider struct { + chatFn func(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]any) (*providers.LLMResponse, error) +} + +func (m *seahorseTestProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + options map[string]any, +) (*providers.LLMResponse, error) { + if m.chatFn != nil { + return m.chatFn(ctx, messages, tools, model, options) + } + return &providers.LLMResponse{Content: "mock response"}, nil +} + +func (m *seahorseTestProvider) GetDefaultModel() string { + return "mock-model" +} + +func TestSeahorseCMRegistration(t *testing.T) { + factory, ok := lookupContextManager("seahorse") + if !ok { + t.Error("expected 'seahorse' context manager to be registered") + } + if factory == nil { + t.Error("expected non-nil factory") + } +} + +func TestProviderToSeahorseMessage(t *testing.T) { + tests := []struct { + name string + input protocoltypes.Message + wantRole string + wantContent string + }{ + { + name: "simple user message", + input: protocoltypes.Message{Role: "user", Content: "hello world"}, + wantRole: "user", + wantContent: "hello world", + }, + { + name: "assistant message", + input: protocoltypes.Message{Role: "assistant", Content: "response text"}, + wantRole: "assistant", + wantContent: "response text", + }, + { + name: "tool result message", + input: protocoltypes.Message{Role: "tool", Content: "tool output", ToolCallID: "tc_123"}, + wantRole: "tool", + wantContent: "tool output", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := providerToSeahorseMessage(tt.input) + if result.Role != tt.wantRole { + t.Errorf("Role = %q, want %q", result.Role, tt.wantRole) + } + if result.Content != tt.wantContent { + t.Errorf("Content = %q, want %q", result.Content, tt.wantContent) + } + }) + } +} + +func TestProviderToSeahorseMessageWithToolCalls(t *testing.T) { + msg := protocoltypes.Message{ + Role: "assistant", + Content: "", + ToolCalls: []protocoltypes.ToolCall{ + { + ID: "tc_1", + Function: &protocoltypes.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"/tmp/test"}`, + }, + }, + }, + } + + result := providerToSeahorseMessage(msg) + if result.Role != "assistant" { + t.Errorf("Role = %q, want assistant", result.Role) + } + if len(result.Parts) == 0 { + t.Fatal("expected at least 1 part from tool calls") + } + if result.Parts[0].Type != "tool_use" { + t.Errorf("Part type = %q, want tool_use", result.Parts[0].Type) + } + if result.Parts[0].Name != "read_file" { + t.Errorf("Part name = %q, want read_file", result.Parts[0].Name) + } + if result.Parts[0].ToolCallID != "tc_1" { + t.Errorf("Part ToolCallID = %q, want tc_1", result.Parts[0].ToolCallID) + } +} + +func TestProviderToSeahorseMessageWithToolResult(t *testing.T) { + msg := protocoltypes.Message{ + Role: "tool", + Content: "file contents here", + ToolCallID: "tc_456", + } + + result := providerToSeahorseMessage(msg) + if result.Role != "tool" { + t.Errorf("Role = %q, want tool", result.Role) + } + found := false + for _, p := range result.Parts { + if p.Type == "tool_result" && p.ToolCallID == "tc_456" { + found = true + break + } + } + if !found { + t.Error("expected tool_result part with ToolCallID tc_456") + } +} + +func TestProviderToSeahorseMessageWithMedia(t *testing.T) { + msg := protocoltypes.Message{ + Role: "user", + Content: "Here is an image", + Media: []string{"data:image/png;base64,abc123"}, + } + + result := providerToSeahorseMessage(msg) + if result.Role != "user" { + t.Errorf("Role = %q, want user", result.Role) + } + + // Should have a media part + found := false + for _, p := range result.Parts { + if p.Type == "media" { + found = true + if p.MediaURI != "data:image/png;base64,abc123" { + t.Errorf("MediaURI = %q, want data:image/png;base64,abc123", p.MediaURI) + } + break + } + } + if !found { + t.Error("expected media part in converted message") + } +} + +func TestProviderToSeahorseMessageWithReasoning(t *testing.T) { + msg := protocoltypes.Message{ + Role: "assistant", + Content: "response text", + ReasoningContent: "I thought about this carefully", + } + + result := providerToSeahorseMessage(msg) + if result.ReasoningContent != "I thought about this carefully" { + t.Errorf("ReasoningContent = %q, want 'I thought about this carefully'", result.ReasoningContent) + } +} + +func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) { + result := &seahorse.AssembleResult{ + Messages: []seahorse.Message{ + { + Role: "assistant", + Content: "response", + ReasoningContent: "thinking process", + }, + }, + } + + messages := seahorseToProviderMessages(result) + if len(messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(messages)) + } + if messages[0].ReasoningContent != "thinking process" { + t.Errorf("ReasoningContent = %q, want 'thinking process'", messages[0].ReasoningContent) + } +} + +func TestSeahorseToProviderMessages(t *testing.T) { + // Summaries should NOT be double-injected. + // The assembler already includes summaries as XML-formatted messages in Messages slice. + // seahorseToProviderMessages should only convert Messages, not Summaries. + summaryXML := ` + + test summary content + +` + summaryMsg := seahorse.Message{ + Role: "user", + Content: summaryXML, + TokenCount: 50, + } + rawMsg := seahorse.Message{ + Role: "user", + Content: "hello", + TokenCount: 5, + } + + result := seahorseToProviderMessages(&seahorse.AssembleResult{ + Messages: []seahorse.Message{summaryMsg, rawMsg}, + }) + + // Should have exactly 2 messages (from Messages slice only) + // NOT 3 (which would happen if Summaries were also converted) + if len(result) != 2 { + t.Fatalf("expected exactly 2 messages (no double injection), got %d", len(result)) + } + // First should be the XML summary message + if result[0].Content != summaryXML { + t.Errorf("first message content = %q, want summary XML", result[0].Content) + } + // Second should be the raw message + if result[1].Content != "hello" { + t.Errorf("second message content = %q, want 'hello'", result[1].Content) + } +} + +func TestSeahorseToProviderMessagesWithToolCalls(t *testing.T) { + msg := seahorse.Message{ + Role: "assistant", + Content: "", + TokenCount: 10, + Parts: []seahorse.MessagePart{ + { + Type: "tool_use", + Name: "read_file", + Arguments: `{"path":"/tmp"}`, + ToolCallID: "tc_1", + }, + }, + } + + result := seahorseToProviderMessages(&seahorse.AssembleResult{ + Messages: []seahorse.Message{msg}, + }) + + if len(result) != 1 { + t.Fatalf("expected 1 message, got %d", len(result)) + } + if result[0].Role != "assistant" { + t.Errorf("Role = %q, want assistant", result[0].Role) + } + if len(result[0].ToolCalls) != 1 { + t.Fatalf("ToolCalls = %d, want 1", len(result[0].ToolCalls)) + } + if result[0].ToolCalls[0].Function.Name != "read_file" { + t.Errorf("ToolCall name = %q, want read_file", result[0].ToolCalls[0].Function.Name) + } + // GLM API and other OpenAI-compatible APIs require Type: "function" + if result[0].ToolCalls[0].Type != "function" { + t.Errorf("ToolCall Type = %q, want 'function' (required by GLM/OpenAI APIs)", + result[0].ToolCalls[0].Type) + } +} + +func TestSeahorseToProviderMessagesToolResult(t *testing.T) { + msg := seahorse.Message{ + Role: "tool", + Content: "file output", + TokenCount: 5, + Parts: []seahorse.MessagePart{ + { + Type: "tool_result", + ToolCallID: "tc_99", + Text: "file output", + }, + }, + } + + result := seahorseToProviderMessages(&seahorse.AssembleResult{ + Messages: []seahorse.Message{msg}, + }) + + if len(result) != 1 { + t.Fatalf("expected 1 message, got %d", len(result)) + } + if result[0].ToolCallID != "tc_99" { + t.Errorf("ToolCallID = %q, want tc_99", result[0].ToolCallID) + } +} + +// --- providerToCompleteFn tests --- + +func TestProviderToCompleteFn(t *testing.T) { + var capturedMessages []providers.Message + var capturedModel string + var capturedOptions map[string]any + + mp := &seahorseTestProvider{ + chatFn: func(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]any) (*providers.LLMResponse, error) { + capturedMessages = messages + capturedModel = model + capturedOptions = options + return &providers.LLMResponse{Content: "summary of conversation"}, nil + }, + } + + completeFn := providerToCompleteFn(mp, "test-model-v1") + result, err := completeFn(context.Background(), "Summarize this text", seahorse.CompleteOptions{ + MaxTokens: 500, + Temperature: 0.3, + }) + if err != nil { + t.Fatalf("completeFn: %v", err) + } + if result != "summary of conversation" { + t.Errorf("result = %q, want 'summary of conversation'", result) + } + + // Verify prompt passed as user message + if len(capturedMessages) != 1 { + t.Fatalf("captured messages = %d, want 1", len(capturedMessages)) + } + if capturedMessages[0].Role != "user" { + t.Errorf("message role = %q, want user", capturedMessages[0].Role) + } + if capturedMessages[0].Content != "Summarize this text" { + t.Errorf("message content = %q, want 'Summarize this text'", capturedMessages[0].Content) + } + + // Verify model + if capturedModel != "test-model-v1" { + t.Errorf("model = %q, want 'test-model-v1'", capturedModel) + } + + // Verify options + if capturedOptions["max_tokens"] != 500 { + t.Errorf("max_tokens = %v, want 500", capturedOptions["max_tokens"]) + } + if capturedOptions["temperature"] != 0.3 { + t.Errorf("temperature = %v, want 0.3", capturedOptions["temperature"]) + } + if capturedOptions["prompt_cache_key"] != "seahorse" { + t.Errorf("prompt_cache_key = %v, want 'seahorse'", capturedOptions["prompt_cache_key"]) + } +} + +func TestSeahorseIgnoreHeartbeat(t *testing.T) { + // Verify that "heartbeat" sessions are ignored by default + // This tests the hardcoded ignore pattern from spec lines 1326-1328 + engine, err := seahorse.NewEngine(seahorse.Config{ + DBPath: t.TempDir() + "/test.db", + }, nil) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + defer engine.Close() + + ctx := context.Background() + result, err := engine.Ingest(ctx, "heartbeat", []seahorse.Message{ + {Role: "user", Content: "heartbeat msg", TokenCount: 5}, + }) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + // Should return nil nil for ignored sessions + if result != nil { + t.Errorf("expected nil result for heartbeat session, got %+v", result) + } +} + +func TestProviderToCompleteFnError(t *testing.T) { + mp := &seahorseTestProvider{ + chatFn: func(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]any) (*providers.LLMResponse, error) { + return nil, context.Canceled + }, + } + + completeFn := providerToCompleteFn(mp, "test-model") + _, err := completeFn(context.Background(), "test prompt", seahorse.CompleteOptions{}) + if err == nil { + t.Error("expected error from canceled context") + } +} + +func TestSeahorseAdapterAssembleSubtractsMaxTokens(t *testing.T) { + // Create a real seahorse engine with temp DB + engine, err := seahorse.NewEngine(seahorse.Config{ + DBPath: t.TempDir() + "/test.db", + }, nil) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + defer engine.Close() + + ctx := context.Background() + mgr := &seahorseContextManager{engine: engine} + + // Ingest lots of large messages (~35 tokens each, 120 total = ~4200 tokens) + for i := 0; i < 60; i++ { + content := fmt.Sprintf( + "This is message number %d. It contains enough text to represent a meaningful conversation turn with the user asking about various topics in software engineering and system design principles that require careful consideration.", + i, + ) + _ = mgr.Ingest(ctx, &IngestRequest{ + SessionKey: "budget-sub", + Message: protocoltypes.Message{Role: "user", Content: content}, + }) + _ = mgr.Ingest(ctx, &IngestRequest{ + SessionKey: "budget-sub", + Message: protocoltypes.Message{Role: "assistant", Content: "Response"}, + }) + } + + // Call adapter Assemble with Budget=5000, MaxTokens=2000 + // Should use effective budget = 5000 - 2000 = 3000 + resp, err := mgr.Assemble(ctx, &AssembleRequest{ + SessionKey: "budget-sub", + Budget: 5000, + MaxTokens: 2000, + }) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + if resp == nil { + t.Fatal("expected non-nil response") + } + + // Directly call engine with budget=3000 to get baseline + baseline, err := engine.Assemble(ctx, "budget-sub", seahorse.AssembleInput{Budget: 3000}) + if err != nil { + t.Fatalf("engine.Assemble baseline: %v", err) + } + + // The adapter result should have same message count as engine with budget 3000 + if len(resp.History) != len(baseline.Messages) { + t.Errorf("adapter Budget=5000 MaxTokens=2000 gave %d messages, engine Budget=3000 gave %d", + len(resp.History), len(baseline.Messages)) + } +} + +func TestSeahorseCompactRetryUsesCompactUntilUnder(t *testing.T) { + // Track which engine method was called + var compactCalled, compactUntilCalled bool + + engine, err := seahorse.NewEngine(seahorse.Config{ + DBPath: t.TempDir() + "/test.db", + }, nil) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + defer engine.Close() + + // Wrap engine to track calls + _ = compactCalled // track via adapter behavior + _ = compactUntilCalled + + mgr := &seahorseContextManager{engine: engine} + + ctx := context.Background() + + // Ingest messages so there's something to compact + for i := 0; i < 40; i++ { + content := fmt.Sprintf( + "message %d with enough text to have meaningful token count that fills up the budget nicely", + i, + ) + _ = mgr.Ingest(ctx, &IngestRequest{ + SessionKey: "compact-test", + Message: protocoltypes.Message{Role: "user", Content: content}, + }) + _ = mgr.Ingest(ctx, &IngestRequest{ + SessionKey: "compact-test", + Message: protocoltypes.Message{Role: "assistant", Content: "ok"}, + }) + } + + // Compact with retry reason and budget should succeed + err = mgr.Compact(ctx, &CompactRequest{ + SessionKey: "compact-test", + Reason: ContextCompressReasonRetry, + Budget: 5000, + }) + if err != nil { + t.Fatalf("Compact retry: %v", err) + } + + // Verify context was actually compacted (should have fewer tokens) + result, err := engine.Assemble(ctx, "compact-test", seahorse.AssembleInput{Budget: 5000}) + if err != nil { + t.Fatalf("Assemble after compact: %v", err) + } + if result == nil { + t.Fatal("expected non-nil assemble result") + } + // Compaction attempted — no assertion on exact count since no LLM + _ = result.Summary +} + +// TestSeahorseRealLoopNoDuplicateMessages tests the real-world scenario: +// 1. Start AgentLoop with seahorse context manager +// 2. Run a turn (user message -> LLM response) +// 3. Check DB for duplicate messages +// This test verifies that bootstrapping at startup (not during first Ingest) prevents duplicates. +func TestSeahorseRealLoopNoDuplicateMessages(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "seahorse", + }, + }, + } + + msgBus := bus.NewMessageBus() + mockProvider := &simpleMockProvider{response: "I received your message."} + al := NewAgentLoop(cfg, msgBus, mockProvider) + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + ctx := context.Background() + sessionKey := "test-real-loop-dup" + + // Run a turn: user message -> LLM response + _, err := al.runAgentLoop(ctx, defaultAgent, processOptions{ + SessionKey: sessionKey, + Channel: "cli", + ChatID: "direct", + UserMessage: "hello", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + // Get the seahorse engine from context manager + seahorseCM, ok := al.contextManager.(*seahorseContextManager) + if !ok { + t.Fatal("expected seahorseContextManager") + } + + // Check DB for messages via RetrievalEngine.Store() + store := seahorseCM.engine.GetRetrieval().Store() + conv, err := store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + stored, err := store.GetMessages(ctx, conv.ConversationID, 20, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + + t.Logf("DB has %d messages:", len(stored)) + for i, msg := range stored { + content := msg.Content + if len(content) > 40 { + content = content[:40] + "..." + } + t.Logf(" msg[%d]: role=%s content=%q", i, msg.Role, content) + } + + // Count duplicates by (role, content) + seen := make(map[string]int) + for _, msg := range stored { + key := msg.Role + ":" + msg.Content + seen[key]++ + } + for key, count := range seen { + if count > 1 { + t.Errorf("DUPLICATE BUG: %q appears %d times in DB", key, count) + } + } + + // Expected: 2 messages (user "hello" + assistant response) + if len(stored) != 2 { + t.Errorf("expected 2 messages in DB (user + assistant), got %d", len(stored)) + } +} + +// TestSeahorseAssembleReturnsAllSummaries verifies that Assemble returns ALL summaries, +// not just the latest one. This is important because summaries represent compressed +// conversation history at different points in time. +func TestSeahorseAssembleReturnsAllSummaries(t *testing.T) { + // Create a real seahorse engine with temp DB + engine, err := seahorse.NewEngine(seahorse.Config{ + DBPath: t.TempDir() + "/test.db", + }, nil) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + defer engine.Close() + + ctx := context.Background() + mgr := &seahorseContextManager{engine: engine} + sessionKey := "test-multi-summary" + + // Get the store to directly create summaries + store := engine.GetRetrieval().Store() + + // Get conversation ID + conv, err := store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + // Create some messages first + for i := 0; i < 20; i++ { + _ = mgr.Ingest(ctx, &IngestRequest{ + SessionKey: sessionKey, + Message: protocoltypes.Message{Role: "user", Content: fmt.Sprintf("Message %d", i)}, + }) + } + + // Directly create multiple summaries in the database to simulate multi-level compaction + testSummaries := []struct { + content string + kind seahorse.SummaryKind + depth int + token int + }{ + {"First summary about early conversation discussing topics A and B", seahorse.SummaryKindLeaf, 0, 100}, + {"Second summary covering middle conversation about topics C and D", seahorse.SummaryKindLeaf, 0, 150}, + {"Third summary is condensed from first two summaries about topics A-D", seahorse.SummaryKindCondensed, 1, 200}, + } + + summaryIDs := make([]string, 0, len(testSummaries)) + for _, s := range testSummaries { + input := seahorse.CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: s.kind, + Depth: s.depth, + Content: s.content, + TokenCount: s.token, + } + summary, createErr := store.CreateSummary(ctx, input) + if createErr != nil { + t.Fatalf("CreateSummary: %v", createErr) + } + summaryIDs = append(summaryIDs, summary.SummaryID) + + // Add summary to context_items + err = store.AppendContextSummary(ctx, conv.ConversationID, summary.SummaryID) + if err != nil { + t.Fatalf("AppendContextSummary: %v", err) + } + } + + t.Logf("Created %d summaries directly in store", len(summaryIDs)) + + // Assemble and check summaries + resp, err := mgr.Assemble(ctx, &AssembleRequest{ + SessionKey: sessionKey, + Budget: 50000, + MaxTokens: 4096, + }) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + // Check seahorse engine directly for how many summaries exist + result, err := engine.Assemble(ctx, sessionKey, seahorse.AssembleInput{Budget: 50000}) + if err != nil { + t.Fatalf("engine.Assemble: %v", err) + } + + t.Logf("Seahorse returned Summary with %d chars", len(result.Summary)) + + // The Summary field should contain XML summaries with metadata (depth, kind) + // The assembler generates this from the Summaries list + if len(resp.Summary) > 0 { + // Should contain XML tag + if !strings.Contains(resp.Summary, " Content-only = %d", + resultWithToolCalls.TokenCount, resultContentOnly.TokenCount) + } + + // Message with ToolCallID + msgWithToolResult := protocoltypes.Message{ + Role: "tool", + Content: "This is a simple response with some text content.", + ToolCallID: "tc_456", + } + resultWithToolResult := providerToSeahorseMessage(msgWithToolResult) + + if resultWithToolResult.TokenCount <= resultContentOnly.TokenCount { + t.Errorf("TokenCount with ToolCallID = %d, should be > Content-only = %d", + resultWithToolResult.TokenCount, resultContentOnly.TokenCount) + } + + // Message with Media + msgWithMedia := protocoltypes.Message{ + Role: "user", + Content: "This is a simple response with some text content.", + Media: []string{"data:image/png;base64,abc123"}, + } + resultWithMedia := providerToSeahorseMessage(msgWithMedia) + + if resultWithMedia.TokenCount <= resultContentOnly.TokenCount { + t.Errorf("TokenCount with Media = %d, should be > Content-only = %d", + resultWithMedia.TokenCount, resultContentOnly.TokenCount) + } +} + +func TestSeahorseToProviderMessagesRebuildsContentFromParts(t *testing.T) { + msg := seahorse.Message{ + Role: "tool", + Content: "", + TokenCount: 50, + Parts: []seahorse.MessagePart{ + { + Type: "tool_result", + ToolCallID: "tc_999", + Text: "This is the actual tool output that should be in Content", + }, + }, + } + + result := seahorseToProviderMessages(&seahorse.AssembleResult{ + Messages: []seahorse.Message{msg}, + }) + + if len(result) != 1 { + t.Fatalf("expected 1 message, got %d", len(result)) + } + + if result[0].Content == "" { + t.Error("Content is empty - tool_result text was not rebuilt into Content") + } + if result[0].Content != "This is the actual tool output that should be in Content" { + t.Errorf("Content = %q, want tool output text from Parts", result[0].Content) + } +} + +func TestSeahorseAssembleSummaryNotInMessages(t *testing.T) { + engine, err := seahorse.NewEngine(seahorse.Config{ + DBPath: t.TempDir() + "/test.db", + }, nil) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + defer engine.Close() + + ctx := context.Background() + mgr := &seahorseContextManager{engine: engine} + sessionKey := "test-no-dup-summary" + + // Get the store to directly create a summary + store := engine.GetRetrieval().Store() + conv, err := store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + // Ingest some messages first + for i := 0; i < 10; i++ { + _ = mgr.Ingest(ctx, &IngestRequest{ + SessionKey: sessionKey, + Message: protocoltypes.Message{Role: "user", Content: fmt.Sprintf("Message %d", i)}, + }) + } + + // Create a summary + input := seahorse.CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: seahorse.SummaryKindLeaf, + Depth: 0, + Content: "This is a test summary about the conversation", + TokenCount: 50, + } + summary, err := store.CreateSummary(ctx, input) + if err != nil { + t.Fatalf("CreateSummary: %v", err) + } + err = store.AppendContextSummary(ctx, conv.ConversationID, summary.SummaryID) + if err != nil { + t.Fatalf("AppendContextSummary: %v", err) + } + + // Assemble + resp, err := mgr.Assemble(ctx, &AssembleRequest{ + SessionKey: sessionKey, + Budget: 50000, + MaxTokens: 4096, + }) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + // Count how many times the summary content appears + summaryContent := "This is a test summary" + countInHistory := 0 + for _, msg := range resp.History { + if strings.Contains(msg.Content, summaryContent) { + countInHistory++ + } + } + + if countInHistory > 0 { + t.Errorf("Summary content appears %d times in History - should be 0", countInHistory) + } + + // Summary should appear in Summary field + if !strings.Contains(resp.Summary, summaryContent) { + t.Error("Summary content should appear in response.Summary field") + } +} + +// TestSeahorseSteeringMessageIngested verifies that steering messages are ingested +// into seahorse SQLite, not just session JSONL. +func TestSeahorseSteeringMessageIngested(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "seahorse", + }, + }, + } + + msgBus := bus.NewMessageBus() + mockProvider := &simpleMockProvider{response: "I received your message."} + al := NewAgentLoop(cfg, msgBus, mockProvider) + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + ctx := context.Background() + sessionKey := "test-steering-ingest" + + // First turn: establish conversation + _, err := al.runAgentLoop(ctx, defaultAgent, processOptions{ + SessionKey: sessionKey, + Channel: "cli", + ChatID: "direct", + UserMessage: "hello", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("first runAgentLoop failed: %v", err) + } + + // Inject a steering message + steerErr := al.InjectSteering(providers.Message{ + Role: "user", + Content: "steering message content", + }) + if steerErr != nil { + t.Fatalf("InjectSteering failed: %v", steerErr) + } + + // Second turn: should process steering message + _, err = al.runAgentLoop(ctx, defaultAgent, processOptions{ + SessionKey: sessionKey, + Channel: "cli", + ChatID: "direct", + UserMessage: "continue", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("second runAgentLoop failed: %v", err) + } + + // Get the seahorse engine from context manager + seahorseCM, ok := al.contextManager.(*seahorseContextManager) + if !ok { + t.Fatal("expected seahorseContextManager") + } + + // Check DB for steering message + store := seahorseCM.engine.GetRetrieval().Store() + conv, err := store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + stored, err := store.GetMessages(ctx, conv.ConversationID, 20, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + + t.Logf("DB has %d messages:", len(stored)) + for i, msg := range stored { + content := msg.Content + if len(content) > 40 { + content = content[:40] + "..." + } + t.Logf(" msg[%d]: role=%s content=%q", i, msg.Role, content) + } + + // Find steering message in stored messages + foundSteering := false + for _, msg := range stored { + if msg.Content == "steering message content" { + foundSteering = true + break + } + } + + if !foundSteering { + t.Error("STEERING MESSAGE NOT IN SEAHORSE DB: steering message should be ingested into SQLite") + } +} + +// TestSeahorseSummarizeSkipsCondensedWhenBelowThreshold verifies that when +// Summarize is triggered but tokens are below ContextWindow threshold, +// condensed compaction should NOT run. +func TestSeahorseSummarizeSkipsCondensedWhenBelowThreshold(t *testing.T) { + contextWindow := 1000 + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "seahorse", + ContextWindow: contextWindow, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &seahorseTestProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + ctx := context.Background() + sessionKey := "test-summarize-skip-condensed" + + seahorseCM, ok := al.contextManager.(*seahorseContextManager) + if !ok { + t.Fatal("expected seahorseContextManager") + } + store := seahorseCM.engine.GetRetrieval().Store() + + conv, err := store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + // Insert leaf summaries directly (bypass leaf compaction requirement) + for i := 0; i < seahorse.CondensedMinFanout; i++ { + now := time.Now().UTC() + summary, sumErr := store.CreateSummary(ctx, seahorse.CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: seahorse.SummaryKindLeaf, + Depth: 0, + Content: fmt.Sprintf("leaf summary %d", i), + TokenCount: 50, + EarliestAt: &now, + LatestAt: &now, + }) + if sumErr != nil { + t.Fatalf("CreateSummary %d: %v", i, sumErr) + } + if appendErr := store.AppendContextSummary(ctx, conv.ConversationID, summary.SummaryID); appendErr != nil { + t.Fatalf("AppendContextSummary %d: %v", i, appendErr) + } + } + + // Add fresh messages (required for condensation candidates) + for i := 0; i < seahorse.FreshTailCount+1; i++ { + m, msgErr := store.AddMessage(ctx, conv.ConversationID, "user", "fresh", 5) + if msgErr != nil { + t.Fatalf("AddMessage %d: %v", i, msgErr) + } + if appendErr := store.AppendContextMessage(ctx, conv.ConversationID, m.ID); appendErr != nil { + t.Fatalf("AppendContextMessage %d: %v", i, appendErr) + } + } + + tokensBefore, err := store.GetContextTokenCount(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetContextTokenCount: %v", err) + } + threshold := int(float64(contextWindow) * seahorse.ContextThreshold) + t.Logf("Tokens before: %d, threshold: %d", tokensBefore, threshold) + + // Trigger Summarize + _, err = al.runAgentLoop(ctx, defaultAgent, processOptions{ + SessionKey: sessionKey, + Channel: "cli", + ChatID: "direct", + UserMessage: "trigger", + DefaultResponse: defaultResponse, + EnableSummary: true, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop: %v", err) + } + + time.Sleep(500 * time.Millisecond) + + summaries, err := store.GetSummariesByConversation(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetSummariesByConversation: %v", err) + } + + condensedCount := 0 + for _, sum := range summaries { + if sum.Kind == seahorse.SummaryKindCondensed { + condensedCount++ + } + } + + t.Logf("Condensed summaries: %d", condensedCount) + + if tokensBefore < threshold && condensedCount > 0 { + t.Errorf("BUG: condensed created when tokens (%d) < threshold (%d)", tokensBefore, threshold) + } +} diff --git a/pkg/agent/context_seahorse_unsupported.go b/pkg/agent/context_seahorse_unsupported.go new file mode 100644 index 000000000..7528f79bc --- /dev/null +++ b/pkg/agent/context_seahorse_unsupported.go @@ -0,0 +1,20 @@ +//go:build mipsle || netbsd || (freebsd && arm) + +package agent + +import ( + "encoding/json" + "fmt" +) + +// newSeahorseContextManager is unavailable on platforms where modernc sqlite/libc +// currently has no stable build path for this project. +func newSeahorseContextManager(_ json.RawMessage, _ *AgentLoop) (ContextManager, error) { + return nil, fmt.Errorf("seahorse context manager is unavailable on this platform") +} + +func init() { + if err := RegisterContextManager("seahorse", newSeahorseContextManager); err != nil { + panic(fmt.Sprintf("register seahorse context manager: %v", err)) + } +} diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index 5756ed911..ed64d1578 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -188,6 +188,72 @@ func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) { assertRoles(t, result, "user", "assistant", "user", "assistant") } +func TestSanitizeHistoryForProvider_DuplicateToolResults(t *testing.T) { + history := []providers.Message{ + msg("user", "do something"), + assistantWithTools("A", "B"), + toolResult("A"), + toolResult("B"), + toolResult("A"), // duplicate + toolResult("B"), // duplicate + msg("assistant", "done"), + } + + result := sanitizeHistoryForProvider(history) + if len(result) != 5 { + t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result)) + } + assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant") + // Verify the kept tool results have the correct IDs + if result[2].ToolCallID != "A" { + t.Errorf("expected tool result A, got %q", result[2].ToolCallID) + } + if result[3].ToolCallID != "B" { + t.Errorf("expected tool result B, got %q", result[3].ToolCallID) + } +} + +func TestSanitizeHistoryForProvider_ReusedToolCallIDAcrossRounds(t *testing.T) { + history := []providers.Message{ + msg("user", "first"), + assistantWithTools("call_0"), + toolResult("call_0"), + msg("assistant", "first done"), + msg("user", "second"), + assistantWithTools("call_0"), + toolResult("call_0"), + msg("assistant", "second done"), + } + + result := sanitizeHistoryForProvider(history) + if len(result) != 8 { + t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result)) + } + assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "assistant", "tool", "assistant") + if result[2].ToolCallID != "call_0" || result[6].ToolCallID != "call_0" { + t.Fatalf( + "expected both tool results to be preserved, got IDs %q and %q", + result[2].ToolCallID, + result[6].ToolCallID, + ) + } +} + +func TestSanitizeHistoryForProvider_DropsAssistantWithEmptyToolCallID(t *testing.T) { + history := []providers.Message{ + msg("user", "do something"), + assistantWithTools(""), + toolResult(""), + msg("assistant", "done"), + } + + result := sanitizeHistoryForProvider(history) + if len(result) != 2 { + t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result)) + } + assertRoles(t, result, "user", "assistant") +} + func roles(msgs []providers.Message) []string { r := make([]string, len(msgs)) for i, m := range msgs { diff --git a/pkg/agent/context_usage.go b/pkg/agent/context_usage.go new file mode 100644 index 000000000..39d4f3dee --- /dev/null +++ b/pkg/agent/context_usage.go @@ -0,0 +1,78 @@ +package agent + +import ( + "github.com/sipeed/picoclaw/pkg/bus" +) + +// computeContextUsage estimates current context window consumption for the +// given agent and session. Includes history, system prompt (with dynamic context, +// summary, and skills — mirroring BuildMessages composition), and tool definitions. +// The output reserve (MaxTokens) is not counted as "used" but reduces the +// effective budget, matching isOverContextBudget's compression trigger: +// +// compress when: history + system + tools + maxTokens > contextWindow +// equivalent to: history + system + tools > contextWindow - maxTokens +// +// Returns nil when the agent or session is unavailable. +func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUsage { + if agent == nil || agent.Sessions == nil { + return nil + } + contextWindow := agent.ContextWindow + if contextWindow <= 0 { + return nil + } + + // History tokens + history := agent.Sessions.GetHistory(sessionKey) + historyTokens := 0 + for _, m := range history { + historyTokens += EstimateMessageTokens(m) + } + + // System message tokens: uses EstimateSystemTokens which mirrors + // the full system message composition in BuildMessages (static prompt, + // dynamic context, active skills, summary with wrapping prefix). + systemTokens := 0 + if agent.ContextBuilder != nil { + summary := agent.Sessions.GetSummary(sessionKey) + // Pass nil for active skills: skills are only injected when the user + // explicitly activates them via /use, which is rare. Using nil matches + // the common case and avoids over-counting all installed skills. + systemTokens = agent.ContextBuilder.EstimateSystemTokens(summary, nil) + } + + // Tool definition tokens + toolTokens := 0 + if agent.Tools != nil { + toolTokens = EstimateToolDefsTokens(agent.Tools.ToProviderDefs()) + } + + // Used = history + system (includes summary) + tools + usedTokens := historyTokens + systemTokens + toolTokens + + // Effective budget = contextWindow minus output reserve (maxTokens) + effectiveWindow := contextWindow - agent.MaxTokens + if effectiveWindow < 0 { + effectiveWindow = contextWindow + } + + // compressAt = effectiveWindow: aligns with isOverContextBudget's + // proactive trigger (msgTokens + toolTokens + maxTokens > contextWindow). + compressAt := effectiveWindow + + usedPercent := 0 + if compressAt > 0 { + usedPercent = usedTokens * 100 / compressAt + } + if usedPercent > 100 { + usedPercent = 100 + } + + return &bus.ContextUsage{ + UsedTokens: usedTokens, + TotalTokens: contextWindow, + CompressAtTokens: compressAt, + UsedPercent: usedPercent, + } +} diff --git a/pkg/agent/definition.go b/pkg/agent/definition.go new file mode 100644 index 000000000..cf73d607c --- /dev/null +++ b/pkg/agent/definition.go @@ -0,0 +1,255 @@ +package agent + +import ( + "os" + "path/filepath" + "slices" + "strings" + + "github.com/gomarkdown/markdown/parser" + "gopkg.in/yaml.v3" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// AgentDefinitionSource identifies which agent bootstrap file produced the definition. +type AgentDefinitionSource string + +const ( + // AgentDefinitionSourceAgent indicates the new AGENT.md format. + AgentDefinitionSourceAgent AgentDefinitionSource = "AGENT.md" + // AgentDefinitionSourceAgents indicates the legacy AGENTS.md format. + AgentDefinitionSourceAgents AgentDefinitionSource = "AGENTS.md" +) + +// AgentFrontmatter holds machine-readable AGENT.md configuration. +// +// Known fields are exposed directly for convenience. Fields keeps the full +// parsed frontmatter so future refactors can read additional keys without +// changing the loader contract again. +type AgentFrontmatter struct { + Name string `json:"name"` + Description string `json:"description"` + Tools []string `json:"tools,omitempty"` + Model string `json:"model,omitempty"` + MaxTurns *int `json:"maxTurns,omitempty"` + Skills []string `json:"skills,omitempty"` + MCPServers []string `json:"mcpServers,omitempty"` + Fields map[string]any `json:"fields,omitempty"` +} + +// AgentPromptDefinition represents the parsed AGENT.md or AGENTS.md prompt file. +type AgentPromptDefinition struct { + Path string `json:"path"` + Raw string `json:"raw"` + Body string `json:"body"` + RawFrontmatter string `json:"raw_frontmatter,omitempty"` + Frontmatter AgentFrontmatter `json:"frontmatter"` +} + +// SoulDefinition represents the resolved SOUL.md file linked to the agent. +type SoulDefinition struct { + Path string `json:"path"` + Content string `json:"content"` +} + +// UserDefinition represents the resolved USER.md file linked to the workspace. +type UserDefinition struct { + Path string `json:"path"` + Content string `json:"content"` +} + +// AgentContextDefinition captures the workspace agent definition in a runtime-friendly shape. +type AgentContextDefinition struct { + Source AgentDefinitionSource `json:"source,omitempty"` + Agent *AgentPromptDefinition `json:"agent,omitempty"` + Soul *SoulDefinition `json:"soul,omitempty"` + User *UserDefinition `json:"user,omitempty"` +} + +// LoadAgentDefinition parses the workspace agent bootstrap files. +// +// It prefers the new AGENT.md format and its paired SOUL.md file. When the +// structured files are absent, it falls back to the legacy AGENTS.md layout so +// the current runtime can transition incrementally. +func (cb *ContextBuilder) LoadAgentDefinition() AgentContextDefinition { + return loadAgentDefinition(cb.workspace) +} + +func loadAgentDefinition(workspace string) AgentContextDefinition { + definition := AgentContextDefinition{} + definition.User = loadUserDefinition(workspace) + agentPath := filepath.Join(workspace, string(AgentDefinitionSourceAgent)) + if content, err := os.ReadFile(agentPath); err == nil { + prompt := parseAgentPromptDefinition(agentPath, string(content)) + definition.Source = AgentDefinitionSourceAgent + definition.Agent = &prompt + soulPath := filepath.Join(workspace, "SOUL.md") + if content, err := os.ReadFile(soulPath); err == nil { + definition.Soul = &SoulDefinition{ + Path: soulPath, + Content: string(content), + } + } + return definition + } + + legacyPath := filepath.Join(workspace, string(AgentDefinitionSourceAgents)) + if content, err := os.ReadFile(legacyPath); err == nil { + definition.Source = AgentDefinitionSourceAgents + definition.Agent = &AgentPromptDefinition{ + Path: legacyPath, + Raw: string(content), + Body: string(content), + } + } + + defaultSoulPath := filepath.Join(workspace, "SOUL.md") + if definition.Source != "" || fileExists(defaultSoulPath) { + if content, err := os.ReadFile(defaultSoulPath); err == nil { + definition.Soul = &SoulDefinition{ + Path: defaultSoulPath, + Content: string(content), + } + } + } + + return definition +} + +func (definition AgentContextDefinition) trackedPaths(workspace string) []string { + paths := []string{ + filepath.Join(workspace, string(AgentDefinitionSourceAgent)), + filepath.Join(workspace, "SOUL.md"), + filepath.Join(workspace, "USER.md"), + } + if definition.Source != AgentDefinitionSourceAgent { + paths = append(paths, + filepath.Join(workspace, string(AgentDefinitionSourceAgents)), + filepath.Join(workspace, "IDENTITY.md"), + ) + } + return uniquePaths(paths) +} + +func loadUserDefinition(workspace string) *UserDefinition { + userPath := filepath.Join(workspace, "USER.md") + if content, err := os.ReadFile(userPath); err == nil { + return &UserDefinition{ + Path: userPath, + Content: string(content), + } + } + + return nil +} + +func parseAgentPromptDefinition(path, content string) AgentPromptDefinition { + frontmatter, body := splitAgentFrontmatter(content) + return AgentPromptDefinition{ + Path: path, + Raw: content, + Body: body, + RawFrontmatter: frontmatter, + Frontmatter: parseAgentFrontmatter(path, frontmatter), + } +} + +func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter { + frontmatter = strings.TrimSpace(frontmatter) + if frontmatter == "" { + return AgentFrontmatter{} + } + + rawFields := make(map[string]any) + if err := yaml.Unmarshal([]byte(frontmatter), &rawFields); err != nil { + logger.WarnCF("agent", "Failed to parse AGENT.md frontmatter", map[string]any{ + "path": path, + "error": err.Error(), + }) + return AgentFrontmatter{} + } + + var typed struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Tools []string `yaml:"tools"` + Model string `yaml:"model"` + MaxTurns *int `yaml:"maxTurns"` + Skills []string `yaml:"skills"` + MCPServers []string `yaml:"mcpServers"` + } + if err := yaml.Unmarshal([]byte(frontmatter), &typed); err != nil { + logger.WarnCF("agent", "Failed to decode AGENT.md frontmatter fields", map[string]any{ + "path": path, + "error": err.Error(), + }) + return AgentFrontmatter{} + } + + return AgentFrontmatter{ + Name: strings.TrimSpace(typed.Name), + Description: strings.TrimSpace(typed.Description), + Tools: append([]string(nil), typed.Tools...), + Model: strings.TrimSpace(typed.Model), + MaxTurns: typed.MaxTurns, + Skills: append([]string(nil), typed.Skills...), + MCPServers: append([]string(nil), typed.MCPServers...), + Fields: rawFields, + } +} + +func splitAgentFrontmatter(content string) (frontmatter, body string) { + normalized := string(parser.NormalizeNewlines([]byte(content))) + lines := strings.Split(normalized, "\n") + if len(lines) == 0 || lines[0] != "---" { + return "", content + } + + end := -1 + for i := 1; i < len(lines); i++ { + if lines[i] == "---" { + end = i + break + } + } + if end == -1 { + return "", content + } + + frontmatter = strings.Join(lines[1:end], "\n") + body = strings.Join(lines[end+1:], "\n") + body = strings.TrimLeft(body, "\n") + return frontmatter, body +} + +func relativeWorkspacePath(workspace, path string) string { + if strings.TrimSpace(path) == "" { + return "" + } + relativePath, err := filepath.Rel(workspace, path) + if err == nil && relativePath != "." && !strings.HasPrefix(relativePath, "..") { + return filepath.ToSlash(relativePath) + } + return filepath.Clean(path) +} + +func uniquePaths(paths []string) []string { + result := make([]string, 0, len(paths)) + for _, path := range paths { + if strings.TrimSpace(path) == "" { + continue + } + cleaned := filepath.Clean(path) + if slices.Contains(result, cleaned) { + continue + } + result = append(result, cleaned) + } + return result +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/pkg/agent/definition_test.go b/pkg/agent/definition_test.go new file mode 100644 index 000000000..5ee996967 --- /dev/null +++ b/pkg/agent/definition_test.go @@ -0,0 +1,302 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLoadAgentDefinitionParsesFrontmatterAndSoul(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +name: pico +description: Structured agent +model: claude-3-7-sonnet +tools: + - shell + - search +maxTurns: 8 +skills: + - review + - search-docs +mcpServers: + - github +metadata: + mode: strict +--- +# Agent + +Act directly and use tools first. +`, + "SOUL.md": "# Soul\nStay precise.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + definition := cb.LoadAgentDefinition() + + if definition.Source != AgentDefinitionSourceAgent { + t.Fatalf("expected source %q, got %q", AgentDefinitionSourceAgent, definition.Source) + } + if definition.Agent == nil { + t.Fatal("expected AGENT.md definition to be loaded") + } + if definition.Agent.Body == "" || !strings.Contains(definition.Agent.Body, "Act directly") { + t.Fatalf("expected AGENT.md body to be preserved, got %q", definition.Agent.Body) + } + if definition.Agent.Frontmatter.Name != "pico" { + t.Fatalf("expected name to be parsed, got %q", definition.Agent.Frontmatter.Name) + } + if definition.Agent.Frontmatter.Model != "claude-3-7-sonnet" { + t.Fatalf("expected model to be parsed, got %q", definition.Agent.Frontmatter.Model) + } + if len(definition.Agent.Frontmatter.Tools) != 2 { + t.Fatalf("expected tools to be parsed, got %v", definition.Agent.Frontmatter.Tools) + } + if definition.Agent.Frontmatter.MaxTurns == nil || *definition.Agent.Frontmatter.MaxTurns != 8 { + t.Fatalf("expected maxTurns to be parsed, got %v", definition.Agent.Frontmatter.MaxTurns) + } + if len(definition.Agent.Frontmatter.Skills) != 2 { + t.Fatalf("expected skills to be parsed, got %v", definition.Agent.Frontmatter.Skills) + } + if len(definition.Agent.Frontmatter.MCPServers) != 1 || definition.Agent.Frontmatter.MCPServers[0] != "github" { + t.Fatalf("expected mcpServers to be parsed, got %v", definition.Agent.Frontmatter.MCPServers) + } + if definition.Agent.Frontmatter.Fields["metadata"] == nil { + t.Fatal("expected arbitrary frontmatter fields to remain available") + } + + if definition.Soul == nil { + t.Fatal("expected SOUL.md to be loaded") + } + if !strings.Contains(definition.Soul.Content, "Stay precise") { + t.Fatalf("expected soul content to be loaded, got %q", definition.Soul.Content) + } + if definition.Soul.Path != filepath.Join(tmpDir, "SOUL.md") { + t.Fatalf("expected default SOUL.md path, got %q", definition.Soul.Path) + } +} + +func TestLoadAgentDefinitionFallsBackToLegacyAgentsMarkdown(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENTS.md": "# Legacy Agent\nKeep compatibility.", + "SOUL.md": "# Soul\nLegacy soul.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + definition := cb.LoadAgentDefinition() + + if definition.Source != AgentDefinitionSourceAgents { + t.Fatalf("expected source %q, got %q", AgentDefinitionSourceAgents, definition.Source) + } + if definition.Agent == nil { + t.Fatal("expected AGENTS.md to be loaded") + } + if definition.Agent.RawFrontmatter != "" { + t.Fatalf("legacy AGENTS.md should not have frontmatter, got %q", definition.Agent.RawFrontmatter) + } + if !strings.Contains(definition.Agent.Body, "Keep compatibility") { + t.Fatalf("expected legacy body to be preserved, got %q", definition.Agent.Body) + } + if definition.Soul == nil || !strings.Contains(definition.Soul.Content, "Legacy soul") { + t.Fatal("expected default SOUL.md to be loaded for legacy format") + } +} + +func TestLoadAgentDefinitionLoadsWorkspaceUserMarkdown(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": "# Agent\nStructured agent.", + "USER.md": "# User\nWorkspace preferences.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + definition := cb.LoadAgentDefinition() + + if definition.User == nil { + t.Fatal("expected USER.md to be loaded") + } + if definition.User.Path != filepath.Join(tmpDir, "USER.md") { + t.Fatalf("expected workspace USER.md path, got %q", definition.User.Path) + } + if !strings.Contains(definition.User.Content, "Workspace preferences") { + t.Fatalf("expected workspace USER.md content, got %q", definition.User.Content) + } +} + +func TestLoadAgentDefinitionInvalidFrontmatterFallsBackToEmptyStructuredFields(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +name: pico +tools: + - shell + broken +--- +# Agent + +Keep going. +`, + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + definition := cb.LoadAgentDefinition() + + if definition.Agent == nil { + t.Fatal("expected AGENT.md definition to be loaded") + } + if !strings.Contains(definition.Agent.Body, "Keep going.") { + t.Fatalf("expected AGENT.md body to be preserved, got %q", definition.Agent.Body) + } + if definition.Agent.Frontmatter.Name != "" || + definition.Agent.Frontmatter.Description != "" || + definition.Agent.Frontmatter.Model != "" || + definition.Agent.Frontmatter.MaxTurns != nil || + len(definition.Agent.Frontmatter.Tools) != 0 || + len(definition.Agent.Frontmatter.Skills) != 0 || + len(definition.Agent.Frontmatter.MCPServers) != 0 || + len(definition.Agent.Frontmatter.Fields) != 0 { + t.Fatalf("expected invalid frontmatter to decode as empty struct, got %+v", definition.Agent.Frontmatter) + } +} + +func TestLoadBootstrapFilesUsesAgentBodyNotFrontmatter(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +name: pico +model: codex-mini +--- +# Agent + +Follow the body prompt. +`, + "SOUL.md": "# Soul\nSpeak plainly.", + "IDENTITY.md": "# Identity\nWorkspace identity.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + bootstrap := cb.LoadBootstrapFiles() + + if !strings.Contains(bootstrap, "Follow the body prompt") { + t.Fatalf("expected AGENT.md body in bootstrap, got %q", bootstrap) + } + if !strings.Contains(bootstrap, "Speak plainly") { + t.Fatalf("expected resolved soul content in bootstrap, got %q", bootstrap) + } + if strings.Contains(bootstrap, "name: pico") { + t.Fatalf("bootstrap should not expose raw frontmatter, got %q", bootstrap) + } + if strings.Contains(bootstrap, "model: codex-mini") { + t.Fatalf("bootstrap should not expose raw frontmatter, got %q", bootstrap) + } + if !strings.Contains(bootstrap, "SOUL.md") { + t.Fatalf("expected bootstrap to label SOUL.md, got %q", bootstrap) + } + if strings.Contains(bootstrap, "Workspace identity") { + t.Fatalf("structured bootstrap should ignore IDENTITY.md, got %q", bootstrap) + } +} + +func TestLoadBootstrapFilesIncludesWorkspaceUserMarkdown(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": "# Agent\nFollow the new structure.", + "SOUL.md": "# Soul\nSpeak plainly.", + "USER.md": "# User\nShared profile.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + bootstrap := cb.LoadBootstrapFiles() + + if !strings.Contains(bootstrap, "Shared profile") { + t.Fatalf("expected workspace USER.md in bootstrap, got %q", bootstrap) + } + if !strings.Contains(bootstrap, "## USER.md") { + t.Fatalf("expected USER.md heading in bootstrap, got %q", bootstrap) + } +} + +func TestStructuredAgentIgnoresIdentityChanges(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": "# Agent\nFollow the new structure.", + "SOUL.md": "# Soul\nVersion one.", + "IDENTITY.md": "# Identity\nLegacy identity.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + + promptV1 := cb.BuildSystemPromptWithCache() + if strings.Contains(promptV1, "Legacy identity") { + t.Fatalf("structured prompt should not include IDENTITY.md, got %q", promptV1) + } + + identityPath := filepath.Join(tmpDir, "IDENTITY.md") + if err := os.WriteFile(identityPath, []byte("# Identity\nVersion two."), 0o644); err != nil { + t.Fatal(err) + } + future := time.Now().Add(2 * time.Second) + if err := os.Chtimes(identityPath, future, future); err != nil { + t.Fatal(err) + } + + cb.systemPromptMutex.RLock() + changed := cb.sourceFilesChangedLocked() + cb.systemPromptMutex.RUnlock() + if changed { + t.Fatal("IDENTITY.md should not invalidate cache for structured agent definitions") + } + + promptV2 := cb.BuildSystemPromptWithCache() + if promptV1 != promptV2 { + t.Fatal("structured prompt should remain stable after IDENTITY.md changes") + } +} + +func TestStructuredAgentUserChangesInvalidateCache(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": "# Agent\nFollow the new structure.", + "SOUL.md": "# Soul\nVersion one.", + "USER.md": "# User\nInitial workspace preferences.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + + promptV1 := cb.BuildSystemPromptWithCache() + if !strings.Contains(promptV1, "Initial workspace preferences") { + t.Fatalf("expected workspace USER.md in prompt, got %q", promptV1) + } + + userPath := filepath.Join(tmpDir, "USER.md") + if err := os.WriteFile(userPath, []byte("# User\nUpdated workspace preferences."), 0o644); err != nil { + t.Fatal(err) + } + future := time.Now().Add(2 * time.Second) + if err := os.Chtimes(userPath, future, future); err != nil { + t.Fatal(err) + } + + cb.systemPromptMutex.RLock() + changed := cb.sourceFilesChangedLocked() + cb.systemPromptMutex.RUnlock() + if !changed { + t.Fatal("workspace USER.md changes should invalidate cache") + } + + promptV2 := cb.BuildSystemPromptWithCache() + if !strings.Contains(promptV2, "Updated workspace preferences") { + t.Fatalf("expected updated workspace USER.md in prompt, got %q", promptV2) + } +} + +func cleanupWorkspace(t *testing.T, path string) { + t.Helper() + if err := os.RemoveAll(path); err != nil { + t.Fatalf("failed to clean up workspace %s: %v", path, err) + } +} diff --git a/pkg/agent/dispatch_request.go b/pkg/agent/dispatch_request.go new file mode 100644 index 000000000..cb54264d6 --- /dev/null +++ b/pkg/agent/dispatch_request.go @@ -0,0 +1,147 @@ +package agent + +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" +) + +// DispatchRequest is the normalized runtime input passed into the agent loop +// after routing and session allocation have completed. +type DispatchRequest struct { + SessionKey string + SessionAliases []string + InboundContext *bus.InboundContext + RouteResult *routing.ResolvedRoute + SessionScope *session.SessionScope + UserMessage string + Media []string +} + +func (r DispatchRequest) Channel() string { + if r.InboundContext == nil { + return "" + } + return r.InboundContext.Channel +} + +func (r DispatchRequest) ChatID() string { + if r.InboundContext == nil { + return "" + } + return r.InboundContext.ChatID +} + +func (r DispatchRequest) MessageID() string { + if r.InboundContext == nil { + return "" + } + return r.InboundContext.MessageID +} + +func (r DispatchRequest) ReplyToMessageID() string { + if r.InboundContext == nil { + return "" + } + return r.InboundContext.ReplyToMessageID +} + +func (r DispatchRequest) SenderID() string { + if r.InboundContext == nil { + return "" + } + return r.InboundContext.SenderID +} + +func normalizeProcessOptionsInPlace(opts *processOptions) { + if opts == nil { + return + } + *opts = normalizeProcessOptions(*opts) +} + +func normalizeProcessOptions(opts processOptions) processOptions { + if opts.Dispatch.SessionKey == "" { + opts.Dispatch.SessionKey = strings.TrimSpace(opts.SessionKey) + } + if len(opts.Dispatch.SessionAliases) == 0 && len(opts.SessionAliases) > 0 { + opts.Dispatch.SessionAliases = append([]string(nil), opts.SessionAliases...) + } + if opts.Dispatch.UserMessage == "" { + opts.Dispatch.UserMessage = opts.UserMessage + } + if len(opts.Dispatch.Media) == 0 && len(opts.Media) > 0 { + opts.Dispatch.Media = append([]string(nil), opts.Media...) + } + if opts.Dispatch.RouteResult == nil { + opts.Dispatch.RouteResult = cloneResolvedRoute(opts.RouteResult) + } + if opts.Dispatch.SessionScope == nil { + opts.Dispatch.SessionScope = session.CloneScope(opts.SessionScope) + } + if opts.Dispatch.InboundContext == nil { + if opts.InboundContext != nil { + opts.Dispatch.InboundContext = cloneInboundContext(opts.InboundContext) + } else if opts.Channel != "" || opts.ChatID != "" || opts.SenderID != "" || + opts.MessageID != "" || opts.ReplyToMessageID != "" { + inbound := bus.InboundContext{ + Channel: strings.TrimSpace(opts.Channel), + ChatID: strings.TrimSpace(opts.ChatID), + SenderID: strings.TrimSpace(opts.SenderID), + MessageID: strings.TrimSpace(opts.MessageID), + ReplyToMessageID: strings.TrimSpace(opts.ReplyToMessageID), + } + inbound.ChatType = inferChatTypeFromSessionScope(opts.Dispatch.SessionScope) + if inbound.Channel != "" || inbound.ChatID != "" || inbound.SenderID != "" || + inbound.MessageID != "" || inbound.ReplyToMessageID != "" { + inbound = bus.NormalizeInboundMessage(bus.InboundMessage{Context: inbound}).Context + opts.Dispatch.InboundContext = &inbound + } + } + } + + // Keep legacy mirrors populated while the rest of the runtime migrates. + opts.SessionKey = opts.Dispatch.SessionKey + opts.SessionAliases = append([]string(nil), opts.Dispatch.SessionAliases...) + opts.UserMessage = opts.Dispatch.UserMessage + opts.Media = append([]string(nil), opts.Dispatch.Media...) + opts.InboundContext = cloneInboundContext(opts.Dispatch.InboundContext) + opts.RouteResult = cloneResolvedRoute(opts.Dispatch.RouteResult) + opts.SessionScope = session.CloneScope(opts.Dispatch.SessionScope) + if opts.InboundContext != nil { + if opts.Channel == "" { + opts.Channel = opts.InboundContext.Channel + } + if opts.ChatID == "" { + opts.ChatID = opts.InboundContext.ChatID + } + if opts.MessageID == "" { + opts.MessageID = opts.InboundContext.MessageID + } + if opts.ReplyToMessageID == "" { + opts.ReplyToMessageID = opts.InboundContext.ReplyToMessageID + } + if opts.SenderID == "" { + opts.SenderID = opts.InboundContext.SenderID + } + } + + return opts +} + +func inferChatTypeFromSessionScope(scope *session.SessionScope) string { + if scope == nil || len(scope.Values) == 0 { + return "" + } + chatValue := strings.TrimSpace(scope.Values["chat"]) + if chatValue == "" { + return "" + } + chatType, _, ok := strings.Cut(chatValue, ":") + if !ok { + return "" + } + return strings.ToLower(strings.TrimSpace(chatType)) +} diff --git a/pkg/agent/dispatch_request_test.go b/pkg/agent/dispatch_request_test.go new file mode 100644 index 000000000..ec5f70339 --- /dev/null +++ b/pkg/agent/dispatch_request_test.go @@ -0,0 +1,135 @@ +package agent + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" +) + +func TestNormalizeProcessOptions_PopulatesDispatchFromLegacyFields(t *testing.T) { + opts := normalizeProcessOptions(processOptions{ + SessionKey: "session-1", + SessionAliases: []string{"legacy:one"}, + Channel: "telegram", + ChatID: "chat-1", + MessageID: "msg-1", + ReplyToMessageID: "reply-1", + SenderID: "user-1", + UserMessage: "hello", + Media: []string{"media://one"}, + }) + + if opts.Dispatch.SessionKey != "session-1" { + t.Fatalf("Dispatch.SessionKey = %q, want session-1", opts.Dispatch.SessionKey) + } + if len(opts.Dispatch.SessionAliases) != 1 || opts.Dispatch.SessionAliases[0] != "legacy:one" { + t.Fatalf("Dispatch.SessionAliases = %v, want [legacy:one]", opts.Dispatch.SessionAliases) + } + if opts.Dispatch.Channel() != "telegram" || opts.Dispatch.ChatID() != "chat-1" { + t.Fatalf( + "dispatch addressing = (%q,%q), want (telegram,chat-1)", + opts.Dispatch.Channel(), + opts.Dispatch.ChatID(), + ) + } + if opts.Dispatch.SenderID() != "user-1" || opts.Dispatch.MessageID() != "msg-1" { + t.Fatalf("dispatch sender/message = (%q,%q)", opts.Dispatch.SenderID(), opts.Dispatch.MessageID()) + } + if opts.Dispatch.ReplyToMessageID() != "reply-1" { + t.Fatalf("Dispatch.ReplyToMessageID() = %q, want reply-1", opts.Dispatch.ReplyToMessageID()) + } + if opts.Dispatch.UserMessage != "hello" { + t.Fatalf("Dispatch.UserMessage = %q, want hello", opts.Dispatch.UserMessage) + } + if len(opts.Dispatch.Media) != 1 || opts.Dispatch.Media[0] != "media://one" { + t.Fatalf("Dispatch.Media = %v, want [media://one]", opts.Dispatch.Media) + } +} + +func TestNormalizeProcessOptions_UsesDispatchAsSourceOfTruth(t *testing.T) { + inbound := &bus.InboundContext{ + Channel: "slack", + ChatID: "C123", + ChatType: "channel", + SenderID: "U123", + MessageID: "m-1", + ReplyToMessageID: "parent-1", + } + route := &routing.ResolvedRoute{ + AgentID: "support", + Channel: "slack", + AccountID: "workspace-a", + MatchedBy: "dispatch.rule:test", + SessionPolicy: routing.SessionPolicy{ + Dimensions: []string{"chat", "sender"}, + }, + } + scope := &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "support", + Channel: "slack", + Account: "workspace-a", + Dimensions: []string{"chat"}, + Values: map[string]string{ + "chat": "channel:c123", + }, + } + + opts := normalizeProcessOptions(processOptions{ + Dispatch: DispatchRequest{ + SessionKey: "sk_v1_example", + SessionAliases: []string{"agent:support:slack:channel:c123"}, + InboundContext: inbound, + RouteResult: route, + SessionScope: scope, + UserMessage: "hello", + Media: []string{"media://one"}, + }, + }) + + if opts.SessionKey != "sk_v1_example" { + t.Fatalf("SessionKey = %q, want sk_v1_example", opts.SessionKey) + } + if opts.Channel != "slack" || opts.ChatID != "C123" { + t.Fatalf("legacy mirrors = (%q,%q), want (slack,C123)", opts.Channel, opts.ChatID) + } + if opts.SenderID != "U123" || opts.MessageID != "m-1" { + t.Fatalf("legacy sender/message = (%q,%q)", opts.SenderID, opts.MessageID) + } + if opts.ReplyToMessageID != "parent-1" { + t.Fatalf("ReplyToMessageID = %q, want parent-1", opts.ReplyToMessageID) + } + if opts.RouteResult == nil || opts.RouteResult.AgentID != "support" { + t.Fatalf("RouteResult = %#v, want support route", opts.RouteResult) + } + if opts.SessionScope == nil || opts.SessionScope.AgentID != "support" { + t.Fatalf("SessionScope = %#v, want support scope", opts.SessionScope) + } +} + +func TestNormalizeProcessOptions_InfersLegacyChatTypeFromSessionScope(t *testing.T) { + opts := normalizeProcessOptions(processOptions{ + Channel: "telegram", + ChatID: "-100123", + SenderID: "user-1", + UserMessage: "hello", + SessionScope: &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "main", + Channel: "telegram", + Dimensions: []string{"chat"}, + Values: map[string]string{ + "chat": "group:-100123", + }, + }, + }) + + if opts.Dispatch.InboundContext == nil { + t.Fatal("Dispatch.InboundContext is nil") + } + if opts.Dispatch.InboundContext.ChatType != "group" { + t.Fatalf("Dispatch.InboundContext.ChatType = %q, want group", opts.Dispatch.InboundContext.ChatType) + } +} diff --git a/pkg/agent/event_payloads.go b/pkg/agent/event_payloads.go new file mode 100644 index 000000000..18fcbd4a0 --- /dev/null +++ b/pkg/agent/event_payloads.go @@ -0,0 +1,171 @@ +package agent + +import "time" + +// TurnEndStatus describes the terminal state of a turn. +type TurnEndStatus string + +const ( + // TurnEndStatusCompleted indicates the turn finished normally. + TurnEndStatusCompleted TurnEndStatus = "completed" + // TurnEndStatusError indicates the turn ended because of an error. + TurnEndStatusError TurnEndStatus = "error" + // TurnEndStatusAborted indicates the turn was hard-aborted and rolled back. + TurnEndStatusAborted TurnEndStatus = "aborted" +) + +// TurnStartPayload describes the start of a turn. +type TurnStartPayload struct { + UserMessage string + MediaCount int +} + +// TurnEndPayload describes the completion of a turn. +type TurnEndPayload struct { + Status TurnEndStatus + Iterations int + Duration time.Duration + FinalContentLen int +} + +// LLMRequestPayload describes an outbound LLM request. +type LLMRequestPayload struct { + Model string + MessagesCount int + ToolsCount int + MaxTokens int + Temperature float64 +} + +// LLMResponsePayload describes an inbound LLM response. +type LLMResponsePayload struct { + ContentLen int + ToolCalls int + HasReasoning bool +} + +// LLMDeltaPayload describes a streamed LLM delta. +type LLMDeltaPayload struct { + ContentDeltaLen int + ReasoningDeltaLen int +} + +// LLMRetryPayload describes a retry of an LLM request. +type LLMRetryPayload struct { + Attempt int + MaxRetries int + Reason string + Error string + Backoff time.Duration +} + +// ContextCompressReason identifies why emergency compression ran. +type ContextCompressReason string + +const ( + // ContextCompressReasonProactive indicates compression before the first LLM call. + ContextCompressReasonProactive ContextCompressReason = "proactive_budget" + // ContextCompressReasonRetry indicates compression during context-error retry handling. + ContextCompressReasonRetry ContextCompressReason = "llm_retry" + // ContextCompressReasonSummarize indicates post-turn async summarization. + ContextCompressReasonSummarize ContextCompressReason = "summarize" +) + +// ContextCompressPayload describes a forced history compression. +type ContextCompressPayload struct { + Reason ContextCompressReason + DroppedMessages int + RemainingMessages int +} + +// SessionSummarizePayload describes a completed async session summarization. +type SessionSummarizePayload struct { + SummarizedMessages int + KeptMessages int + SummaryLen int + OmittedOversized bool +} + +// ToolExecStartPayload describes a tool execution request. +type ToolExecStartPayload struct { + Tool string + Arguments map[string]any +} + +// ToolExecEndPayload describes the outcome of a tool execution. +type ToolExecEndPayload struct { + Tool string + Duration time.Duration + ForLLMLen int + ForUserLen int + IsError bool + Async bool +} + +// ToolExecSkippedPayload describes a skipped tool call. +type ToolExecSkippedPayload struct { + Tool string + Reason string +} + +// SteeringInjectedPayload describes steering messages appended before the next LLM call. +type SteeringInjectedPayload struct { + Count int + TotalContentLen int +} + +// FollowUpQueuedPayload describes an async follow-up queued back into the inbound bus. +type FollowUpQueuedPayload struct { + SourceTool string + ContentLen int +} + +type InterruptKind string + +const ( + InterruptKindSteering InterruptKind = "steering" + InterruptKindGraceful InterruptKind = "graceful" + InterruptKindHard InterruptKind = "hard_abort" +) + +// InterruptReceivedPayload describes accepted turn-control input. +type InterruptReceivedPayload struct { + Kind InterruptKind + Role string + ContentLen int + QueueDepth int + HintLen int +} + +// SubTurnSpawnPayload describes the creation of a child turn. +type SubTurnSpawnPayload struct { + AgentID string + Label string + ParentTurnID string +} + +// SubTurnEndPayload describes the completion of a child turn. +type SubTurnEndPayload struct { + AgentID string + Status string +} + +// SubTurnResultDeliveredPayload describes delivery of a sub-turn result. +type SubTurnResultDeliveredPayload struct { + TargetChannel string + TargetChatID string + ContentLen int +} + +// SubTurnOrphanPayload describes a sub-turn result that could not be delivered. +type SubTurnOrphanPayload struct { + ParentTurnID string + ChildTurnID string + Reason string +} + +// ErrorPayload describes an execution error inside the agent loop. +type ErrorPayload struct { + Stage string + Message string +} diff --git a/pkg/agent/eventbus_test.go b/pkg/agent/eventbus_test.go new file mode 100644 index 000000000..86d7f4afa --- /dev/null +++ b/pkg/agent/eventbus_test.go @@ -0,0 +1,740 @@ +package agent + +import ( + "context" + "os" + "slices" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/tools" +) + +func TestAgentLoop_PublishesRuntimeEvents(t *testing.T) { + runtimeBus := runtimeevents.NewBus() + al := &AgentLoop{ + runtimeEvents: runtimeBus, + } + defer func() { + if err := runtimeBus.Close(); err != nil { + t.Errorf("runtime bus close failed: %v", err) + } + }() + + runtimeSub, runtimeCh, err := al.RuntimeEvents().OfKind(runtimeevents.KindAgentToolExecStart).SubscribeChan( + context.Background(), + runtimeevents.SubscribeOptions{Name: "runtime", Buffer: 1}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + defer func() { + if err := runtimeSub.Close(); err != nil { + t.Errorf("runtime subscription close failed: %v", err) + } + }() + + al.emitEvent( + runtimeevents.KindAgentToolExecStart, + HookMeta{ + AgentID: "main", + TurnID: "turn-1", + ParentTurnID: "parent-turn", + SessionKey: "session-1", + Iteration: 2, + TracePath: "trace/root", + Source: "pipeline_execute", + turnContext: &TurnContext{ + Inbound: &bus.InboundContext{ + Channel: "cli", + Account: "default", + ChatID: "direct", + ChatType: "direct", + SenderID: "tester", + MessageID: "msg-1", + TopicID: "topic-1", + }, + }, + }, + ToolExecStartPayload{Tool: "mock_custom", Arguments: map[string]any{"task": "ping"}}, + ) + + runtimeEvt := receiveRuntimeEvent(t, runtimeCh) + if runtimeEvt.Kind != runtimeevents.KindAgentToolExecStart { + t.Fatalf("runtime kind = %q, want %q", runtimeEvt.Kind, runtimeevents.KindAgentToolExecStart) + } + if runtimeEvt.Source != (runtimeevents.Source{Component: "agent", Name: "main"}) { + t.Fatalf("runtime source = %+v", runtimeEvt.Source) + } + if runtimeEvt.Scope.AgentID != "main" || + runtimeEvt.Scope.SessionKey != "session-1" || + runtimeEvt.Scope.TurnID != "turn-1" || + runtimeEvt.Scope.Channel != "cli" || + runtimeEvt.Scope.Account != "default" || + runtimeEvt.Scope.ChatID != "direct" || + runtimeEvt.Scope.TopicID != "topic-1" || + runtimeEvt.Scope.ChatType != "direct" || + runtimeEvt.Scope.SenderID != "tester" || + runtimeEvt.Scope.MessageID != "msg-1" { + t.Fatalf("runtime scope = %+v", runtimeEvt.Scope) + } + if runtimeEvt.Correlation.TraceID != "trace/root" || + runtimeEvt.Correlation.ParentTurnID != "parent-turn" { + t.Fatalf("runtime correlation = %+v", runtimeEvt.Correlation) + } + if runtimeEvt.Attrs["agent_source"] != "pipeline_execute" || runtimeEvt.Attrs["iteration"] != 2 { + t.Fatalf("runtime attrs = %+v", runtimeEvt.Attrs) + } + payload, ok := runtimeEvt.Payload.(ToolExecStartPayload) + if !ok { + t.Fatalf("runtime payload = %T, want ToolExecStartPayload", runtimeEvt.Payload) + } + if payload.Tool != "mock_custom" { + t.Fatalf("runtime payload tool = %q, want mock_custom", payload.Tool) + } +} + +type scriptedToolProvider struct { + calls int +} + +func (m *scriptedToolProvider) Chat( + ctx context.Context, + messages []providers.Message, + toolDefs []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{ + { + ID: "call-1", + Name: "mock_custom", + Arguments: map[string]any{"task": "ping"}, + }, + }, + }, nil + } + + return &providers.LLMResponse{ + Content: "done", + }, nil +} + +func (m *scriptedToolProvider) GetDefaultModel() string { + return "scripted-tool-model" +} + +func TestAgentLoop_EmitsMinimalTurnEvents(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-eventbus-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &scriptedToolProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + al.RegisterTool(&mockCustomTool{}) + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + expectedKinds := []runtimeevents.Kind{ + runtimeevents.KindAgentTurnStart, + runtimeevents.KindAgentLLMRequest, + runtimeevents.KindAgentLLMResponse, + runtimeevents.KindAgentToolExecStart, + runtimeevents.KindAgentToolExecEnd, + runtimeevents.KindAgentLLMRequest, + runtimeevents.KindAgentLLMResponse, + runtimeevents.KindAgentTurnEnd, + } + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest(t, al, 16, expectedKinds...) + defer closeRuntimeEvents() + + response, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "run tool", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + InboundContext: &bus.InboundContext{ + Channel: "cli", + ChatID: "direct", + ChatType: "direct", + SenderID: "tester", + }, + RouteResult: &routing.ResolvedRoute{ + AgentID: "main", + Channel: "cli", + AccountID: routing.DefaultAccountID, + SessionPolicy: routing.SessionPolicy{ + Dimensions: []string{"sender"}, + }, + MatchedBy: "default", + }, + SessionScope: &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "main", + Channel: "cli", + Account: routing.DefaultAccountID, + Dimensions: []string{"sender"}, + Values: map[string]string{ + "sender": "tester", + }, + }, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + if response != "done" { + t.Fatalf("expected final response 'done', got %q", response) + } + + events := collectRuntimeEventStream(runtimeCh) + if len(events) != 8 { + t.Fatalf("expected 8 events, got %d", len(events)) + } + + kinds := make([]runtimeevents.Kind, 0, len(events)) + for _, evt := range events { + kinds = append(kinds, evt.Kind) + } + + if !slices.Equal(kinds, expectedKinds) { + t.Fatalf("unexpected event sequence: got %v want %v", kinds, expectedKinds) + } + + turnID := events[0].Scope.TurnID + if turnID == "" { + t.Fatal("expected runtime events to include turn id") + } + for i, evt := range events { + if evt.Scope.TurnID != turnID { + t.Fatalf("event %d has mismatched turn id %q, want %q", i, evt.Scope.TurnID, turnID) + } + if evt.Scope.SessionKey != "session-1" { + t.Fatalf("event %d has session key %q, want session-1", i, evt.Scope.SessionKey) + } + if evt.Scope.Channel != "cli" || evt.Scope.ChatID != "direct" || evt.Scope.SenderID != "tester" { + t.Fatalf("event %d scope = %+v", i, evt.Scope) + } + if evt.Scope.AgentID != "main" { + t.Fatalf("event %d has agent id %q, want main", i, evt.Scope.AgentID) + } + } + + startPayload, ok := events[0].Payload.(TurnStartPayload) + if !ok { + t.Fatalf("expected TurnStartPayload, got %T", events[0].Payload) + } + if startPayload.UserMessage != "run tool" { + t.Fatalf("expected user message 'run tool', got %q", startPayload.UserMessage) + } + + toolStartPayload, ok := events[3].Payload.(ToolExecStartPayload) + if !ok { + t.Fatalf("expected ToolExecStartPayload, got %T", events[3].Payload) + } + if toolStartPayload.Tool != "mock_custom" { + t.Fatalf("expected tool name mock_custom, got %q", toolStartPayload.Tool) + } + + toolEndPayload, ok := events[4].Payload.(ToolExecEndPayload) + if !ok { + t.Fatalf("expected ToolExecEndPayload, got %T", events[4].Payload) + } + if toolEndPayload.Tool != "mock_custom" { + t.Fatalf("expected tool end payload for mock_custom, got %q", toolEndPayload.Tool) + } + if toolEndPayload.IsError { + t.Fatal("expected mock_custom tool to succeed") + } + + turnEndPayload, ok := events[len(events)-1].Payload.(TurnEndPayload) + if !ok { + t.Fatalf("expected TurnEndPayload, got %T", events[len(events)-1].Payload) + } + if turnEndPayload.Status != TurnEndStatusCompleted { + t.Fatalf("expected completed turn, got %q", turnEndPayload.Status) + } + if turnEndPayload.Iterations != 2 { + t.Fatalf("expected 2 iterations, got %d", turnEndPayload.Iterations) + } +} + +func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-eventbus-steering-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + tool1ExecCh := make(chan struct{}) + tool1 := &slowTool{name: "tool_one", duration: 50 * time.Millisecond, execCh: tool1ExecCh} + tool2 := &slowTool{name: "tool_two", duration: 50 * time.Millisecond} + + provider := &toolCallProvider{ + toolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Name: "tool_one", + Function: &providers.FunctionCall{ + Name: "tool_one", + Arguments: "{}", + }, + Arguments: map[string]any{}, + }, + { + ID: "call_2", + Type: "function", + Name: "tool_two", + Function: &providers.FunctionCall{ + Name: "tool_two", + Arguments: "{}", + }, + Arguments: map[string]any{}, + }, + }, + finalResp: "steered response", + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + al.RegisterTool(tool1) + al.RegisterTool(tool2) + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentSteeringInjected, + runtimeevents.KindAgentToolExecSkipped, + runtimeevents.KindAgentInterruptReceived, + ) + defer closeRuntimeEvents() + + resultCh := make(chan string, 1) + go func() { + resp, _ := al.ProcessDirectWithChannel(context.Background(), "do something", "test-session", "test", "chat1") + resultCh <- resp + }() + + select { + case <-tool1ExecCh: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for tool_one to start") + } + + if err := al.Steer(providers.Message{Role: "user", Content: "change course"}); err != nil { + t.Fatalf("Steer failed: %v", err) + } + + select { + case resp := <-resultCh: + if resp != "steered response" { + t.Fatalf("expected steered response, got %q", resp) + } + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for steered response") + } + + events := collectRuntimeEventStream(runtimeCh) + steeringEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentSteeringInjected) + if !ok { + t.Fatal("expected steering injected event") + } + steeringPayload, ok := steeringEvt.Payload.(SteeringInjectedPayload) + if !ok { + t.Fatalf("expected SteeringInjectedPayload, got %T", steeringEvt.Payload) + } + if steeringPayload.Count != 1 { + t.Fatalf("expected 1 steering message, got %d", steeringPayload.Count) + } + + skippedEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecSkipped) + if !ok { + t.Fatal("expected skipped tool event") + } + skippedPayload, ok := skippedEvt.Payload.(ToolExecSkippedPayload) + if !ok { + t.Fatalf("expected ToolExecSkippedPayload, got %T", skippedEvt.Payload) + } + if skippedPayload.Tool != "tool_two" { + t.Fatalf("expected skipped tool_two, got %q", skippedPayload.Tool) + } + + interruptEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentInterruptReceived) + if !ok { + t.Fatal("expected interrupt received event") + } + interruptPayload, ok := interruptEvt.Payload.(InterruptReceivedPayload) + if !ok { + t.Fatalf("expected InterruptReceivedPayload, got %T", interruptEvt.Payload) + } + if interruptPayload.Role != "user" { + t.Fatalf("expected interrupt role user, got %q", interruptPayload.Role) + } + if interruptPayload.Kind != InterruptKindSteering { + t.Fatalf("expected steering interrupt kind, got %q", interruptPayload.Kind) + } + if interruptPayload.ContentLen != len("change course") { + t.Fatalf("expected interrupt content len %d, got %d", len("change course"), interruptPayload.ContentLen) + } +} + +func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-eventbus-compress-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + contextErr := stringError("InvalidParameter: Total tokens of image and text exceed max message tokens") + provider := &failFirstMockProvider{ + failures: 1, + failError: contextErr, + successResp: "Recovered from context error", + } + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + defaultAgent.Sessions.SetHistory("session-1", []providers.Message{ + {Role: "user", Content: "Old message 1"}, + {Role: "assistant", Content: "Old response 1"}, + {Role: "user", Content: "Old message 2"}, + {Role: "assistant", Content: "Old response 2"}, + {Role: "user", Content: "Trigger message"}, + }) + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentLLMRetry, + runtimeevents.KindAgentContextCompress, + ) + defer closeRuntimeEvents() + + resp, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "Trigger message", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + if resp != "Recovered from context error" { + t.Fatalf("expected retry success, got %q", resp) + } + + events := collectRuntimeEventStream(runtimeCh) + retryEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentLLMRetry) + if !ok { + t.Fatal("expected llm retry event") + } + retryPayload, ok := retryEvt.Payload.(LLMRetryPayload) + if !ok { + t.Fatalf("expected LLMRetryPayload, got %T", retryEvt.Payload) + } + if retryPayload.Reason != "context_limit" { + t.Fatalf("expected context_limit retry reason, got %q", retryPayload.Reason) + } + if retryPayload.Attempt != 1 { + t.Fatalf("expected retry attempt 1, got %d", retryPayload.Attempt) + } + + compressEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentContextCompress) + if !ok { + t.Fatal("expected context compress event") + } + payload, ok := compressEvt.Payload.(ContextCompressPayload) + if !ok { + t.Fatalf("expected ContextCompressPayload, got %T", compressEvt.Payload) + } + if payload.Reason != ContextCompressReasonRetry { + t.Fatalf("expected retry compress reason, got %q", payload.Reason) + } + if payload.DroppedMessages == 0 { + t.Fatal("expected dropped messages to be recorded") + } +} + +func TestAgentLoop_EmitsSessionSummarizeEvent(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-eventbus-summary-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextWindow: 8000, + SummarizeMessageThreshold: 2, + SummarizeTokenPercent: 75, + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "summary text"}) + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + defaultAgent.Sessions.SetHistory("session-1", []providers.Message{ + {Role: "user", Content: "Question one"}, + {Role: "assistant", Content: "Answer one"}, + {Role: "user", Content: "Question two"}, + {Role: "assistant", Content: "Answer two"}, + {Role: "user", Content: "Question three"}, + {Role: "assistant", Content: "Answer three"}, + }) + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentSessionSummarize, + ) + defer closeRuntimeEvents() + + lcm := &legacyContextManager{al: al} + lcm.summarizeSession(defaultAgent, "session-1") + + events := collectRuntimeEventStream(runtimeCh) + summaryEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentSessionSummarize) + if !ok { + t.Fatal("expected session summarize event") + } + payload, ok := summaryEvt.Payload.(SessionSummarizePayload) + if !ok { + t.Fatalf("expected SessionSummarizePayload, got %T", summaryEvt.Payload) + } + if payload.SummaryLen == 0 { + t.Fatal("expected non-empty summary length") + } +} + +func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-eventbus-followup-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + provider := &toolCallProvider{ + toolCalls: []providers.ToolCall{ + { + ID: "call_async_1", + Type: "function", + Name: "async_followup", + Function: &providers.FunctionCall{ + Name: "async_followup", + Arguments: "{}", + }, + Arguments: map[string]any{}, + }, + }, + finalResp: "async launched", + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + doneCh := make(chan struct{}) + al.RegisterTool(&asyncFollowUpTool{ + name: "async_followup", + followUpText: "background result", + completionSig: doneCh, + }) + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentFollowUpQueued, + ) + defer closeRuntimeEvents() + + resp, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "run async tool", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + if resp != "async launched" { + t.Fatalf("expected final response 'async launched', got %q", resp) + } + + select { + case <-doneCh: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for async tool completion") + } + + followUpEvt := waitForRuntimeEvent(t, runtimeCh, 2*time.Second, func(evt runtimeevents.Event) bool { + return evt.Kind == runtimeevents.KindAgentFollowUpQueued + }) + payload, ok := followUpEvt.Payload.(FollowUpQueuedPayload) + if !ok { + t.Fatalf("expected FollowUpQueuedPayload, got %T", followUpEvt.Payload) + } + if payload.SourceTool != "async_followup" { + t.Fatalf("expected source tool async_followup, got %q", payload.SourceTool) + } + if payload.ContentLen != len("background result") { + t.Fatalf("expected content len %d, got %d", len("background result"), payload.ContentLen) + } + if followUpEvt.Scope.SessionKey != "session-1" { + t.Fatalf("expected session key session-1, got %q", followUpEvt.Scope.SessionKey) + } + if followUpEvt.Scope.TurnID == "" { + t.Fatal("expected follow-up event to include turn id") + } +} + +func receiveRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event { + t.Helper() + + select { + case evt, ok := <-ch: + if !ok { + t.Fatal("runtime event stream closed before expected event arrived") + } + return evt + case <-time.After(time.Second): + t.Fatal("timed out waiting for runtime event") + return runtimeevents.Event{} + } +} + +type stringError string + +func (e stringError) Error() string { + return string(e) +} + +type asyncFollowUpTool struct { + name string + followUpText string + completionSig chan struct{} +} + +func (t *asyncFollowUpTool) Name() string { + return t.name +} + +func (t *asyncFollowUpTool) Description() string { + return "async follow-up tool for testing" +} + +func (t *asyncFollowUpTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + } +} + +func (t *asyncFollowUpTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + return tools.AsyncResult("async follow-up scheduled") +} + +func (t *asyncFollowUpTool) ExecuteAsync( + ctx context.Context, + args map[string]any, + cb tools.AsyncCallback, +) *tools.ToolResult { + go func() { + cb(ctx, &tools.ToolResult{ForLLM: t.followUpText}) + if t.completionSig != nil { + close(t.completionSig) + } + }() + return tools.AsyncResult("async follow-up scheduled") +} + +var ( + _ tools.Tool = (*mockCustomTool)(nil) + _ tools.AsyncExecutor = (*asyncFollowUpTool)(nil) +) diff --git a/pkg/agent/events.go b/pkg/agent/events.go new file mode 100644 index 000000000..0dd861f43 --- /dev/null +++ b/pkg/agent/events.go @@ -0,0 +1,14 @@ +package agent + +// HookMeta contains correlation fields shared by agent hook requests and +// runtime events emitted from turn processing. +type HookMeta struct { + AgentID string + TurnID string + ParentTurnID string + SessionKey string + Iteration int + TracePath string + Source string + turnContext *TurnContext +} diff --git a/pkg/agent/events_runtime.go b/pkg/agent/events_runtime.go new file mode 100644 index 000000000..2284665e6 --- /dev/null +++ b/pkg/agent/events_runtime.go @@ -0,0 +1,88 @@ +package agent + +import runtimeevents "github.com/sipeed/picoclaw/pkg/events" + +func (al *AgentLoop) publishRuntimeEvent(evt runtimeevents.Event) { + if al == nil || al.runtimeEvents == nil { + return + } + + al.runtimeEvents.PublishNonBlocking(evt) +} + +func runtimeScopeFromHookMeta(meta HookMeta, eventCtx *TurnContext) runtimeevents.Scope { + scope := runtimeevents.Scope{ + AgentID: meta.AgentID, + SessionKey: meta.SessionKey, + TurnID: meta.TurnID, + } + + if eventCtx == nil || eventCtx.Inbound == nil { + return scope + } + + inbound := eventCtx.Inbound + scope.Channel = inbound.Channel + scope.Account = inbound.Account + scope.ChatID = inbound.ChatID + scope.TopicID = inbound.TopicID + scope.SpaceID = inbound.SpaceID + scope.SpaceType = inbound.SpaceType + scope.ChatType = inbound.ChatType + scope.SenderID = inbound.SenderID + scope.MessageID = inbound.MessageID + return scope +} + +func runtimeCorrelationFromHookMeta(meta HookMeta) runtimeevents.Correlation { + return runtimeevents.Correlation{ + TraceID: meta.TracePath, + ParentTurnID: meta.ParentTurnID, + } +} + +func runtimeSeverityForAgentEvent(kind runtimeevents.Kind, payload any) runtimeevents.Severity { + switch kind { + case runtimeevents.KindAgentError, runtimeevents.KindAgentSubTurnOrphan: + return runtimeevents.SeverityError + case runtimeevents.KindAgentLLMRetry, + runtimeevents.KindAgentContextCompress, + runtimeevents.KindAgentToolExecSkipped: + return runtimeevents.SeverityWarn + case runtimeevents.KindAgentTurnEnd: + payload, ok := payload.(TurnEndPayload) + if !ok { + return runtimeevents.SeverityInfo + } + switch payload.Status { + case TurnEndStatusError: + return runtimeevents.SeverityError + case TurnEndStatusAborted: + return runtimeevents.SeverityWarn + default: + return runtimeevents.SeverityInfo + } + case runtimeevents.KindAgentToolExecEnd: + payload, ok := payload.(ToolExecEndPayload) + if ok && payload.IsError { + return runtimeevents.SeverityWarn + } + return runtimeevents.SeverityInfo + default: + return runtimeevents.SeverityInfo + } +} + +func runtimeAttrsFromHookMeta(meta HookMeta) map[string]any { + attrs := make(map[string]any, 2) + if meta.Source != "" { + attrs["agent_source"] = meta.Source + } + if meta.Iteration != 0 { + attrs["iteration"] = meta.Iteration + } + if len(attrs) == 0 { + return nil + } + return attrs +} diff --git a/pkg/agent/hook_mount.go b/pkg/agent/hook_mount.go new file mode 100644 index 000000000..c518feee8 --- /dev/null +++ b/pkg/agent/hook_mount.go @@ -0,0 +1,339 @@ +package agent + +import ( + "context" + "fmt" + "sort" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +type hookRuntime struct { + initOnce sync.Once + mu sync.Mutex + initErr error + mounted []string +} + +func (r *hookRuntime) setInitErr(err error) { + r.mu.Lock() + r.initErr = err + r.mu.Unlock() +} + +func (r *hookRuntime) getInitErr() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.initErr +} + +func (r *hookRuntime) setMounted(names []string) { + r.mu.Lock() + r.mounted = append([]string(nil), names...) + r.mu.Unlock() +} + +func (r *hookRuntime) reset(al *AgentLoop) { + r.mu.Lock() + names := append([]string(nil), r.mounted...) + r.mounted = nil + r.initErr = nil + r.initOnce = sync.Once{} + r.mu.Unlock() + + for _, name := range names { + al.UnmountHook(name) + } +} + +// BuiltinHookFactory constructs an in-process hook from config. +type BuiltinHookFactory func(ctx context.Context, spec config.BuiltinHookConfig) (any, error) + +var ( + builtinHookRegistryMu sync.RWMutex + builtinHookRegistry = map[string]BuiltinHookFactory{} +) + +// RegisterBuiltinHook registers a named in-process hook factory for config-driven mounting. +func RegisterBuiltinHook(name string, factory BuiltinHookFactory) error { + if name == "" { + return fmt.Errorf("builtin hook name is required") + } + if factory == nil { + return fmt.Errorf("builtin hook %q factory is nil", name) + } + + builtinHookRegistryMu.Lock() + defer builtinHookRegistryMu.Unlock() + + if _, exists := builtinHookRegistry[name]; exists { + return fmt.Errorf("builtin hook %q is already registered", name) + } + builtinHookRegistry[name] = factory + return nil +} + +func unregisterBuiltinHook(name string) { + if name == "" { + return + } + builtinHookRegistryMu.Lock() + delete(builtinHookRegistry, name) + builtinHookRegistryMu.Unlock() +} + +func lookupBuiltinHook(name string) (BuiltinHookFactory, bool) { + builtinHookRegistryMu.RLock() + defer builtinHookRegistryMu.RUnlock() + + factory, ok := builtinHookRegistry[name] + return factory, ok +} + +func configureHookManagerFromConfig(hm *HookManager, cfg *config.Config) { + if hm == nil || cfg == nil { + return + } + hm.ConfigureTimeouts( + hookTimeoutFromMS(cfg.Hooks.Defaults.ObserverTimeoutMS), + hookTimeoutFromMS(cfg.Hooks.Defaults.InterceptorTimeoutMS), + hookTimeoutFromMS(cfg.Hooks.Defaults.ApprovalTimeoutMS), + ) +} + +func hookTimeoutFromMS(ms int) time.Duration { + if ms <= 0 { + return 0 + } + return time.Duration(ms) * time.Millisecond +} + +func (al *AgentLoop) ensureHooksInitialized(ctx context.Context) error { + if al == nil || al.cfg == nil || al.hooks == nil { + return nil + } + + al.hookRuntime.initOnce.Do(func() { + al.hookRuntime.setInitErr(al.loadConfiguredHooks(ctx)) + }) + + return al.hookRuntime.getInitErr() +} + +func (al *AgentLoop) loadConfiguredHooks(ctx context.Context) (err error) { + if al == nil || al.cfg == nil || !al.cfg.Hooks.Enabled { + return nil + } + + mounted := make([]string, 0) + defer func() { + if err != nil { + for _, name := range mounted { + al.UnmountHook(name) + } + return + } + al.hookRuntime.setMounted(mounted) + }() + + builtinNames := enabledBuiltinHookNames(al.cfg.Hooks.Builtins) + for _, name := range builtinNames { + spec := al.cfg.Hooks.Builtins[name] + factory, ok := lookupBuiltinHook(name) + if !ok { + return fmt.Errorf("builtin hook %q is not registered", name) + } + + hook, factoryErr := factory(ctx, spec) + if factoryErr != nil { + return fmt.Errorf("build builtin hook %q: %w", name, factoryErr) + } + if err := al.MountHook(HookRegistration{ + Name: name, + Priority: spec.Priority, + Source: HookSourceInProcess, + Hook: hook, + }); err != nil { + return fmt.Errorf("mount builtin hook %q: %w", name, err) + } + mounted = append(mounted, name) + } + + processNames := enabledProcessHookNames(al.cfg.Hooks.Processes) + for _, name := range processNames { + spec := al.cfg.Hooks.Processes[name] + opts, buildErr := processHookOptionsFromConfig(spec) + if buildErr != nil { + return fmt.Errorf("configure process hook %q: %w", name, buildErr) + } + + processHook, buildErr := NewProcessHook(ctx, name, opts) + if buildErr != nil { + return fmt.Errorf("start process hook %q: %w", name, buildErr) + } + if err := al.MountHook(HookRegistration{ + Name: name, + Priority: spec.Priority, + Source: HookSourceProcess, + Hook: processHook, + }); err != nil { + _ = processHook.Close() + return fmt.Errorf("mount process hook %q: %w", name, err) + } + mounted = append(mounted, name) + } + + return nil +} + +func enabledBuiltinHookNames(specs map[string]config.BuiltinHookConfig) []string { + if len(specs) == 0 { + return nil + } + + names := make([]string, 0, len(specs)) + for name, spec := range specs { + if spec.Enabled { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + +func enabledProcessHookNames(specs map[string]config.ProcessHookConfig) []string { + if len(specs) == 0 { + return nil + } + + names := make([]string, 0, len(specs)) + for name, spec := range specs { + if spec.Enabled { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + +func processHookOptionsFromConfig(spec config.ProcessHookConfig) (ProcessHookOptions, error) { + transport := spec.Transport + if transport == "" { + transport = "stdio" + } + if transport != "stdio" { + return ProcessHookOptions{}, fmt.Errorf("unsupported transport %q", transport) + } + if len(spec.Command) == 0 { + return ProcessHookOptions{}, fmt.Errorf("command is required") + } + + opts := ProcessHookOptions{ + Command: append([]string(nil), spec.Command...), + Dir: spec.Dir, + Env: processHookEnvFromMap(spec.Env), + } + + observeKinds, observeEnabled, err := processHookObserveKindsFromConfig(spec.Observe) + if err != nil { + return ProcessHookOptions{}, err + } + opts.Observe = observeEnabled + opts.ObserveKinds = observeKinds + + for _, intercept := range spec.Intercept { + switch intercept { + case "before_llm", "after_llm": + opts.InterceptLLM = true + case "before_tool", "after_tool": + opts.InterceptTool = true + case "approve_tool": + opts.ApproveTool = true + case "": + continue + default: + return ProcessHookOptions{}, fmt.Errorf("unsupported intercept %q", intercept) + } + } + + if !opts.Observe && !opts.InterceptLLM && !opts.InterceptTool && !opts.ApproveTool { + return ProcessHookOptions{}, fmt.Errorf("no hook modes enabled") + } + + return opts, nil +} + +func processHookEnvFromMap(envMap map[string]string) []string { + if len(envMap) == 0 { + return nil + } + + keys := make([]string, 0, len(envMap)) + for key := range envMap { + keys = append(keys, key) + } + sort.Strings(keys) + + env := make([]string, 0, len(keys)) + for _, key := range keys { + env = append(env, key+"="+envMap[key]) + } + return env +} + +func processHookObserveKindsFromConfig(observe []string) ([]string, bool, error) { + if len(observe) == 0 { + return nil, false, nil + } + + validKinds := validHookEventKinds() + normalized := make([]string, 0, len(observe)) + for _, kind := range observe { + switch kind { + case "", "*", "all": + return nil, true, nil + default: + normalizedKind, ok := validKinds[kind] + if !ok { + return nil, false, fmt.Errorf("unsupported observe event %q", kind) + } + normalized = append(normalized, normalizedKind) + } + } + + if len(normalized) == 0 { + return nil, false, nil + } + return normalized, true, nil +} + +func validHookEventKinds() map[string]string { + runtimeKinds := runtimeevents.KnownKinds() + kinds := make(map[string]string, len(runtimeKinds)*2) + for _, kind := range runtimeKinds { + kinds[kind.String()] = kind.String() + } + kinds["turn_start"] = runtimeevents.KindAgentTurnStart.String() + kinds["turn_end"] = runtimeevents.KindAgentTurnEnd.String() + kinds["llm_request"] = runtimeevents.KindAgentLLMRequest.String() + kinds["llm_delta"] = runtimeevents.KindAgentLLMDelta.String() + kinds["llm_response"] = runtimeevents.KindAgentLLMResponse.String() + kinds["llm_retry"] = runtimeevents.KindAgentLLMRetry.String() + kinds["context_compress"] = runtimeevents.KindAgentContextCompress.String() + kinds["session_summarize"] = runtimeevents.KindAgentSessionSummarize.String() + kinds["tool_exec_start"] = runtimeevents.KindAgentToolExecStart.String() + kinds["tool_exec_end"] = runtimeevents.KindAgentToolExecEnd.String() + kinds["tool_exec_skipped"] = runtimeevents.KindAgentToolExecSkipped.String() + kinds["steering_injected"] = runtimeevents.KindAgentSteeringInjected.String() + kinds["follow_up_queued"] = runtimeevents.KindAgentFollowUpQueued.String() + kinds["interrupt_received"] = runtimeevents.KindAgentInterruptReceived.String() + kinds["subturn_spawn"] = runtimeevents.KindAgentSubTurnSpawn.String() + kinds["subturn_end"] = runtimeevents.KindAgentSubTurnEnd.String() + kinds["subturn_result_delivered"] = runtimeevents.KindAgentSubTurnResultDelivered.String() + kinds["subturn_orphan"] = runtimeevents.KindAgentSubTurnOrphan.String() + kinds["error"] = runtimeevents.KindAgentError.String() + return kinds +} diff --git a/pkg/agent/hook_mount_test.go b/pkg/agent/hook_mount_test.go new file mode 100644 index 000000000..5cd64af7b --- /dev/null +++ b/pkg/agent/hook_mount_test.go @@ -0,0 +1,200 @@ +package agent + +import ( + "context" + "encoding/json" + "path/filepath" + "slices" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +type builtinAutoHookConfig struct { + Model string `json:"model"` + Suffix string `json:"suffix"` +} + +type builtinAutoHook struct { + model string + suffix string +} + +func (h *builtinAutoHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + next := req.Clone() + next.Model = h.model + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h *builtinAutoHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + next := resp.Clone() + if next.Response != nil { + next.Response.Content += h.suffix + } + return next, HookDecision{Action: HookActionModify}, nil +} + +func newConfiguredHookLoop(t *testing.T, provider *llmHookTestProvider, hooks config.HooksConfig) *AgentLoop { + t.Helper() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Hooks: hooks, + } + + return NewAgentLoop(cfg, bus.NewMessageBus(), provider) +} + +func TestAgentLoop_ProcessDirectWithChannel_AutoMountsBuiltinHook(t *testing.T) { + const hookName = "test-auto-builtin-hook" + + if err := RegisterBuiltinHook(hookName, func( + ctx context.Context, + spec config.BuiltinHookConfig, + ) (any, error) { + var hookCfg builtinAutoHookConfig + if len(spec.Config) > 0 { + if err := json.Unmarshal(spec.Config, &hookCfg); err != nil { + return nil, err + } + } + return &builtinAutoHook{ + model: hookCfg.Model, + suffix: hookCfg.Suffix, + }, nil + }); err != nil { + t.Fatalf("RegisterBuiltinHook failed: %v", err) + } + t.Cleanup(func() { + unregisterBuiltinHook(hookName) + }) + + rawCfg, err := json.Marshal(builtinAutoHookConfig{ + Model: "builtin-model", + Suffix: "|builtin", + }) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + + provider := &llmHookTestProvider{} + al := newConfiguredHookLoop(t, provider, config.HooksConfig{ + Enabled: true, + Builtins: map[string]config.BuiltinHookConfig{ + hookName: { + Enabled: true, + Config: rawCfg, + }, + }, + }) + defer al.Close() + + resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + if resp != "provider content|builtin" { + t.Fatalf("expected builtin-hooked content, got %q", resp) + } + + provider.mu.Lock() + lastModel := provider.lastModel + provider.mu.Unlock() + if lastModel != "builtin-model" { + t.Fatalf("expected builtin model, got %q", lastModel) + } +} + +func TestAgentLoop_ProcessDirectWithChannel_AutoMountsProcessHook(t *testing.T) { + provider := &llmHookTestProvider{} + eventLog := filepath.Join(t.TempDir(), "events.log") + + al := newConfiguredHookLoop(t, provider, config.HooksConfig{ + Enabled: true, + Processes: map[string]config.ProcessHookConfig{ + "ipc-auto": { + Enabled: true, + Command: processHookHelperCommand(), + Env: map[string]string{ + "PICOCLAW_HOOK_HELPER": "1", + "PICOCLAW_HOOK_MODE": "rewrite", + "PICOCLAW_HOOK_EVENT_LOG": eventLog, + }, + Observe: []string{"turn_end"}, + Intercept: []string{"before_llm", "after_llm"}, + }, + }, + }) + defer al.Close() + + resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + if resp != "provider content|ipc" { + t.Fatalf("expected process-hooked content, got %q", resp) + } + + provider.mu.Lock() + lastModel := provider.lastModel + provider.mu.Unlock() + if lastModel != "process-model" { + t.Fatalf("expected process model, got %q", lastModel) + } + + waitForFileContains(t, eventLog, "agent.turn.end") +} + +func TestProcessHookObserveKindsFromConfigAcceptsRuntimeNames(t *testing.T) { + kinds, enabled, err := processHookObserveKindsFromConfig([]string{ + "tool_exec_start", + "agent.tool.exec_end", + "gateway.ready", + "mcp.server.failed", + }) + if err != nil { + t.Fatalf("processHookObserveKindsFromConfig failed: %v", err) + } + if !enabled { + t.Fatal("expected observe to be enabled") + } + + want := []string{"agent.tool.exec_start", "agent.tool.exec_end", "gateway.ready", "mcp.server.failed"} + if !slices.Equal(kinds, want) { + t.Fatalf("observe kinds = %v, want %v", kinds, want) + } +} + +func TestAgentLoop_ProcessDirectWithChannel_InvalidConfiguredHookFails(t *testing.T) { + provider := &llmHookTestProvider{} + al := newConfiguredHookLoop(t, provider, config.HooksConfig{ + Enabled: true, + Processes: map[string]config.ProcessHookConfig{ + "bad-hook": { + Enabled: true, + Command: processHookHelperCommand(), + Intercept: []string{"not_supported"}, + }, + }, + }) + defer al.Close() + + _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct") + if err == nil { + t.Fatal("expected invalid configured hook error") + } +} diff --git a/pkg/agent/hook_process.go b/pkg/agent/hook_process.go new file mode 100644 index 000000000..ce8e932d2 --- /dev/null +++ b/pkg/agent/hook_process.go @@ -0,0 +1,521 @@ +package agent + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "sync" + "sync/atomic" + "time" + + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/isolation" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/tools" +) + +const ( + processHookJSONRPCVersion = "2.0" + processHookReadBufferSize = 1024 * 1024 + processHookCloseTimeout = 2 * time.Second +) + +type ProcessHookOptions struct { + Command []string + Dir string + Env []string + Observe bool + ObserveKinds []string + InterceptLLM bool + InterceptTool bool + ApproveTool bool +} + +type ProcessHook struct { + name string + opts ProcessHookOptions + + cmd *exec.Cmd + stdin io.WriteCloser + observeKinds map[string]struct{} + + writeMu sync.Mutex + + pendingMu sync.Mutex + pending map[uint64]chan processHookRPCMessage + nextID atomic.Uint64 + + closed atomic.Bool + done chan struct{} + closeErr error + closeMu sync.Mutex + closeOnce sync.Once +} + +type processHookRPCMessage struct { + JSONRPC string `json:"jsonrpc,omitempty"` + ID uint64 `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *processHookRPCError `json:"error,omitempty"` +} + +type processHookRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type processHookHelloParams struct { + Name string `json:"name"` + Version int `json:"version"` + Modes []string `json:"modes,omitempty"` +} + +type processHookDecisionResponse struct { + Action HookAction `json:"action"` + Reason string `json:"reason,omitempty"` +} + +type processHookBeforeLLMResponse struct { + processHookDecisionResponse + Request *LLMHookRequest `json:"request,omitempty"` +} + +type processHookAfterLLMResponse struct { + processHookDecisionResponse + Response *LLMHookResponse `json:"response,omitempty"` +} + +type processHookBeforeToolResponse struct { + processHookDecisionResponse + Call *ToolCallHookRequest `json:"call,omitempty"` + Result *tools.ToolResult `json:"result,omitempty"` // Result returned directly by hook (for respond action) +} + +type processHookAfterToolResponse struct { + processHookDecisionResponse + Result *ToolResultHookResponse `json:"result,omitempty"` +} + +func NewProcessHook(ctx context.Context, name string, opts ProcessHookOptions) (*ProcessHook, error) { + if len(opts.Command) == 0 { + return nil, fmt.Errorf("process hook command is required") + } + + cmd := exec.Command(opts.Command[0], opts.Command[1:]...) + cmd.Dir = opts.Dir + if len(opts.Env) > 0 { + cmd.Env = append(os.Environ(), opts.Env...) + } + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, fmt.Errorf("create process hook stdin: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("create process hook stdout: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("create process hook stderr: %w", err) + } + // Route hook subprocess startup through the shared isolation entry point so + // process hooks inherit the same isolation behavior as other child processes. + if err := isolation.Start(cmd); err != nil { + return nil, fmt.Errorf("start process hook: %w", err) + } + + ph := &ProcessHook{ + name: name, + opts: opts, + cmd: cmd, + stdin: stdin, + observeKinds: newProcessHookObserveKinds(opts.ObserveKinds), + pending: make(map[uint64]chan processHookRPCMessage), + done: make(chan struct{}), + } + + go ph.readLoop(stdout) + go ph.readStderr(stderr) + go ph.waitLoop() + + helloCtx := ctx + if helloCtx == nil { + var cancel context.CancelFunc + helloCtx, cancel = context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + } + if err := ph.hello(helloCtx); err != nil { + _ = ph.Close() + return nil, err + } + + return ph, nil +} + +func (ph *ProcessHook) Close() error { + if ph == nil { + return nil + } + + ph.closeOnce.Do(func() { + ph.closed.Store(true) + if ph.stdin != nil { + _ = ph.stdin.Close() + } + + select { + case <-ph.done: + case <-time.After(processHookCloseTimeout): + if ph.cmd != nil && ph.cmd.Process != nil { + _ = ph.cmd.Process.Kill() + } + <-ph.done + } + }) + + ph.closeMu.Lock() + defer ph.closeMu.Unlock() + return ph.closeErr +} + +func (ph *ProcessHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error { + if ph == nil || !ph.opts.Observe { + return nil + } + if len(ph.observeKinds) > 0 { + if _, ok := ph.observeKinds[evt.Kind.String()]; !ok { + return nil + } + } + return ph.notify(ctx, "hook.runtime_event", evt) +} + +func (ph *ProcessHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + if ph == nil || !ph.opts.InterceptLLM { + return req, HookDecision{Action: HookActionContinue}, nil + } + + var resp processHookBeforeLLMResponse + if err := ph.call(ctx, "hook.before_llm", req, &resp); err != nil { + return nil, HookDecision{}, err + } + if resp.Request == nil { + resp.Request = req + } + return resp.Request, HookDecision{Action: resp.Action, Reason: resp.Reason}, nil +} + +func (ph *ProcessHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + if ph == nil || !ph.opts.InterceptLLM { + return resp, HookDecision{Action: HookActionContinue}, nil + } + + var result processHookAfterLLMResponse + if err := ph.call(ctx, "hook.after_llm", resp, &result); err != nil { + return nil, HookDecision{}, err + } + if result.Response == nil { + result.Response = resp + } + return result.Response, HookDecision{Action: result.Action, Reason: result.Reason}, nil +} + +func (ph *ProcessHook) BeforeTool( + ctx context.Context, + call *ToolCallHookRequest, +) (*ToolCallHookRequest, HookDecision, error) { + if ph == nil || !ph.opts.InterceptTool { + return call, HookDecision{Action: HookActionContinue}, nil + } + + var resp processHookBeforeToolResponse + if err := ph.call(ctx, "hook.before_tool", call, &resp); err != nil { + return nil, HookDecision{}, err + } + if resp.Call == nil { + resp.Call = call + } + // If hook returned a Result, carry it in ToolCallHookRequest + if resp.Result != nil { + resp.Call.HookResult = resp.Result + } + return resp.Call, HookDecision{Action: resp.Action, Reason: resp.Reason}, nil +} + +func (ph *ProcessHook) AfterTool( + ctx context.Context, + result *ToolResultHookResponse, +) (*ToolResultHookResponse, HookDecision, error) { + if ph == nil || !ph.opts.InterceptTool { + return result, HookDecision{Action: HookActionContinue}, nil + } + + var resp processHookAfterToolResponse + if err := ph.call(ctx, "hook.after_tool", result, &resp); err != nil { + return nil, HookDecision{}, err + } + if resp.Result == nil { + resp.Result = result + } + return resp.Result, HookDecision{Action: resp.Action, Reason: resp.Reason}, nil +} + +func (ph *ProcessHook) ApproveTool(ctx context.Context, req *ToolApprovalRequest) (ApprovalDecision, error) { + if ph == nil || !ph.opts.ApproveTool { + return ApprovalDecision{Approved: true}, nil + } + + var resp ApprovalDecision + if err := ph.call(ctx, "hook.approve_tool", req, &resp); err != nil { + return ApprovalDecision{}, err + } + return resp, nil +} + +func (ph *ProcessHook) hello(ctx context.Context) error { + modes := make([]string, 0, 4) + if ph.opts.Observe { + modes = append(modes, "observe") + } + if ph.opts.InterceptLLM { + modes = append(modes, "llm") + } + if ph.opts.InterceptTool { + modes = append(modes, "tool") + } + if ph.opts.ApproveTool { + modes = append(modes, "approve") + } + + var result map[string]any + return ph.call(ctx, "hook.hello", processHookHelloParams{ + Name: ph.name, + Version: 1, + Modes: modes, + }, &result) +} + +func (ph *ProcessHook) notify(ctx context.Context, method string, params any) error { + msg := processHookRPCMessage{ + JSONRPC: processHookJSONRPCVersion, + Method: method, + } + if params != nil { + body, err := json.Marshal(params) + if err != nil { + return err + } + msg.Params = body + } + return ph.send(ctx, msg) +} + +func (ph *ProcessHook) call(ctx context.Context, method string, params any, out any) error { + if ph.closed.Load() { + return fmt.Errorf("process hook %q is closed", ph.name) + } + + id := ph.nextID.Add(1) + respCh := make(chan processHookRPCMessage, 1) + ph.pendingMu.Lock() + ph.pending[id] = respCh + ph.pendingMu.Unlock() + + msg := processHookRPCMessage{ + JSONRPC: processHookJSONRPCVersion, + ID: id, + Method: method, + } + if params != nil { + body, err := json.Marshal(params) + if err != nil { + ph.removePending(id) + return err + } + msg.Params = body + } + + if err := ph.send(ctx, msg); err != nil { + ph.removePending(id) + return err + } + + select { + case resp, ok := <-respCh: + if !ok { + return fmt.Errorf("process hook %q closed while waiting for %s", ph.name, method) + } + if resp.Error != nil { + return fmt.Errorf("process hook %q %s failed: %s", ph.name, method, resp.Error.Message) + } + if out != nil && len(resp.Result) > 0 { + if err := json.Unmarshal(resp.Result, out); err != nil { + return fmt.Errorf("decode process hook %q %s result: %w", ph.name, method, err) + } + } + return nil + case <-ctx.Done(): + ph.removePending(id) + return ctx.Err() + } +} + +func (ph *ProcessHook) send(ctx context.Context, msg processHookRPCMessage) error { + body, err := json.Marshal(msg) + if err != nil { + return err + } + body = append(body, '\n') + + ph.writeMu.Lock() + defer ph.writeMu.Unlock() + + if ph.closed.Load() { + return fmt.Errorf("process hook %q is closed", ph.name) + } + + done := make(chan error, 1) + go func() { + _, writeErr := ph.stdin.Write(body) + done <- writeErr + }() + + select { + case err := <-done: + if err != nil { + return fmt.Errorf("write process hook %q message: %w", ph.name, err) + } + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (ph *ProcessHook) readLoop(stdout io.Reader) { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), processHookReadBufferSize) + + for scanner.Scan() { + var msg processHookRPCMessage + if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { + logger.WarnCF("hooks", "Failed to decode process hook message", map[string]any{ + "hook": ph.name, + "error": err.Error(), + }) + continue + } + if msg.ID == 0 { + continue + } + ph.pendingMu.Lock() + respCh, ok := ph.pending[msg.ID] + if ok { + delete(ph.pending, msg.ID) + } + ph.pendingMu.Unlock() + if ok { + respCh <- msg + close(respCh) + } + } +} + +func (ph *ProcessHook) readStderr(stderr io.Reader) { + scanner := bufio.NewScanner(stderr) + scanner.Buffer(make([]byte, 0, 16*1024), processHookReadBufferSize) + for scanner.Scan() { + logger.WarnCF("hooks", "Process hook stderr", map[string]any{ + "hook": ph.name, + "stderr": scanner.Text(), + }) + } +} + +func (ph *ProcessHook) waitLoop() { + err := ph.cmd.Wait() + ph.closeMu.Lock() + ph.closeErr = err + ph.closeMu.Unlock() + ph.failPending(err) + close(ph.done) +} + +func (ph *ProcessHook) failPending(err error) { + ph.pendingMu.Lock() + defer ph.pendingMu.Unlock() + + msg := processHookRPCMessage{ + Error: &processHookRPCError{ + Code: -32000, + Message: "process exited", + }, + } + if err != nil { + msg.Error.Message = err.Error() + } + + for id, ch := range ph.pending { + delete(ph.pending, id) + ch <- msg + close(ch) + } +} + +func (ph *ProcessHook) removePending(id uint64) { + ph.pendingMu.Lock() + defer ph.pendingMu.Unlock() + + if ch, ok := ph.pending[id]; ok { + delete(ph.pending, id) + close(ch) + } +} + +func (al *AgentLoop) MountProcessHook(ctx context.Context, name string, opts ProcessHookOptions) error { + if al == nil { + return fmt.Errorf("agent loop is nil") + } + processHook, err := NewProcessHook(ctx, name, opts) + if err != nil { + return err + } + if err := al.MountHook(HookRegistration{ + Name: name, + Source: HookSourceProcess, + Hook: processHook, + }); err != nil { + _ = processHook.Close() + return err + } + return nil +} + +func newProcessHookObserveKinds(kinds []string) map[string]struct{} { + if len(kinds) == 0 { + return nil + } + + normalized := make(map[string]struct{}, len(kinds)) + for _, kind := range kinds { + if kind == "" { + continue + } + normalized[kind] = struct{}{} + } + if len(normalized) == 0 { + return nil + } + return normalized +} diff --git a/pkg/agent/hook_process_test.go b/pkg/agent/hook_process_test.go new file mode 100644 index 000000000..0fd1ec38d --- /dev/null +++ b/pkg/agent/hook_process_test.go @@ -0,0 +1,470 @@ +package agent + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/isolation" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestProcessHook_HelperProcess(t *testing.T) { + if os.Getenv("PICOCLAW_HOOK_HELPER") != "1" { + return + } + if err := runProcessHookHelper(); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + os.Exit(0) +} + +func TestAgentLoop_MountProcessHook_LLMAndObserver(t *testing.T) { + provider := &llmHookTestProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + eventLog := filepath.Join(t.TempDir(), "events.log") + if err := al.MountProcessHook(context.Background(), "ipc-llm", ProcessHookOptions{ + Command: processHookHelperCommand(), + Env: processHookHelperEnv("rewrite", eventLog), + Observe: true, + InterceptLLM: true, + }); err != nil { + t.Fatalf("MountProcessHook failed: %v", err) + } + + resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "hello", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + if resp != "provider content|ipc" { + t.Fatalf("expected process-hooked llm content, got %q", resp) + } + + provider.mu.Lock() + lastModel := provider.lastModel + provider.mu.Unlock() + if lastModel != "process-model" { + t.Fatalf("expected process model, got %q", lastModel) + } + + waitForFileContains(t, eventLog, "agent.turn.end") +} + +func TestAgentLoop_MountProcessHook_ToolRewrite(t *testing.T) { + provider := &toolHookProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + al.RegisterTool(&echoTextTool{}) + if err := al.MountProcessHook(context.Background(), "ipc-tool", ProcessHookOptions{ + Command: processHookHelperCommand(), + Env: processHookHelperEnv("rewrite", ""), + InterceptTool: true, + }); err != nil { + t.Fatalf("MountProcessHook failed: %v", err) + } + + resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "run tool", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + if resp != "ipc:ipc" { + t.Fatalf("expected rewritten process-hook tool result, got %q", resp) + } +} + +type blockedToolProvider struct { + calls int +} + +func (p *blockedToolProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.calls++ + if p.calls == 1 { + return &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{ + { + ID: "call-1", + Name: "blocked_tool", + Arguments: map[string]any{}, + }, + }, + }, nil + } + + return &providers.LLMResponse{ + Content: messages[len(messages)-1].Content, + }, nil +} + +func (p *blockedToolProvider) GetDefaultModel() string { + return "blocked-tool-provider" +} + +func TestAgentLoop_MountProcessHook_ApprovalDeny(t *testing.T) { + provider := &blockedToolProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + if err := al.MountProcessHook(context.Background(), "ipc-approval", ProcessHookOptions{ + Command: processHookHelperCommand(), + Env: processHookHelperEnv("deny", ""), + ApproveTool: true, + }); err != nil { + t.Fatalf("MountProcessHook failed: %v", err) + } + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentToolExecSkipped, + ) + defer closeRuntimeEvents() + + resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "run blocked tool", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + expected := "Tool execution denied by approval hook: blocked by ipc hook" + if resp != expected { + t.Fatalf("expected %q, got %q", expected, resp) + } + + events := collectRuntimeEventStream(runtimeCh) + skippedEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecSkipped) + if !ok { + t.Fatal("expected tool skipped event") + } + payload, ok := skippedEvt.Payload.(ToolExecSkippedPayload) + if !ok { + t.Fatalf("expected ToolExecSkippedPayload, got %T", skippedEvt.Payload) + } + if payload.Reason != expected { + t.Fatalf("expected reason %q, got %q", expected, payload.Reason) + } +} + +func TestAgentLoop_MountProcessHook_IsolationSupportsRelativeDirAndCommand(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("linux-only isolation path handling") + } + + provider := &llmHookTestProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + root := t.TempDir() + t.Setenv(config.EnvHome, filepath.Join(root, "picoclaw-home")) + binDir := filepath.Join(root, "bin") + hookDir := filepath.Join(root, "hooks") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(hookDir, 0o755); err != nil { + t.Fatal(err) + } + writeFakeBwrap(t, filepath.Join(binDir, "bwrap")) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + linkTestBinary(t, os.Args[0], filepath.Join(hookDir, "hook-helper")) + + cfg := config.DefaultConfig() + cfg.Isolation.Enabled = true + isolation.Configure(cfg) + t.Cleanup(func() { isolation.Configure(config.DefaultConfig()) }) + + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + relHookDir, err := filepath.Rel(cwd, hookDir) + if err != nil { + t.Fatal(err) + } + + mountErr := al.MountProcessHook(context.Background(), "ipc-relative", ProcessHookOptions{ + Command: []string{"./hook-helper", "-test.run=TestProcessHook_HelperProcess", "--"}, + Dir: relHookDir, + Env: processHookHelperEnv("rewrite", ""), + InterceptLLM: true, + }) + if mountErr != nil { + t.Fatalf("MountProcessHook failed with relative dir/command under isolation: %v", mountErr) + } + + resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-relative", + Channel: "cli", + ChatID: "direct", + UserMessage: "hello", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + if resp != "provider content|ipc" { + t.Fatalf("expected process-hooked llm content, got %q", resp) + } + provider.mu.Lock() + lastModel := provider.lastModel + provider.mu.Unlock() + if lastModel != "process-model" { + t.Fatalf("expected process model, got %q", lastModel) + } +} + +func processHookHelperCommand() []string { + return []string{os.Args[0], "-test.run=TestProcessHook_HelperProcess", "--"} +} + +func processHookHelperEnv(mode, eventLog string) []string { + env := []string{ + "PICOCLAW_HOOK_HELPER=1", + "PICOCLAW_HOOK_MODE=" + mode, + } + if eventLog != "" { + env = append(env, "PICOCLAW_HOOK_EVENT_LOG="+eventLog) + } + return env +} + +func writeFakeBwrap(t *testing.T, path string) { + t.Helper() + script := `#!/bin/sh +set -eu +workdir= +while [ "$#" -gt 0 ]; do + case "$1" in + --) + shift + break + ;; + --chdir) + workdir="$2" + shift 2 + ;; + --bind|--ro-bind) + shift 3 + ;; + --proc|--dev) + shift 2 + ;; + --die-with-parent|--unshare-ipc) + shift + ;; + *) + shift + ;; + esac +done +if [ -n "$workdir" ]; then + cd "$workdir" +fi +exec "$@" +` + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatalf("write fake bwrap: %v", err) + } +} + +func linkTestBinary(t *testing.T, source, target string) { + t.Helper() + if err := os.Symlink(source, target); err == nil { + return + } + data, err := os.ReadFile(source) + if err != nil { + t.Fatalf("read test binary: %v", err) + } + if err := os.WriteFile(target, data, 0o755); err != nil { + t.Fatalf("create hook helper binary: %v", err) + } +} + +func waitForFileContains(t *testing.T, path, substring string) { + t.Helper() + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + data, err := os.ReadFile(path) + if err == nil && strings.Contains(string(data), substring) { + return + } + time.Sleep(20 * time.Millisecond) + } + + data, _ := os.ReadFile(path) + t.Fatalf("timed out waiting for %q in %s; current content: %q", substring, path, string(data)) +} + +func runProcessHookHelper() error { + mode := os.Getenv("PICOCLAW_HOOK_MODE") + eventLog := os.Getenv("PICOCLAW_HOOK_EVENT_LOG") + + scanner := bufio.NewScanner(os.Stdin) + scanner.Buffer(make([]byte, 0, 64*1024), processHookReadBufferSize) + encoder := json.NewEncoder(os.Stdout) + + for scanner.Scan() { + var msg processHookRPCMessage + if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { + return err + } + + if msg.ID == 0 { + if msg.Method == "hook.runtime_event" && eventLog != "" { + var evt map[string]any + if err := json.Unmarshal(msg.Params, &evt); err == nil { + if kind, ok := evt["kind"].(string); ok { + _ = os.WriteFile(eventLog, []byte(kind+"\n"), 0o644) + } + } + } + continue + } + + result, rpcErr := handleProcessHookRequest(mode, msg) + resp := processHookRPCMessage{ + JSONRPC: processHookJSONRPCVersion, + ID: msg.ID, + } + if rpcErr != nil { + resp.Error = rpcErr + } else if result != nil { + body, err := json.Marshal(result) + if err != nil { + return err + } + resp.Result = body + } else { + resp.Result = []byte("{}") + } + + if err := encoder.Encode(resp); err != nil { + return err + } + } + + return scanner.Err() +} + +func handleProcessHookRequest(mode string, msg processHookRPCMessage) (any, *processHookRPCError) { + switch msg.Method { + case "hook.hello": + return map[string]any{"ok": true}, nil + case "hook.before_llm": + if mode != "rewrite" { + return map[string]any{"action": HookActionContinue}, nil + } + var req map[string]any + _ = json.Unmarshal(msg.Params, &req) + req["model"] = "process-model" + return map[string]any{ + "action": HookActionModify, + "request": req, + }, nil + case "hook.after_llm": + if mode != "rewrite" { + return map[string]any{"action": HookActionContinue}, nil + } + var resp map[string]any + _ = json.Unmarshal(msg.Params, &resp) + if rawResponse, ok := resp["response"].(map[string]any); ok { + if content, ok := rawResponse["content"].(string); ok { + rawResponse["content"] = content + "|ipc" + } + } + return map[string]any{ + "action": HookActionModify, + "response": resp, + }, nil + case "hook.before_tool": + if mode != "rewrite" { + return map[string]any{"action": HookActionContinue}, nil + } + var call map[string]any + _ = json.Unmarshal(msg.Params, &call) + rawArgs, ok := call["arguments"].(map[string]any) + if !ok || rawArgs == nil { + rawArgs = map[string]any{} + } + rawArgs["text"] = "ipc" + call["arguments"] = rawArgs + return map[string]any{ + "action": HookActionModify, + "call": call, + }, nil + case "hook.after_tool": + if mode != "rewrite" { + return map[string]any{"action": HookActionContinue}, nil + } + var result map[string]any + _ = json.Unmarshal(msg.Params, &result) + if rawResult, ok := result["result"].(map[string]any); ok { + if forLLM, ok := rawResult["for_llm"].(string); ok { + rawResult["for_llm"] = "ipc:" + forLLM + } + } + return map[string]any{ + "action": HookActionModify, + "result": result, + }, nil + case "hook.approve_tool": + if mode == "deny" { + return ApprovalDecision{ + Approved: false, + Reason: "blocked by ipc hook", + }, nil + } + return ApprovalDecision{Approved: true}, nil + default: + return nil, &processHookRPCError{ + Code: -32601, + Message: "method not found", + } + } +} diff --git a/pkg/agent/hooks.go b/pkg/agent/hooks.go new file mode 100644 index 000000000..a4f0fac82 --- /dev/null +++ b/pkg/agent/hooks.go @@ -0,0 +1,930 @@ +package agent + +import ( + "context" + "fmt" + "io" + "reflect" + "sort" + "sync" + "time" + + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" +) + +const ( + defaultHookObserverTimeout = 500 * time.Millisecond + defaultHookInterceptorTimeout = 5 * time.Second + defaultHookApprovalTimeout = 60 * time.Second + hookObserverBufferSize = 64 +) + +type HookAction string + +const ( + HookActionContinue HookAction = "continue" + HookActionModify HookAction = "modify" + HookActionRespond HookAction = "respond" // Return result directly, skip tool execution. SECURITY: This bypasses ApproveTool checks, allowing hooks to return results for any tool (including sensitive ones like bash) without approval. Use with caution. + HookActionDenyTool HookAction = "deny_tool" + HookActionAbortTurn HookAction = "abort_turn" + HookActionHardAbort HookAction = "hard_abort" +) + +type HookDecision struct { + Action HookAction `json:"action"` + Reason string `json:"reason,omitempty"` +} + +func (d HookDecision) normalizedAction() HookAction { + if d.Action == "" { + return HookActionContinue + } + return d.Action +} + +type ApprovalDecision struct { + Approved bool `json:"approved"` + Reason string `json:"reason,omitempty"` +} + +type HookSource uint8 + +const ( + HookSourceInProcess HookSource = iota + HookSourceProcess +) + +type HookRegistration struct { + Name string + Priority int + Source HookSource + Hook any +} + +func NamedHook(name string, hook any) HookRegistration { + return HookRegistration{ + Name: name, + Source: HookSourceInProcess, + Hook: hook, + } +} + +type RuntimeEventObserver interface { + OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error +} + +type LLMInterceptor interface { + BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLMHookRequest, HookDecision, error) + AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LLMHookResponse, HookDecision, error) +} + +type ToolInterceptor interface { + BeforeTool(ctx context.Context, call *ToolCallHookRequest) (*ToolCallHookRequest, HookDecision, error) + AfterTool(ctx context.Context, result *ToolResultHookResponse) (*ToolResultHookResponse, HookDecision, error) +} + +type ToolApprover interface { + ApproveTool(ctx context.Context, req *ToolApprovalRequest) (ApprovalDecision, error) +} + +type LLMHookRequest struct { + Meta HookMeta `json:"meta"` + Context *TurnContext `json:"context,omitempty"` + Model string `json:"model"` + Messages []providers.Message `json:"messages,omitempty"` + Tools []providers.ToolDefinition `json:"tools,omitempty"` + Options map[string]any `json:"options,omitempty"` + GracefulTerminal bool `json:"graceful_terminal,omitempty"` +} + +func (r *LLMHookRequest) Clone() *LLMHookRequest { + if r == nil { + return nil + } + cloned := *r + cloned.Meta = cloneHookMeta(r.Meta) + cloned.Context = cloneTurnContext(r.Context) + cloned.Messages = cloneProviderMessages(r.Messages) + cloned.Tools = cloneToolDefinitions(r.Tools) + cloned.Options = cloneStringAnyMap(r.Options) + return &cloned +} + +type LLMHookResponse struct { + Meta HookMeta `json:"meta"` + Context *TurnContext `json:"context,omitempty"` + Model string `json:"model"` + Response *providers.LLMResponse `json:"response,omitempty"` +} + +func (r *LLMHookResponse) Clone() *LLMHookResponse { + if r == nil { + return nil + } + cloned := *r + cloned.Meta = cloneHookMeta(r.Meta) + cloned.Context = cloneTurnContext(r.Context) + cloned.Response = cloneLLMResponse(r.Response) + return &cloned +} + +type ToolCallHookRequest struct { + Meta HookMeta `json:"meta"` + Context *TurnContext `json:"context,omitempty"` + Tool string `json:"tool"` + Arguments map[string]any `json:"arguments,omitempty"` + Channel string `json:"channel,omitempty"` + ChatID string `json:"chat_id,omitempty"` + HookResult *tools.ToolResult `json:"hook_result,omitempty"` // Result returned directly by hook (for respond action). Media is supported - see Media handling section in docs. +} + +func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest { + if r == nil { + return nil + } + cloned := *r + cloned.Meta = cloneHookMeta(r.Meta) + cloned.Context = cloneTurnContext(r.Context) + cloned.Arguments = cloneStringAnyMap(r.Arguments) + cloned.HookResult = cloneToolResult(r.HookResult) + return &cloned +} + +type ToolApprovalRequest struct { + Meta HookMeta `json:"meta"` + Context *TurnContext `json:"context,omitempty"` + Tool string `json:"tool"` + Arguments map[string]any `json:"arguments,omitempty"` +} + +func (r *ToolApprovalRequest) Clone() *ToolApprovalRequest { + if r == nil { + return nil + } + cloned := *r + cloned.Meta = cloneHookMeta(r.Meta) + cloned.Context = cloneTurnContext(r.Context) + cloned.Arguments = cloneStringAnyMap(r.Arguments) + return &cloned +} + +type ToolResultHookResponse struct { + Meta HookMeta `json:"meta"` + Context *TurnContext `json:"context,omitempty"` + Tool string `json:"tool"` + Arguments map[string]any `json:"arguments,omitempty"` + Result *tools.ToolResult `json:"result,omitempty"` + Duration time.Duration `json:"duration"` +} + +func (r *ToolResultHookResponse) Clone() *ToolResultHookResponse { + if r == nil { + return nil + } + cloned := *r + cloned.Meta = cloneHookMeta(r.Meta) + cloned.Context = cloneTurnContext(r.Context) + cloned.Arguments = cloneStringAnyMap(r.Arguments) + cloned.Result = cloneToolResult(r.Result) + return &cloned +} + +type HookManager struct { + runtimeEvents runtimeevents.EventChannel + observerTimeout time.Duration + interceptorTimeout time.Duration + approvalTimeout time.Duration + + mu sync.RWMutex + hooks map[string]HookRegistration + ordered []HookRegistration + + runtimeSub runtimeevents.Subscription + runtimeDone chan struct{} + closeOnce sync.Once +} + +func NewHookManager(runtimeEvents runtimeevents.EventChannel) *HookManager { + hm := &HookManager{ + runtimeEvents: runtimeEvents, + observerTimeout: defaultHookObserverTimeout, + interceptorTimeout: defaultHookInterceptorTimeout, + approvalTimeout: defaultHookApprovalTimeout, + hooks: make(map[string]HookRegistration), + runtimeDone: make(chan struct{}), + } + + if runtimeEvents != nil { + sub, ch, err := runtimeEvents.SubscribeChan(context.Background(), runtimeevents.SubscribeOptions{ + Name: "hook-manager-observer", + Buffer: hookObserverBufferSize, + }) + if err != nil { + logger.WarnCF("hooks", "Failed to subscribe runtime events for hooks", map[string]any{ + "error": err.Error(), + }) + close(hm.runtimeDone) + } else { + hm.runtimeSub = sub + go hm.dispatchRuntimeEvents(ch) + } + } else { + close(hm.runtimeDone) + } + + return hm +} + +func (hm *HookManager) Close() { + if hm == nil { + return + } + + hm.closeOnce.Do(func() { + if hm.runtimeSub != nil { + if err := hm.runtimeSub.Close(); err != nil { + logger.WarnCF("hooks", "Failed to close runtime event hook subscription", map[string]any{ + "error": err.Error(), + }) + } + } + <-hm.runtimeDone + hm.closeAllHooks() + }) +} + +func (hm *HookManager) ConfigureTimeouts(observer, interceptor, approval time.Duration) { + if hm == nil { + return + } + if observer > 0 { + hm.observerTimeout = observer + } + if interceptor > 0 { + hm.interceptorTimeout = interceptor + } + if approval > 0 { + hm.approvalTimeout = approval + } +} + +func (hm *HookManager) Mount(reg HookRegistration) error { + if hm == nil { + return fmt.Errorf("hook manager is nil") + } + if reg.Name == "" { + return fmt.Errorf("hook name is required") + } + if reg.Hook == nil { + return fmt.Errorf("hook %q is nil", reg.Name) + } + + hm.mu.Lock() + defer hm.mu.Unlock() + + if existing, ok := hm.hooks[reg.Name]; ok { + closeHookIfPossible(existing.Hook) + } + hm.hooks[reg.Name] = reg + hm.rebuildOrdered() + return nil +} + +func (hm *HookManager) Unmount(name string) { + if hm == nil || name == "" { + return + } + + hm.mu.Lock() + defer hm.mu.Unlock() + + if existing, ok := hm.hooks[name]; ok { + closeHookIfPossible(existing.Hook) + } + delete(hm.hooks, name) + hm.rebuildOrdered() +} + +func (hm *HookManager) dispatchRuntimeEvents(ch <-chan runtimeevents.Event) { + defer close(hm.runtimeDone) + + for evt := range ch { + for _, reg := range hm.snapshotHooks() { + observer, ok := reg.Hook.(RuntimeEventObserver) + if !ok { + continue + } + hm.runRuntimeObserver(reg.Name, observer, evt) + } + } +} + +func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLMHookRequest, HookDecision) { + if hm == nil || req == nil { + return req, HookDecision{Action: HookActionContinue} + } + + current := req.Clone() + for _, reg := range hm.snapshotHooks() { + interceptor, ok := reg.Hook.(LLMInterceptor) + if !ok { + continue + } + + next, decision, ok := hm.callBeforeLLM(ctx, reg.Name, interceptor, current.Clone()) + if !ok { + continue + } + + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if next != nil { + next = hm.applyBeforeLLMControls(reg.Name, current, next) + current = next + } + case HookActionAbortTurn, HookActionHardAbort: + return current, decision + default: + hm.logUnsupportedAction(reg.Name, "before_llm", decision.Action) + } + } + return current, HookDecision{Action: HookActionContinue} +} + +func (hm *HookManager) AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LLMHookResponse, HookDecision) { + if hm == nil || resp == nil { + return resp, HookDecision{Action: HookActionContinue} + } + + current := resp.Clone() + for _, reg := range hm.snapshotHooks() { + interceptor, ok := reg.Hook.(LLMInterceptor) + if !ok { + continue + } + + next, decision, ok := hm.callAfterLLM(ctx, reg.Name, interceptor, current.Clone()) + if !ok { + continue + } + + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if next != nil { + current = next + } + case HookActionAbortTurn, HookActionHardAbort: + return current, decision + default: + hm.logUnsupportedAction(reg.Name, "after_llm", decision.Action) + } + } + return current, HookDecision{Action: HookActionContinue} +} + +func (hm *HookManager) applyBeforeLLMControls( + hookName string, + current *LLMHookRequest, + next *LLMHookRequest, +) *LLMHookRequest { + if next == nil || current == nil { + return next + } + if !llmHookSystemMessagesUnchanged(current.Messages, next.Messages) { + logger.WarnCF("hooks", "Hook attempted to modify system prompt; preserving original messages", map[string]any{ + "hook": hookName, + }) + next.Messages = cloneProviderMessages(current.Messages) + } + if !llmHookToolDefinitionsUnchanged(current.Tools, next.Tools) { + logger.WarnCF("hooks", "Hook attempted to modify tool definitions; preserving original tools", map[string]any{ + "hook": hookName, + }) + next.Tools = cloneToolDefinitions(current.Tools) + } + return next +} + +func llmHookSystemMessagesUnchanged(before, after []providers.Message) bool { + beforeSystem := systemMessageFingerprints(before) + afterSystem := systemMessageFingerprints(after) + return reflect.DeepEqual(beforeSystem, afterSystem) +} + +type systemMessageFingerprint struct { + Index int + Message providers.Message +} + +func systemMessageFingerprints(messages []providers.Message) []systemMessageFingerprint { + var fingerprints []systemMessageFingerprint + for i, msg := range messages { + if msg.Role != "system" { + continue + } + msg = providerVisibleMessage(msg) + fingerprints = append(fingerprints, systemMessageFingerprint{ + Index: i, + Message: cloneProviderMessages([]providers.Message{msg})[0], + }) + } + return fingerprints +} + +func llmHookToolDefinitionsUnchanged(before, after []providers.ToolDefinition) bool { + return reflect.DeepEqual(providerVisibleToolDefinitions(before), providerVisibleToolDefinitions(after)) +} + +func providerVisibleMessage(msg providers.Message) providers.Message { + msg.PromptLayer = "" + msg.PromptSlot = "" + msg.PromptSource = "" + if len(msg.SystemParts) > 0 { + msg.SystemParts = append([]providers.ContentBlock(nil), msg.SystemParts...) + for i := range msg.SystemParts { + msg.SystemParts[i].PromptLayer = "" + msg.SystemParts[i].PromptSlot = "" + msg.SystemParts[i].PromptSource = "" + } + } + return msg +} + +func providerVisibleToolDefinitions(defs []providers.ToolDefinition) []providers.ToolDefinition { + cloned := cloneToolDefinitions(defs) + for i := range cloned { + cloned[i].PromptLayer = "" + cloned[i].PromptSlot = "" + cloned[i].PromptSource = "" + } + return cloned +} + +func (hm *HookManager) BeforeTool( + ctx context.Context, + call *ToolCallHookRequest, +) (*ToolCallHookRequest, HookDecision) { + if hm == nil || call == nil { + return call, HookDecision{Action: HookActionContinue} + } + + current := call.Clone() + for _, reg := range hm.snapshotHooks() { + interceptor, ok := reg.Hook.(ToolInterceptor) + if !ok { + continue + } + + next, decision, ok := hm.callBeforeTool(ctx, reg.Name, interceptor, current.Clone()) + if !ok { + continue + } + + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if next != nil { + current = next + } + case HookActionRespond: + // Hook returns result directly, skip tool execution + // Carry HookResult in ToolCallHookRequest and return + return next, decision + case HookActionDenyTool, HookActionAbortTurn, HookActionHardAbort: + return current, decision + default: + hm.logUnsupportedAction(reg.Name, "before_tool", decision.Action) + } + } + return current, HookDecision{Action: HookActionContinue} +} + +func (hm *HookManager) AfterTool( + ctx context.Context, + result *ToolResultHookResponse, +) (*ToolResultHookResponse, HookDecision) { + if hm == nil || result == nil { + return result, HookDecision{Action: HookActionContinue} + } + + current := result.Clone() + for _, reg := range hm.snapshotHooks() { + interceptor, ok := reg.Hook.(ToolInterceptor) + if !ok { + continue + } + + next, decision, ok := hm.callAfterTool(ctx, reg.Name, interceptor, current.Clone()) + if !ok { + continue + } + + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if next != nil { + current = next + } + case HookActionAbortTurn, HookActionHardAbort: + return current, decision + default: + hm.logUnsupportedAction(reg.Name, "after_tool", decision.Action) + } + } + return current, HookDecision{Action: HookActionContinue} +} + +func (hm *HookManager) ApproveTool(ctx context.Context, req *ToolApprovalRequest) ApprovalDecision { + if hm == nil || req == nil { + return ApprovalDecision{Approved: true} + } + + for _, reg := range hm.snapshotHooks() { + approver, ok := reg.Hook.(ToolApprover) + if !ok { + continue + } + + decision, ok := hm.callApproveTool(ctx, reg.Name, approver, req.Clone()) + if !ok { + return ApprovalDecision{ + Approved: false, + Reason: fmt.Sprintf("tool approval hook %q failed", reg.Name), + } + } + if !decision.Approved { + return decision + } + } + + return ApprovalDecision{Approved: true} +} + +func (hm *HookManager) rebuildOrdered() { + hm.ordered = hm.ordered[:0] + for _, reg := range hm.hooks { + hm.ordered = append(hm.ordered, reg) + } + sort.SliceStable(hm.ordered, func(i, j int) bool { + if hm.ordered[i].Source != hm.ordered[j].Source { + return hm.ordered[i].Source < hm.ordered[j].Source + } + if hm.ordered[i].Priority == hm.ordered[j].Priority { + return hm.ordered[i].Name < hm.ordered[j].Name + } + return hm.ordered[i].Priority < hm.ordered[j].Priority + }) +} + +func (hm *HookManager) snapshotHooks() []HookRegistration { + hm.mu.RLock() + defer hm.mu.RUnlock() + + snapshot := make([]HookRegistration, len(hm.ordered)) + copy(snapshot, hm.ordered) + return snapshot +} + +func (hm *HookManager) closeAllHooks() { + hm.mu.Lock() + defer hm.mu.Unlock() + + for name, reg := range hm.hooks { + closeHookIfPossible(reg.Hook) + delete(hm.hooks, name) + } + hm.ordered = nil +} + +func (hm *HookManager) runRuntimeObserver( + name string, + observer RuntimeEventObserver, + evt runtimeevents.Event, +) { + ctx, cancel := context.WithTimeout(context.Background(), hm.observerTimeout) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- observer.OnRuntimeEvent(ctx, evt) + }() + + select { + case err := <-done: + if err != nil { + logger.WarnCF("hooks", "Runtime event observer failed", map[string]any{ + "hook": name, + "event": evt.Kind.String(), + "error": err.Error(), + }) + } + case <-ctx.Done(): + logger.WarnCF("hooks", "Runtime event observer timed out", map[string]any{ + "hook": name, + "event": evt.Kind.String(), + "timeout_ms": hm.observerTimeout.Milliseconds(), + }) + } +} + +func (hm *HookManager) callBeforeLLM( + parent context.Context, + name string, + interceptor LLMInterceptor, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, bool) { + return runInterceptorHook( + parent, + hm.interceptorTimeout, + name, + "before_llm", + func(ctx context.Context) (*LLMHookRequest, HookDecision, error) { + return interceptor.BeforeLLM(ctx, req) + }, + ) +} + +func (hm *HookManager) callAfterLLM( + parent context.Context, + name string, + interceptor LLMInterceptor, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, bool) { + return runInterceptorHook( + parent, + hm.interceptorTimeout, + name, + "after_llm", + func(ctx context.Context) (*LLMHookResponse, HookDecision, error) { + return interceptor.AfterLLM(ctx, resp) + }, + ) +} + +func (hm *HookManager) callBeforeTool( + parent context.Context, + name string, + interceptor ToolInterceptor, + call *ToolCallHookRequest, +) (*ToolCallHookRequest, HookDecision, bool) { + return runInterceptorHook( + parent, + hm.interceptorTimeout, + name, + "before_tool", + func(ctx context.Context) (*ToolCallHookRequest, HookDecision, error) { + return interceptor.BeforeTool(ctx, call) + }, + ) +} + +func (hm *HookManager) callAfterTool( + parent context.Context, + name string, + interceptor ToolInterceptor, + resultView *ToolResultHookResponse, +) (*ToolResultHookResponse, HookDecision, bool) { + return runInterceptorHook( + parent, + hm.interceptorTimeout, + name, + "after_tool", + func(ctx context.Context) (*ToolResultHookResponse, HookDecision, error) { + return interceptor.AfterTool(ctx, resultView) + }, + ) +} + +func (hm *HookManager) callApproveTool( + parent context.Context, + name string, + approver ToolApprover, + req *ToolApprovalRequest, +) (ApprovalDecision, bool) { + return runApprovalHook( + parent, + hm.approvalTimeout, + name, + "approve_tool", + func(ctx context.Context) (ApprovalDecision, error) { + return approver.ApproveTool(ctx, req) + }, + ) +} + +func runInterceptorHook[T any]( + parent context.Context, + timeout time.Duration, + name string, + stage string, + fn func(ctx context.Context) (T, HookDecision, error), +) (T, HookDecision, bool) { + var zero T + + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + type result struct { + value T + decision HookDecision + err error + } + done := make(chan result, 1) + go func() { + value, decision, err := fn(ctx) + done <- result{value: value, decision: decision, err: err} + }() + + select { + case res := <-done: + if res.err != nil { + logger.WarnCF("hooks", "Interceptor hook failed", map[string]any{ + "hook": name, + "stage": stage, + "error": res.err.Error(), + }) + return zero, HookDecision{}, false + } + return res.value, res.decision, true + case <-ctx.Done(): + logger.WarnCF("hooks", "Interceptor hook timed out", map[string]any{ + "hook": name, + "stage": stage, + "timeout_ms": timeout.Milliseconds(), + }) + return zero, HookDecision{}, false + } +} + +func runApprovalHook( + parent context.Context, + timeout time.Duration, + name string, + stage string, + fn func(ctx context.Context) (ApprovalDecision, error), +) (ApprovalDecision, bool) { + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + type result struct { + decision ApprovalDecision + err error + } + done := make(chan result, 1) + go func() { + decision, err := fn(ctx) + done <- result{decision: decision, err: err} + }() + + select { + case res := <-done: + if res.err != nil { + logger.WarnCF("hooks", "Approval hook failed", map[string]any{ + "hook": name, + "stage": stage, + "error": res.err.Error(), + }) + return ApprovalDecision{}, false + } + return res.decision, true + case <-ctx.Done(): + logger.WarnCF("hooks", "Approval hook timed out", map[string]any{ + "hook": name, + "stage": stage, + "timeout_ms": timeout.Milliseconds(), + }) + return ApprovalDecision{ + Approved: false, + Reason: fmt.Sprintf("tool approval hook %q timed out", name), + }, true + } +} + +func (hm *HookManager) logUnsupportedAction(name, stage string, action HookAction) { + logger.WarnCF("hooks", "Hook returned unsupported action for stage", map[string]any{ + "hook": name, + "stage": stage, + "action": action, + }) +} + +func cloneProviderMessages(messages []providers.Message) []providers.Message { + if len(messages) == 0 { + return nil + } + + cloned := make([]providers.Message, len(messages)) + for i, msg := range messages { + cloned[i] = msg + if len(msg.Media) > 0 { + cloned[i].Media = append([]string(nil), msg.Media...) + } + if len(msg.SystemParts) > 0 { + cloned[i].SystemParts = append([]providers.ContentBlock(nil), msg.SystemParts...) + } + if len(msg.ToolCalls) > 0 { + cloned[i].ToolCalls = cloneProviderToolCalls(msg.ToolCalls) + } + } + return cloned +} + +func cloneProviderToolCalls(calls []providers.ToolCall) []providers.ToolCall { + if len(calls) == 0 { + return nil + } + + cloned := make([]providers.ToolCall, len(calls)) + for i, call := range calls { + cloned[i] = call + if call.Function != nil { + fn := *call.Function + cloned[i].Function = &fn + } + if call.Arguments != nil { + cloned[i].Arguments = cloneStringAnyMap(call.Arguments) + } + if call.ExtraContent != nil { + extra := *call.ExtraContent + if call.ExtraContent.Google != nil { + google := *call.ExtraContent.Google + extra.Google = &google + } + cloned[i].ExtraContent = &extra + } + } + return cloned +} + +func cloneToolDefinitions(defs []providers.ToolDefinition) []providers.ToolDefinition { + if len(defs) == 0 { + return nil + } + + cloned := make([]providers.ToolDefinition, len(defs)) + for i, def := range defs { + cloned[i] = def + cloned[i].Function.Parameters = cloneStringAnyMap(def.Function.Parameters) + } + return cloned +} + +func cloneLLMResponse(resp *providers.LLMResponse) *providers.LLMResponse { + if resp == nil { + return nil + } + cloned := *resp + cloned.ToolCalls = cloneProviderToolCalls(resp.ToolCalls) + if len(resp.ReasoningDetails) > 0 { + cloned.ReasoningDetails = append(cloned.ReasoningDetails[:0:0], resp.ReasoningDetails...) + } + if resp.Usage != nil { + usage := *resp.Usage + cloned.Usage = &usage + } + return &cloned +} + +func cloneStringAnyMap(src map[string]any) map[string]any { + if len(src) == 0 { + return map[string]any{} + } + + cloned := make(map[string]any, len(src)) + for k, v := range src { + cloned[k] = v + } + return cloned +} + +func cloneToolResult(result *tools.ToolResult) *tools.ToolResult { + if result == nil { + return nil + } + + cloned := *result + if len(result.Media) > 0 { + cloned.Media = append([]string(nil), result.Media...) + } + if len(result.ArtifactTags) > 0 { + cloned.ArtifactTags = append([]string(nil), result.ArtifactTags...) + } + if len(result.Messages) > 0 { + cloned.Messages = make([]providers.Message, len(result.Messages)) + copy(cloned.Messages, result.Messages) + } + return &cloned +} + +func closeHookIfPossible(hook any) { + closer, ok := hook.(io.Closer) + if !ok { + return + } + if err := closer.Close(); err != nil { + logger.WarnCF("hooks", "Failed to close hook", map[string]any{ + "error": err.Error(), + }) + } +} diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go new file mode 100644 index 000000000..4deef38c7 --- /dev/null +++ b/pkg/agent/hooks_test.go @@ -0,0 +1,1578 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/tools" +) + +func newHookTestLoop( + t *testing.T, + provider providers.LLMProvider, +) (*AgentLoop, *AgentInstance, func()) { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "agent-hooks-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + return al, agent, func() { + al.Close() + _ = os.RemoveAll(tmpDir) + } +} + +func TestHookManager_SortsInProcessBeforeProcess(t *testing.T) { + hm := NewHookManager(nil) + defer hm.Close() + + if err := hm.Mount(HookRegistration{ + Name: "process", + Priority: -10, + Source: HookSourceProcess, + Hook: struct{}{}, + }); err != nil { + t.Fatalf("mount process hook: %v", err) + } + if err := hm.Mount(HookRegistration{ + Name: "in-process", + Priority: 100, + Source: HookSourceInProcess, + Hook: struct{}{}, + }); err != nil { + t.Fatalf("mount in-process hook: %v", err) + } + + ordered := hm.snapshotHooks() + if len(ordered) != 2 { + t.Fatalf("expected 2 hooks, got %d", len(ordered)) + } + if ordered[0].Name != "in-process" { + t.Fatalf("expected in-process hook first, got %q", ordered[0].Name) + } + if ordered[1].Name != "process" { + t.Fatalf("expected process hook second, got %q", ordered[1].Name) + } +} + +type llmHookTestProvider struct { + mu sync.Mutex + lastModel string +} + +func (p *llmHookTestProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + p.lastModel = model + p.mu.Unlock() + + return &providers.LLMResponse{ + Content: "provider content", + }, nil +} + +func (p *llmHookTestProvider) GetDefaultModel() string { + return "llm-hook-provider" +} + +type llmObserverHook struct { + eventCh chan runtimeevents.Event + lastInbound *bus.InboundContext + lastRoute *routing.ResolvedRoute + lastScope *session.SessionScope +} + +func (h *llmObserverHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error { + if evt.Kind == runtimeevents.KindAgentTurnEnd { + select { + case h.eventCh <- evt: + default: + } + } + return nil +} + +func (h *llmObserverHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + if req.Context != nil { + h.lastInbound = cloneInboundContext(req.Context.Inbound) + h.lastRoute = cloneResolvedRoute(req.Context.Route) + h.lastScope = session.CloneScope(req.Context.Scope) + } + next := req.Clone() + next.Model = "hook-model" + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h *llmObserverHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + next := resp.Clone() + next.Response.Content = "hooked content" + return next, HookDecision{Action: HookActionModify}, nil +} + +type dualRuntimeObserverHook struct { + runtimeCh chan runtimeevents.Event +} + +func (h *dualRuntimeObserverHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error { + if evt.Kind == runtimeevents.KindAgentTurnEnd { + select { + case h.runtimeCh <- evt: + default: + } + } + return nil +} + +type llmSystemRewriteHook struct{} + +func (h *llmSystemRewriteHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + next := req.Clone() + next.Model = "changed-model" + next.Messages[0].Content = "rewritten system" + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h *llmSystemRewriteHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + return resp.Clone(), HookDecision{Action: HookActionContinue}, nil +} + +type llmUserAppendHook struct{} + +func (h *llmUserAppendHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + next := req.Clone() + next.Messages = append(next.Messages, providers.Message{Role: "user", Content: "extra user context"}) + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h *llmUserAppendHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + return resp.Clone(), HookDecision{Action: HookActionContinue}, nil +} + +type llmJSONRoundTripUserAppendHook struct{} + +type jsonRoundTripLLMHookRequest struct { + Model string `json:"model"` + Messages []providers.Message `json:"messages,omitempty"` + Tools []providers.ToolDefinition `json:"tools,omitempty"` +} + +func (h *llmJSONRoundTripUserAppendHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + payload := jsonRoundTripLLMHookRequest{ + Model: req.Model, + Messages: req.Messages, + Tools: req.Tools, + } + data, err := json.Marshal(payload) + if err != nil { + return nil, HookDecision{}, err + } + var decoded jsonRoundTripLLMHookRequest + if err := json.Unmarshal(data, &decoded); err != nil { + return nil, HookDecision{}, err + } + next := req.Clone() + next.Model = decoded.Model + next.Messages = decoded.Messages + next.Tools = decoded.Tools + next.Messages = append(next.Messages, providers.Message{Role: "user", Content: "json extra user context"}) + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h *llmJSONRoundTripUserAppendHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + return resp.Clone(), HookDecision{Action: HookActionContinue}, nil +} + +type llmToolRewriteHook struct{} + +func (h *llmToolRewriteHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + next := req.Clone() + next.Model = "changed-model" + next.Tools[0].Function.Description = "rewritten tool" + next.Tools = append(next.Tools, providers.ToolDefinition{ + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: "hook_tool", + Description: "hook tool", + Parameters: map[string]any{"type": "object"}, + }, + PromptLayer: string(PromptLayerCapability), + PromptSlot: string(PromptSlotTooling), + PromptSource: "hook:test", + }) + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h *llmToolRewriteHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + return resp.Clone(), HookDecision{Action: HookActionContinue}, nil +} + +func TestHookManager_BeforeLLMControlsSystemPromptMutation(t *testing.T) { + hm := NewHookManager(nil) + if err := hm.Mount(NamedHook("rewrite-system", &llmSystemRewriteHook{})); err != nil { + t.Fatalf("Mount() error = %v", err) + } + + req := &LLMHookRequest{ + Model: "original-model", + Messages: []providers.Message{ + { + Role: "system", + Content: "original system", + SystemParts: []providers.ContentBlock{ + {Type: "text", Text: "original system"}, + }, + }, + {Role: "user", Content: "hello"}, + }, + } + + got, decision := hm.BeforeLLM(context.Background(), req) + if decision.normalizedAction() != HookActionContinue { + t.Fatalf("decision = %v, want continue", decision) + } + if got.Model != "changed-model" { + t.Fatalf("model = %q, want changed-model", got.Model) + } + if got.Messages[0].Content != "original system" { + t.Fatalf("system content = %q, want original system", got.Messages[0].Content) + } + if got.Messages[1].Content != "hello" { + t.Fatalf("user content = %q, want hello", got.Messages[1].Content) + } +} + +func TestHookManager_BeforeLLMAllowsNonSystemMessageMutation(t *testing.T) { + hm := NewHookManager(nil) + if err := hm.Mount(NamedHook("append-user", &llmUserAppendHook{})); err != nil { + t.Fatalf("Mount() error = %v", err) + } + + req := &LLMHookRequest{ + Model: "model", + Messages: []providers.Message{ + {Role: "system", Content: "system"}, + {Role: "user", Content: "hello"}, + }, + } + + got, _ := hm.BeforeLLM(context.Background(), req) + if len(got.Messages) != 3 { + t.Fatalf("messages len = %d, want 3", len(got.Messages)) + } + if got.Messages[2].Role != "user" || got.Messages[2].Content != "extra user context" { + t.Fatalf("appended message = %#v, want extra user context", got.Messages[2]) + } +} + +func TestHookManager_BeforeLLMAllowsJSONRoundTripNonSystemMessageMutation(t *testing.T) { + hm := NewHookManager(nil) + if err := hm.Mount(NamedHook("json-append-user", &llmJSONRoundTripUserAppendHook{})); err != nil { + t.Fatalf("Mount() error = %v", err) + } + + req := &LLMHookRequest{ + Model: "model", + Messages: []providers.Message{ + { + Role: "system", + Content: "system", + PromptLayer: string(PromptLayerKernel), + PromptSlot: string(PromptSlotIdentity), + PromptSource: string(PromptSourceKernel), + SystemParts: []providers.ContentBlock{ + { + Type: "text", + Text: "system", + CacheControl: &providers.CacheControl{Type: "ephemeral"}, + PromptLayer: string(PromptLayerKernel), + PromptSlot: string(PromptSlotIdentity), + PromptSource: string(PromptSourceKernel), + }, + }, + }, + {Role: "user", Content: "hello"}, + }, + Tools: []providers.ToolDefinition{ + { + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: "mcp_github_create_issue", + Description: "create issue", + Parameters: map[string]any{"type": "object"}, + }, + PromptLayer: string(PromptLayerCapability), + PromptSlot: string(PromptSlotMCP), + PromptSource: "mcp:github", + }, + }, + } + + got, _ := hm.BeforeLLM(context.Background(), req) + if len(got.Messages) != 3 { + t.Fatalf("messages len = %d, want 3", len(got.Messages)) + } + if got.Messages[2].Role != "user" || got.Messages[2].Content != "json extra user context" { + t.Fatalf("appended message = %#v, want json extra user context", got.Messages[2]) + } +} + +func TestHookManager_BeforeLLMControlsToolDefinitionMutation(t *testing.T) { + hm := NewHookManager(nil) + if err := hm.Mount(NamedHook("rewrite-tool", &llmToolRewriteHook{})); err != nil { + t.Fatalf("Mount() error = %v", err) + } + + req := &LLMHookRequest{ + Model: "original-model", + Messages: []providers.Message{ + {Role: "system", Content: "system"}, + {Role: "user", Content: "hello"}, + }, + Tools: []providers.ToolDefinition{ + { + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: "mcp_github_create_issue", + Description: "create issue", + Parameters: map[string]any{"type": "object"}, + }, + PromptLayer: string(PromptLayerCapability), + PromptSlot: string(PromptSlotMCP), + PromptSource: "mcp:github", + }, + }, + } + + got, decision := hm.BeforeLLM(context.Background(), req) + if decision.normalizedAction() != HookActionContinue { + t.Fatalf("decision = %v, want continue", decision) + } + if got.Model != "changed-model" { + t.Fatalf("model = %q, want changed-model", got.Model) + } + if len(got.Tools) != 1 { + t.Fatalf("tools len = %d, want original 1", len(got.Tools)) + } + if got.Tools[0].Function.Description != "create issue" { + t.Fatalf("tool description = %q, want original", got.Tools[0].Function.Description) + } + if got.Tools[0].PromptSource != "mcp:github" || got.Tools[0].PromptSlot != string(PromptSlotMCP) { + t.Fatalf("tool prompt metadata = %#v, want original mcp metadata", got.Tools[0]) + } +} + +func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) { + provider := &llmHookTestProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + hook := &llmObserverHook{eventCh: make(chan runtimeevents.Event, 1)} + if err := al.MountHook(NamedHook("llm-observer", hook)); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "hello", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + InboundContext: &bus.InboundContext{ + Channel: "cli", + ChatID: "direct", + ChatType: "direct", + SenderID: "hook-user", + }, + RouteResult: &routing.ResolvedRoute{ + AgentID: "main", + Channel: "cli", + AccountID: routing.DefaultAccountID, + SessionPolicy: routing.SessionPolicy{ + Dimensions: []string{"sender"}, + }, + MatchedBy: "default", + }, + SessionScope: &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "main", + Channel: "cli", + Account: routing.DefaultAccountID, + Dimensions: []string{"sender"}, + Values: map[string]string{ + "sender": "hook-user", + }, + }, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + if resp != "hooked content" { + t.Fatalf("expected hooked content, got %q", resp) + } + + provider.mu.Lock() + lastModel := provider.lastModel + provider.mu.Unlock() + if lastModel != "hook-model" { + t.Fatalf("expected model hook-model, got %q", lastModel) + } + if hook.lastInbound == nil { + t.Fatal("expected hook to receive inbound context") + } + if hook.lastInbound.Channel != "cli" || hook.lastInbound.SenderID != "hook-user" { + t.Fatalf("hook inbound context = %+v", hook.lastInbound) + } + if hook.lastInbound != nil && hook.lastInbound.ChatID != "direct" { + t.Fatalf("hook inbound chat ID = %q, want direct", hook.lastInbound.ChatID) + } + + select { + case evt := <-hook.eventCh: + if evt.Kind != runtimeevents.KindAgentTurnEnd { + t.Fatalf("expected turn end event, got %v", evt.Kind) + } + if evt.Scope.AgentID != "main" || + evt.Scope.SessionKey != "session-1" || + evt.Scope.Channel != "cli" || + evt.Scope.ChatID != "direct" || + evt.Scope.SenderID != "hook-user" { + t.Fatalf("runtime observer scope = %+v", evt.Scope) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for hook observer event") + } +} + +func TestAgentLoop_Hooks_RuntimeObserverReceivesEvents(t *testing.T) { + provider := &llmHookTestProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + hook := &dualRuntimeObserverHook{ + runtimeCh: make(chan runtimeevents.Event, 1), + } + if err := al.MountHook(NamedHook("runtime-observer", hook)); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "hello", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + InboundContext: &bus.InboundContext{ + Channel: "cli", + Account: "default", + ChatID: "direct", + ChatType: "direct", + SenderID: "hook-user", + MessageID: "msg-1", + }, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + if resp != "provider content" { + t.Fatalf("expected provider content, got %q", resp) + } + + select { + case evt := <-hook.runtimeCh: + if evt.Kind != runtimeevents.KindAgentTurnEnd { + t.Fatalf("runtime observer kind = %q", evt.Kind) + } + if evt.Scope.SessionKey != "session-1" || + evt.Scope.Channel != "cli" || + evt.Scope.ChatID != "direct" || + evt.Scope.MessageID != "msg-1" { + t.Fatalf("runtime observer scope = %+v", evt.Scope) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for runtime observer event") + } +} + +func TestAgentLoop_BtwCommand_UsesLLMHooks(t *testing.T) { + provider := &llmHookTestProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + useTestSideQuestionProvider(al, provider) + + hook := &llmObserverHook{eventCh: make(chan runtimeevents.Event, 1)} + if err := al.MountHook(NamedHook("llm-observer", hook)); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + response, handled := al.handleCommand(context.Background(), bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "cli", + ChatID: "direct", + ChatType: "direct", + SenderID: "hook-user", + }, + Content: "/btw hello", + }, agent, &processOptions{ + Dispatch: DispatchRequest{ + SessionKey: "session-1", + InboundContext: &bus.InboundContext{ + Channel: "cli", + ChatID: "direct", + ChatType: "direct", + SenderID: "hook-user", + }, + RouteResult: &routing.ResolvedRoute{ + AgentID: "main", + Channel: "cli", + AccountID: routing.DefaultAccountID, + SessionPolicy: routing.SessionPolicy{ + Dimensions: []string{"sender"}, + }, + MatchedBy: "default", + }, + SessionScope: &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "main", + Channel: "cli", + Account: routing.DefaultAccountID, + Dimensions: []string{"sender"}, + Values: map[string]string{ + "sender": "hook-user", + }, + }, + UserMessage: "/btw hello", + }, + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + SenderID: "hook-user", + SenderDisplayName: "Hook User", + }) + if !handled { + t.Fatal("expected /btw command to be handled") + } + if response != "hooked content" { + t.Fatalf("expected hooked content, got %q", response) + } + + provider.mu.Lock() + lastModel := provider.lastModel + provider.mu.Unlock() + if lastModel != "hook-model" { + t.Fatalf("expected model hook-model, got %q", lastModel) + } + if hook.lastInbound == nil { + t.Fatal("expected hook to receive inbound context") + } + if hook.lastInbound.Channel != "cli" || hook.lastInbound.SenderID != "hook-user" { + t.Fatalf("hook inbound context = %+v", hook.lastInbound) + } + if hook.lastInbound.ChatID != "direct" { + t.Fatalf("hook inbound chat ID = %q, want direct", hook.lastInbound.ChatID) + } + if hook.lastRoute == nil || hook.lastRoute.AgentID != "main" { + t.Fatalf("expected hook route context for /btw, got %+v", hook.lastRoute) + } + if hook.lastScope == nil || hook.lastScope.Values["sender"] != "hook-user" { + t.Fatalf("expected hook session scope for /btw, got %+v", hook.lastScope) + } +} + +type toolHookProvider struct { + mu sync.Mutex + calls int +} + +func (p *toolHookProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + defer p.mu.Unlock() + + p.calls++ + if p.calls == 1 { + return &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{ + { + ID: "call-1", + Name: "echo_text", + Arguments: map[string]any{"text": "original"}, + }, + }, + }, nil + } + + last := messages[len(messages)-1] + return &providers.LLMResponse{ + Content: last.Content, + }, nil +} + +func (p *toolHookProvider) GetDefaultModel() string { + return "tool-hook-provider" +} + +type echoTextTool struct{} + +func (t *echoTextTool) Name() string { + return "echo_text" +} + +func (t *echoTextTool) Description() string { + return "echo a text argument" +} + +func (t *echoTextTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{ + "type": "string", + }, + }, + } +} + +func (t *echoTextTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + text, _ := args["text"].(string) + return tools.SilentResult(text) +} + +type toolRewriteHook struct{} + +func (h *toolRewriteHook) BeforeTool( + ctx context.Context, + call *ToolCallHookRequest, +) (*ToolCallHookRequest, HookDecision, error) { + next := call.Clone() + next.Arguments["text"] = "modified" + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h *toolRewriteHook) AfterTool( + ctx context.Context, + result *ToolResultHookResponse, +) (*ToolResultHookResponse, HookDecision, error) { + next := result.Clone() + next.Result.ForLLM = "after:" + next.Result.ForLLM + return next, HookDecision{Action: HookActionModify}, nil +} + +type toolRenameHook struct{} + +func (h *toolRenameHook) BeforeTool( + ctx context.Context, + call *ToolCallHookRequest, +) (*ToolCallHookRequest, HookDecision, error) { + next := call.Clone() + next.Tool = "echo_text_rewritten" + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h *toolRenameHook) AfterTool( + ctx context.Context, + result *ToolResultHookResponse, +) (*ToolResultHookResponse, HookDecision, error) { + return result.Clone(), HookDecision{Action: HookActionContinue}, nil +} + +func TestAgentLoop_Hooks_ToolInterceptorCanRewrite(t *testing.T) { + provider := &toolHookProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + al.RegisterTool(&echoTextTool{}) + if err := al.MountHook(NamedHook("tool-rewrite", &toolRewriteHook{})); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "run tool", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + if resp != "after:modified" { + t.Fatalf("expected rewritten tool result, got %q", resp) + } +} + +type echoTextRewrittenTool struct{} + +func (t *echoTextRewrittenTool) Name() string { + return "echo_text_rewritten" +} + +func (t *echoTextRewrittenTool) Description() string { + return "echo a rewritten text argument" +} + +func (t *echoTextRewrittenTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{ + "type": "string", + }, + }, + } +} + +func (t *echoTextRewrittenTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + text, _ := args["text"].(string) + return tools.SilentResult("rewritten:" + text) +} + +func TestAgentLoop_Hooks_ToolFeedbackUsesRewrittenToolName(t *testing.T) { + provider := &toolHookProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + al.cfg.Agents.Defaults.ToolFeedback.Enabled = true + al.RegisterTool(&echoTextTool{}) + al.RegisterTool(&echoTextRewrittenTool{}) + if err := al.MountHook(NamedHook("tool-rename", &toolRenameHook{})); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "run tool", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + msgBus, ok := al.bus.(*bus.MessageBus) + if !ok { + t.Fatalf("expected concrete MessageBus, got %T", al.bus) + } + + select { + case outbound := <-msgBus.OutboundChan(): + if !strings.Contains(outbound.Content, "`echo_text_rewritten`") { + t.Fatalf("tool feedback content = %q, want rewritten tool name", outbound.Content) + } + if strings.Contains(outbound.Content, "`echo_text`") { + t.Fatalf("tool feedback content = %q, want no original tool name", outbound.Content) + } + case <-time.After(2 * time.Second): + t.Fatal("expected outbound tool feedback") + } +} + +type denyApprovalHook struct{} + +func (h *denyApprovalHook) ApproveTool(ctx context.Context, req *ToolApprovalRequest) (ApprovalDecision, error) { + return ApprovalDecision{ + Approved: false, + Reason: "blocked", + }, nil +} + +func TestAgentLoop_Hooks_ToolApproverCanDeny(t *testing.T) { + provider := &toolHookProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + al.RegisterTool(&echoTextTool{}) + if err := al.MountHook(NamedHook("deny-approval", &denyApprovalHook{})); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentToolExecSkipped, + ) + defer closeRuntimeEvents() + + resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "run tool", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + expected := "Tool execution denied by approval hook: blocked" + if resp != expected { + t.Fatalf("expected %q, got %q", expected, resp) + } + + events := collectRuntimeEventStream(runtimeCh) + skippedEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecSkipped) + if !ok { + t.Fatal("expected tool skipped event") + } + payload, ok := skippedEvt.Payload.(ToolExecSkippedPayload) + if !ok { + t.Fatalf("expected ToolExecSkippedPayload, got %T", skippedEvt.Payload) + } + if payload.Reason != expected { + t.Fatalf("expected skipped reason %q, got %q", expected, payload.Reason) + } +} + +// respondHook is a test hook for testing HookActionRespond functionality +type respondHook struct { + respondTools map[string]bool // tool names to respond to +} + +func (h *respondHook) BeforeTool( + ctx context.Context, + call *ToolCallHookRequest, +) (*ToolCallHookRequest, HookDecision, error) { + if h.respondTools[call.Tool] { + next := call.Clone() + next.HookResult = &tools.ToolResult{ + ForLLM: "hook-responded: " + call.Tool, + ForUser: "", + Silent: false, + IsError: false, + } + return next, HookDecision{Action: HookActionRespond}, nil + } + return call, HookDecision{Action: HookActionContinue}, nil +} + +func (h *respondHook) AfterTool( + ctx context.Context, + result *ToolResultHookResponse, +) (*ToolResultHookResponse, HookDecision, error) { + // Should not be called since respond skips tool execution + return result, HookDecision{Action: HookActionContinue}, nil +} + +func TestAgentLoop_Hooks_ToolRespondAction(t *testing.T) { + provider := &toolHookProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + al.RegisterTool(&echoTextTool{}) + if err := al.MountHook(NamedHook("respond-hook", &respondHook{ + respondTools: map[string]bool{"echo_text": true}, + })); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentToolExecEnd, + ) + defer closeRuntimeEvents() + + resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "run tool", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + // Verify response comes from hook, not tool + expected := "hook-responded: echo_text" + if resp != expected { + t.Fatalf("expected %q, got %q", expected, resp) + } + + // Verify event stream has ToolExecEnd, not actual tool execution + events := collectRuntimeEventStream(runtimeCh) + endEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecEnd) + if !ok { + t.Fatal("expected tool exec end event") + } + payload, ok := endEvt.Payload.(ToolExecEndPayload) + if !ok { + t.Fatalf("expected ToolExecEndPayload, got %T", endEvt.Payload) + } + if payload.Tool != "echo_text" { + t.Fatalf("expected tool echo_text, got %q", payload.Tool) + } + if payload.ForLLMLen != len(expected) { + t.Fatalf("expected ForLLMLen %d, got %d", len(expected), payload.ForLLMLen) + } +} + +// denyToolHook tests HookActionDenyTool functionality +type denyToolHook struct { + denyTools map[string]bool +} + +func (h *denyToolHook) BeforeTool( + ctx context.Context, + call *ToolCallHookRequest, +) (*ToolCallHookRequest, HookDecision, error) { + if h.denyTools[call.Tool] { + return call, HookDecision{Action: HookActionDenyTool, Reason: "tool denied by hook"}, nil + } + return call, HookDecision{Action: HookActionContinue}, nil +} + +func (h *denyToolHook) AfterTool( + ctx context.Context, + result *ToolResultHookResponse, +) (*ToolResultHookResponse, HookDecision, error) { + return result, HookDecision{Action: HookActionContinue}, nil +} + +func TestAgentLoop_Hooks_ToolDenyAction(t *testing.T) { + provider := &toolHookProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + al.RegisterTool(&echoTextTool{}) + if err := al.MountHook(NamedHook("deny-hook", &denyToolHook{ + denyTools: map[string]bool{"echo_text": true}, + })); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "run tool", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + expected := "Tool execution denied by hook: tool denied by hook" + if resp != expected { + t.Fatalf("expected %q, got %q", expected, resp) + } +} + +func TestHookManager_BeforeTool_RespondAction(t *testing.T) { + hm := NewHookManager(nil) + defer hm.Close() + + hook := &respondHook{ + respondTools: map[string]bool{"test_tool": true}, + } + if err := hm.Mount(NamedHook("respond-test", hook)); err != nil { + t.Fatalf("mount hook: %v", err) + } + + req := &ToolCallHookRequest{ + Tool: "test_tool", + Arguments: map[string]any{"arg": "value"}, + } + result, decision := hm.BeforeTool(context.Background(), req) + + if decision.Action != HookActionRespond { + t.Fatalf("expected action %q, got %q", HookActionRespond, decision.Action) + } + + if result.HookResult == nil { + t.Fatal("expected HookResult to be set") + } + if result.HookResult.ForLLM != "hook-responded: test_tool" { + t.Fatalf("unexpected HookResult.ForLLM: %q", result.HookResult.ForLLM) + } +} + +type respondWithMediaHook struct { + respondTools map[string]bool + media []string + responseHandled bool + forLLM string +} + +func (h *respondWithMediaHook) BeforeTool( + ctx context.Context, + call *ToolCallHookRequest, +) (*ToolCallHookRequest, HookDecision, error) { + if h.respondTools[call.Tool] { + next := call.Clone() + next.HookResult = &tools.ToolResult{ + ForLLM: h.forLLM, + ForUser: "media result", + Media: h.media, + ResponseHandled: h.responseHandled, + Silent: false, + IsError: false, + } + return next, HookDecision{Action: HookActionRespond}, nil + } + return call, HookDecision{Action: HookActionContinue}, nil +} + +func (h *respondWithMediaHook) AfterTool( + ctx context.Context, + result *ToolResultHookResponse, +) (*ToolResultHookResponse, HookDecision, error) { + return result, HookDecision{Action: HookActionContinue}, nil +} + +type errorMediaChannel struct { + fakeChannel + sendErr error +} + +func (f *errorMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { + return nil, f.sendErr +} + +func TestAgentLoop_HookRespond_MediaError(t *testing.T) { + provider := &multiToolProvider{ + toolCalls: []providers.ToolCall{ + {ID: "call-1", Name: "media_tool", Arguments: map[string]any{}}, + }, + finalContent: "done", + } + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + hook := &respondWithMediaHook{ + respondTools: map[string]bool{"media_tool": true}, + media: []string{"media://test/image.png"}, + responseHandled: true, + forLLM: "media sent successfully", + } + if err := al.MountHook(NamedHook("media-hook", hook)); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + al.channelManager = newStartedTestChannelManager(t, + al.bus.(*bus.MessageBus), al.mediaStore, "discord", &errorMediaChannel{ + sendErr: errors.New("channel unavailable"), + }) + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentToolExecEnd, + ) + defer closeRuntimeEvents() + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-media-err", + Channel: "discord", + ChatID: "chat1", + UserMessage: "send media", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + events := collectRuntimeEventStream(runtimeCh) + endEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecEnd) + if !ok { + t.Fatal("expected ToolExecEnd event") + } + payload, ok := endEvt.Payload.(ToolExecEndPayload) + if !ok { + t.Fatalf("expected ToolExecEndPayload, got %T", endEvt.Payload) + } + + if !payload.IsError { + t.Fatal("expected IsError=true when SendMedia fails") + } + + if payload.ForLLMLen < 30 { + t.Fatalf("expected ForLLM to contain error message, got ForLLMLen=%d", payload.ForLLMLen) + } +} + +func TestAgentLoop_HookRespond_BusFallback(t *testing.T) { + provider := &multiToolProvider{ + toolCalls: []providers.ToolCall{ + {ID: "call-1", Name: "media_tool", Arguments: map[string]any{}}, + }, + finalContent: "done", + } + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + hook := &respondWithMediaHook{ + respondTools: map[string]bool{"media_tool": true}, + media: []string{"media://test/image.png"}, + responseHandled: true, + forLLM: "media queued", + } + if err := al.MountHook(NamedHook("media-hook", hook)); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentToolExecEnd, + ) + defer closeRuntimeEvents() + + resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-bus-fallback", + Channel: "cli", + ChatID: "chat1", + UserMessage: "send media", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + events := collectRuntimeEventStream(runtimeCh) + endEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecEnd) + if !ok { + t.Fatal("expected ToolExecEnd event") + } + payload, ok := endEvt.Payload.(ToolExecEndPayload) + if !ok { + t.Fatalf("expected ToolExecEndPayload, got %T", endEvt.Payload) + } + + if payload.IsError { + t.Fatal("expected IsError=false for bus fallback (media queued, not delivered)") + } + + if resp != "done" { + t.Fatalf("expected response 'done', got %q", resp) + } +} + +func TestAgentLoop_HookRespond_ResponseHandledMediaPreservesOutboundContext(t *testing.T) { + provider := &multiToolProvider{ + toolCalls: []providers.ToolCall{ + {ID: "call-1", Name: "media_tool", Arguments: map[string]any{}}, + }, + finalContent: "done", + } + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + hook := &respondWithMediaHook{ + respondTools: map[string]bool{"media_tool": true}, + media: []string{"media://test/image.png"}, + responseHandled: true, + forLLM: "media sent successfully", + } + if err := al.MountHook(NamedHook("media-hook", hook)); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} + al.channelManager = newStartedTestChannelManager(t, + al.bus.(*bus.MessageBus), al.mediaStore, "telegram", telegramChannel) + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + Dispatch: DispatchRequest{ + SessionKey: "session-topic-media", + SessionScope: &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: agent.ID, + Channel: "telegram", + Dimensions: []string{"chat"}, + Values: map[string]string{ + "chat": "forum:-100123/42", + }, + }, + InboundContext: &bus.InboundContext{ + Channel: "telegram", + ChatID: "-100123", + TopicID: "42", + ChatType: "group", + SenderID: "user1", + }, + UserMessage: "send media", + }, + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + if len(telegramChannel.sentMedia) != 1 { + t.Fatalf("expected exactly 1 sent media message, got %d", len(telegramChannel.sentMedia)) + } + sent := telegramChannel.sentMedia[0] + if sent.Context.Channel != "telegram" || sent.Context.ChatID != "-100123" || sent.Context.TopicID != "42" { + t.Fatalf("unexpected media context: %+v", sent.Context) + } + if sent.AgentID != agent.ID { + t.Fatalf("sent media agent_id = %q, want %q", sent.AgentID, agent.ID) + } + if sent.SessionKey != "session-topic-media" { + t.Fatalf("sent media session_key = %q, want session-topic-media", sent.SessionKey) + } + if sent.Scope == nil || sent.Scope.Values["chat"] != "forum:-100123/42" { + t.Fatalf("unexpected sent media scope: %+v", sent.Scope) + } +} + +type multiToolProvider struct { + mu sync.Mutex + callCount int + toolCalls []providers.ToolCall + finalContent string +} + +func (p *multiToolProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + defer p.mu.Unlock() + + p.callCount++ + if p.callCount == 1 && len(p.toolCalls) > 0 { + return &providers.LLMResponse{ + ToolCalls: p.toolCalls, + }, nil + } + + return &providers.LLMResponse{ + Content: p.finalContent, + }, nil +} + +func (p *multiToolProvider) GetDefaultModel() string { + return "multi-tool-provider" +} + +func TestAgentLoop_HookRespond_InterruptSkipsRemaining(t *testing.T) { + provider := &multiToolProvider{ + toolCalls: []providers.ToolCall{ + {ID: "call-1", Name: "tool_one", Arguments: map[string]any{}}, + {ID: "call-2", Name: "tool_two", Arguments: map[string]any{}}, + {ID: "call-3", Name: "tool_three", Arguments: map[string]any{}}, + }, + finalContent: "done", + } + al, _, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + tool1ExecCh := make(chan struct{}, 1) + al.RegisterTool(&slowTool{name: "tool_two", duration: 100 * time.Millisecond, execCh: tool1ExecCh}) + al.RegisterTool(&slowTool{name: "tool_three", duration: 100 * time.Millisecond}) + + hook := &respondHook{ + respondTools: map[string]bool{"tool_one": true}, + } + if err := al.MountHook(NamedHook("respond-hook", hook)); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentToolExecSkipped, + ) + defer closeRuntimeEvents() + + sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) + + type result struct { + resp string + err error + } + resultCh := make(chan result, 1) + go func() { + resp, err := al.ProcessDirectWithChannel( + context.Background(), + "run tools", + sessionKey, + "cli", + "chat1", + ) + resultCh <- result{resp: resp, err: err} + }() + + select { + case <-tool1ExecCh: + case <-time.After(3 * time.Second): + t.Fatal("timeout waiting for tool execution to start") + } + + if err := al.InterruptGraceful("stop now"); err != nil { + t.Fatalf("InterruptGraceful failed: %v", err) + } + + select { + case r := <-resultCh: + if r.err != nil { + t.Fatalf("unexpected error: %v", r.err) + } + case <-time.After(3 * time.Second): + t.Fatal("timeout waiting for result") + } + + events := collectRuntimeEventStream(runtimeCh) + + skippedEvts := filterRuntimeEvents(events, runtimeevents.KindAgentToolExecSkipped) + if len(skippedEvts) < 1 { + t.Fatal("expected at least one ToolExecSkipped event after interrupt") + } + + for _, evt := range skippedEvts { + payload, ok := evt.Payload.(ToolExecSkippedPayload) + if !ok { + t.Fatalf("expected ToolExecSkippedPayload, got %T", evt.Payload) + } + if payload.Reason != "graceful interrupt requested" { + t.Fatalf("expected skip reason 'graceful interrupt requested', got %q", payload.Reason) + } + } +} + +func TestAgentLoop_HookRespond_SteeringSkipsRemaining(t *testing.T) { + provider := &multiToolProvider{ + toolCalls: []providers.ToolCall{ + {ID: "call-1", Name: "tool_one", Arguments: map[string]any{}}, + {ID: "call-2", Name: "tool_two", Arguments: map[string]any{}}, + {ID: "call-3", Name: "tool_three", Arguments: map[string]any{}}, + }, + finalContent: "done", + } + al, _, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + al.RegisterTool(&slowTool{name: "tool_two", duration: 100 * time.Millisecond}) + al.RegisterTool(&slowTool{name: "tool_three", duration: 100 * time.Millisecond}) + + hook := &respondHook{ + respondTools: map[string]bool{"tool_one": true}, + } + if err := al.MountHook(NamedHook("respond-hook", hook)); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentToolExecEnd, + runtimeevents.KindAgentToolExecSkipped, + ) + defer closeRuntimeEvents() + + sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) + + type result struct { + resp string + err error + } + resultCh := make(chan result, 1) + go func() { + resp, err := al.ProcessDirectWithChannel( + context.Background(), + "run tools", + sessionKey, + "cli", + "chat1", + ) + resultCh <- result{resp: resp, err: err} + }() + + collectedEvents := make([]runtimeevents.Event, 0, 8) + steered := false + deadline := time.After(3 * time.Second) + for !steered { + select { + case evt := <-runtimeCh: + collectedEvents = append(collectedEvents, evt) + if evt.Kind != runtimeevents.KindAgentToolExecEnd { + continue + } + payload, ok := evt.Payload.(ToolExecEndPayload) + if !ok || payload.Tool != "tool_one" { + continue + } + al.Steer(providers.Message{Role: "user", Content: "change direction"}) + steered = true + case <-deadline: + t.Fatal("timeout waiting for tool_one to finish before steering") + } + } + + select { + case r := <-resultCh: + if r.err != nil { + t.Fatalf("unexpected error: %v", r.err) + } + case <-time.After(3 * time.Second): + t.Fatal("timeout waiting for result") + } + + events := append(collectedEvents, collectRuntimeEventStream(runtimeCh)...) + + skippedEvts := filterRuntimeEvents(events, runtimeevents.KindAgentToolExecSkipped) + if len(skippedEvts) < 1 { + t.Fatal("expected at least one ToolExecSkipped event after steering") + } + + for _, evt := range skippedEvts { + payload, ok := evt.Payload.(ToolExecSkippedPayload) + if !ok { + t.Fatalf("expected ToolExecSkippedPayload, got %T", evt.Payload) + } + if payload.Reason != "queued user steering message" { + t.Fatalf("expected skip reason 'queued user steering message', got %q", payload.Reason) + } + } +} + +func TestCloneStringAnyMap_EmptyMapReturnsNonNil(t *testing.T) { + tests := []struct { + name string + input map[string]any + wantNil bool + wantLen int + }{ + { + name: "nil input returns empty map", + input: nil, + wantNil: false, + wantLen: 0, + }, + { + name: "empty map returns empty map", + input: map[string]any{}, + wantNil: false, + wantLen: 0, + }, + { + name: "populated map is cloned", + input: map[string]any{"key": "value"}, + wantNil: false, + wantLen: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := cloneStringAnyMap(tt.input) + if result == nil { + t.Fatal("cloneStringAnyMap returned nil — MCP tool calls " + + "with no arguments would send null instead of {}") + } + if len(result) != tt.wantLen { + t.Fatalf("expected len %d, got %d", tt.wantLen, len(result)) + } + }) + } + + t.Run("clone does not share underlying map", func(t *testing.T) { + src := map[string]any{"a": 1} + cloned := cloneStringAnyMap(src) + cloned["b"] = 2 + if _, ok := src["b"]; ok { + t.Fatal("modifying clone should not affect source") + } + }) +} diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index f6d036dfb..3f0089eec 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -1,14 +1,18 @@ package agent import ( + "context" "fmt" - "log" "os" "path/filepath" "regexp" "strings" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/isolation" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" @@ -31,7 +35,7 @@ type AgentInstance struct { SummarizeMessageThreshold int SummarizeTokenPercent int Provider providers.LLMProvider - Sessions *session.SessionManager + Sessions session.SessionStore ContextBuilder *ContextBuilder Tools *tools.ToolRegistry Subagents *config.SubagentsConfig @@ -45,6 +49,13 @@ type AgentInstance struct { // LightCandidates holds the resolved provider candidates for the light model. // Pre-computed at agent creation to avoid repeated model_list lookups at runtime. LightCandidates []providers.FallbackCandidate + // LightProvider is the concrete provider instance for the configured light model. + // It is only used when routing selects the light tier for a turn. + LightProvider providers.LLMProvider + // CandidateProviders maps "provider/model" keys to per-candidate LLMProvider + // instances. This allows each fallback model to use its own api_base and api_key + // from model_list, instead of inheriting the primary model's provider config. + CandidateProviders map[string]providers.LLMProvider } // NewAgentInstance creates an agent instance from config. @@ -54,6 +65,12 @@ func NewAgentInstance( cfg *config.Config, provider providers.LLMProvider, ) *AgentInstance { + if cfg != nil { + // Keep the subprocess isolation runtime aligned with the latest loaded config + // before any tools or providers start spawning child processes. + isolation.Configure(cfg) + } + workspace := resolveAgentWorkspace(agentCfg, defaults) os.MkdirAll(workspace, 0o755) @@ -64,13 +81,19 @@ func NewAgentInstance( readRestrict := restrict && !defaults.AllowReadOutsideWorkspace // Compile path whitelist patterns from config. - allowReadPaths := compilePatterns(cfg.Tools.AllowReadPaths) + allowReadPaths := buildAllowReadPatterns(cfg) allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths) toolsRegistry := tools.NewToolRegistry() if cfg.Tools.IsToolEnabled("read_file") { - toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths)) + maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize + switch cfg.Tools.ReadFile.EffectiveMode() { + case config.ReadFileModeLines: + toolsRegistry.Register(tools.NewReadFileLinesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths)) + default: + toolsRegistry.Register(tools.NewReadFileBytesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths)) + } } if cfg.Tools.IsToolEnabled("write_file") { toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths)) @@ -79,11 +102,13 @@ func NewAgentInstance( toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths)) } if cfg.Tools.IsToolEnabled("exec") { - execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg) + execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg, allowReadPaths) if err != nil { - log.Fatalf("Critical error: unable to initialize exec tool: %v", err) + logger.ErrorCF("agent", "Failed to initialize exec tool; continuing without exec", + map[string]any{"error": err.Error()}) + } else { + toolsRegistry.Register(execTool) } - toolsRegistry.Register(execTool) } if cfg.Tools.IsToolEnabled("edit_file") { @@ -94,9 +119,15 @@ func NewAgentInstance( } sessionsDir := filepath.Join(workspace, "sessions") - sessionsManager := session.NewSessionManager(sessionsDir) + sessions := initSessionStore(sessionsDir) - contextBuilder := NewContextBuilder(workspace) + mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled + contextBuilder := NewContextBuilder(workspace). + WithToolDiscovery( + mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25, + mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex, + ). + WithSplitOnMarker(cfg.Agents.Defaults.SplitOnMarker) // SkillsFilter will be applied after we know the agent config agentID := routing.DefaultAgentID @@ -126,6 +157,17 @@ func NewAgentInstance( maxTokens = 8192 } + contextWindow := defaults.ContextWindow + if contextWindow == 0 { + // Default heuristic: 4x the output token limit. + // Most models have context windows well above their output limits + // (e.g., GPT-4o 128k ctx / 16k out, Claude 200k ctx / 8k out). + // 4x is a conservative lower bound that avoids premature + // summarization while remaining safe — the reactive + // forceCompression handles any overshoot. + contextWindow = maxTokens * 4 + } + temperature := 0.7 if defaults.Temperature != nil { temperature = *defaults.Temperature @@ -148,68 +190,41 @@ func NewAgentInstance( } // Resolve fallback candidates - modelCfg := providers.ModelConfig{ - Primary: model, - Fallbacks: fallbacks, - } - resolveFromModelList := func(raw string) (string, bool) { - ensureProtocol := func(model string) string { - model = strings.TrimSpace(model) - if model == "" { - return "" - } - if strings.Contains(model, "/") { - return model - } - return "openai/" + model - } + candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks) - raw = strings.TrimSpace(raw) - if raw == "" { - return "", false - } - - if cfg != nil { - if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { - return ensureProtocol(mc.Model), true - } - - for i := range cfg.ModelList { - fullModel := strings.TrimSpace(cfg.ModelList[i].Model) - if fullModel == "" { - continue - } - if fullModel == raw { - return ensureProtocol(fullModel), true - } - _, modelID := providers.ExtractProtocol(fullModel) - if modelID == raw { - return ensureProtocol(fullModel), true - } - } - } - - return "", false - } - - candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList) + candidateProviders := make(map[string]providers.LLMProvider) + populateCandidateProvidersFromNames(cfg, workspace, fallbacks, candidateProviders) // Model routing setup: pre-resolve light model candidates at creation time // to avoid repeated model_list lookups on every incoming message. var router *routing.Router var lightCandidates []providers.FallbackCandidate + var lightProvider providers.LLMProvider if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" { - lightModelCfg := providers.ModelConfig{Primary: rc.LightModel} - resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList) + resolved := resolveModelCandidates(cfg, defaults.Provider, rc.LightModel, nil) if len(resolved) > 0 { - router = routing.New(routing.RouterConfig{ - LightModel: rc.LightModel, - Threshold: rc.Threshold, - }) - lightCandidates = resolved + lightModelCfg, err := resolvedModelConfig(cfg, rc.LightModel, workspace) + if err != nil { + logger.WarnCF("agent", "Routing light model config invalid; routing disabled", + map[string]any{"light_model": rc.LightModel, "agent_id": agentID, "error": err.Error()}) + } else { + lp, _, err := providers.CreateProviderFromConfig(lightModelCfg) + if err != nil { + logger.WarnCF("agent", "Routing light model provider init failed; routing disabled", + map[string]any{"light_model": rc.LightModel, "agent_id": agentID, "error": err.Error()}) + } else { + router = routing.New(routing.RouterConfig{ + LightModel: rc.LightModel, + Threshold: rc.Threshold, + }) + lightCandidates = resolved + lightProvider = lp + populateCandidateProvidersFromNames(cfg, workspace, []string{rc.LightModel}, candidateProviders) + } + } } else { - log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q", - rc.LightModel, agentID) + logger.WarnCF("agent", "Routing light model not found; routing disabled", + map[string]any{"light_model": rc.LightModel, "agent_id": agentID}) } } @@ -223,11 +238,11 @@ func NewAgentInstance( MaxTokens: maxTokens, Temperature: temperature, ThinkingLevel: thinkingLevel, - ContextWindow: maxTokens, + ContextWindow: contextWindow, SummarizeMessageThreshold: summarizeMessageThreshold, SummarizeTokenPercent: summarizeTokenPercent, Provider: provider, - Sessions: sessionsManager, + Sessions: sessions, ContextBuilder: contextBuilder, Tools: toolsRegistry, Subagents: subagents, @@ -235,6 +250,44 @@ func NewAgentInstance( Candidates: candidates, Router: router, LightCandidates: lightCandidates, + LightProvider: lightProvider, + CandidateProviders: candidateProviders, + } +} + +// populateCandidateProvidersFromNames resolves each model name (alias or +// "provider/model") via resolvedModelConfig and creates a dedicated LLMProvider +// for it. This reuses the canonical config resolution path (GetModelConfig) so +// alias handling and load-balancing stay consistent with the rest of the codebase. +func populateCandidateProvidersFromNames( + cfg *config.Config, + workspace string, + names []string, + out map[string]providers.LLMProvider, +) { + if cfg == nil || len(names) == 0 { + return + } + for _, name := range names { + mc, err := resolvedModelConfig(cfg, strings.TrimSpace(name), workspace) + if err != nil { + logger.WarnCF("agent", + "fallback provider: no model_list entry found; will inherit primary provider credentials", + map[string]any{"name": name, "error": err.Error()}) + continue + } + protocol, modelID := providers.ExtractProtocol(mc) + key := providers.ModelKey(protocol, modelID) + if _, exists := out[key]; exists { + continue + } + p, _, err := providers.CreateProviderFromConfig(mc) + if err != nil { + logger.WarnCF("agent", "fallback provider: failed to create provider", + map[string]any{"model": mc.Model, "error": err.Error()}) + continue + } + out[key] = p } } @@ -281,6 +334,63 @@ func compilePatterns(patterns []string) []*regexp.Regexp { return compiled } +func buildAllowReadPatterns(cfg *config.Config) []*regexp.Regexp { + var configured []string + if cfg != nil { + configured = cfg.Tools.AllowReadPaths + } + + compiled := compilePatterns(configured) + mediaDirPattern := regexp.MustCompile(mediaTempDirPattern()) + for _, pattern := range compiled { + if pattern.String() == mediaDirPattern.String() { + return compiled + } + } + + return append(compiled, mediaDirPattern) +} + +func mediaTempDirPattern() string { + sep := regexp.QuoteMeta(string(os.PathSeparator)) + return "^" + regexp.QuoteMeta(filepath.Clean(media.TempDir())) + "(?:" + sep + "|$)" +} + +// Close releases resources held by the agent's session store. +func (a *AgentInstance) Close() error { + if a.Sessions != nil { + return a.Sessions.Close() + } + return nil +} + +// initSessionStore creates the session persistence backend. +// It uses the JSONL store by default and auto-migrates legacy JSON sessions. +// Falls back to SessionManager if the JSONL store cannot be initialized or +// if migration fails (which indicates the store cannot write reliably). +func initSessionStore(dir string) session.SessionStore { + store, err := memory.NewJSONLStore(dir) + if err != nil { + logger.WarnCF("agent", "Memory JSONL store init failed; falling back to json sessions", + map[string]any{"error": err.Error()}) + return session.NewSessionManager(dir) + } + + if n, merr := memory.MigrateFromJSON(context.Background(), dir, store); merr != nil { + // Migration failure means the store could not write data. + // Fall back to SessionManager to avoid a split state where + // some sessions are in JSONL and others remain in JSON. + logger.WarnCF("agent", "Memory migration failed; falling back to json sessions", + map[string]any{"error": merr.Error()}) + store.Close() + return session.NewSessionManager(dir) + } else if n > 0 { + logger.InfoCF("agent", "Memory migrated to JSONL", map[string]any{"sessions_migrated": n}) + } + + return session.NewJSONLBackend(store) +} + func expandHome(path string) string { if path == "" { return path diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 4f41ecd1c..42bb53d86 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -1,10 +1,15 @@ package agent import ( + "context" "os" + "path/filepath" + "strings" "testing" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" ) func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) { @@ -18,7 +23,7 @@ func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 1234, MaxToolIterations: 5, }, @@ -50,7 +55,7 @@ func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 1234, MaxToolIterations: 5, }, @@ -79,7 +84,7 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 1234, MaxToolIterations: 5, }, @@ -99,6 +104,7 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { name string aliasName string modelName string + provider string apiBase string wantProvider string wantModel string @@ -119,6 +125,15 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { wantProvider: "openai", wantModel: "glm-5", }, + { + name: "explicit provider overrides model prefix", + aliasName: "nvidia-gpt", + modelName: "z-ai/glm-5.1", + provider: "nvidia", + apiBase: "https://integrate.api.nvidia.com/v1", + wantProvider: "nvidia", + wantModel: "z-ai/glm-5.1", + }, } for _, tt := range tests { @@ -133,13 +148,14 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: tt.aliasName, + ModelName: tt.aliasName, }, }, - ModelList: []config.ModelConfig{ + ModelList: []*config.ModelConfig{ { ModelName: tt.aliasName, Model: tt.modelName, + Provider: tt.provider, APIBase: tt.apiBase, }, }, @@ -160,3 +176,443 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { }) } } + +func TestNewAgentInstance_PreservesDistinctLimiterIdentityForSharedResolvedModel(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "glm-4.7", + ModelFallbacks: []string{"glm-4.7__key_1"}, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "glm-4.7", + Model: "zhipu/glm-4.7", + RPM: 1, + }, + { + ModelName: "glm-4.7__key_1", + Model: "zhipu/glm-4.7", + RPM: 3, + }, + }, + } + + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{}) + if len(agent.Candidates) != 2 { + t.Fatalf("len(Candidates) = %d, want 2", len(agent.Candidates)) + } + + first := agent.Candidates[0] + second := agent.Candidates[1] + if first.Provider != "zhipu" || first.Model != "glm-4.7" { + t.Fatalf("first candidate = %s/%s, want zhipu/glm-4.7", first.Provider, first.Model) + } + if second.Provider != "zhipu" || second.Model != "glm-4.7" { + t.Fatalf("second candidate = %s/%s, want zhipu/glm-4.7", second.Provider, second.Model) + } + if first.IdentityKey != "model_name:glm-4.7" { + t.Fatalf("first identity key = %q, want %q", first.IdentityKey, "model_name:glm-4.7") + } + if second.IdentityKey != "model_name:glm-4.7__key_1" { + t.Fatalf("second identity key = %q, want %q", second.IdentityKey, "model_name:glm-4.7__key_1") + } + if first.RPM != 1 { + t.Fatalf("first RPM = %d, want 1", first.RPM) + } + if second.RPM != 3 { + t.Fatalf("second RPM = %d, want 3", second.RPM) + } +} + +func TestNewAgentInstance_PreservesConfigIdentityForExplicitProviderModelRef(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "nvidia/z-ai/glm-5.1", + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "nvidia-glm", + Provider: "nvidia", + Model: "z-ai/glm-5.1", + RPM: 7, + }, + }, + } + + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{}) + if len(agent.Candidates) != 1 { + t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) + } + + candidate := agent.Candidates[0] + if candidate.Provider != "nvidia" || candidate.Model != "z-ai/glm-5.1" { + t.Fatalf("candidate = %s/%s, want nvidia/z-ai/glm-5.1", candidate.Provider, candidate.Model) + } + if candidate.IdentityKey != "model_name:nvidia-glm" { + t.Fatalf("identity key = %q, want %q", candidate.IdentityKey, "model_name:nvidia-glm") + } + if candidate.RPM != 7 { + t.Fatalf("RPM = %d, want 7", candidate.RPM) + } +} + +func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) { + workspace := t.TempDir() + mediaDir := media.TempDir() + if err := os.MkdirAll(mediaDir, 0o700); err != nil { + t.Fatalf("MkdirAll(mediaDir) error = %v", err) + } + + mediaFile, err := os.CreateTemp(mediaDir, "instance-tool-*.txt") + if err != nil { + t.Fatalf("CreateTemp(mediaDir) error = %v", err) + } + mediaPath := mediaFile.Name() + if _, err := mediaFile.WriteString("attachment content"); err != nil { + mediaFile.Close() + t.Fatalf("WriteString(mediaFile) error = %v", err) + } + if err := mediaFile.Close(); err != nil { + t.Fatalf("Close(mediaFile) error = %v", err) + } + t.Cleanup(func() { _ = os.Remove(mediaPath) }) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "test-model", + RestrictToWorkspace: true, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{Enabled: true}, + ListDir: config.ToolConfig{Enabled: true}, + Exec: config.ExecConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + EnableDenyPatterns: true, + AllowRemote: true, + }, + }, + } + + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{}) + + readTool, ok := agent.Tools.Get("read_file") + if !ok { + t.Fatal("read_file tool not registered") + } + readResult := readTool.Execute(context.Background(), map[string]any{"path": mediaPath}) + if readResult.IsError { + t.Fatalf("read_file should allow media temp dir, got: %s", readResult.ForLLM) + } + if !strings.Contains(readResult.ForLLM, "attachment content") { + t.Fatalf("read_file output missing media content: %s", readResult.ForLLM) + } + + listTool, ok := agent.Tools.Get("list_dir") + if !ok { + t.Fatal("list_dir tool not registered") + } + listResult := listTool.Execute(context.Background(), map[string]any{"path": mediaDir}) + if listResult.IsError { + t.Fatalf("list_dir should allow media temp dir, got: %s", listResult.ForLLM) + } + if !strings.Contains(listResult.ForLLM, filepath.Base(mediaPath)) { + t.Fatalf("list_dir output missing media file: %s", listResult.ForLLM) + } + + execTool, ok := agent.Tools.Get("exec") + if !ok { + t.Fatal("exec tool not registered") + } + execResult := execTool.Execute(context.Background(), map[string]any{ + "action": "run", + "command": "cat " + filepath.Base(mediaPath), + "cwd": mediaDir, + }) + if execResult.IsError { + t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM) + } + if !strings.Contains(execResult.ForLLM, "attachment content") { + t.Fatalf("exec output missing media content: %s", execResult.ForLLM) + } +} + +// TestPopulateCandidateProviders_NilCfgIsNoop verifies that passing a nil +// config does not panic and leaves the output map empty. +func TestPopulateCandidateProviders_NilCfgIsNoop(t *testing.T) { + out := map[string]providers.LLMProvider{} + populateCandidateProvidersFromNames(nil, t.TempDir(), []string{"gpt-4o"}, out) + if len(out) != 0 { + t.Fatalf("expected empty map, got %d entries", len(out)) + } +} + +// TestPopulateCandidateProviders_SkipsExistingKeys verifies that a key already +// present in the output map is not overwritten. +func TestPopulateCandidateProviders_SkipsExistingKeys(t *testing.T) { + existing := &mockProvider{} + key := providers.ModelKey("openai", "gpt-4o") + out := map[string]providers.LLMProvider{key: existing} + + cfg := &config.Config{ + ModelList: []*config.ModelConfig{ + {ModelName: "my-gpt", Model: "openai/gpt-4o", APIKeys: config.SimpleSecureStrings("test-key")}, + }, + } + populateCandidateProvidersFromNames(cfg, t.TempDir(), []string{"my-gpt"}, out) + + if out[key] != existing { + t.Fatal("existing provider entry was overwritten; expected it to be preserved") + } +} + +// TestPopulateCandidateProviders_ResolvesAlias verifies that a model_name +// alias (e.g. "my-gpt") is resolved via GetModelConfig and the provider +// is created using the underlying model's config. +func TestPopulateCandidateProviders_ResolvesAlias(t *testing.T) { + workspace := t.TempDir() + out := map[string]providers.LLMProvider{} + + cfg := &config.Config{ + ModelList: []*config.ModelConfig{ + {ModelName: "my-gpt", Model: "openai/gpt-4o", APIBase: "https://api.openai.com/v1", Workspace: workspace}, + }, + } + populateCandidateProvidersFromNames(cfg, workspace, []string{"my-gpt"}, out) + + key := providers.ModelKey("openai", "gpt-4o") + if out[key] == nil { + t.Fatalf("expected CandidateProviders[%q] to be populated for alias", key) + } +} + +// TestPopulateCandidateProviders_ResolvesProtocolPrefix verifies that a +// model_list entry using full "provider/model" notation (e.g. +// "gemini/gemma-3-27b-it") is matched correctly when referenced by model_name. +func TestPopulateCandidateProviders_ResolvesProtocolPrefix(t *testing.T) { + workspace := t.TempDir() + out := map[string]providers.LLMProvider{} + + cfg := &config.Config{ + ModelList: []*config.ModelConfig{ + { + ModelName: "gemma", + Model: "gemini/gemma-3-27b-it", + APIKeys: config.SimpleSecureStrings("gemini-test-key"), + Workspace: workspace, + }, + }, + } + populateCandidateProvidersFromNames(cfg, workspace, []string{"gemma"}, out) + + key := providers.ModelKey("gemini", "gemma-3-27b-it") + if out[key] == nil { + t.Fatalf("expected CandidateProviders[%q] to be populated for protocol-prefixed model", key) + } +} + +// TestPopulateCandidateProviders_EmptyNamesIsNoop verifies the early-exit +// path when the names slice is empty. +func TestPopulateCandidateProviders_EmptyNamesIsNoop(t *testing.T) { + out := map[string]providers.LLMProvider{} + cfg := &config.Config{ + ModelList: []*config.ModelConfig{ + {ModelName: "my-gpt", Model: "openai/gpt-4o", APIKeys: config.SimpleSecureStrings("key")}, + }, + } + populateCandidateProvidersFromNames(cfg, t.TempDir(), nil, out) + if len(out) != 0 { + t.Fatalf("expected empty map, got %d entries", len(out)) + } +} + +// TestPopulateCandidateProviders_EmptyModelListIsNoop verifies the early-exit +// path when model_list is empty — no provider can be created. +func TestPopulateCandidateProviders_EmptyModelListIsNoop(t *testing.T) { + out := map[string]providers.LLMProvider{} + cfg := &config.Config{} + populateCandidateProvidersFromNames(cfg, t.TempDir(), []string{"gpt-4o"}, out) + if len(out) != 0 { + t.Fatalf("expected empty map, got %d entries", len(out)) + } +} + +// TestPopulateCandidateProviders_UnmatchedNameIsSkipped verifies that a +// name with no matching model_list entry is skipped and does not +// cause a panic or leave a nil entry in the map. +func TestPopulateCandidateProviders_UnmatchedNameIsSkipped(t *testing.T) { + out := map[string]providers.LLMProvider{} + cfg := &config.Config{ + ModelList: []*config.ModelConfig{ + {ModelName: "my-gpt", Model: "openai/gpt-4o", APIKeys: config.SimpleSecureStrings("key")}, + }, + } + populateCandidateProvidersFromNames(cfg, t.TempDir(), []string{"nonexistent-model"}, out) + + if len(out) != 0 { + t.Fatalf("expected empty map for unmatched name, got %d entries", len(out)) + } +} + +// TestNewAgentInstance_CandidateProvidersPopulatedForCrossProviderFallbacks +// mirrors the exact scenario from bug #2140: primary model on OpenRouter with +// Gemini fallbacks. Each entry must get its own provider instance so that +// fallback requests go to the correct API endpoint, not the primary's. +func TestNewAgentInstance_CandidateProvidersPopulatedForCrossProviderFallbacks(t *testing.T) { + workspace := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "mistral-small-3.1", + ModelFallbacks: []string{"gemma-3-27b", "gemini-images"}, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "mistral-small-3.1", + Model: "openrouter/mistralai/mistral-small-3.1-24b-instruct:free", + APIBase: "https://openrouter.ai/api/v1", + APIKeys: config.SimpleSecureStrings("sk-or-test"), + Workspace: workspace, + }, + { + ModelName: "gemma-3-27b", + Model: "gemini/gemma-3-27b-it", + APIKeys: config.SimpleSecureStrings("AIzaSy-test"), + Workspace: workspace, + }, + { + ModelName: "gemini-images", + Model: "gemini/gemini-2.5-flash-lite", + APIKeys: config.SimpleSecureStrings("AIzaSy-test"), + Workspace: workspace, + }, + }, + } + + primaryProvider := &mockProvider{} + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, primaryProvider) + + // Only fallback models need entries — the primary uses the injected provider directly. + wantKeys := []string{ + providers.ModelKey("gemini", "gemma-3-27b-it"), + providers.ModelKey("gemini", "gemini-2.5-flash-lite"), + } + + for _, key := range wantKeys { + p, ok := agent.CandidateProviders[key] + if !ok { + t.Errorf("CandidateProviders missing key %q", key) + continue + } + if p == nil { + t.Errorf("CandidateProviders[%q] is nil", key) + } + // Each fallback must use its own provider, not the injected primary. + if p == primaryProvider { + t.Errorf( + "CandidateProviders[%q] is the same instance as the primary provider; fallback would inherit primary credentials", + key, + ) + } + } + + if t.Failed() { + t.Logf("CandidateProviders keys present: %v", func() []string { + keys := make([]string, 0, len(agent.CandidateProviders)) + for k := range agent.CandidateProviders { + keys = append(keys, k) + } + return keys + }()) + } +} + +func TestNewAgentInstance_ReadFileModeSelectsSchema(t *testing.T) { + workspace := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "test-model", + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + Mode: config.ReadFileModeLines, + MaxReadFileSize: 4096, + }, + }, + } + + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{}) + readTool, ok := agent.Tools.Get("read_file") + if !ok { + t.Fatal("read_file tool not registered") + } + + params := readTool.Parameters() + props, _ := params["properties"].(map[string]any) + if _, ok := props["start_line"]; !ok { + t.Fatalf("expected line-mode schema to expose start_line, got %#v", props) + } + if _, ok := props["max_lines"]; !ok { + t.Fatalf("expected line-mode schema to expose max_lines, got %#v", props) + } + if _, ok := props["offset"]; ok { + t.Fatalf("did not expect line-mode schema to expose offset, got %#v", props) + } + if _, ok := props["length"]; ok { + t.Fatalf("did not expect line-mode schema to expose length, got %#v", props) + } +} + +func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) { + workspace := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "test-model", + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{Enabled: true}, + Exec: config.ExecConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + EnableDenyPatterns: true, + CustomDenyPatterns: []string{"[invalid-regex"}, + }, + }, + } + + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{}) + if agent == nil { + t.Fatal("expected agent instance, got nil") + } + + if _, ok := agent.Tools.Get("exec"); ok { + t.Fatal("exec tool should not be registered when exec config is invalid") + } + + if _, ok := agent.Tools.Get("read_file"); !ok { + t.Fatal("read_file tool should still be registered") + } +} diff --git a/pkg/agent/interfaces/interfaces.go b/pkg/agent/interfaces/interfaces.go new file mode 100644 index 000000000..2efec05e1 --- /dev/null +++ b/pkg/agent/interfaces/interfaces.go @@ -0,0 +1,54 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package interfaces + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" +) + +// MessageBus publishes inbound and outbound messages. +// It is the primary communication channel for the agent loop. +type MessageBus interface { + // PublishInbound sends an inbound message to be processed. + PublishInbound(ctx context.Context, msg bus.InboundMessage) error + + // PublishOutbound sends an outbound message to the appropriate channel. + PublishOutbound(ctx context.Context, msg bus.OutboundMessage) error + + // PublishOutboundMedia sends an outbound media message. + PublishOutboundMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + + // InboundChan returns the channel for receiving inbound messages. + InboundChan() <-chan bus.InboundMessage +} + +// ChannelManager manages channel lifecycle and provides channel access. +type ChannelManager interface { + // GetChannel returns the channel with the given name. + GetChannel(name string) (channels.Channel, bool) + + // GetEnabledChannels returns the list of enabled channel names. + GetEnabledChannels() []string + + // InvokeTypingStop signals that typing has stopped. + InvokeTypingStop(channel, chatID string) + + // SendMessage sends a text message to the specified channel and chat. + SendMessage(ctx context.Context, msg bus.OutboundMessage) error + + // SendMedia sends a media message to the specified channel and chat. + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + + // SendPlaceholder sends a placeholder message (e.g., for audio transcription). + SendPlaceholder(ctx context.Context, channel, chatID string) bool + + // DismissToolFeedback clears any tracked tool feedback animation for the + // given channel/chat. Call this when a turn ends without a final response + // (e.g., ResponseHandled tools) to avoid orphaned animation goroutines. + // outboundCtx carries topic/thread info needed for channels that use + // scoped tracker keys (e.g., Telegram forum topics); may be nil. + DismissToolFeedback(ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext) +} diff --git a/pkg/agent/llm_media.go b/pkg/agent/llm_media.go new file mode 100644 index 000000000..31692174b --- /dev/null +++ b/pkg/agent/llm_media.go @@ -0,0 +1,67 @@ +package agent + +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func messagesContainMedia(messages []providers.Message) bool { + for _, msg := range messages { + for _, ref := range msg.Media { + if strings.TrimSpace(ref) != "" { + return true + } + } + } + return false +} + +func stripMessageMedia(messages []providers.Message) []providers.Message { + if !messagesContainMedia(messages) { + return messages + } + stripped := make([]providers.Message, len(messages)) + for i, msg := range messages { + stripped[i] = msg + stripped[i].Media = nil + } + return stripped +} + +func isVisionUnsupportedError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + + // OpenRouter (and OpenAI-compatible) style. + if strings.Contains(msg, "no endpoints found that support image input") { + return true + } + + // Common provider variants. + if strings.Contains(msg, "does not support image input") || + strings.Contains(msg, "does not support image inputs") || + strings.Contains(msg, "does not support images") || + strings.Contains(msg, "image input is not supported") || + strings.Contains(msg, "images are not supported") || + strings.Contains(msg, "does not support vision") || + strings.Contains(msg, "unsupported content type: image_url") { + return true + } + + // Some providers return a generic "invalid" message that still mentions image_url. + if strings.Contains(msg, "image_url") && strings.Contains(msg, "invalid") { + return true + } + + // DeepSeek and other strict providers reject the image_url field at the + // JSON schema level with an "unknown variant" error rather than a semantic + // "not supported" message. + if strings.Contains(msg, "unknown variant") && strings.Contains(msg, "image_url") { + return true + } + + return false +} diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go deleted file mode 100644 index c516f6d8d..000000000 --- a/pkg/agent/loop.go +++ /dev/null @@ -1,1980 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package agent - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - "sync" - "sync/atomic" - "time" - "unicode/utf8" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/pathutil" - "github.com/sipeed/picoclaw/pkg/channels" - "github.com/sipeed/picoclaw/pkg/commands" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/constants" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/mcp" - "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/providers" - "github.com/sipeed/picoclaw/pkg/routing" - "github.com/sipeed/picoclaw/pkg/session" - "github.com/sipeed/picoclaw/pkg/skills" - "github.com/sipeed/picoclaw/pkg/state" - "github.com/sipeed/picoclaw/pkg/tools" - "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" -) - -type AgentLoop struct { - bus *bus.MessageBus - cfg *config.Config - registry *AgentRegistry - state *state.Manager - running atomic.Bool - summarizing sync.Map - fallback *providers.FallbackChain - channelManager *channels.Manager - mediaStore media.MediaStore - transcriber voice.Transcriber - cmdRegistry *commands.Registry -} - -// processOptions configures how a message is processed -type processOptions struct { - SessionKey string // Session identifier for history/context - Channel string // Target channel for tool execution - ChatID string // Target chat ID for tool execution - UserMessage string // User message content (may include prefix) - Media []string // media:// refs from inbound message - DefaultResponse string // Response when LLM returns empty - EnableSummary bool // Whether to trigger summarization - SendResponse bool // Whether to send response via bus - NoHistory bool // If true, don't load session history (for heartbeat) - WorkspaceOverride string // If set, use this workspace instead of agent.Workspace - ConfigDir string // If set, config directory for workspace-local overrides - AllowedTools []string // If non-empty, only these tools are active for this request - AllowedSkills []string // If non-empty, only these skills are loaded for this request - - // effSessions and effContextBuilder are set by runAgentLoop when a workspace - // override is active. All downstream code (runLLMIteration, forceCompressionWith - // retry) MUST use these instead of agent.Sessions / agent.ContextBuilder. - effSessions *session.SessionManager - effContextBuilder *ContextBuilder - - // effProvider and effModel are set by runAgentLoop when a workspace config - // provides per-request provider overrides. All LLM call sites (runLLMIteration, - // summarization) MUST use these instead of agent.Provider / agent.Model. - effProvider providers.LLMProvider - effModel string -} - -const ( - defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." - sessionKeyAgentPrefix = "agent:" - metadataKeyAccountID = "account_id" - metadataKeyGuildID = "guild_id" - metadataKeyTeamID = "team_id" - metadataKeyParentPeerKind = "parent_peer_kind" - metadataKeyParentPeerID = "parent_peer_id" -) - -func NewAgentLoop( - cfg *config.Config, - msgBus *bus.MessageBus, - provider providers.LLMProvider, -) *AgentLoop { - registry := NewAgentRegistry(cfg, provider) - - // Register shared tools to all agents - registerSharedTools(cfg, msgBus, registry, provider) - - // Set up shared fallback chain - cooldown := providers.NewCooldownTracker() - fallbackChain := providers.NewFallbackChain(cooldown) - - // Create state manager using default agent's workspace for channel recording - defaultAgent := registry.GetDefaultAgent() - var stateManager *state.Manager - if defaultAgent != nil { - stateManager = state.NewManager(defaultAgent.Workspace) - } - - al := &AgentLoop{ - bus: msgBus, - cfg: cfg, - registry: registry, - state: stateManager, - summarizing: sync.Map{}, - fallback: fallbackChain, - cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), - } - - return al -} - -// registerSharedTools registers tools that are shared across all agents (web, message, spawn). -func registerSharedTools( - cfg *config.Config, - msgBus *bus.MessageBus, - registry *AgentRegistry, - provider providers.LLMProvider, -) { - for _, agentID := range registry.ListAgentIDs() { - agent, ok := registry.GetAgent(agentID) - if !ok { - continue - } - - // Web tools - if cfg.Tools.IsToolEnabled("web") { - searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ - BraveAPIKey: cfg.Tools.Web.Brave.APIKey, - BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, - BraveEnabled: cfg.Tools.Web.Brave.Enabled, - TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, - TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, - TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, - TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, - DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, - DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, - PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, - PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, - PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, - SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, - SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, - SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, - GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey, - GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, - GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, - GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, - GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, - Proxy: cfg.Tools.Web.Proxy, - }) - if err != nil { - logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) - } else if searchTool != nil { - agent.Tools.Register(searchTool) - } - } - if cfg.Tools.IsToolEnabled("web_fetch") { - fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } else { - agent.Tools.Register(fetchTool) - } - } - - // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms - if cfg.Tools.IsToolEnabled("i2c") { - agent.Tools.Register(tools.NewI2CTool()) - } - if cfg.Tools.IsToolEnabled("spi") { - agent.Tools.Register(tools.NewSPITool()) - } - - // Message tool - if cfg.Tools.IsToolEnabled("message") { - messageTool := tools.NewMessageTool() - messageTool.SetSendCallback(func(channel, chatID, content string) error { - pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: content, - }) - }) - agent.Tools.Register(messageTool) - } - - // Send file tool (outbound media via MediaStore — store injected later by SetMediaStore) - if cfg.Tools.IsToolEnabled("send_file") { - sendFileTool := tools.NewSendFileTool( - agent.Workspace, - cfg.Agents.Defaults.RestrictToWorkspace, - cfg.Agents.Defaults.GetMaxMediaSize(), - nil, - ) - agent.Tools.Register(sendFileTool) - } - - // Skill discovery and installation tools - skills_enabled := cfg.Tools.IsToolEnabled("skills") - find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") - install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") - if skills_enabled && (find_skills_enable || install_skills_enable) { - registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ - MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, - ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), - }) - - if find_skills_enable { - searchCache := skills.NewSearchCache( - cfg.Tools.Skills.SearchCache.MaxSize, - time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, - ) - agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) - } - - if install_skills_enable { - agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) - } - } - - // Spawn tool with allowlist checker - if cfg.Tools.IsToolEnabled("spawn") { - if cfg.Tools.IsToolEnabled("subagent") { - subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) - subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) - spawnTool := tools.NewSpawnTool(subagentManager) - currentAgentID := agentID - spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { - return registry.CanSpawnSubagent(currentAgentID, targetAgentID) - }) - agent.Tools.Register(spawnTool) - } else { - logger.WarnCF("agent", "spawn tool requires subagent to be enabled", nil) - } - } - } -} - -func (al *AgentLoop) Run(ctx context.Context) error { - al.running.Store(true) - - // Initialize MCP servers for all agents - if al.cfg.Tools.IsToolEnabled("mcp") { - mcpManager := mcp.NewManager() - // Ensure MCP connections are cleaned up on exit, regardless of initialization success - // This fixes resource leak when LoadFromMCPConfig partially succeeds then fails - defer func() { - if err := mcpManager.Close(); err != nil { - logger.ErrorCF("agent", "Failed to close MCP manager", - map[string]any{ - "error": err.Error(), - }) - } - }() - - defaultAgent := al.registry.GetDefaultAgent() - var workspacePath string - if defaultAgent != nil && defaultAgent.Workspace != "" { - workspacePath = defaultAgent.Workspace - } else { - workspacePath = al.cfg.WorkspacePath() - } - - if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { - logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", - map[string]any{ - "error": err.Error(), - }) - } else { - // Register MCP tools for all agents - servers := mcpManager.GetServers() - uniqueTools := 0 - totalRegistrations := 0 - agentIDs := al.registry.ListAgentIDs() - agentCount := len(agentIDs) - - for serverName, conn := range servers { - uniqueTools += len(conn.Tools) - for _, tool := range conn.Tools { - for _, agentID := range agentIDs { - agent, ok := al.registry.GetAgent(agentID) - if !ok { - continue - } - - mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) - agent.Tools.Register(mcpTool) - totalRegistrations++ - logger.DebugCF("agent", "Registered MCP tool", - map[string]any{ - "agent_id": agentID, - "server": serverName, - "tool": tool.Name, - "name": mcpTool.Name(), - }) - } - } - } - logger.InfoCF("agent", "MCP tools registered successfully", - map[string]any{ - "server_count": len(servers), - "unique_tools": uniqueTools, - "total_registrations": totalRegistrations, - "agent_count": agentCount, - }) - } - } - - for al.running.Load() { - select { - case <-ctx.Done(): - return nil - default: - msg, ok := al.bus.ConsumeInbound(ctx) - if !ok { - continue - } - - // Process message - func() { - // TODO: Re-enable media cleanup after inbound media is properly consumed by the agent. - // Currently disabled because files are deleted before the LLM can access their content. - // defer func() { - // if al.mediaStore != nil && msg.MediaScope != "" { - // if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil { - // logger.WarnCF("agent", "Failed to release media", map[string]any{ - // "scope": msg.MediaScope, - // "error": releaseErr.Error(), - // }) - // } - // } - // }() - - response, metrics, err := al.processMessage(ctx, msg) - if err != nil { - response = fmt.Sprintf("Error processing message: %v", err) - metrics = nil // don't attach metrics to error responses - } - - if response != "" { - // Check if the message tool already sent a response during this round. - // If so, skip publishing to avoid duplicate messages to the user. - // Use default agent's tools to check (message tool is shared). - alreadySent := false - defaultAgent := al.registry.GetDefaultAgent() - if defaultAgent != nil { - if tool, ok := defaultAgent.Tools.Get("message"); ok { - if mt, ok := tool.(*tools.MessageTool); ok { - alreadySent = mt.HasSentInRound() - } - } - } - - if !alreadySent { - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: msg.Channel, - ChatID: msg.ChatID, - Content: response, - Metrics: metrics, - }) - logger.InfoCF("agent", "Published outbound response", - map[string]any{ - "channel": msg.Channel, - "chat_id": msg.ChatID, - "content_len": len(response), - }) - } else { - logger.DebugCF( - "agent", - "Skipped outbound (message tool already sent)", - map[string]any{"channel": msg.Channel}, - ) - } - } - }() - } - } - - return nil -} - -func (al *AgentLoop) Stop() { - al.running.Store(false) -} - -func (al *AgentLoop) RegisterTool(tool tools.Tool) { - for _, agentID := range al.registry.ListAgentIDs() { - if agent, ok := al.registry.GetAgent(agentID); ok { - agent.Tools.Register(tool) - } - } -} - -func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { - al.channelManager = cm -} - -// SetMediaStore injects a MediaStore for media lifecycle management. -func (al *AgentLoop) SetMediaStore(s media.MediaStore) { - al.mediaStore = s - - // Propagate store to send_file tools in all agents. - al.registry.ForEachTool("send_file", func(t tools.Tool) { - if sf, ok := t.(*tools.SendFileTool); ok { - sf.SetMediaStore(s) - } - }) -} - -// SetTranscriber injects a voice transcriber for agent-level audio transcription. -func (al *AgentLoop) SetTranscriber(t voice.Transcriber) { - al.transcriber = t -} - -var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) - -// transcribeAudioInMessage resolves audio media refs, transcribes them, and -// replaces audio annotations in msg.Content with the transcribed text. -func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) bus.InboundMessage { - if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 { - return msg - } - - // Transcribe each audio media ref in order. - var transcriptions []string - for _, ref := range msg.Media { - path, meta, err := al.mediaStore.ResolveWithMeta(ref) - if err != nil { - logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) - continue - } - if !utils.IsAudioFile(meta.Filename, meta.ContentType) { - continue - } - result, err := al.transcriber.Transcribe(ctx, path) - if err != nil { - logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err}) - transcriptions = append(transcriptions, "") - continue - } - transcriptions = append(transcriptions, result.Text) - } - - if len(transcriptions) == 0 { - return msg - } - - // Replace audio annotations sequentially with transcriptions. - idx := 0 - newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string { - if idx >= len(transcriptions) { - return match - } - text := transcriptions[idx] - idx++ - return "[voice: " + text + "]" - }) - - // Append any remaining transcriptions not matched by an annotation. - for ; idx < len(transcriptions); idx++ { - newContent += "\n[voice: " + transcriptions[idx] + "]" - } - - msg.Content = newContent - return msg -} - -// inferMediaType determines the media type ("image", "audio", "video", "file") -// from a filename and MIME content type. -func inferMediaType(filename, contentType string) string { - ct := strings.ToLower(contentType) - fn := strings.ToLower(filename) - - if strings.HasPrefix(ct, "image/") { - return "image" - } - if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" { - return "audio" - } - if strings.HasPrefix(ct, "video/") { - return "video" - } - - // Fallback: infer from extension - ext := filepath.Ext(fn) - switch ext { - case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": - return "image" - case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": - return "audio" - case ".mp4", ".avi", ".mov", ".webm", ".mkv": - return "video" - } - - return "file" -} - -// RecordLastChannel records the last active channel for this workspace. -// This uses the atomic state save mechanism to prevent data loss on crash. -func (al *AgentLoop) RecordLastChannel(channel string) error { - if al.state == nil { - return nil - } - return al.state.SetLastChannel(channel) -} - -// RecordLastChatID records the last active chat ID for this workspace. -// This uses the atomic state save mechanism to prevent data loss on crash. -func (al *AgentLoop) RecordLastChatID(chatID string) error { - if al.state == nil { - return nil - } - return al.state.SetLastChatID(chatID) -} - -func (al *AgentLoop) ProcessDirect( - ctx context.Context, - content, sessionKey string, -) (string, error) { - return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") -} - -func (al *AgentLoop) ProcessDirectWithChannel( - ctx context.Context, - content, sessionKey, channel, chatID string, -) (string, error) { - msg := bus.InboundMessage{ - Channel: channel, - SenderID: "cron", - ChatID: chatID, - Content: content, - SessionKey: sessionKey, - } - - result, _, err := al.processMessage(ctx, msg) - return result, err -} - -// ProcessHeartbeat processes a heartbeat request without session history. -// Each heartbeat is independent and doesn't accumulate context. -func (al *AgentLoop) ProcessHeartbeat( - ctx context.Context, - content, channel, chatID string, -) (string, error) { - agent := al.registry.GetDefaultAgent() - if agent == nil { - return "", fmt.Errorf("no default agent for heartbeat") - } - result, _, err := al.runAgentLoop(ctx, agent, processOptions{ - SessionKey: "heartbeat", - Channel: channel, - ChatID: chatID, - UserMessage: content, - DefaultResponse: defaultResponse, - EnableSummary: false, - SendResponse: false, - NoHistory: true, // Don't load session history for heartbeat - }) - return result, err -} - -func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, *bus.ResponseMetrics, error) { - // Add message preview to log (show full content for error messages) - var logContent string - if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { - logContent = msg.Content // Full content for errors - } else { - logContent = utils.Truncate(msg.Content, 80) - } - logger.InfoCF( - "agent", - fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), - map[string]any{ - "channel": msg.Channel, - "chat_id": msg.ChatID, - "sender_id": msg.SenderID, - "session_key": msg.SessionKey, - }, - ) - - msg = al.transcribeAudioInMessage(ctx, msg) - - // Route system messages to processSystemMessage - if msg.Channel == "system" { - result, err := al.processSystemMessage(ctx, msg) - return result, nil, err - } - - route, agent, routeErr := al.resolveMessageRoute(msg) - - // Commands are checked before requiring a successful route. - // Global commands (/help, /show, /switch) work even when routing fails; - // context-dependent commands check their own Runtime fields and report - // "unavailable" when the required capability is nil. - if response, handled := al.handleCommand(ctx, msg, agent); handled { - return response, nil, nil - } - - if routeErr != nil { - return "", nil, routeErr - } - - // Reset message-tool state for this round so we don't skip publishing due to a previous round. - if tool, ok := agent.Tools.Get("message"); ok { - if resetter, ok := tool.(interface{ ResetSentInRound() }); ok { - resetter.ResetSentInRound() - } - } - - // Resolve session key from route, while preserving explicit agent-scoped keys. - scopeKey := resolveScopeKey(route, msg.SessionKey) - sessionKey := scopeKey - - logger.InfoCF("agent", "Routed message", - map[string]any{ - "agent_id": agent.ID, - "scope_key": scopeKey, - "session_key": sessionKey, - "matched_by": route.MatchedBy, - "route_agent": route.AgentID, - "route_channel": route.Channel, - }) - - // Extract overrides from metadata (used by magicform channel / gateway mode) - workspaceOverride := msg.Metadata["workspace_override"] - configDir := msg.Metadata["config_dir"] - - // Defense-in-depth: validate workspace/configDir overrides against workspace_root - // even though the originating channel should have already validated them. - wsRoot := al.cfg.Agents.Defaults.WorkspaceRoot - if workspaceOverride != "" { - resolved, err := pathutil.ResolveWorkspacePath(wsRoot, workspaceOverride) - if err != nil { - logger.WarnCF("agent", "Rejecting workspace_override from metadata", - map[string]any{"workspace_override": workspaceOverride, "error": err.Error()}) - return "", nil, fmt.Errorf("invalid workspace_override in metadata: %w", err) - } - workspaceOverride = resolved - } - if configDir != "" { - resolved, err := pathutil.ResolveWorkspacePath(wsRoot, configDir) - if err != nil { - logger.WarnCF("agent", "Rejecting config_dir from metadata", - map[string]any{"config_dir": configDir, "error": err.Error()}) - return "", nil, fmt.Errorf("invalid config_dir in metadata: %w", err) - } - configDir = resolved - } - - var allowedTools, allowedSkills []string - if v := msg.Metadata["allowed_tools"]; v != "" { - for _, t := range strings.Split(v, ",") { - if s := strings.TrimSpace(t); s != "" { - allowedTools = append(allowedTools, s) - } - } - } - if v := msg.Metadata["allowed_skills"]; v != "" { - for _, s := range strings.Split(v, ",") { - if trimmed := strings.TrimSpace(s); trimmed != "" { - allowedSkills = append(allowedSkills, trimmed) - } - } - } - - return al.runAgentLoop(ctx, agent, processOptions{ - SessionKey: sessionKey, - Channel: msg.Channel, - ChatID: msg.ChatID, - UserMessage: msg.Content, - Media: msg.Media, - DefaultResponse: defaultResponse, - EnableSummary: true, - SendResponse: false, - WorkspaceOverride: workspaceOverride, - ConfigDir: configDir, - AllowedTools: allowedTools, - AllowedSkills: allowedSkills, - }) -} - -func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { - route := al.registry.ResolveRoute(routing.RouteInput{ - Channel: msg.Channel, - AccountID: inboundMetadata(msg, metadataKeyAccountID), - Peer: extractPeer(msg), - ParentPeer: extractParentPeer(msg), - GuildID: inboundMetadata(msg, metadataKeyGuildID), - TeamID: inboundMetadata(msg, metadataKeyTeamID), - }) - - agent, ok := al.registry.GetAgent(route.AgentID) - if !ok { - agent = al.registry.GetDefaultAgent() - } - if agent == nil { - return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) - } - - return route, agent, nil -} - -func resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string) string { - if msgSessionKey != "" && strings.HasPrefix(msgSessionKey, sessionKeyAgentPrefix) { - return msgSessionKey - } - return route.SessionKey -} - -func (al *AgentLoop) processSystemMessage( - ctx context.Context, - msg bus.InboundMessage, -) (string, error) { - if msg.Channel != "system" { - return "", fmt.Errorf( - "processSystemMessage called with non-system message channel: %s", - msg.Channel, - ) - } - - logger.InfoCF("agent", "Processing system message", - map[string]any{ - "sender_id": msg.SenderID, - "chat_id": msg.ChatID, - }) - - // Parse origin channel from chat_id (format: "channel:chat_id") - var originChannel, originChatID string - if idx := strings.Index(msg.ChatID, ":"); idx > 0 { - originChannel = msg.ChatID[:idx] - originChatID = msg.ChatID[idx+1:] - } else { - originChannel = "cli" - originChatID = msg.ChatID - } - - // Extract subagent result from message content - // Format: "Task 'label' completed.\n\nResult:\n" - content := msg.Content - if idx := strings.Index(content, "Result:\n"); idx >= 0 { - content = content[idx+8:] // Extract just the result part - } - - // Skip internal channels - only log, don't send to user - if constants.IsInternalChannel(originChannel) { - logger.InfoCF("agent", "Subagent completed (internal channel)", - map[string]any{ - "sender_id": msg.SenderID, - "content_len": len(content), - "channel": originChannel, - }) - return "", nil - } - - // Use default agent for system messages - agent := al.registry.GetDefaultAgent() - if agent == nil { - return "", fmt.Errorf("no default agent for system message") - } - - // Use the origin session for context - sessionKey := routing.BuildAgentMainSessionKey(agent.ID) - - result, _, err := al.runAgentLoop(ctx, agent, processOptions{ - SessionKey: sessionKey, - Channel: originChannel, - ChatID: originChatID, - UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content), - DefaultResponse: "Background task completed.", - EnableSummary: false, - SendResponse: true, - }) - return result, err -} - -// runAgentLoop is the core message processing logic. -func (al *AgentLoop) runAgentLoop( - ctx context.Context, - agent *AgentInstance, - opts processOptions, -) (string, *bus.ResponseMetrics, error) { - // 0. Record last channel for heartbeat notifications (skip internal channels and cli) - if opts.Channel != "" && opts.ChatID != "" { - if !constants.IsInternalChannel(opts.Channel) { - channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) - if err := al.RecordLastChannel(channelKey); err != nil { - logger.WarnCF( - "agent", - "Failed to record last channel", - map[string]any{"error": err.Error()}, - ) - } - } - } - - // Resolve effective sessions and context builder. - // When a workspace override is provided (e.g. from magicform channel), - // create temporary instances pointing at the override path for full isolation. - effSessions := agent.Sessions - effContextBuilder := agent.ContextBuilder - - if opts.WorkspaceOverride != "" { - wp := opts.WorkspaceOverride - os.MkdirAll(wp, 0o755) - effSessions = session.NewSessionManager(filepath.Join(wp, "sessions")) - effContextBuilder = NewContextBuilder(wp) - } - - // Copy bootstrap files from config-dir to workspace - if opts.ConfigDir != "" && opts.WorkspaceOverride != "" { - CopyBootstrapFiles(opts.ConfigDir, opts.WorkspaceOverride) - } - - // Load workspace-local config.json for per-request overrides - configSource := opts.ConfigDir - if configSource == "" { - configSource = opts.WorkspaceOverride // fallback: check workspace itself - } - if configSource != "" { - if wc, err := config.LoadWorkspaceConfig(configSource); err != nil { - logger.WarnCF("agent", "Failed to load workspace config", - map[string]any{"path": configSource, "error": err.Error()}) - } else if wc != nil { - tmpCfg := al.cfg.Clone() - if err := tmpCfg.MergeWorkspaceConfig(wc); err != nil { - return "", nil, fmt.Errorf("workspace config overlay rejected: %w", err) - } - if tmpCfg.Agents.Defaults.GetModelName() == "" { - tmpCfg.Agents.Defaults.ModelName = agent.Model - } - if provider, modelID, err := providers.CreateProvider(tmpCfg); err != nil { - logger.ErrorCF("agent", "Failed to create workspace provider", - map[string]any{"path": configSource, "error": err.Error()}) - } else { - opts.effProvider = provider - opts.effModel = modelID - if sp, ok := provider.(providers.StatefulProvider); ok { - defer sp.Close() - } - } - } - } - - // Apply skills filter unconditionally — works with or without workspace override - if len(opts.AllowedSkills) > 0 { - effContextBuilder.SetSkillsFilter(opts.AllowedSkills) - } - - // 1. Build messages (skip history for heartbeat) - var history []providers.Message - var summary string - if !opts.NoHistory { - history = effSessions.GetHistory(opts.SessionKey) - summary = effSessions.GetSummary(opts.SessionKey) - } - messages := effContextBuilder.BuildMessages( - history, - summary, - opts.UserMessage, - opts.Media, - opts.Channel, - opts.ChatID, - ) - - // Resolve media:// refs to base64 data URLs (streaming) - maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize() - messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - - // 2. Save user message to session - effSessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) - - // Store effective sessions/context on opts so runLLMIteration can use them - opts.effSessions = effSessions - opts.effContextBuilder = effContextBuilder - - // 3. Run LLM iteration loop - startTime := time.Now() - finalContent, iteration, iterMetrics, err := al.runLLMIteration(ctx, agent, messages, opts) - durationMs := time.Since(startTime).Milliseconds() - if err != nil { - return "", nil, err - } - - // Build response metrics from accumulated iteration metrics. - var respMetrics *bus.ResponseMetrics - if iterMetrics != nil { - respMetrics = &bus.ResponseMetrics{ - DurationMs: durationMs, - ToolCalls: iterMetrics.ToolCalls, - Iterations: iteration, - Model: iterMetrics.Model, - } - if iterMetrics.PromptTokens > 0 || iterMetrics.CompletionTokens > 0 { - respMetrics.TokenUsage = &bus.TokenUsage{ - PromptTokens: iterMetrics.PromptTokens, - CompletionTokens: iterMetrics.CompletionTokens, - TotalTokens: iterMetrics.PromptTokens + iterMetrics.CompletionTokens, - Model: iterMetrics.Model, - } - } - } - - // If last tool had ForUser content and we already sent it, we might not need to send final response - // This is controlled by the tool's Silent flag and ForUser content - - // 4. Handle empty response - if finalContent == "" { - finalContent = opts.DefaultResponse - } - - // 5. Save final assistant message to session - effSessions.AddMessage(opts.SessionKey, "assistant", finalContent) - effSessions.Save(opts.SessionKey) - - // 6. Optional: summarization - if opts.EnableSummary { - al.maybeSummarizeWith( - effSessions, - agent, - opts.SessionKey, - opts.Channel, - opts.ChatID, - opts.effProvider, - opts.effModel, - ) - } - - // 7. Optional: send response via bus (with metrics on the final message) - if opts.SendResponse { - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: finalContent, - Metrics: respMetrics, - }) - } - - // 8. Log response - responsePreview := utils.Truncate(finalContent, 120) - logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), - map[string]any{ - "agent_id": agent.ID, - "session_key": opts.SessionKey, - "iterations": iteration, - "final_length": len(finalContent), - "duration_ms": durationMs, - }) - - return finalContent, respMetrics, nil -} - -func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) { - if al.channelManager == nil { - return "" - } - if ch, ok := al.channelManager.GetChannel(channelName); ok { - return ch.ReasoningChannelID() - } - return "" -} - -func (al *AgentLoop) handleReasoning( - ctx context.Context, - reasoningContent, channelName, channelID string, -) { - if reasoningContent == "" || channelName == "" || channelID == "" { - return - } - - // Check context cancellation before attempting to publish, - // since PublishOutbound's select may race between send and ctx.Done(). - if ctx.Err() != nil { - return - } - - // Use a short timeout so the goroutine does not block indefinitely when - // the outbound bus is full. Reasoning output is best-effort; dropping it - // is acceptable to avoid goroutine accumulation. - pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) - defer pubCancel() - - if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Channel: channelName, - ChatID: channelID, - Content: reasoningContent, - }); err != nil { - // Treat context.DeadlineExceeded / context.Canceled as expected - // (bus full under load, or parent canceled). Check the error - // itself rather than ctx.Err(), because pubCtx may time out - // (5 s) while the parent ctx is still active. - // Also treat ErrBusClosed as expected — it occurs during normal - // shutdown when the bus is closed before all goroutines finish. - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || - errors.Is(err, bus.ErrBusClosed) { - logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{ - "channel": channelName, - "error": err.Error(), - }) - } else { - logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ - "channel": channelName, - "error": err.Error(), - }) - } - } -} - -// turnMetrics accumulates cumulative totals across all LLM iterations within -// a single agent processing turn. Values are summed after each iteration in -// runLLMIteration, not per-iteration snapshots. -type turnMetrics struct { - PromptTokens int - CompletionTokens int - ToolCalls int - Model string // last model used -} - -// runLLMIteration executes the LLM call loop with tool handling. -func (al *AgentLoop) runLLMIteration( - ctx context.Context, - agent *AgentInstance, - messages []providers.Message, - opts processOptions, -) (string, int, *turnMetrics, error) { - iteration := 0 - var finalContent string - metrics := &turnMetrics{} - - // Resolve effective provider/model — workspace config overrides win - effProvider := agent.Provider - effModel := agent.Model - if opts.effProvider != nil { - effProvider = opts.effProvider - } - if opts.effModel != "" { - effModel = opts.effModel - } - - // Determine effective model tier for this conversation turn. - // selectCandidates evaluates routing once and the decision is sticky for - // all tool-follow-up iterations within the same turn so that a multi-step - // tool chain doesn't switch models mid-way through. - var activeCandidates []providers.FallbackCandidate - var activeModel string - if opts.effProvider != nil { - // Workspace overrides the provider — skip routing and fallback - // candidates since they may reference different provider credentials. - activeModel = effModel - } else { - activeCandidates, activeModel = al.selectCandidates(agent, opts.UserMessage, messages) - if opts.effModel != "" { - activeModel = effModel - } - } - - for iteration < agent.MaxIterations { - iteration++ - - logger.DebugCF("agent", "LLM iteration", - map[string]any{ - "agent_id": agent.ID, - "iteration": iteration, - "max": agent.MaxIterations, - }) - - // Build tool definitions, filtered by AllowedTools if set - providerToolDefs := agent.Tools.ToProviderDefs() - if len(opts.AllowedTools) > 0 { - allowSet := make(map[string]bool, len(opts.AllowedTools)) - for _, t := range opts.AllowedTools { - allowSet[t] = true - } - filtered := providerToolDefs[:0] - for _, td := range providerToolDefs { - if allowSet[td.Function.Name] { - filtered = append(filtered, td) - } - } - providerToolDefs = filtered - } - - // Log LLM request details - logger.DebugCF("agent", "LLM request", - map[string]any{ - "agent_id": agent.ID, - "iteration": iteration, - "model": activeModel, - "messages_count": len(messages), - "tools_count": len(providerToolDefs), - "max_tokens": agent.MaxTokens, - "temperature": agent.Temperature, - "system_prompt_len": len(messages[0].Content), - }) - - // Log full messages (detailed) - logger.DebugCF("agent", "Full LLM request", - map[string]any{ - "iteration": iteration, - "messages_json": formatMessagesForLog(messages), - "tools_json": formatToolsForLog(providerToolDefs), - }) - - // Call LLM with fallback chain if multiple candidates are configured. - var response *providers.LLMResponse - var err error - - llmOpts := map[string]any{ - "max_tokens": agent.MaxTokens, - "temperature": agent.Temperature, - "prompt_cache_key": agent.ID, - } - // parseThinkingLevel guarantees ThinkingOff for empty/unknown values, - // so checking != ThinkingOff is sufficient. - if agent.ThinkingLevel != ThinkingOff { - if tc, ok := effProvider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { - llmOpts["thinking_level"] = string(agent.ThinkingLevel) - } else { - logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", - map[string]any{"agent_id": agent.ID, "thinking_level": string(agent.ThinkingLevel)}) - } - } - - callLLM := func() (*providers.LLMResponse, error) { - if len(activeCandidates) > 1 && al.fallback != nil { - fbResult, fbErr := al.fallback.Execute( - ctx, - activeCandidates, - func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { - return effProvider.Chat(ctx, messages, providerToolDefs, model, llmOpts) - }, - ) - if fbErr != nil { - return nil, fbErr - } - if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { - logger.InfoCF( - "agent", - fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", - fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), - map[string]any{"agent_id": agent.ID, "iteration": iteration}, - ) - } - return fbResult.Response, nil - } - return effProvider.Chat(ctx, messages, providerToolDefs, activeModel, llmOpts) - } - - // Retry loop for context/token errors - maxRetries := 2 - for retry := 0; retry <= maxRetries; retry++ { - response, err = callLLM() - if err == nil { - break - } - - errMsg := strings.ToLower(err.Error()) - - // Check if this is a network/HTTP timeout — not a context window error. - isTimeoutError := errors.Is(err, context.DeadlineExceeded) || - strings.Contains(errMsg, "deadline exceeded") || - strings.Contains(errMsg, "client.timeout") || - strings.Contains(errMsg, "timed out") || - strings.Contains(errMsg, "timeout exceeded") - - // Detect real context window / token limit errors, excluding network timeouts. - isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || - strings.Contains(errMsg, "context window") || - strings.Contains(errMsg, "maximum context length") || - strings.Contains(errMsg, "token limit") || - strings.Contains(errMsg, "too many tokens") || - strings.Contains(errMsg, "max_tokens") || - strings.Contains(errMsg, "invalidparameter") || - strings.Contains(errMsg, "prompt is too long") || - strings.Contains(errMsg, "request too large")) - - if isTimeoutError && retry < maxRetries { - backoff := time.Duration(retry+1) * 5 * time.Second - logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{ - "error": err.Error(), - "retry": retry, - "backoff": backoff.String(), - }) - time.Sleep(backoff) - continue - } - - if isContextError && retry < maxRetries { - logger.WarnCF( - "agent", - "Context window error detected, attempting compression", - map[string]any{ - "error": err.Error(), - "retry": retry, - }, - ) - - if retry == 0 && !constants.IsInternalChannel(opts.Channel) { - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: "Context window exceeded. Compressing history and retrying...", - }) - } - - al.forceCompressionWith(opts.effSessions, agent, opts.SessionKey) - newHistory := opts.effSessions.GetHistory(opts.SessionKey) - newSummary := opts.effSessions.GetSummary(opts.SessionKey) - messages = opts.effContextBuilder.BuildMessages( - newHistory, newSummary, "", - nil, opts.Channel, opts.ChatID, - ) - continue - } - break - } - - if err != nil { - logger.ErrorCF("agent", "LLM call failed", - map[string]any{ - "agent_id": agent.ID, - "iteration": iteration, - "error": err.Error(), - }) - return "", iteration, metrics, fmt.Errorf("LLM call failed after retries: %w", err) - } - - go al.handleReasoning( - ctx, - response.Reasoning, - opts.Channel, - al.targetReasoningChannelID(opts.Channel), - ) - - logger.DebugCF("agent", "LLM response", - map[string]any{ - "agent_id": agent.ID, - "iteration": iteration, - "content_chars": len(response.Content), - "tool_calls": len(response.ToolCalls), - "reasoning": response.Reasoning, - "target_channel": al.targetReasoningChannelID(opts.Channel), - "channel": opts.Channel, - }) - - // Accumulate token usage and tool call counts across iterations. - if response.Usage != nil { - metrics.PromptTokens += response.Usage.PromptTokens - metrics.CompletionTokens += response.Usage.CompletionTokens - } - metrics.ToolCalls += len(response.ToolCalls) - metrics.Model = activeModel - - // Check if no tool calls - then check reasoning content if any - if len(response.ToolCalls) == 0 { - finalContent = response.Content - if finalContent == "" && response.ReasoningContent != "" { - finalContent = response.ReasoningContent - } - logger.InfoCF("agent", "LLM response without tool calls (direct answer)", - map[string]any{ - "agent_id": agent.ID, - "iteration": iteration, - "content_chars": len(finalContent), - }) - break - } - - normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) - for _, tc := range response.ToolCalls { - normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) - } - - // Log tool calls - toolNames := make([]string, 0, len(normalizedToolCalls)) - for _, tc := range normalizedToolCalls { - toolNames = append(toolNames, tc.Name) - } - logger.InfoCF("agent", "LLM requested tool calls", - map[string]any{ - "agent_id": agent.ID, - "tools": toolNames, - "count": len(normalizedToolCalls), - "iteration": iteration, - }) - - // Build assistant message with tool calls - assistantMsg := providers.Message{ - Role: "assistant", - Content: response.Content, - ReasoningContent: response.ReasoningContent, - } - for _, tc := range normalizedToolCalls { - argumentsJSON, _ := json.Marshal(tc.Arguments) - // Copy ExtraContent to ensure thought_signature is persisted for Gemini 3 - extraContent := tc.ExtraContent - thoughtSignature := "" - if tc.Function != nil { - thoughtSignature = tc.Function.ThoughtSignature - } - - assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ - ID: tc.ID, - Type: "function", - Name: tc.Name, - Function: &providers.FunctionCall{ - Name: tc.Name, - Arguments: string(argumentsJSON), - ThoughtSignature: thoughtSignature, - }, - ExtraContent: extraContent, - ThoughtSignature: thoughtSignature, - }) - } - messages = append(messages, assistantMsg) - - // Save assistant message with tool calls to session - opts.effSessions.AddFullMessage(opts.SessionKey, assistantMsg) - - // Publish progress callback for channels that support it (e.g. MagicForm). - // Non-internal channels receive a progress update before tool execution. - if !constants.IsInternalChannel(opts.Channel) && opts.Channel != "" { - toolNamesList := strings.Join(toolNames, ", ") - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Type: bus.MessageTypeProgress, - Content: fmt.Sprintf("Running tools: %s", toolNamesList), - Progress: &bus.OutboundProgress{ - Status: "thinking", - ToolName: toolNames[0], - StepNumber: iteration, - Message: fmt.Sprintf("Running tools: %s", toolNamesList), - }, - }) - } - - // Execute tool calls in parallel - type indexedAgentResult struct { - result *tools.ToolResult - tc providers.ToolCall - } - - agentResults := make([]indexedAgentResult, len(normalizedToolCalls)) - var wg sync.WaitGroup - - for i, tc := range normalizedToolCalls { - agentResults[i].tc = tc - - wg.Add(1) - go func(idx int, tc providers.ToolCall) { - defer wg.Done() - - argsJSON, _ := json.Marshal(tc.Arguments) - argsPreview := utils.Truncate(string(argsJSON), 200) - logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), - map[string]any{ - "agent_id": agent.ID, - "tool": tc.Name, - "iteration": iteration, - }) - - // Enforce tool allowlist at execution time (defense-in-depth) - if len(opts.AllowedTools) > 0 { - allowed := false - for _, t := range opts.AllowedTools { - if t == tc.Name { - allowed = true - break - } - } - if !allowed { - agentResults[idx].result = &tools.ToolResult{ - ForLLM: fmt.Sprintf("Tool %q is not allowed for this request", tc.Name), - IsError: true, - } - return - } - } - - // Create async callback for tools that implement AsyncExecutor. - // When the background work completes, this publishes the result - // as an inbound system message so processSystemMessage routes it - // back to the user via the normal agent loop. - asyncCallback := func(_ context.Context, result *tools.ToolResult) { - // Send ForUser content directly to the user (immediate feedback), - // mirroring the synchronous tool execution path. - if !result.Silent && result.ForUser != "" { - outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer outCancel() - _ = al.bus.PublishOutbound(outCtx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: result.ForUser, - }) - } - - // Determine content for the agent loop (ForLLM or error). - content := result.ForLLM - if content == "" && result.Err != nil { - content = result.Err.Error() - } - if content == "" { - return - } - - logger.InfoCF("agent", "Async tool completed, publishing result", - map[string]any{ - "tool": tc.Name, - "content_len": len(content), - "channel": opts.Channel, - }) - - pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ - Channel: "system", - SenderID: fmt.Sprintf("async:%s", tc.Name), - ChatID: fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID), - Content: content, - }) - } - - toolResult := agent.Tools.ExecuteWithContext( - ctx, - tc.Name, - tc.Arguments, - opts.Channel, - opts.ChatID, - asyncCallback, - ) - agentResults[idx].result = toolResult - }(i, tc) - } - wg.Wait() - - // Process results in original order (send to user, save to session) - for _, r := range agentResults { - // Send ForUser content to user immediately if not Silent - if !r.result.Silent && r.result.ForUser != "" && opts.SendResponse { - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: r.result.ForUser, - }) - logger.DebugCF("agent", "Sent tool result to user", - map[string]any{ - "tool": r.tc.Name, - "content_len": len(r.result.ForUser), - }) - } - - // If tool returned media refs, publish them as outbound media - if len(r.result.Media) > 0 { - parts := make([]bus.MediaPart, 0, len(r.result.Media)) - for _, ref := range r.result.Media { - part := bus.MediaPart{Ref: ref} - if al.mediaStore != nil { - if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { - part.Filename = meta.Filename - part.ContentType = meta.ContentType - part.Type = inferMediaType(meta.Filename, meta.ContentType) - } - } - parts = append(parts, part) - } - al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Parts: parts, - }) - } - - // Determine content for LLM based on tool result - contentForLLM := r.result.ForLLM - if contentForLLM == "" && r.result.Err != nil { - contentForLLM = r.result.Err.Error() - } - - toolResultMsg := providers.Message{ - Role: "tool", - Content: contentForLLM, - ToolCallID: r.tc.ID, - } - messages = append(messages, toolResultMsg) - - // Save tool result message to session - opts.effSessions.AddFullMessage(opts.SessionKey, toolResultMsg) - } - } - - return finalContent, iteration, metrics, nil -} - -// selectCandidates returns the model candidates and resolved model name to use -// for a conversation turn. When model routing is configured and the incoming -// message scores below the complexity threshold, it returns the light model -// candidates instead of the primary ones. -// -// The returned (candidates, model) pair is used for all LLM calls within one -// turn — tool follow-up iterations use the same tier as the initial call so -// that a multi-step tool chain doesn't switch models mid-way. -func (al *AgentLoop) selectCandidates( - agent *AgentInstance, - userMsg string, - history []providers.Message, -) (candidates []providers.FallbackCandidate, model string) { - if agent.Router == nil || len(agent.LightCandidates) == 0 { - return agent.Candidates, agent.Model - } - - _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) - if !usedLight { - logger.DebugCF("agent", "Model routing: primary model selected", - map[string]any{ - "agent_id": agent.ID, - "score": score, - "threshold": agent.Router.Threshold(), - }) - return agent.Candidates, agent.Model - } - - logger.InfoCF("agent", "Model routing: light model selected", - map[string]any{ - "agent_id": agent.ID, - "light_model": agent.Router.LightModel(), - "score": score, - "threshold": agent.Router.Threshold(), - }) - return agent.LightCandidates, agent.Router.LightModel() -} - -// maybeSummarizeWith triggers summarization if the session history exceeds thresholds. -// and optional per-request provider/model overrides. -func (al *AgentLoop) maybeSummarizeWith( - sessions *session.SessionManager, - agent *AgentInstance, - sessionKey, channel, chatID string, - effProvider providers.LLMProvider, - effModel string, -) { - newHistory := sessions.GetHistory(sessionKey) - tokenEstimate := al.estimateTokens(newHistory) - threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 - - if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold { - summarizeKey := agent.ID + ":" + sessionKey - if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { - go func() { - defer al.summarizing.Delete(summarizeKey) - logger.Debug("Memory threshold reached. Optimizing conversation history...") - al.summarizeSessionWith(sessions, agent, sessionKey, effProvider, effModel) - }() - } - } -} - -// forceCompressionWith aggressively reduces context when the limit is hit. -// It drops the oldest 50% of messages (keeping system prompt and last user message). -func (al *AgentLoop) forceCompressionWith(sessions *session.SessionManager, agent *AgentInstance, sessionKey string) { - history := sessions.GetHistory(sessionKey) - if len(history) <= 4 { - return - } - - // Keep system prompt (usually [0]) and the very last message (user's trigger) - // We want to drop the oldest half of the *conversation* - // Assuming [0] is system, [1:] is conversation - conversation := history[1 : len(history)-1] - if len(conversation) == 0 { - return - } - - // Helper to find the mid-point of the conversation - mid := len(conversation) / 2 - - // New history structure: - // 1. System Prompt (with compression note appended) - // 2. Second half of conversation - // 3. Last message - - droppedCount := mid - keptConversation := conversation[mid:] - - newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1) - - // Append compression note to the original system prompt instead of adding a new system message - // This avoids having two consecutive system messages which some APIs (like Zhipu) reject - compressionNote := fmt.Sprintf( - "\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]", - droppedCount, - ) - enhancedSystemPrompt := history[0] - enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote - newHistory = append(newHistory, enhancedSystemPrompt) - - newHistory = append(newHistory, keptConversation...) - newHistory = append(newHistory, history[len(history)-1]) // Last message - - // Update session - sessions.SetHistory(sessionKey, newHistory) - sessions.Save(sessionKey) - - logger.WarnCF("agent", "Forced compression executed", map[string]any{ - "session_key": sessionKey, - "dropped_msgs": droppedCount, - "new_count": len(newHistory), - }) -} - -// GetStartupInfo returns information about loaded tools and skills for logging. -func (al *AgentLoop) GetStartupInfo() map[string]any { - info := make(map[string]any) - - agent := al.registry.GetDefaultAgent() - if agent == nil { - return info - } - - // Tools info - toolsList := agent.Tools.List() - info["tools"] = map[string]any{ - "count": len(toolsList), - "names": toolsList, - } - - // Skills info - info["skills"] = agent.ContextBuilder.GetSkillsInfo() - - // Agents info - info["agents"] = map[string]any{ - "count": len(al.registry.ListAgentIDs()), - "ids": al.registry.ListAgentIDs(), - } - - return info -} - -// formatMessagesForLog formats messages for logging -func formatMessagesForLog(messages []providers.Message) string { - if len(messages) == 0 { - return "[]" - } - - var sb strings.Builder - sb.WriteString("[\n") - for i, msg := range messages { - fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role) - if len(msg.ToolCalls) > 0 { - sb.WriteString(" ToolCalls:\n") - for _, tc := range msg.ToolCalls { - fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) - if tc.Function != nil { - fmt.Fprintf( - &sb, - " Arguments: %s\n", - utils.Truncate(tc.Function.Arguments, 200), - ) - } - } - } - if msg.Content != "" { - content := utils.Truncate(msg.Content, 200) - fmt.Fprintf(&sb, " Content: %s\n", content) - } - if msg.ToolCallID != "" { - fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID) - } - sb.WriteString("\n") - } - sb.WriteString("]") - return sb.String() -} - -// formatToolsForLog formats tool definitions for logging -func formatToolsForLog(toolDefs []providers.ToolDefinition) string { - if len(toolDefs) == 0 { - return "[]" - } - - var sb strings.Builder - sb.WriteString("[\n") - for i, tool := range toolDefs { - fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) - fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) - if len(tool.Function.Parameters) > 0 { - fmt.Fprintf( - &sb, - " Parameters: %s\n", - utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200), - ) - } - } - sb.WriteString("]") - return sb.String() -} - -// summarizeSessionWith summarizes the conversation history for a session. -func (al *AgentLoop) summarizeSessionWith( - sessions *session.SessionManager, - agent *AgentInstance, - sessionKey string, - effProvider providers.LLMProvider, - effModel string, -) { - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - - // Resolve effective provider/model - sumProvider := agent.Provider - sumModel := agent.Model - if effProvider != nil { - sumProvider = effProvider - } - if effModel != "" { - sumModel = effModel - } - - history := sessions.GetHistory(sessionKey) - summary := sessions.GetSummary(sessionKey) - - // Keep last 4 messages for continuity - if len(history) <= 4 { - return - } - - toSummarize := history[:len(history)-4] - - // Oversized Message Guard - maxMessageTokens := agent.ContextWindow / 2 - validMessages := make([]providers.Message, 0) - omitted := false - - for _, m := range toSummarize { - if m.Role != "user" && m.Role != "assistant" { - continue - } - msgTokens := len(m.Content) / 2 - if msgTokens > maxMessageTokens { - omitted = true - continue - } - validMessages = append(validMessages, m) - } - - if len(validMessages) == 0 { - return - } - - // Multi-Part Summarization - var finalSummary string - if len(validMessages) > 10 { - mid := len(validMessages) / 2 - part1 := validMessages[:mid] - part2 := validMessages[mid:] - - s1, _ := al.summarizeBatch(ctx, sumProvider, sumModel, agent.ID, part1, "") - s2, _ := al.summarizeBatch(ctx, sumProvider, sumModel, agent.ID, part2, "") - - mergePrompt := fmt.Sprintf( - "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", - s1, - s2, - ) - resp, err := sumProvider.Chat( - ctx, - []providers.Message{{Role: "user", Content: mergePrompt}}, - nil, - sumModel, - map[string]any{ - "max_tokens": 1024, - "temperature": 0.3, - "prompt_cache_key": agent.ID, - }, - ) - if err == nil { - finalSummary = resp.Content - } else { - finalSummary = s1 + " " + s2 - } - } else { - finalSummary, _ = al.summarizeBatch(ctx, sumProvider, sumModel, agent.ID, validMessages, summary) - } - - if omitted && finalSummary != "" { - finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]" - } - - if finalSummary != "" { - sessions.SetSummary(sessionKey, finalSummary) - sessions.TruncateHistory(sessionKey, 4) - sessions.Save(sessionKey) - } -} - -// summarizeBatch summarizes a batch of messages using the given provider/model. -func (al *AgentLoop) summarizeBatch( - ctx context.Context, - provider providers.LLMProvider, - model string, - agentID string, - batch []providers.Message, - existingSummary string, -) (string, error) { - var sb strings.Builder - sb.WriteString( - "Provide a concise summary of this conversation segment, preserving core context and key points.\n", - ) - if existingSummary != "" { - sb.WriteString("Existing context: ") - sb.WriteString(existingSummary) - sb.WriteString("\n") - } - sb.WriteString("\nCONVERSATION:\n") - for _, m := range batch { - fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content) - } - prompt := sb.String() - - response, err := provider.Chat( - ctx, - []providers.Message{{Role: "user", Content: prompt}}, - nil, - model, - map[string]any{ - "max_tokens": 1024, - "temperature": 0.3, - "prompt_cache_key": agentID, - }, - ) - if err != nil { - return "", err - } - return response.Content, nil -} - -// estimateTokens estimates the number of tokens in a message list. -// Uses a safe heuristic of 2.5 characters per token to account for CJK and other -// overheads better than the previous 3 chars/token. -func (al *AgentLoop) estimateTokens(messages []providers.Message) int { - totalChars := 0 - for _, m := range messages { - totalChars += utf8.RuneCountInString(m.Content) - } - // 2.5 chars per token = totalChars * 2 / 5 - return totalChars * 2 / 5 -} - -func (al *AgentLoop) handleCommand( - ctx context.Context, - msg bus.InboundMessage, - agent *AgentInstance, -) (string, bool) { - if !commands.HasCommandPrefix(msg.Content) { - return "", false - } - - if al.cmdRegistry == nil { - return "", false - } - - rt := al.buildCommandsRuntime(agent) - executor := commands.NewExecutor(al.cmdRegistry, rt) - - var commandReply string - result := executor.Execute(ctx, commands.Request{ - Channel: msg.Channel, - ChatID: msg.ChatID, - SenderID: msg.SenderID, - Text: msg.Content, - Reply: func(text string) error { - commandReply = text - return nil - }, - }) - - switch result.Outcome { - case commands.OutcomeHandled: - if result.Err != nil { - return mapCommandError(result), true - } - if commandReply != "" { - return commandReply, true - } - return "", true - default: // OutcomePassthrough — let the message fall through to LLM - return "", false - } -} - -func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance) *commands.Runtime { - rt := &commands.Runtime{ - Config: al.cfg, - ListAgentIDs: al.registry.ListAgentIDs, - ListDefinitions: al.cmdRegistry.Definitions, - GetEnabledChannels: func() []string { - if al.channelManager == nil { - return nil - } - return al.channelManager.GetEnabledChannels() - }, - SwitchChannel: func(value string) error { - if al.channelManager == nil { - return fmt.Errorf("channel manager not initialized") - } - if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { - return fmt.Errorf("channel '%s' not found or not enabled", value) - } - return nil - }, - } - if agent != nil { - rt.GetModelInfo = func() (string, string) { - return agent.Model, al.cfg.Agents.Defaults.Provider - } - rt.SwitchModel = func(value string) (string, error) { - oldModel := agent.Model - agent.Model = value - return oldModel, nil - } - } - return rt -} - -func mapCommandError(result commands.ExecuteResult) string { - if result.Command == "" { - return fmt.Sprintf("Failed to execute command: %v", result.Err) - } - return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err) -} - -// extractPeer extracts the routing peer from the inbound message's structured Peer field. -func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { - if msg.Peer.Kind == "" { - return nil - } - peerID := msg.Peer.ID - if peerID == "" { - if msg.Peer.Kind == "direct" { - peerID = msg.SenderID - } else { - peerID = msg.ChatID - } - } - return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID} -} - -func inboundMetadata(msg bus.InboundMessage, key string) string { - if msg.Metadata == nil { - return "" - } - return msg.Metadata[key] -} - -// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata. -func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { - parentKind := inboundMetadata(msg, metadataKeyParentPeerKind) - parentID := inboundMetadata(msg, metadataKeyParentPeerID) - if parentKind == "" || parentID == "" { - return nil - } - return &routing.RoutePeer{Kind: parentKind, ID: parentID} -} diff --git a/pkg/agent/loop_media.go b/pkg/agent/loop_media.go deleted file mode 100644 index 82547a008..000000000 --- a/pkg/agent/loop_media.go +++ /dev/null @@ -1,122 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package agent - -import ( - "bytes" - "encoding/base64" - "io" - "os" - "strings" - - "github.com/h2non/filetype" - - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/providers" -) - -// resolveMediaRefs replaces media:// refs in message Media fields with base64 data URLs. -// Uses streaming base64 encoding (file handle → encoder → buffer) to avoid holding -// both raw bytes and encoded string in memory simultaneously. -// Returns a new slice; original messages are not mutated. -func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message { - if store == nil { - return messages - } - - result := make([]providers.Message, len(messages)) - copy(result, messages) - - for i, m := range result { - if len(m.Media) == 0 { - continue - } - - resolved := make([]string, 0, len(m.Media)) - for _, ref := range m.Media { - if !strings.HasPrefix(ref, "media://") { - resolved = append(resolved, ref) - continue - } - - localPath, meta, err := store.ResolveWithMeta(ref) - if err != nil { - logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{ - "ref": ref, - "error": err.Error(), - }) - continue - } - - info, err := os.Stat(localPath) - if err != nil { - logger.WarnCF("agent", "Failed to stat media file", map[string]any{ - "path": localPath, - "error": err.Error(), - }) - continue - } - if info.Size() > int64(maxSize) { - logger.WarnCF("agent", "Media file too large, skipping", map[string]any{ - "path": localPath, - "size": info.Size(), - "max_size": maxSize, - }) - continue - } - - // Determine MIME type: prefer metadata, fallback to magic-bytes detection - mime := meta.ContentType - if mime == "" { - kind, ftErr := filetype.MatchFile(localPath) - if ftErr != nil || kind == filetype.Unknown { - logger.WarnCF("agent", "Unknown media type, skipping", map[string]any{ - "path": localPath, - }) - continue - } - mime = kind.MIME.Value - } - - // Streaming base64: open file → base64 encoder → buffer - // Peak memory: ~1.33x file size (buffer only, no raw bytes copy) - f, err := os.Open(localPath) - if err != nil { - logger.WarnCF("agent", "Failed to open media file", map[string]any{ - "path": localPath, - "error": err.Error(), - }) - continue - } - - prefix := "data:" + mime + ";base64," - encodedLen := base64.StdEncoding.EncodedLen(int(info.Size())) - var buf bytes.Buffer - buf.Grow(len(prefix) + encodedLen) - buf.WriteString(prefix) - - encoder := base64.NewEncoder(base64.StdEncoding, &buf) - if _, err := io.Copy(encoder, f); err != nil { - f.Close() - logger.WarnCF("agent", "Failed to encode media file", map[string]any{ - "path": localPath, - "error": err.Error(), - }) - continue - } - encoder.Close() - f.Close() - - resolved = append(resolved, buf.String()) - } - - result[i].Media = resolved - } - - return result -} diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go deleted file mode 100644 index ac618ad34..000000000 --- a/pkg/agent/loop_test.go +++ /dev/null @@ -1,1118 +0,0 @@ -package agent - -import ( - "context" - "fmt" - "os" - "path/filepath" - "slices" - "strings" - "testing" - "time" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/channels" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/providers" - "github.com/sipeed/picoclaw/pkg/routing" - "github.com/sipeed/picoclaw/pkg/tools" -) - -type fakeChannel struct{ id string } - -func (f *fakeChannel) Name() string { return "fake" } -func (f *fakeChannel) Start(ctx context.Context) error { return nil } -func (f *fakeChannel) Stop(ctx context.Context) error { return nil } -func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil } -func (f *fakeChannel) IsRunning() bool { return true } -func (f *fakeChannel) IsAllowed(string) bool { return true } -func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } -func (f *fakeChannel) ReasoningChannelID() string { return f.id } - -func newTestAgentLoop( - t *testing.T, -) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) { - t.Helper() - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - cfg = &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - } - msgBus = bus.NewMessageBus() - provider = &mockProvider{} - al = NewAgentLoop(cfg, msgBus, provider) - return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) } -} - -func TestRecordLastChannel(t *testing.T) { - al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) - defer cleanup() - - testChannel := "test-channel" - if err := al.RecordLastChannel(testChannel); err != nil { - t.Fatalf("RecordLastChannel failed: %v", err) - } - if got := al.state.GetLastChannel(); got != testChannel { - t.Errorf("Expected channel '%s', got '%s'", testChannel, got) - } - al2 := NewAgentLoop(cfg, msgBus, provider) - if got := al2.state.GetLastChannel(); got != testChannel { - t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, got) - } -} - -func TestRecordLastChatID(t *testing.T) { - al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) - defer cleanup() - - testChatID := "test-chat-id-123" - if err := al.RecordLastChatID(testChatID); err != nil { - t.Fatalf("RecordLastChatID failed: %v", err) - } - if got := al.state.GetLastChatID(); got != testChatID { - t.Errorf("Expected chat ID '%s', got '%s'", testChatID, got) - } - al2 := NewAgentLoop(cfg, msgBus, provider) - if got := al2.state.GetLastChatID(); got != testChatID { - t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, got) - } -} - -func TestNewAgentLoop_StateInitialized(t *testing.T) { - // Create temp workspace - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - // Create test config - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - } - - // Create agent loop - msgBus := bus.NewMessageBus() - provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) - - // Verify state manager is initialized - if al.state == nil { - t.Error("Expected state manager to be initialized") - } - - // Verify state directory was created - stateDir := filepath.Join(tmpDir, "state") - if _, err := os.Stat(stateDir); os.IsNotExist(err) { - t.Error("Expected state directory to exist") - } -} - -// TestToolRegistry_ToolRegistration verifies tools can be registered and retrieved -func TestToolRegistry_ToolRegistration(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) - - // Register a custom tool - customTool := &mockCustomTool{} - al.RegisterTool(customTool) - - // Verify tool is registered by checking it doesn't panic on GetStartupInfo - // (actual tool retrieval is tested in tools package tests) - info := al.GetStartupInfo() - toolsInfo := info["tools"].(map[string]any) - toolsList := toolsInfo["names"].([]string) - - // Check that our custom tool name is in the list - found := slices.Contains(toolsList, "mock_custom") - if !found { - t.Error("Expected custom tool to be registered") - } -} - -// TestToolContext_Updates verifies tool context helpers work correctly -func TestToolContext_Updates(t *testing.T) { - ctx := tools.WithToolContext(context.Background(), "telegram", "chat-42") - - if got := tools.ToolChannel(ctx); got != "telegram" { - t.Errorf("expected channel 'telegram', got %q", got) - } - if got := tools.ToolChatID(ctx); got != "chat-42" { - t.Errorf("expected chatID 'chat-42', got %q", got) - } - - // Empty context returns empty strings - if got := tools.ToolChannel(context.Background()); got != "" { - t.Errorf("expected empty channel from bare context, got %q", got) - } -} - -// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved -func TestToolRegistry_GetDefinitions(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) - - // Register a test tool and verify it shows up in startup info - testTool := &mockCustomTool{} - al.RegisterTool(testTool) - - info := al.GetStartupInfo() - toolsInfo := info["tools"].(map[string]any) - toolsList := toolsInfo["names"].([]string) - - // Check that our custom tool name is in the list - found := slices.Contains(toolsList, "mock_custom") - if !found { - t.Error("Expected custom tool to be registered") - } -} - -// TestAgentLoop_GetStartupInfo verifies startup info contains tools -func TestAgentLoop_GetStartupInfo(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Workspace = tmpDir - cfg.Agents.Defaults.Model = "test-model" - cfg.Agents.Defaults.MaxTokens = 4096 - cfg.Agents.Defaults.MaxToolIterations = 10 - - msgBus := bus.NewMessageBus() - provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) - - info := al.GetStartupInfo() - - // Verify tools info exists - toolsInfo, ok := info["tools"] - if !ok { - t.Fatal("Expected 'tools' key in startup info") - } - - toolsMap, ok := toolsInfo.(map[string]any) - if !ok { - t.Fatal("Expected 'tools' to be a map") - } - - count, ok := toolsMap["count"] - if !ok { - t.Fatal("Expected 'count' in tools info") - } - - // Should have default tools registered - if count.(int) == 0 { - t.Error("Expected at least some tools to be registered") - } -} - -// TestAgentLoop_Stop verifies Stop() sets running to false -func TestAgentLoop_Stop(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) - - // Note: running is only set to true when Run() is called - // We can't test that without starting the event loop - // Instead, verify the Stop method can be called safely - al.Stop() - - // Verify running is false (initial state or after Stop) - if al.running.Load() { - t.Error("Expected agent to be stopped (or never started)") - } -} - -// Mock implementations for testing - -type simpleMockProvider struct { - response string -} - -func (m *simpleMockProvider) Chat( - ctx context.Context, - messages []providers.Message, - tools []providers.ToolDefinition, - model string, - opts map[string]any, -) (*providers.LLMResponse, error) { - return &providers.LLMResponse{ - Content: m.response, - ToolCalls: []providers.ToolCall{}, - }, nil -} - -func (m *simpleMockProvider) GetDefaultModel() string { - return "mock-model" -} - -type countingMockProvider struct { - response string - calls int -} - -func (m *countingMockProvider) Chat( - ctx context.Context, - messages []providers.Message, - tools []providers.ToolDefinition, - model string, - opts map[string]any, -) (*providers.LLMResponse, error) { - m.calls++ - return &providers.LLMResponse{ - Content: m.response, - ToolCalls: []providers.ToolCall{}, - }, nil -} - -func (m *countingMockProvider) GetDefaultModel() string { - return "counting-mock-model" -} - -// mockCustomTool is a simple mock tool for registration testing -type mockCustomTool struct{} - -func (m *mockCustomTool) Name() string { - return "mock_custom" -} - -func (m *mockCustomTool) Description() string { - return "Mock custom tool for testing" -} - -func (m *mockCustomTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{}, - } -} - -func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { - return tools.SilentResult("Custom tool executed") -} - -// testHelper executes a message and returns the response -type testHelper struct { - al *AgentLoop -} - -func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string { - // Use a short timeout to avoid hanging - timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout) - defer cancel() - - response, _, err := h.al.processMessage(timeoutCtx, msg) - if err != nil { - tb.Fatalf("processMessage failed: %v", err) - } - return response -} - -const responseTimeout = 3 * time.Second - -func TestProcessMessage_UsesRouteSessionKey(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - provider := &simpleMockProvider{response: "ok"} - al := NewAgentLoop(cfg, msgBus, provider) - - msg := bus.InboundMessage{ - Channel: "telegram", - SenderID: "user1", - ChatID: "chat1", - Content: "hello", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", - }, - } - - route := al.registry.ResolveRoute(routing.RouteInput{ - Channel: msg.Channel, - Peer: extractPeer(msg), - }) - sessionKey := route.SessionKey - - defaultAgent := al.registry.GetDefaultAgent() - if defaultAgent == nil { - t.Fatal("No default agent found") - } - - helper := testHelper{al: al} - _ = helper.executeAndGetResponse(t, context.Background(), msg) - - history := defaultAgent.Sessions.GetHistory(sessionKey) - if len(history) != 2 { - t.Fatalf("expected session history len=2, got %d", len(history)) - } - if history[0].Role != "user" || history[0].Content != "hello" { - t.Fatalf("unexpected first message in session: %+v", history[0]) - } -} - -func TestProcessMessage_CommandOutcomes(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - Session: config.SessionConfig{ - DMScope: "per-channel-peer", - }, - } - - msgBus := bus.NewMessageBus() - provider := &countingMockProvider{response: "LLM reply"} - al := NewAgentLoop(cfg, msgBus, provider) - helper := testHelper{al: al} - - baseMsg := bus.InboundMessage{ - Channel: "whatsapp", - SenderID: "user1", - ChatID: "chat1", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", - }, - } - - showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ - Channel: baseMsg.Channel, - SenderID: baseMsg.SenderID, - ChatID: baseMsg.ChatID, - Content: "/show channel", - Peer: baseMsg.Peer, - }) - if showResp != "Current Channel: whatsapp" { - t.Fatalf("unexpected /show reply: %q", showResp) - } - if provider.calls != 0 { - t.Fatalf("LLM should not be called for handled command, calls=%d", provider.calls) - } - - fooResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ - Channel: baseMsg.Channel, - SenderID: baseMsg.SenderID, - ChatID: baseMsg.ChatID, - Content: "/foo", - Peer: baseMsg.Peer, - }) - if fooResp != "LLM reply" { - t.Fatalf("unexpected /foo reply: %q", fooResp) - } - if provider.calls != 1 { - t.Fatalf("LLM should be called exactly once after /foo passthrough, calls=%d", provider.calls) - } - - newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ - Channel: baseMsg.Channel, - SenderID: baseMsg.SenderID, - ChatID: baseMsg.ChatID, - Content: "/new", - Peer: baseMsg.Peer, - }) - if newResp != "LLM reply" { - t.Fatalf("unexpected /new reply: %q", newResp) - } - if provider.calls != 2 { - t.Fatalf("LLM should be called for passthrough /new command, calls=%d", provider.calls) - } -} - -func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Provider: "openai", - Model: "before-switch", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - provider := &countingMockProvider{response: "LLM reply"} - al := NewAgentLoop(cfg, msgBus, provider) - helper := testHelper{al: al} - - switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ - Channel: "telegram", - SenderID: "user1", - ChatID: "chat1", - Content: "/switch model to after-switch", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", - }, - }) - if !strings.Contains(switchResp, "Switched model from before-switch to after-switch") { - t.Fatalf("unexpected /switch reply: %q", switchResp) - } - - showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ - Channel: "telegram", - SenderID: "user1", - ChatID: "chat1", - Content: "/show model", - Peer: bus.Peer{ - Kind: "direct", - ID: "user1", - }, - }) - if !strings.Contains(showResp, "Current Model: after-switch (Provider: openai)") { - t.Fatalf("unexpected /show model reply after switch: %q", showResp) - } - - if provider.calls != 0 { - t.Fatalf("LLM should not be called for /switch and /show, calls=%d", provider.calls) - } -} - -// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound -func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - provider := &simpleMockProvider{response: "File operation complete"} - al := NewAgentLoop(cfg, msgBus, provider) - helper := testHelper{al: al} - - // ReadFileTool returns SilentResult, which should not send user message - ctx := context.Background() - msg := bus.InboundMessage{ - Channel: "test", - SenderID: "user1", - ChatID: "chat1", - Content: "read test.txt", - SessionKey: "test-session", - } - - response := helper.executeAndGetResponse(t, ctx, msg) - - // Silent tool should return the LLM's response directly - if response != "File operation complete" { - t.Errorf("Expected 'File operation complete', got: %s", response) - } -} - -// TestToolResult_UserFacingToolDoesSendMessage verifies user-facing tools trigger outbound -func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - provider := &simpleMockProvider{response: "Command output: hello world"} - al := NewAgentLoop(cfg, msgBus, provider) - helper := testHelper{al: al} - - // ExecTool returns UserResult, which should send user message - ctx := context.Background() - msg := bus.InboundMessage{ - Channel: "test", - SenderID: "user1", - ChatID: "chat1", - Content: "run hello", - SessionKey: "test-session", - } - - response := helper.executeAndGetResponse(t, ctx, msg) - - // User-facing tool should include the output in final response - if response != "Command output: hello world" { - t.Errorf("Expected 'Command output: hello world', got: %s", response) - } -} - -// failFirstMockProvider fails on the first N calls with a specific error -type failFirstMockProvider struct { - failures int - currentCall int - failError error - successResp string -} - -func (m *failFirstMockProvider) Chat( - ctx context.Context, - messages []providers.Message, - tools []providers.ToolDefinition, - model string, - opts map[string]any, -) (*providers.LLMResponse, error) { - m.currentCall++ - if m.currentCall <= m.failures { - return nil, m.failError - } - return &providers.LLMResponse{ - Content: m.successResp, - ToolCalls: []providers.ToolCall{}, - }, nil -} - -func (m *failFirstMockProvider) GetDefaultModel() string { - return "mock-fail-model" -} - -// TestAgentLoop_ContextExhaustionRetry verify that the agent retries on context errors -func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - - // Create a provider that fails once with a context error - contextErr := fmt.Errorf("InvalidParameter: Total tokens of image and text exceed max message tokens") - provider := &failFirstMockProvider{ - failures: 1, - failError: contextErr, - successResp: "Recovered from context error", - } - - al := NewAgentLoop(cfg, msgBus, provider) - - // Inject some history to simulate a full context - sessionKey := "test-session-context" - // Create dummy history - history := []providers.Message{ - {Role: "system", Content: "System prompt"}, - {Role: "user", Content: "Old message 1"}, - {Role: "assistant", Content: "Old response 1"}, - {Role: "user", Content: "Old message 2"}, - {Role: "assistant", Content: "Old response 2"}, - {Role: "user", Content: "Trigger message"}, - } - defaultAgent := al.registry.GetDefaultAgent() - if defaultAgent == nil { - t.Fatal("No default agent found") - } - defaultAgent.Sessions.SetHistory(sessionKey, history) - - // Call ProcessDirectWithChannel - // Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration - response, err := al.ProcessDirectWithChannel( - context.Background(), - "Trigger message", - sessionKey, - "test", - "test-chat", - ) - if err != nil { - t.Fatalf("Expected success after retry, got error: %v", err) - } - - if response != "Recovered from context error" { - t.Errorf("Expected 'Recovered from context error', got '%s'", response) - } - - // We expect 2 calls: 1st failed, 2nd succeeded - if provider.currentCall != 2 { - t.Errorf("Expected 2 calls (1 fail + 1 success), got %d", provider.currentCall) - } - - // Check final history length - finalHistory := defaultAgent.Sessions.GetHistory(sessionKey) - // We verify that the history has been modified (compressed) - // Original length: 6 - // Expected behavior: compression drops ~50% of history (mid slice) - // We can assert that the length is NOT what it would be without compression. - // Without compression: 6 + 1 (new user msg) + 1 (assistant msg) = 8 - if len(finalHistory) >= 8 { - t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory)) - } -} - -func TestTargetReasoningChannelID_AllChannels(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - } - - al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) - chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil) - if err != nil { - t.Fatalf("Failed to create channel manager: %v", err) - } - for name, id := range map[string]string{ - "whatsapp": "rid-whatsapp", - "telegram": "rid-telegram", - "feishu": "rid-feishu", - "discord": "rid-discord", - "maixcam": "rid-maixcam", - "qq": "rid-qq", - "dingtalk": "rid-dingtalk", - "slack": "rid-slack", - "line": "rid-line", - "onebot": "rid-onebot", - "wecom": "rid-wecom", - "wecom_app": "rid-wecom-app", - } { - chManager.RegisterChannel(name, &fakeChannel{id: id}) - } - al.SetChannelManager(chManager) - tests := []struct { - channel string - wantID string - }{ - {channel: "whatsapp", wantID: "rid-whatsapp"}, - {channel: "telegram", wantID: "rid-telegram"}, - {channel: "feishu", wantID: "rid-feishu"}, - {channel: "discord", wantID: "rid-discord"}, - {channel: "maixcam", wantID: "rid-maixcam"}, - {channel: "qq", wantID: "rid-qq"}, - {channel: "dingtalk", wantID: "rid-dingtalk"}, - {channel: "slack", wantID: "rid-slack"}, - {channel: "line", wantID: "rid-line"}, - {channel: "onebot", wantID: "rid-onebot"}, - {channel: "wecom", wantID: "rid-wecom"}, - {channel: "wecom_app", wantID: "rid-wecom-app"}, - {channel: "unknown", wantID: ""}, - } - - for _, tt := range tests { - t.Run(tt.channel, func(t *testing.T) { - got := al.targetReasoningChannelID(tt.channel) - if got != tt.wantID { - t.Fatalf("targetReasoningChannelID(%q) = %q, want %q", tt.channel, got, tt.wantID) - } - }) - } -} - -func TestHandleReasoning(t *testing.T) { - newLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) { - t.Helper() - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - Model: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - } - msgBus := bus.NewMessageBus() - return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus - } - - t.Run("skips when any required field is empty", func(t *testing.T) { - al, msgBus := newLoop(t) - al.handleReasoning(context.Background(), "reasoning", "telegram", "") - - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() - if msg, ok := msgBus.SubscribeOutbound(ctx); ok { - t.Fatalf("expected no outbound message, got %+v", msg) - } - }) - - t.Run("publishes one message for non telegram", func(t *testing.T) { - al, msgBus := newLoop(t) - al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1") - - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - msg, ok := msgBus.SubscribeOutbound(ctx) - if !ok { - t.Fatal("expected an outbound message") - } - if msg.Channel != "slack" || msg.ChatID != "channel-1" || msg.Content != "hello reasoning" { - t.Fatalf("unexpected outbound message: %+v", msg) - } - }) - - t.Run("publishes one message for telegram", func(t *testing.T) { - al, msgBus := newLoop(t) - reasoning := "hello telegram reasoning" - al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat") - - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - msg, ok := msgBus.SubscribeOutbound(ctx) - if !ok { - t.Fatal("expected outbound message") - } - - if msg.Channel != "telegram" { - t.Fatalf("expected telegram channel message, got %+v", msg) - } - if msg.ChatID != "tg-chat" { - t.Fatalf("expected chatID tg-chat, got %+v", msg) - } - if msg.Content != reasoning { - t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning) - } - }) - t.Run("expired ctx", func(t *testing.T) { - al, msgBus := newLoop(t) - reasoning := "hello telegram reasoning" - ctx, cancel := context.WithCancel(context.Background()) - cancel() - al.handleReasoning(ctx, reasoning, "telegram", "tg-chat") - - ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - msg, ok := msgBus.SubscribeOutbound(ctx) - if ok { - t.Fatalf("expected no outbound message, got %+v", msg) - } - }) - - t.Run("returns promptly when bus is full", func(t *testing.T) { - al, msgBus := newLoop(t) - - // Fill the outbound bus buffer until a publish would block. - // Use a short timeout to detect when the buffer is full, - // rather than hardcoding the buffer size. - for i := 0; ; i++ { - fillCtx, fillCancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - err := msgBus.PublishOutbound(fillCtx, bus.OutboundMessage{ - Channel: "filler", - ChatID: "filler", - Content: fmt.Sprintf("filler-%d", i), - }) - fillCancel() - if err != nil { - // Buffer is full (timed out trying to send). - break - } - } - - // Use a short-deadline parent context to bound the test. - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - - start := time.Now() - al.handleReasoning(ctx, "should timeout", "slack", "channel-full") - elapsed := time.Since(start) - - // handleReasoning uses a 5s internal timeout, but the parent ctx - // expires in 500ms. It should return within ~500ms, not 5s. - if elapsed > 2*time.Second { - t.Fatalf("handleReasoning blocked too long (%v); expected prompt return", elapsed) - } - - // Drain the bus and verify the reasoning message was NOT published - // (it should have been dropped due to timeout). - drainCtx, drainCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer drainCancel() - foundReasoning := false - for { - msg, ok := msgBus.SubscribeOutbound(drainCtx) - if !ok { - break - } - if msg.Content == "should timeout" { - foundReasoning = true - } - } - if foundReasoning { - t.Fatal("expected reasoning message to be dropped when bus is full, but it was published") - } - }) -} - -func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { - store := media.NewFileMediaStore() - dir := t.TempDir() - - // Create a minimal valid PNG (8-byte header is enough for filetype detection) - pngPath := filepath.Join(dir, "test.png") - // PNG magic: 0x89 P N G \r \n 0x1A \n + minimal IHDR - pngHeader := []byte{ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature - 0x00, 0x00, 0x00, 0x0D, // IHDR length - 0x49, 0x48, 0x44, 0x52, // "IHDR" - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, // 1x1 RGB - 0x00, 0x00, 0x00, // no interlace - 0x90, 0x77, 0x53, 0xDE, // CRC - } - if err := os.WriteFile(pngPath, pngHeader, 0o644); err != nil { - t.Fatal(err) - } - ref, err := store.Store(pngPath, media.MediaMeta{}, "test") - if err != nil { - t.Fatal(err) - } - - messages := []providers.Message{ - {Role: "user", Content: "describe this", Media: []string{ref}}, - } - result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) - - if len(result[0].Media) != 1 { - t.Fatalf("expected 1 resolved media, got %d", len(result[0].Media)) - } - if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { - t.Fatalf("expected data:image/png;base64, prefix, got %q", result[0].Media[0][:40]) - } -} - -func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) { - store := media.NewFileMediaStore() - dir := t.TempDir() - - bigPath := filepath.Join(dir, "big.png") - // Write PNG header + padding to exceed limit - data := make([]byte, 1024+1) // 1KB + 1 byte - copy(data, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) - if err := os.WriteFile(bigPath, data, 0o644); err != nil { - t.Fatal(err) - } - ref, _ := store.Store(bigPath, media.MediaMeta{}, "test") - - messages := []providers.Message{ - {Role: "user", Content: "hi", Media: []string{ref}}, - } - // Use a tiny limit (1KB) so the file is oversized - result := resolveMediaRefs(messages, store, 1024) - - if len(result[0].Media) != 0 { - t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media)) - } -} - -func TestResolveMediaRefs_SkipsUnknownType(t *testing.T) { - store := media.NewFileMediaStore() - dir := t.TempDir() - - txtPath := filepath.Join(dir, "readme.txt") - if err := os.WriteFile(txtPath, []byte("hello world"), 0o644); err != nil { - t.Fatal(err) - } - ref, _ := store.Store(txtPath, media.MediaMeta{}, "test") - - messages := []providers.Message{ - {Role: "user", Content: "hi", Media: []string{ref}}, - } - result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) - - if len(result[0].Media) != 0 { - t.Fatalf("expected 0 media (unknown type), got %d", len(result[0].Media)) - } -} - -func TestResolveMediaRefs_PassesThroughNonMediaRefs(t *testing.T) { - messages := []providers.Message{ - {Role: "user", Content: "hi", Media: []string{"https://example.com/img.png"}}, - } - result := resolveMediaRefs(messages, nil, config.DefaultMaxMediaSize) - - if len(result[0].Media) != 1 || result[0].Media[0] != "https://example.com/img.png" { - t.Fatalf("expected passthrough of non-media:// URL, got %v", result[0].Media) - } -} - -func TestResolveMediaRefs_DoesNotMutateOriginal(t *testing.T) { - store := media.NewFileMediaStore() - dir := t.TempDir() - pngPath := filepath.Join(dir, "test.png") - pngHeader := []byte{ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, - 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, - 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, - } - os.WriteFile(pngPath, pngHeader, 0o644) - ref, _ := store.Store(pngPath, media.MediaMeta{}, "test") - - original := []providers.Message{ - {Role: "user", Content: "hi", Media: []string{ref}}, - } - originalRef := original[0].Media[0] - - resolveMediaRefs(original, store, config.DefaultMaxMediaSize) - - if original[0].Media[0] != originalRef { - t.Fatal("resolveMediaRefs mutated original message slice") - } -} - -func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) { - store := media.NewFileMediaStore() - dir := t.TempDir() - - // File with JPEG content but stored with explicit content type - jpegPath := filepath.Join(dir, "photo") - jpegHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0} // JPEG magic bytes - os.WriteFile(jpegPath, jpegHeader, 0o644) - ref, _ := store.Store(jpegPath, media.MediaMeta{ContentType: "image/jpeg"}, "test") - - messages := []providers.Message{ - {Role: "user", Content: "hi", Media: []string{ref}}, - } - result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) - - if len(result[0].Media) != 1 { - t.Fatalf("expected 1 media, got %d", len(result[0].Media)) - } - if !strings.HasPrefix(result[0].Media[0], "data:image/jpeg;base64,") { - t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30]) - } -} diff --git a/pkg/agent/model_resolution.go b/pkg/agent/model_resolution.go new file mode 100644 index 000000000..6065f6403 --- /dev/null +++ b/pkg/agent/model_resolution.go @@ -0,0 +1,179 @@ +package agent + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func ensureProtocolModel(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" + } + if strings.Contains(model, "/") { + return model + } + return "openai/" + model +} + +func modelConfigIdentityKey(mc *config.ModelConfig) string { + if mc == nil { + return "" + } + if name := strings.TrimSpace(mc.ModelName); name != "" { + return "model_name:" + name + } + return "" +} + +func candidateFromModelConfig( + defaultProvider string, + mc *config.ModelConfig, +) (providers.FallbackCandidate, bool) { + if mc == nil { + return providers.FallbackCandidate{}, false + } + + protocol, modelID := providers.ExtractProtocol(mc) + if strings.TrimSpace(modelID) == "" { + return providers.FallbackCandidate{}, false + } + + return providers.FallbackCandidate{ + Provider: protocol, + Model: modelID, + RPM: mc.RPM, + IdentityKey: modelConfigIdentityKey(mc), + }, true +} + +func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig { + raw = strings.TrimSpace(raw) + if raw == "" || cfg == nil { + return nil + } + + if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { + return mc + } + + rawRef := providers.ParseModelRef(raw, "") + rawKey := "" + if rawRef != nil && strings.TrimSpace(rawRef.Provider) != "" && strings.TrimSpace(rawRef.Model) != "" { + rawKey = providers.ModelKey(rawRef.Provider, rawRef.Model) + } + + for i := range cfg.ModelList { + mc := cfg.ModelList[i] + if mc == nil { + continue + } + fullModel := strings.TrimSpace(mc.Model) + if fullModel == "" { + continue + } + if fullModel == raw { + return mc + } + protocol, modelID := providers.ExtractProtocol(mc) + if modelID == raw { + return mc + } + if rawKey != "" && providers.ModelKey(protocol, modelID) == rawKey { + return mc + } + } + + return nil +} + +func resolveModelCandidate( + cfg *config.Config, + defaultProvider string, + raw string, +) (providers.FallbackCandidate, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return providers.FallbackCandidate{}, false + } + + if mc := lookupModelConfigByRef(cfg, raw); mc != nil { + return candidateFromModelConfig(defaultProvider, mc) + } + + ref := providers.ParseModelRef(raw, defaultProvider) + if ref == nil { + return providers.FallbackCandidate{}, false + } + + return providers.FallbackCandidate{ + Provider: ref.Provider, + Model: ref.Model, + }, true +} + +func resolveModelCandidates( + cfg *config.Config, + defaultProvider string, + primary string, + fallbacks []string, +) []providers.FallbackCandidate { + seen := make(map[string]bool) + candidates := make([]providers.FallbackCandidate, 0, 1+len(fallbacks)) + + addCandidate := func(raw string) { + candidate, ok := resolveModelCandidate(cfg, defaultProvider, raw) + if !ok { + return + } + + key := candidate.StableKey() + if seen[key] { + return + } + seen[key] = true + candidates = append(candidates, candidate) + } + + addCandidate(primary) + for _, fallback := range fallbacks { + addCandidate(fallback) + } + + return candidates +} + +func resolvedCandidateModel(candidates []providers.FallbackCandidate, fallback string) string { + if len(candidates) > 0 && strings.TrimSpace(candidates[0].Model) != "" { + return candidates[0].Model + } + return fallback +} + +func resolvedCandidateProvider(candidates []providers.FallbackCandidate, fallback string) string { + if len(candidates) > 0 && strings.TrimSpace(candidates[0].Provider) != "" { + return candidates[0].Provider + } + return fallback +} + +func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*config.ModelConfig, error) { + if cfg == nil { + return nil, fmt.Errorf("config is nil") + } + + modelCfg, err := cfg.GetModelConfig(strings.TrimSpace(modelName)) + if err != nil { + return nil, err + } + + clone := *modelCfg + if clone.Workspace == "" { + clone.Workspace = workspace + } + + return &clone, nil +} diff --git a/pkg/agent/pipeline.go b/pkg/agent/pipeline.go new file mode 100644 index 000000000..c4b9ec3af --- /dev/null +++ b/pkg/agent/pipeline.go @@ -0,0 +1,40 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// Pipeline holds the runtime dependencies used by Pipeline methods. +// It is constructed by runTurn via NewPipeline and passed to sub-methods +// so that the coordinator can delegate phase execution. +type Pipeline struct { + Bus interfaces.MessageBus + Cfg *config.Config + ContextManager ContextManager + Hooks *HookManager + Fallback *providers.FallbackChain + ChannelManager interfaces.ChannelManager + MediaStore media.MediaStore + Steering any // TODO: *Steering + al *AgentLoop +} + +// NewPipeline creates a Pipeline from an AgentLoop instance. +func NewPipeline(al *AgentLoop) *Pipeline { + return &Pipeline{ + Bus: al.bus, + Cfg: al.GetConfig(), + ContextManager: al.contextManager, + Hooks: al.hooks, + Fallback: al.fallback, + ChannelManager: al.channelManager, + MediaStore: al.mediaStore, + Steering: al.steering, + al: al, + } +} diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go new file mode 100644 index 000000000..0f71c7432 --- /dev/null +++ b/pkg/agent/pipeline_execute.go @@ -0,0 +1,728 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/constants" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// ExecuteTools executes the tool loop, handling BeforeTool/ApproveTool/AfterTool hooks, +// tool execution with async callbacks, media delivery, and steering injection. +// Returns ToolControl indicating what the coordinator should do next: +// - ToolControlContinue: all tool results handled, pendingMessages or steering exists, continue turn +// - ToolControlBreak: tool loop exited, proceed to coordinator's hardAbort/finalContent/finalize +func (p *Pipeline) ExecuteTools( + ctx context.Context, + turnCtx context.Context, + ts *turnState, + exec *turnExecution, + iteration int, +) ToolControl { + al := p.al + normalizedToolCalls := exec.normalizedToolCalls + + ts.setPhase(TurnPhaseTools) + messages := exec.messages + handledAttachments := make([]providers.Attachment, 0) + +toolLoop: + for i, tc := range normalizedToolCalls { + if ts.hardAbortRequested() { + exec.abortedByHardAbort = true + return ToolControlBreak + } + + toolName := tc.Name + toolArgs := cloneStringAnyMap(tc.Arguments) + + if al.hooks != nil { + toolReq, decision := al.hooks.BeforeTool(turnCtx, &ToolCallHookRequest{ + Meta: ts.eventMeta("runTurn", "turn.tool.before"), + Context: cloneTurnContext(ts.turnCtx), + Tool: toolName, + Arguments: toolArgs, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if toolReq != nil { + toolName = toolReq.Tool + toolArgs = toolReq.Arguments + } + case HookActionRespond: + if toolReq != nil && toolReq.HookResult != nil { + hookResult := toolReq.HookResult + + argsJSON, _ := json.Marshal(toolArgs) + argsPreview := utils.Truncate(string(argsJSON), 200) + logger.InfoCF("agent", fmt.Sprintf("Tool call (hook respond): %s(%s)", toolName, argsPreview), + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "iteration": iteration, + }) + + al.emitEvent( + runtimeevents.KindAgentToolExecStart, + ts.eventMeta("runTurn", "turn.tool.start"), + ToolExecStartPayload{ + Tool: toolName, + Arguments: cloneEventArguments(toolArgs), + }, + ) + + if shouldPublishToolFeedback(al.cfg, ts) && ts.channel != "pico" { + toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength() + toolFeedbackExplanation := toolFeedbackExplanationForToolCall( + exec.response, + tc, + messages, + ) + feedbackMsg := utils.FormatToolFeedbackMessage( + toolName, + toolFeedbackExplanation, + toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), + ) + fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) + _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) + fbCancel() + } + + toolDuration := time.Duration(0) + + shouldSendForUser := !hookResult.Silent && hookResult.ForUser != "" && + (ts.opts.SendResponse || hookResult.ResponseHandled) + if shouldSendForUser { + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Context: bus.InboundContext{ + Channel: ts.channel, + ChatID: ts.chatID, + Raw: map[string]string{ + "is_tool_call": "true", + }, + }, + Content: hookResult.ForUser, + }) + } + + if len(hookResult.Media) > 0 && hookResult.ResponseHandled { + parts := make([]bus.MediaPart, 0, len(hookResult.Media)) + for _, ref := range hookResult.Media { + part := bus.MediaPart{Ref: ref} + if al.mediaStore != nil { + if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { + part.Filename = meta.Filename + part.ContentType = meta.ContentType + part.Type = inferMediaType(meta.Filename, meta.ContentType) + } + } + parts = append(parts, part) + } + outboundMedia := bus.OutboundMediaMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Context: outboundContextFromInbound( + ts.opts.Dispatch.InboundContext, + ts.channel, + ts.chatID, + ts.opts.Dispatch.ReplyToMessageID(), + ), + AgentID: ts.agent.ID, + SessionKey: ts.sessionKey, + Scope: outboundScopeFromSessionScope(ts.opts.Dispatch.SessionScope), + Parts: parts, + } + if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { + if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { + logger.WarnCF("agent", "Failed to deliver hook media", + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + hookResult.IsError = true + hookResult.ForLLM = fmt.Sprintf("failed to deliver attachment: %v", err) + } else { + handledAttachments = append( + handledAttachments, + buildProviderAttachments(al.mediaStore, hookResult.Media)..., + ) + } + } else if al.bus != nil { + al.bus.PublishOutboundMedia(ctx, outboundMedia) + hookResult.ResponseHandled = false + } + } + + if !hookResult.ResponseHandled { + exec.allResponsesHandled = false + } + + contentForLLM := hookResult.ContentForLLM() + if al.cfg.Tools.IsFilterSensitiveDataEnabled() { + contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) + } + + toolResultMsg := providers.Message{ + Role: "tool", + Content: contentForLLM, + ToolCallID: tc.ID, + } + + if len(hookResult.Media) > 0 && !hookResult.ResponseHandled { + hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media) + contentForLLM = hookResult.ContentForLLM() + if al.cfg.Tools.IsFilterSensitiveDataEnabled() { + contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) + } + toolResultMsg.Content = contentForLLM + toolResultMsg.Media = append(toolResultMsg.Media, hookResult.Media...) + } + + al.emitEvent( + runtimeevents.KindAgentToolExecEnd, + ts.eventMeta("runTurn", "turn.tool.end"), + ToolExecEndPayload{ + Tool: toolName, + Duration: toolDuration, + ForLLMLen: len(contentForLLM), + ForUserLen: len(hookResult.ForUser), + IsError: hookResult.IsError, + Async: hookResult.Async, + }, + ) + + messages = append(messages, toolResultMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) + ts.recordPersistedMessage(toolResultMsg) + ts.ingestMessage(turnCtx, al, toolResultMsg) + } + + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + } + + skipReason := "" + skipMessage := "" + if len(exec.pendingMessages) > 0 { + skipReason = "queued user steering message" + skipMessage = "Skipped due to queued user message." + } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { + skipReason = "graceful interrupt requested" + skipMessage = "Skipped due to graceful interrupt." + } + + if skipReason != "" { + remaining := len(normalizedToolCalls) - i - 1 + if remaining > 0 { + logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools after hook respond", + map[string]any{ + "agent_id": ts.agent.ID, + "completed": i + 1, + "skipped": remaining, + "reason": skipReason, + }) + for j := i + 1; j < len(normalizedToolCalls); j++ { + skippedTC := normalizedToolCalls[j] + al.emitEvent( + runtimeevents.KindAgentToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: skippedTC.Name, + Reason: skipReason, + }, + ) + skippedMsg := providers.Message{ + Role: "tool", + Content: skipMessage, + ToolCallID: skippedTC.ID, + } + messages = append(messages, skippedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) + ts.recordPersistedMessage(skippedMsg) + } + } + } + break toolLoop + } + + if ts.pendingResults != nil { + select { + case result, ok := <-ts.pendingResults: + if ok && result != nil && result.ForLLM != "" { + content := al.cfg.FilterSensitiveData(result.ForLLM) + msg := subTurnResultPromptMessage(content) + messages = append(messages, msg) + ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + } + default: + } + } + + continue + } + logger.WarnCF("agent", "Hook returned respond action but no HookResult provided", + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "action": "respond", + }) + case HookActionDenyTool: + exec.allResponsesHandled = false + denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason) + al.emitEvent( + runtimeevents.KindAgentToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: toolName, + Reason: denyContent, + }, + ) + deniedMsg := providers.Message{ + Role: "tool", + Content: denyContent, + ToolCallID: tc.ID, + } + messages = append(messages, deniedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) + ts.recordPersistedMessage(deniedMsg) + } + continue + case HookActionAbortTurn: + exec.abortedByHook = true + return ToolControlBreak + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ToolControlBreak + } + } + + if al.hooks != nil { + approval := al.hooks.ApproveTool(turnCtx, &ToolApprovalRequest{ + Meta: ts.eventMeta("runTurn", "turn.tool.approve"), + Context: cloneTurnContext(ts.turnCtx), + Tool: toolName, + Arguments: toolArgs, + }) + if !approval.Approved { + exec.allResponsesHandled = false + denyContent := hookDeniedToolContent("Tool execution denied by approval hook", approval.Reason) + al.emitEvent( + runtimeevents.KindAgentToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: toolName, + Reason: denyContent, + }, + ) + deniedMsg := providers.Message{ + Role: "tool", + Content: denyContent, + ToolCallID: tc.ID, + } + messages = append(messages, deniedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) + ts.recordPersistedMessage(deniedMsg) + } + continue + } + } + + argsJSON, _ := json.Marshal(toolArgs) + argsPreview := utils.Truncate(string(argsJSON), 200) + logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview), + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "iteration": iteration, + }) + al.emitEvent( + runtimeevents.KindAgentToolExecStart, + ts.eventMeta("runTurn", "turn.tool.start"), + ToolExecStartPayload{ + Tool: toolName, + Arguments: cloneEventArguments(toolArgs), + }, + ) + + if shouldPublishToolFeedback(al.cfg, ts) && ts.channel != "pico" { + toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength() + toolFeedbackExplanation := toolFeedbackExplanationForToolCall( + exec.response, + tc, + messages, + ) + feedbackMsg := utils.FormatToolFeedbackMessage( + toolName, + toolFeedbackExplanation, + toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), + ) + fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) + _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) + fbCancel() + } + + toolCallID := tc.ID + asyncToolName := toolName + asyncCallback := func(_ context.Context, result *tools.ToolResult) { + if !result.Silent && result.ForUser != "" { + outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer outCancel() + _ = al.bus.PublishOutbound(outCtx, outboundMessageForTurn(ts, result.ForUser)) + } + + content := result.ContentForLLM() + if content == "" { + return + } + + content = al.cfg.FilterSensitiveData(content) + + logger.InfoCF("agent", "Async tool completed, publishing result", + map[string]any{ + "tool": asyncToolName, + "content_len": len(content), + "channel": ts.channel, + }) + al.emitEvent( + runtimeevents.KindAgentFollowUpQueued, + ts.scope.meta(iteration, "runTurn", "turn.follow_up.queued"), + FollowUpQueuedPayload{ + SourceTool: asyncToolName, + ContentLen: len(content), + }, + ) + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "system", + ChatID: fmt.Sprintf("%s:%s", ts.channel, ts.chatID), + ChatType: "direct", + SenderID: fmt.Sprintf("async:%s", asyncToolName), + }, + Content: content, + }) + } + + toolStart := time.Now() + execCtx := tools.WithToolInboundContext( + turnCtx, + ts.channel, + ts.chatID, + ts.opts.Dispatch.MessageID(), + ts.opts.Dispatch.ReplyToMessageID(), + ) + execCtx = tools.WithToolSessionContext( + execCtx, + ts.agent.ID, + ts.sessionKey, + ts.opts.Dispatch.SessionScope, + ) + toolResult := ts.agent.Tools.ExecuteWithContext( + execCtx, + toolName, + toolArgs, + ts.channel, + ts.chatID, + asyncCallback, + ) + toolDuration := time.Since(toolStart) + + if ts.hardAbortRequested() { + exec.abortedByHardAbort = true + return ToolControlBreak + } + + if al.hooks != nil { + toolResp, decision := al.hooks.AfterTool(turnCtx, &ToolResultHookResponse{ + Meta: ts.eventMeta("runTurn", "turn.tool.after"), + Context: cloneTurnContext(ts.turnCtx), + Tool: toolName, + Arguments: toolArgs, + Result: toolResult, + Duration: toolDuration, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if toolResp != nil { + if toolResp.Tool != "" { + toolName = toolResp.Tool + } + if toolResp.Result != nil { + toolResult = toolResp.Result + } + } + case HookActionAbortTurn: + exec.abortedByHook = true + return ToolControlBreak + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ToolControlBreak + } + } + + if toolResult == nil { + toolResult = tools.ErrorResult("hook returned nil tool result") + } + + if len(toolResult.Media) > 0 && toolResult.ResponseHandled { + parts := make([]bus.MediaPart, 0, len(toolResult.Media)) + for _, ref := range toolResult.Media { + part := bus.MediaPart{Ref: ref} + if al.mediaStore != nil { + if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { + part.Filename = meta.Filename + part.ContentType = meta.ContentType + part.Type = inferMediaType(meta.Filename, meta.ContentType) + } + } + parts = append(parts, part) + } + outboundMedia := bus.OutboundMediaMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Context: outboundContextFromInbound( + ts.opts.Dispatch.InboundContext, + ts.channel, + ts.chatID, + ts.opts.Dispatch.ReplyToMessageID(), + ), + AgentID: ts.agent.ID, + SessionKey: ts.sessionKey, + Scope: outboundScopeFromSessionScope(ts.opts.Dispatch.SessionScope), + Parts: parts, + } + if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { + if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { + logger.WarnCF("agent", "Failed to deliver handled tool media", + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + toolResult = tools.ErrorResult(fmt.Sprintf("failed to deliver attachment: %v", err)).WithError(err) + } else { + handledAttachments = append( + handledAttachments, + buildProviderAttachments(al.mediaStore, toolResult.Media)..., + ) + } + } else if al.bus != nil { + al.bus.PublishOutboundMedia(ctx, outboundMedia) + toolResult.ResponseHandled = false + } + } + + if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { + toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media) + } + + if !toolResult.ResponseHandled { + exec.allResponsesHandled = false + } + + shouldSendForUser := !toolResult.Silent && + toolResult.ForUser != "" && + (ts.opts.SendResponse || toolResult.ResponseHandled) + if shouldSendForUser { + al.bus.PublishOutbound(ctx, outboundMessageForTurn(ts, toolResult.ForUser)) + logger.DebugCF("agent", "Sent tool result to user", + map[string]any{ + "tool": toolName, + "content_len": len(toolResult.ForUser), + }) + } + contentForLLM := toolResult.ContentForLLM() + + if al.cfg.Tools.IsFilterSensitiveDataEnabled() { + contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) + } + + toolResultMsg := providers.Message{ + Role: "tool", + Content: contentForLLM, + ToolCallID: toolCallID, + } + if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { + toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...) + } + al.emitEvent( + runtimeevents.KindAgentToolExecEnd, + ts.eventMeta("runTurn", "turn.tool.end"), + ToolExecEndPayload{ + Tool: toolName, + Duration: toolDuration, + ForLLMLen: len(contentForLLM), + ForUserLen: len(toolResult.ForUser), + IsError: toolResult.IsError, + Async: toolResult.Async, + }, + ) + messages = append(messages, toolResultMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) + ts.recordPersistedMessage(toolResultMsg) + ts.ingestMessage(turnCtx, al, toolResultMsg) + } + + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + } + + skipReason := "" + skipMessage := "" + if len(exec.pendingMessages) > 0 { + skipReason = "queued user steering message" + skipMessage = "Skipped due to queued user message." + } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { + skipReason = "graceful interrupt requested" + skipMessage = "Skipped due to graceful interrupt." + } + + if skipReason != "" { + remaining := len(normalizedToolCalls) - i - 1 + if remaining > 0 { + logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools", + map[string]any{ + "agent_id": ts.agent.ID, + "completed": i + 1, + "skipped": remaining, + "reason": skipReason, + }) + for j := i + 1; j < len(normalizedToolCalls); j++ { + skippedTC := normalizedToolCalls[j] + al.emitEvent( + runtimeevents.KindAgentToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: skippedTC.Name, + Reason: skipReason, + }, + ) + skippedMsg := providers.Message{ + Role: "tool", + Content: skipMessage, + ToolCallID: skippedTC.ID, + } + messages = append(messages, skippedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) + ts.recordPersistedMessage(skippedMsg) + } + } + } + break toolLoop + } + + if ts.pendingResults != nil { + select { + case result, ok := <-ts.pendingResults: + if ok && result != nil && result.ForLLM != "" { + content := al.cfg.FilterSensitiveData(result.ForLLM) + msg := subTurnResultPromptMessage(content) + messages = append(messages, msg) + ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + } + default: + } + } + } + + exec.messages = messages + + // Continue if pending steering exists (regardless of allResponsesHandled). + // This covers the case where tools were partially executed and skipped due to steering, + // but one tool had ResponseHandled=false (so allResponsesHandled=false). + if len(exec.pendingMessages) > 0 { + logger.InfoCF("agent", "Pending steering after partial tool execution; continuing turn", + map[string]any{ + "agent_id": ts.agent.ID, + "pending_count": len(exec.pendingMessages), + "allResponsesHandled": exec.allResponsesHandled, + }) + exec.allResponsesHandled = false + return ToolControlContinue + } + + // Poll for newly arrived steering + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + logger.InfoCF("agent", "Steering arrived after tool delivery; continuing turn", + map[string]any{ + "agent_id": ts.agent.ID, + "steering_count": len(steerMsgs), + }) + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + exec.allResponsesHandled = false + return ToolControlContinue + } + + // No pending steering: finalize or break depending on allResponsesHandled + if exec.allResponsesHandled { + summaryMsg := providers.Message{ + Role: "assistant", + Content: handledToolResponseSummary, + Attachments: append([]providers.Attachment(nil), handledAttachments...), + } + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, summaryMsg) + ts.recordPersistedMessage(summaryMsg) + ts.ingestMessage(turnCtx, al, summaryMsg) + if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { + logger.WarnCF("agent", "Failed to save session after tool delivery", + map[string]any{ + "agent_id": ts.agent.ID, + "error": err.Error(), + }) + } + } + if ts.opts.EnableSummary { + al.contextManager.Compact(turnCtx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonSummarize, + Budget: ts.agent.ContextWindow, + }) + } + ts.setPhase(TurnPhaseCompleted) + ts.setFinalContent("") + if al.channelManager != nil && ts.channel != "" { + al.channelManager.DismissToolFeedback(ctx, ts.channel, ts.chatID, ts.opts.InboundContext) + } + logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "tool_count": len(normalizedToolCalls), + }) + return ToolControlBreak + } + + // allResponsesHandled=false and no pending steering: continue so coordinator + // makes another LLM call. The tool result is in messages and the LLM will + // return it as finalContent in the next iteration. + ts.agent.Tools.TickTTL() + logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{ + "agent_id": ts.agent.ID, "iteration": iteration, + }) + return ToolControlContinue +} diff --git a/pkg/agent/pipeline_finalize.go b/pkg/agent/pipeline_finalize.go new file mode 100644 index 000000000..1f407825e --- /dev/null +++ b/pkg/agent/pipeline_finalize.go @@ -0,0 +1,82 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/bus" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// Finalize handles turn finalization, either: +// - Early return when allResponsesHandled=true (ExecuteTools already finalized) +// - Normal finalization for allResponsesHandled=false (sets finalContent, saves session, compact) +func (p *Pipeline) Finalize( + ctx context.Context, + turnCtx context.Context, + ts *turnState, + exec *turnExecution, + turnStatus TurnEndStatus, + finalContent string, +) (turnResult, error) { + al := p.al + + // When allResponsesHandled=true, ExecuteTools already finalized + // (added handledToolResponseSummary, saved session, set phase to Completed). + // But still check for hard abort - if requested, abort the turn. + if exec.allResponsesHandled { + if ts.hardAbortRequested() { + return al.abortTurn(ts) + } + ts.setPhase(TurnPhaseCompleted) + return turnResult{ + finalContent: finalContent, + status: turnStatus, + followUps: append([]bus.InboundMessage(nil), ts.followUps...), + }, nil + } + + ts.setPhase(TurnPhaseFinalizing) + ts.setFinalContent(finalContent) + if !ts.opts.NoHistory { + finalMsg := providers.Message{ + Role: "assistant", + Content: finalContent, + ReasoningContent: responseReasoningContent(exec.response), + } + ts.agent.Sessions.AddFullMessage(ts.sessionKey, finalMsg) + ts.recordPersistedMessage(finalMsg) + ts.ingestMessage(turnCtx, al, finalMsg) + if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { + al.emitEvent( + runtimeevents.KindAgentError, + ts.eventMeta("runTurn", "turn.error"), + ErrorPayload{ + Stage: "session_save", + Message: err.Error(), + }, + ) + return turnResult{status: TurnEndStatusError}, err + } + } + + if ts.opts.EnableSummary { + al.contextManager.Compact( + turnCtx, + &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonSummarize, + Budget: ts.agent.ContextWindow, + }, + ) + } + + ts.setPhase(TurnPhaseCompleted) + return turnResult{ + finalContent: finalContent, + status: turnStatus, + followUps: append([]bus.InboundMessage(nil), ts.followUps...), + }, nil +} diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go new file mode 100644 index 000000000..496fcd7e4 --- /dev/null +++ b/pkg/agent/pipeline_llm.go @@ -0,0 +1,569 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/constants" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// CallLLM performs an LLM call with fallback support, hook invocation, and retry logic. +// It handles PreLLM setup, the actual LLM invocation with retry, and AfterLLM processing. +// Returns Control indicating what the coordinator should do next. +func (p *Pipeline) CallLLM( + ctx context.Context, + turnCtx context.Context, + ts *turnState, + exec *turnExecution, + iteration int, +) (Control, error) { + al := p.al + maxMediaSize := p.Cfg.Agents.Defaults.GetMaxMediaSize() + + // PreLLM: resolve media refs (except on iteration 1 where user media is already resolved) + if iteration > 1 { + exec.messages = resolveMediaRefs(exec.messages, p.MediaStore, maxMediaSize) + } + + // PreLLM: graceful terminal handling + exec.gracefulTerminal, _ = ts.gracefulInterruptRequested() + exec.providerToolDefs = ts.agent.Tools.ToProviderDefs() + + // Native web search support + webSearchEnabled := al.cfg.Tools.IsToolEnabled("web") + exec.useNativeSearch = webSearchEnabled && al.cfg.Tools.Web.PreferNative && + func() bool { + if ns, ok := ts.agent.Provider.(providers.NativeSearchCapable); ok { + return ns.SupportsNativeSearch() + } + return false + }() + + if exec.useNativeSearch { + filtered := make([]providers.ToolDefinition, 0, len(exec.providerToolDefs)) + for _, td := range exec.providerToolDefs { + if td.Function.Name != "web_search" { + filtered = append(filtered, td) + } + } + exec.providerToolDefs = filtered + } + + exec.callMessages = exec.messages + if exec.gracefulTerminal { + exec.callMessages = append(append([]providers.Message(nil), exec.messages...), ts.interruptHintMessage()) + exec.providerToolDefs = nil + ts.markGracefulTerminalUsed() + } + + exec.llmOpts = map[string]any{ + "max_tokens": ts.agent.MaxTokens, + "temperature": ts.agent.Temperature, + "prompt_cache_key": ts.agent.ID, + } + if exec.useNativeSearch { + exec.llmOpts["native_search"] = true + } + if ts.agent.ThinkingLevel != ThinkingOff { + if tc, ok := ts.agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { + exec.llmOpts["thinking_level"] = string(ts.agent.ThinkingLevel) + } else { + logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", + map[string]any{"agent_id": ts.agent.ID, "thinking_level": string(ts.agent.ThinkingLevel)}) + } + } + + exec.llmModel = exec.activeModel + + // BeforeLLM hook + if p.Hooks != nil { + llmReq, decision := p.Hooks.BeforeLLM(turnCtx, &LLMHookRequest{ + Meta: ts.eventMeta("runTurn", "turn.llm.request"), + Context: cloneTurnContext(ts.turnCtx), + Model: exec.llmModel, + Messages: exec.callMessages, + Tools: exec.providerToolDefs, + Options: exec.llmOpts, + GracefulTerminal: exec.gracefulTerminal, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmReq != nil { + exec.llmModel = llmReq.Model + exec.callMessages = llmReq.Messages + exec.providerToolDefs = llmReq.Tools + exec.llmOpts = llmReq.Options + } + case HookActionAbortTurn: + exec.abortedByHook = true + return ControlBreak, nil + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ControlBreak, nil + } + } + + al.emitEvent( + runtimeevents.KindAgentLLMRequest, + ts.eventMeta("runTurn", "turn.llm.request"), + LLMRequestPayload{ + Model: exec.llmModel, + MessagesCount: len(exec.callMessages), + ToolsCount: len(exec.providerToolDefs), + MaxTokens: ts.agent.MaxTokens, + Temperature: ts.agent.Temperature, + }, + ) + + logger.DebugCF("agent", "LLM request", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "model": exec.llmModel, + "messages_count": len(exec.callMessages), + "tools_count": len(exec.providerToolDefs), + "max_tokens": ts.agent.MaxTokens, + "temperature": ts.agent.Temperature, + "system_prompt_len": len(exec.callMessages[0].Content), + }) + logger.DebugCF("agent", "Full LLM request", + map[string]any{ + "iteration": iteration, + "messages_json": formatMessagesForLog(exec.callMessages), + "tools_json": formatToolsForLog(exec.providerToolDefs), + }) + + // LLM call closure with fallback support + callLLM := func(messagesForCall []providers.Message, toolDefsForCall []providers.ToolDefinition) (*providers.LLMResponse, error) { + providerCtx, providerCancel := context.WithCancel(turnCtx) + ts.setProviderCancel(providerCancel) + defer func() { + providerCancel() + ts.clearProviderCancel(providerCancel) + }() + + al.activeRequests.Add(1) + defer al.activeRequests.Done() + + if len(exec.activeCandidates) > 1 && p.Fallback != nil { + fbResult, fbErr := p.Fallback.Execute( + providerCtx, + exec.activeCandidates, + func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { + candidateProvider := exec.activeProvider + if cp, ok := ts.agent.CandidateProviders[providers.ModelKey(provider, model)]; ok { + candidateProvider = cp + } + return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, model, exec.llmOpts) + }, + ) + if fbErr != nil { + return nil, fbErr + } + if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { + logger.InfoCF( + "agent", + fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", + fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), + map[string]any{"agent_id": ts.agent.ID, "iteration": iteration}, + ) + } + return fbResult.Response, nil + } + return exec.activeProvider.Chat(providerCtx, messagesForCall, toolDefsForCall, exec.llmModel, exec.llmOpts) + } + + // Retry loop + var err error + maxRetries := p.Cfg.Agents.Defaults.MaxLLMRetries + if maxRetries <= 0 { + maxRetries = 2 + } + backoffSecs := p.Cfg.Agents.Defaults.LLMRetryBackoffSecs + if backoffSecs <= 0 { + backoffSecs = 2 + } + for retry := 0; retry <= maxRetries; retry++ { + exec.response, err = callLLM(exec.callMessages, exec.providerToolDefs) + if err == nil { + break + } + if ts.hardAbortRequested() && errors.Is(err, context.Canceled) { + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ControlBreak, nil + } + + // Retry without media if vision is unsupported + if hasMediaRefs(exec.callMessages) && isVisionUnsupportedError(err) && retry < maxRetries { + al.emitEvent( + runtimeevents.KindAgentLLMRetry, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "vision_unsupported", + Error: err.Error(), + Backoff: 0, + }, + ) + logger.WarnCF("agent", "Vision unsupported, retrying without media", map[string]any{ + "error": err.Error(), + "retry": retry, + }) + exec.callMessages = stripMessageMedia(exec.callMessages) + if !ts.opts.NoHistory { + exec.history = stripMessageMedia(exec.history) + ts.agent.Sessions.SetHistory(ts.sessionKey, exec.history) + for i := range ts.persistedMessages { + ts.persistedMessages[i].Media = nil + } + ts.refreshRestorePointFromSession(ts.agent) + } + continue + } + + errMsg := strings.ToLower(err.Error()) + isTimeoutError := errors.Is(err, context.DeadlineExceeded) || + strings.Contains(errMsg, "deadline exceeded") || + strings.Contains(errMsg, "client.timeout") || + strings.Contains(errMsg, "timed out") || + strings.Contains(errMsg, "timeout exceeded") + + isNetworkError := !isTimeoutError && (strings.Contains(errMsg, "connection reset") || + strings.Contains(errMsg, "connection refused") || + strings.Contains(errMsg, "broken pipe") || + strings.Contains(errMsg, "no such host") || + strings.Contains(errMsg, "network is unreachable") || + strings.Contains(errMsg, "read tcp") || + strings.Contains(errMsg, "write tcp") || + strings.Contains(errMsg, "eof")) + + isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || + strings.Contains(errMsg, "context window") || + strings.Contains(errMsg, "context_window") || + strings.Contains(errMsg, "maximum context length") || + strings.Contains(errMsg, "token limit") || + strings.Contains(errMsg, "too many tokens") || + strings.Contains(errMsg, "max_tokens") || + strings.Contains(errMsg, "invalidparameter") || + strings.Contains(errMsg, "prompt is too long") || + strings.Contains(errMsg, "request too large")) + + if isTimeoutError && retry < maxRetries { + backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second + al.emitEvent( + runtimeevents.KindAgentLLMRetry, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "timeout", + Error: err.Error(), + Backoff: backoff, + }, + ) + logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{ + "error": err.Error(), + "retry": retry, + "backoff": backoff.String(), + }) + if sleepErr := sleepWithContext(turnCtx, backoff); sleepErr != nil { + if ts.hardAbortRequested() { + _ = ts.requestHardAbort() + return ControlBreak, nil + } + err = sleepErr + break + } + continue + } + + if isNetworkError && retry < maxRetries { + backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second + al.emitEvent( + runtimeevents.KindAgentLLMRetry, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "network", + Error: err.Error(), + Backoff: backoff, + }, + ) + logger.WarnCF("agent", "Network error, retrying after backoff", map[string]any{ + "error": err.Error(), + "retry": retry, + "backoff": backoff.String(), + }) + if sleepErr := sleepWithContext(turnCtx, backoff); sleepErr != nil { + if ts.hardAbortRequested() { + _ = ts.requestHardAbort() + return ControlBreak, nil + } + err = sleepErr + break + } + continue + } + + if isContextError && retry < maxRetries && !ts.opts.NoHistory { + al.emitEvent( + runtimeevents.KindAgentLLMRetry, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "context_limit", + Error: err.Error(), + }, + ) + logger.WarnCF( + "agent", + "Context window error detected, attempting compression", + map[string]any{ + "error": err.Error(), + "retry": retry, + }, + ) + + if retry == 0 && !constants.IsInternalChannel(ts.channel) { + al.bus.PublishOutbound(ctx, outboundMessageForTurn( + ts, + "Context window exceeded. Compressing history and retrying...", + )) + } + + if compactErr := p.ContextManager.Compact(ctx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonRetry, + Budget: ts.agent.ContextWindow, + }); compactErr != nil { + logger.WarnCF("agent", "Context overflow compact failed", map[string]any{ + "session_key": ts.sessionKey, + "error": compactErr.Error(), + }) + } + ts.refreshRestorePointFromSession(ts.agent) + if asmResp, asmErr := p.ContextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); asmErr == nil && asmResp != nil { + exec.history = asmResp.History + exec.summary = asmResp.Summary + } + exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt( + promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil), + ) + exec.callMessages = exec.messages + if exec.gracefulTerminal { + msgs := append([]providers.Message(nil), exec.messages...) + exec.callMessages = append(msgs, ts.interruptHintMessage()) + } + continue + } + break + } + + if err != nil { + al.emitEvent( + runtimeevents.KindAgentError, + ts.eventMeta("runTurn", "turn.error"), + ErrorPayload{ + Stage: "llm", + Message: err.Error(), + }, + ) + logger.ErrorCF("agent", "LLM call failed", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "model": exec.llmModel, + "error": err.Error(), + }) + return ControlBreak, fmt.Errorf("LLM call failed after retries: %w", err) + } + + // AfterLLM hook + if p.Hooks != nil { + llmResp, decision := p.Hooks.AfterLLM(turnCtx, &LLMHookResponse{ + Meta: ts.eventMeta("runTurn", "turn.llm.response"), + Context: cloneTurnContext(ts.turnCtx), + Model: exec.llmModel, + Response: exec.response, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmResp != nil && llmResp.Response != nil { + exec.response = llmResp.Response + } + case HookActionAbortTurn: + exec.abortedByHook = true + return ControlBreak, nil + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ControlBreak, nil + } + } + + // Save finishReason to turnState for SubTurn truncation detection + if innerTS := turnStateFromContext(ctx); innerTS != nil { + innerTS.SetLastFinishReason(exec.response.FinishReason) + if exec.response.Usage != nil { + innerTS.SetLastUsage(exec.response.Usage) + } + } + + reasoningContent := responseReasoningContent(exec.response) + shouldPublishPicoToolCallInterim := ts.channel == "pico" && len(exec.response.ToolCalls) > 0 + if shouldPublishPicoToolCallInterim { + // Pico tool-call turns publish their reasoning/content/tool summary as a + // structured sequence after the tool-call payload is normalized below. + } else if ts.channel == "pico" { + go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID) + } else { + go al.handleReasoning( + turnCtx, + reasoningContent, + ts.channel, + al.targetReasoningChannelID(ts.channel), + ) + } + al.emitEvent( + runtimeevents.KindAgentLLMResponse, + ts.eventMeta("runTurn", "turn.llm.response"), + LLMResponsePayload{ + ContentLen: len(exec.response.Content), + ToolCalls: len(exec.response.ToolCalls), + HasReasoning: exec.response.Reasoning != "" || exec.response.ReasoningContent != "", + }, + ) + + llmResponseFields := map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "content_chars": len(exec.response.Content), + "tool_calls": len(exec.response.ToolCalls), + "reasoning": exec.response.Reasoning, + "target_channel": al.targetReasoningChannelID(ts.channel), + "channel": ts.channel, + } + if exec.response.Usage != nil { + llmResponseFields["prompt_tokens"] = exec.response.Usage.PromptTokens + llmResponseFields["completion_tokens"] = exec.response.Usage.CompletionTokens + llmResponseFields["total_tokens"] = exec.response.Usage.TotalTokens + } + logger.DebugCF("agent", "LLM response", llmResponseFields) + + // No-tool-call path: steering check and direct response + if len(exec.response.ToolCalls) == 0 || exec.gracefulTerminal { + responseContent := exec.response.Content + if responseContent == "" && exec.response.ReasoningContent != "" && ts.channel != "pico" { + responseContent = exec.response.ReasoningContent + } + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + logger.InfoCF("agent", "Steering arrived after direct LLM response; continuing turn", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "steering_count": len(steerMsgs), + }) + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + return ControlContinue, nil + } + exec.finalContent = responseContent + logger.InfoCF("agent", "LLM response without tool calls (direct answer)", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "content_chars": len(exec.finalContent), + }) + return ControlBreak, nil + } + + // Tool-call path: normalize and prepare for tool execution + exec.normalizedToolCalls = make([]providers.ToolCall, 0, len(exec.response.ToolCalls)) + for _, tc := range exec.response.ToolCalls { + exec.normalizedToolCalls = append(exec.normalizedToolCalls, providers.NormalizeToolCall(tc)) + } + + toolNames := make([]string, 0, len(exec.normalizedToolCalls)) + for _, tc := range exec.normalizedToolCalls { + toolNames = append(toolNames, tc.Name) + } + logger.InfoCF("agent", "LLM requested tool calls", + map[string]any{ + "agent_id": ts.agent.ID, + "tools": toolNames, + "count": len(exec.normalizedToolCalls), + "iteration": iteration, + }) + + exec.allResponsesHandled = len(exec.normalizedToolCalls) > 0 + assistantMsg := providers.Message{ + Role: "assistant", + Content: exec.response.Content, + ReasoningContent: reasoningContent, + } + for _, tc := range exec.normalizedToolCalls { + argumentsJSON, _ := json.Marshal(tc.Arguments) + toolFeedbackExplanation := toolFeedbackExplanationForToolCall( + exec.response, + tc, + exec.messages, + ) + extraContent := tc.ExtraContent + if strings.TrimSpace(toolFeedbackExplanation) != "" { + if extraContent == nil { + extraContent = &providers.ExtraContent{} + } + extraContent.ToolFeedbackExplanation = toolFeedbackExplanation + } + thoughtSignature := "" + if tc.Function != nil { + thoughtSignature = tc.Function.ThoughtSignature + } + assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ + ID: tc.ID, + Type: "function", + Name: tc.Name, + Function: &providers.FunctionCall{ + Name: tc.Name, + Arguments: string(argumentsJSON), + ThoughtSignature: thoughtSignature, + }, + ExtraContent: extraContent, + ThoughtSignature: thoughtSignature, + }) + } + exec.messages = append(exec.messages, assistantMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, assistantMsg) + ts.recordPersistedMessage(assistantMsg) + ts.ingestMessage(turnCtx, al, assistantMsg) + } + if shouldPublishPicoToolCallInterim { + al.publishPicoToolCallInterim( + turnCtx, + ts, + reasoningContent, + exec.response.Content, + assistantMsg.ToolCalls, + ) + } + + return ControlToolLoop, nil +} diff --git a/pkg/agent/pipeline_setup.go b/pkg/agent/pipeline_setup.go new file mode 100644 index 000000000..219e4e5de --- /dev/null +++ b/pkg/agent/pipeline_setup.go @@ -0,0 +1,101 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "strings" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// SetupTurn extracts the one-time initialization phase, returning a +// turnExecution populated with history, messages, and candidate selection. +// It replaces lines 56-145 of the original runTurn. +func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution, error) { + cfg := p.Cfg + maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize() + + var history []providers.Message + var summary string + if !ts.opts.NoHistory { + if resp, err := p.ContextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary + } + } + ts.captureRestorePoint(history, summary) + + messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt( + promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media), + ) + + messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) + + if !ts.opts.NoHistory { + toolDefs := ts.agent.Tools.ToProviderDefs() + if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) { + logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call", + map[string]any{"session_key": ts.sessionKey}) + if err := p.ContextManager.Compact(ctx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonProactive, + Budget: ts.agent.ContextWindow, + }); err != nil { + logger.WarnCF("agent", "Proactive compact failed", map[string]any{ + "session_key": ts.sessionKey, + "error": err.Error(), + }) + } + ts.refreshRestorePointFromSession(ts.agent) + if resp, err := p.ContextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary + } + messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt( + promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media), + ) + messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) + } + } + + if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) { + rootMsg := userPromptMessage(ts.userMessage, ts.media) + if len(rootMsg.Media) > 0 { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg) + } else { + ts.agent.Sessions.AddMessage(ts.sessionKey, rootMsg.Role, rootMsg.Content) + } + ts.recordPersistedMessage(rootMsg) + ts.ingestMessage(ctx, p.al, rootMsg) + } + + activeCandidates, activeModel, usedLight := p.al.selectCandidates(ts.agent, ts.userMessage, messages) + activeProvider := ts.agent.Provider + if usedLight && ts.agent.LightProvider != nil { + activeProvider = ts.agent.LightProvider + } + + exec := newTurnExecution( + ts.agent, + ts.opts, + history, + summary, + messages, + ) + exec.activeCandidates = activeCandidates + exec.activeModel = activeModel + exec.activeProvider = activeProvider + exec.usedLight = usedLight + + return exec, nil +} diff --git a/pkg/agent/prompt.go b/pkg/agent/prompt.go new file mode 100644 index 000000000..be5ccddf2 --- /dev/null +++ b/pkg/agent/prompt.go @@ -0,0 +1,496 @@ +package agent + +import ( + "context" + "fmt" + "slices" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +type PromptLayer string + +const ( + PromptLayerKernel PromptLayer = "kernel" + PromptLayerInstruction PromptLayer = "instruction" + PromptLayerCapability PromptLayer = "capability" + PromptLayerContext PromptLayer = "context" + PromptLayerTurn PromptLayer = "turn" +) + +type PromptSlot string + +const ( + PromptSlotIdentity PromptSlot = "identity" + PromptSlotHierarchy PromptSlot = "hierarchy" + PromptSlotWorkspace PromptSlot = "workspace" + PromptSlotTooling PromptSlot = "tooling" + PromptSlotMCP PromptSlot = "mcp" + PromptSlotSkillCatalog PromptSlot = "skill_catalog" + PromptSlotActiveSkill PromptSlot = "active_skill" + PromptSlotMemory PromptSlot = "memory" + PromptSlotRuntime PromptSlot = "runtime" + PromptSlotSummary PromptSlot = "summary" + PromptSlotMessage PromptSlot = "message" + PromptSlotSteering PromptSlot = "steering" + PromptSlotSubTurn PromptSlot = "subturn" + PromptSlotInterrupt PromptSlot = "interrupt" + PromptSlotOutput PromptSlot = "output" +) + +type PromptSourceID string + +const ( + PromptSourceKernel PromptSourceID = "runtime.kernel" + PromptSourceHierarchy PromptSourceID = "runtime.hierarchy" + PromptSourceWorkspace PromptSourceID = "workspace.definition" + PromptSourceRuntime PromptSourceID = "runtime.context" + PromptSourceSummary PromptSourceID = "context.summary" + PromptSourceMemory PromptSourceID = "memory:workspace" + PromptSourceSkillCatalog PromptSourceID = "skill:index" + PromptSourceActiveSkills PromptSourceID = "skill:active" + PromptSourceToolRegistry PromptSourceID = "tool_registry:native" + PromptSourceToolDiscovery PromptSourceID = "tool_registry:discovery" + PromptSourceOutputPolicy PromptSourceID = "runtime.output" + PromptSourceSubTurnProfile PromptSourceID = "subturn.profile" + PromptSourceUserMessage PromptSourceID = "turn:user_message" + PromptSourceSteering PromptSourceID = "turn:steering" + PromptSourceSubTurnResult PromptSourceID = "turn:subturn_result" + PromptSourceInterrupt PromptSourceID = "turn:interrupt" +) + +type PromptCachePolicy string + +const ( + PromptCacheDefault PromptCachePolicy = "" + PromptCacheEphemeral PromptCachePolicy = "ephemeral" + PromptCacheNone PromptCachePolicy = "none" +) + +type PromptPlacement struct { + Layer PromptLayer + Slot PromptSlot +} + +type PromptSourceDescriptor struct { + ID PromptSourceID + Owner string + Description string + Allowed []PromptPlacement + StableByDefault bool +} + +type PromptSource struct { + ID PromptSourceID + Name string + Path string +} + +type PromptPart struct { + ID string + Layer PromptLayer + Slot PromptSlot + Source PromptSource + Title string + Content string + Stable bool + Cache PromptCachePolicy +} + +type PromptBuildRequest struct { + History []providers.Message + Summary string + + CurrentMessage string + Media []string + + Channel string + ChatID string + SenderID string + SenderDisplayName string + + ActiveSkills []string + Overlays []PromptPart +} + +type PromptContributor interface { + PromptSource() PromptSourceDescriptor + ContributePrompt(ctx context.Context, req PromptBuildRequest) ([]PromptPart, error) +} + +type PromptRegistry struct { + mu sync.RWMutex + sources map[PromptSourceID]PromptSourceDescriptor + contributors []PromptContributor + warned map[PromptSourceID]struct{} +} + +func NewPromptRegistry() *PromptRegistry { + r := &PromptRegistry{ + sources: make(map[PromptSourceID]PromptSourceDescriptor), + warned: make(map[PromptSourceID]struct{}), + } + for _, desc := range builtinPromptSources() { + if err := r.RegisterSource(desc); err != nil { + logger.WarnCF("agent", "Failed to register builtin prompt source", map[string]any{ + "source": desc.ID, + "error": err.Error(), + }) + } + } + return r +} + +func builtinPromptSources() []PromptSourceDescriptor { + return []PromptSourceDescriptor{ + { + ID: PromptSourceKernel, + Owner: "agent", + Description: "Core picoclaw identity and hard rules", + Allowed: []PromptPlacement{{Layer: PromptLayerKernel, Slot: PromptSlotIdentity}}, + StableByDefault: true, + }, + { + ID: PromptSourceHierarchy, + Owner: "agent", + Description: "Prompt hierarchy rules", + Allowed: []PromptPlacement{{Layer: PromptLayerKernel, Slot: PromptSlotHierarchy}}, + StableByDefault: true, + }, + { + ID: PromptSourceWorkspace, + Owner: "workspace", + Description: "Workspace and agent definition files", + Allowed: []PromptPlacement{{Layer: PromptLayerInstruction, Slot: PromptSlotWorkspace}}, + StableByDefault: true, + }, + { + ID: PromptSourceToolDiscovery, + Owner: "tools", + Description: "Tool discovery instructions", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}}, + StableByDefault: true, + }, + { + ID: PromptSourceToolRegistry, + Owner: "tools", + Description: "Native provider tool definitions", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}}, + StableByDefault: true, + }, + { + ID: PromptSourceSkillCatalog, + Owner: "skills", + Description: "Installed skill catalog", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotSkillCatalog}}, + StableByDefault: true, + }, + { + ID: PromptSourceActiveSkills, + Owner: "skills", + Description: "Active skill instructions for the current request", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotActiveSkill}}, + StableByDefault: false, + }, + { + ID: PromptSourceMemory, + Owner: "memory", + Description: "Workspace memory context", + Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotMemory}}, + StableByDefault: true, + }, + { + ID: PromptSourceRuntime, + Owner: "agent", + Description: "Per-request runtime context", + Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotRuntime}}, + StableByDefault: false, + }, + { + ID: PromptSourceSummary, + Owner: "context_manager", + Description: "Conversation summary context", + Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotSummary}}, + StableByDefault: false, + }, + { + ID: PromptSourceOutputPolicy, + Owner: "agent", + Description: "Output formatting policy", + Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotOutput}}, + StableByDefault: true, + }, + { + ID: PromptSourceSubTurnProfile, + Owner: "subturn", + Description: "Child agent profile instructions", + Allowed: []PromptPlacement{{Layer: PromptLayerInstruction, Slot: PromptSlotWorkspace}}, + StableByDefault: false, + }, + { + ID: PromptSourceUserMessage, + Owner: "turn", + Description: "Current user message for this turn", + Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotMessage}}, + StableByDefault: false, + }, + { + ID: PromptSourceSteering, + Owner: "turn", + Description: "Steering message injected into a running turn", + Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotSteering}}, + StableByDefault: false, + }, + { + ID: PromptSourceSubTurnResult, + Owner: "turn", + Description: "SubTurn result injected into a parent turn", + Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotSubTurn}}, + StableByDefault: false, + }, + { + ID: PromptSourceInterrupt, + Owner: "turn", + Description: "Graceful interrupt hint injected into the terminal LLM call", + Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotInterrupt}}, + StableByDefault: false, + }, + } +} + +func (r *PromptRegistry) RegisterSource(desc PromptSourceDescriptor) error { + if r == nil { + return fmt.Errorf("prompt registry is nil") + } + desc.ID = PromptSourceID(strings.TrimSpace(string(desc.ID))) + if desc.ID == "" { + return fmt.Errorf("prompt source id is required") + } + if len(desc.Allowed) == 0 { + return fmt.Errorf("prompt source %q must declare at least one placement", desc.ID) + } + + r.mu.Lock() + defer r.mu.Unlock() + r.sources[desc.ID] = clonePromptSourceDescriptor(desc) + return nil +} + +func (r *PromptRegistry) RegisterContributor(contributor PromptContributor) error { + if r == nil { + return fmt.Errorf("prompt registry is nil") + } + if contributor == nil { + return fmt.Errorf("prompt contributor is nil") + } + desc := contributor.PromptSource() + desc.ID = PromptSourceID(strings.TrimSpace(string(desc.ID))) + if err := r.RegisterSource(desc); err != nil { + return err + } + + r.mu.Lock() + defer r.mu.Unlock() + r.contributors = slices.DeleteFunc(r.contributors, func(existing PromptContributor) bool { + return PromptSourceID(strings.TrimSpace(string(existing.PromptSource().ID))) == desc.ID + }) + r.contributors = append(r.contributors, contributor) + return nil +} + +func (r *PromptRegistry) Collect(ctx context.Context, req PromptBuildRequest) ([]PromptPart, error) { + if r == nil { + return nil, nil + } + + r.mu.RLock() + contributors := append([]PromptContributor(nil), r.contributors...) + r.mu.RUnlock() + + var parts []PromptPart + for _, contributor := range contributors { + contributed, err := contributor.ContributePrompt(ctx, req) + if err != nil { + return nil, err + } + for _, part := range contributed { + if err := r.ValidatePart(part); err != nil { + return nil, err + } + parts = append(parts, part) + } + } + return parts, nil +} + +func (r *PromptRegistry) ValidatePart(part PromptPart) error { + if r == nil { + return nil + } + sourceID := PromptSourceID(strings.TrimSpace(string(part.Source.ID))) + if sourceID == "" { + return fmt.Errorf("prompt part %q has empty source id", part.ID) + } + + r.mu.Lock() + defer r.mu.Unlock() + + desc, ok := r.sources[sourceID] + if !ok { + if _, warned := r.warned[sourceID]; !warned { + r.warned[sourceID] = struct{}{} + logger.WarnCF("agent", "Unregistered prompt source allowed in compatibility mode", map[string]any{ + "source": sourceID, + "layer": part.Layer, + "slot": part.Slot, + "part": part.ID, + }) + } + return nil + } + if promptPlacementAllowed(desc.Allowed, PromptPlacement{Layer: part.Layer, Slot: part.Slot}) { + return nil + } + return fmt.Errorf("prompt source %q cannot write to %s/%s", sourceID, part.Layer, part.Slot) +} + +func promptPlacementAllowed(allowed []PromptPlacement, placement PromptPlacement) bool { + return slices.ContainsFunc(allowed, func(candidate PromptPlacement) bool { + return candidate.Layer == placement.Layer && candidate.Slot == placement.Slot + }) +} + +func clonePromptSourceDescriptor(desc PromptSourceDescriptor) PromptSourceDescriptor { + desc.Allowed = append([]PromptPlacement(nil), desc.Allowed...) + return desc +} + +type PromptStack struct { + registry *PromptRegistry + parts []PromptPart + sealed bool +} + +func NewPromptStack(registry *PromptRegistry) *PromptStack { + return &PromptStack{registry: registry} +} + +func (s *PromptStack) Add(part PromptPart) error { + if s == nil { + return fmt.Errorf("prompt stack is nil") + } + if s.sealed { + return fmt.Errorf("prompt stack is sealed") + } + if strings.TrimSpace(part.Content) == "" { + return nil + } + if strings.TrimSpace(part.ID) == "" { + return fmt.Errorf("prompt part id is required") + } + if s.registry != nil { + if err := s.registry.ValidatePart(part); err != nil { + return err + } + } + s.parts = append(s.parts, part) + return nil +} + +func (s *PromptStack) Seal() { + if s != nil { + s.sealed = true + } +} + +func (s *PromptStack) Parts() []PromptPart { + if s == nil || len(s.parts) == 0 { + return nil + } + return append([]PromptPart(nil), s.parts...) +} + +func renderPromptPartsLegacy(parts []PromptPart) string { + textParts := make([]string, 0, len(parts)) + for _, part := range sortPromptParts(parts) { + if strings.TrimSpace(part.Content) == "" { + continue + } + textParts = append(textParts, part.Content) + } + return strings.Join(textParts, "\n\n---\n\n") +} + +func sortPromptParts(parts []PromptPart) []PromptPart { + sorted := append([]PromptPart(nil), parts...) + slices.SortStableFunc(sorted, func(a, b PromptPart) int { + if d := layerPriority(b.Layer) - layerPriority(a.Layer); d != 0 { + return d + } + if d := slotPriority(b.Slot) - slotPriority(a.Slot); d != 0 { + return d + } + if a.Source.ID != b.Source.ID { + return strings.Compare(string(a.Source.ID), string(b.Source.ID)) + } + return strings.Compare(a.ID, b.ID) + }) + return sorted +} + +func layerPriority(layer PromptLayer) int { + switch layer { + case PromptLayerKernel: + return 100 + case PromptLayerInstruction: + return 80 + case PromptLayerCapability: + return 60 + case PromptLayerContext: + return 40 + case PromptLayerTurn: + return 20 + default: + return 0 + } +} + +func slotPriority(slot PromptSlot) int { + switch slot { + case PromptSlotIdentity: + return 1000 + case PromptSlotHierarchy: + return 990 + case PromptSlotWorkspace: + return 900 + case PromptSlotTooling: + return 800 + case PromptSlotMCP: + return 790 + case PromptSlotSkillCatalog: + return 780 + case PromptSlotActiveSkill: + return 770 + case PromptSlotMemory: + return 700 + case PromptSlotOutput: + return 695 + case PromptSlotRuntime: + return 690 + case PromptSlotSummary: + return 680 + case PromptSlotMessage: + return 600 + case PromptSlotSteering: + return 590 + case PromptSlotSubTurn: + return 580 + case PromptSlotInterrupt: + return 570 + default: + return 0 + } +} diff --git a/pkg/agent/prompt_contributors.go b/pkg/agent/prompt_contributors.go new file mode 100644 index 000000000..960572e03 --- /dev/null +++ b/pkg/agent/prompt_contributors.go @@ -0,0 +1,139 @@ +package agent + +import ( + "context" + "fmt" + "strings" +) + +type toolDiscoveryPromptContributor struct { + useBM25 bool + useRegex bool +} + +func (c toolDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor { + return PromptSourceDescriptor{ + ID: PromptSourceToolDiscovery, + Owner: "tools", + Description: "Tool discovery instructions", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}}, + StableByDefault: true, + } +} + +func (c toolDiscoveryPromptContributor) ContributePrompt( + _ context.Context, + _ PromptBuildRequest, +) ([]PromptPart, error) { + content := formatToolDiscoveryRule(c.useBM25, c.useRegex) + if strings.TrimSpace(content) == "" { + return nil, nil + } + + return []PromptPart{ + { + ID: "capability.tool_discovery", + Layer: PromptLayerCapability, + Slot: PromptSlotTooling, + Source: PromptSource{ID: PromptSourceToolDiscovery, Name: "tool_registry:discovery"}, + Title: "tool discovery", + Content: content, + Stable: true, + Cache: PromptCacheEphemeral, + }, + }, nil +} + +type mcpServerPromptContributor struct { + serverName string + toolCount int + deferred bool +} + +func (c mcpServerPromptContributor) PromptSource() PromptSourceDescriptor { + return PromptSourceDescriptor{ + ID: mcpPromptSourceID(c.serverName), + Owner: "mcp", + Description: fmt.Sprintf("MCP server %q capability prompt", c.serverName), + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotMCP}}, + StableByDefault: true, + } +} + +func (c mcpServerPromptContributor) ContributePrompt( + _ context.Context, + _ PromptBuildRequest, +) ([]PromptPart, error) { + serverName := strings.TrimSpace(c.serverName) + if serverName == "" || c.toolCount <= 0 { + return nil, nil + } + + availability := "available as native tools" + if c.deferred { + availability = "hidden behind tool discovery until unlocked" + } + + return []PromptPart{ + { + ID: "capability.mcp." + promptSourceComponent(serverName), + Layer: PromptLayerCapability, + Slot: PromptSlotMCP, + Source: PromptSource{ID: mcpPromptSourceID(serverName), Name: "mcp:" + serverName}, + Title: "MCP server capability", + Content: fmt.Sprintf( + "MCP server `%s` is connected. It contributes %d tool(s), currently %s.", + serverName, + c.toolCount, + availability, + ), + Stable: true, + Cache: PromptCacheEphemeral, + }, + }, nil +} + +func mcpPromptSourceID(serverName string) PromptSourceID { + return PromptSourceID("mcp:" + promptSourceComponent(serverName)) +} + +func promptSourceComponent(value string) string { + const maxLen = 64 + + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "unnamed" + } + + var b strings.Builder + lastWasSep := false + for _, r := range value { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + lastWasSep = false + case r >= '0' && r <= '9': + b.WriteRune(r) + lastWasSep = false + case r == '-' || r == '_': + if !lastWasSep && b.Len() > 0 { + b.WriteRune(r) + lastWasSep = true + } + default: + if !lastWasSep && b.Len() > 0 { + b.WriteRune('_') + lastWasSep = true + } + } + } + + result := strings.Trim(b.String(), "_") + if result == "" { + return "unnamed" + } + if len(result) > maxLen { + return result[:maxLen] + } + return result +} diff --git a/pkg/agent/prompt_test.go b/pkg/agent/prompt_test.go new file mode 100644 index 000000000..b76b0040d --- /dev/null +++ b/pkg/agent/prompt_test.go @@ -0,0 +1,275 @@ +package agent + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestPromptRegistry_RejectsRegisteredSourceWrongPlacement(t *testing.T) { + registry := NewPromptRegistry() + if err := registry.RegisterSource(PromptSourceDescriptor{ + ID: "test:source", + Owner: "test", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}}, + }); err != nil { + t.Fatalf("RegisterSource() error = %v", err) + } + + err := registry.ValidatePart(PromptPart{ + ID: "wrong.placement", + Layer: PromptLayerContext, + Slot: PromptSlotRuntime, + Source: PromptSource{ID: "test:source"}, + Content: "runtime text", + }) + if err == nil { + t.Fatal("ValidatePart() error = nil, want placement error") + } +} + +func TestPromptRegistry_AllowsUnregisteredSourceInCompatibilityMode(t *testing.T) { + registry := NewPromptRegistry() + + err := registry.ValidatePart(PromptPart{ + ID: "unregistered.part", + Layer: PromptLayerCapability, + Slot: PromptSlotMCP, + Source: PromptSource{ID: "mcp:dynamic-server"}, + Content: "dynamic MCP prompt", + }) + if err != nil { + t.Fatalf("ValidatePart() error = %v, want nil for unregistered source", err) + } +} + +func TestRenderPromptPartsLegacy_UsesLayerAndSlotOrder(t *testing.T) { + parts := []PromptPart{ + { + ID: "context.runtime", + Layer: PromptLayerContext, + Slot: PromptSlotRuntime, + Source: PromptSource{ID: PromptSourceRuntime}, + Content: "runtime", + }, + { + ID: "kernel.identity", + Layer: PromptLayerKernel, + Slot: PromptSlotIdentity, + Source: PromptSource{ID: PromptSourceKernel}, + Content: "kernel", + }, + { + ID: "capability.skill", + Layer: PromptLayerCapability, + Slot: PromptSlotActiveSkill, + Source: PromptSource{ID: "skill:test"}, + Content: "skill", + }, + { + ID: "instruction.workspace", + Layer: PromptLayerInstruction, + Slot: PromptSlotWorkspace, + Source: PromptSource{ID: PromptSourceWorkspace}, + Content: "workspace", + }, + } + + got := renderPromptPartsLegacy(parts) + want := strings.Join([]string{"kernel", "workspace", "skill", "runtime"}, "\n\n---\n\n") + if got != want { + t.Fatalf("renderPromptPartsLegacy() = %q, want %q", got, want) + } +} + +func TestBuildMessagesFromPrompt_IncludesSystemPromptOverlay(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "do child task", + Overlays: promptOverlaysForOptions(processOptions{ + SystemPromptOverride: "Use child-only system instructions.", + }), + }) + + if len(messages) < 2 { + t.Fatalf("messages len = %d, want at least 2", len(messages)) + } + if messages[0].Role != "system" { + t.Fatalf("messages[0].Role = %q, want system", messages[0].Role) + } + if !strings.Contains(messages[0].Content, "Use child-only system instructions.") { + t.Fatalf("system prompt missing overlay: %q", messages[0].Content) + } + if messages[1].Role != "user" || messages[1].Content != "do child task" { + t.Fatalf("messages[1] = %#v, want user task", messages[1]) + } +} + +func TestBuildMessagesFromPrompt_AttachesInternalPromptMetadata(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + Summary: "prior context", + }) + if len(messages) != 2 { + t.Fatalf("messages len = %d, want 2", len(messages)) + } + + system := messages[0] + if len(system.SystemParts) < 3 { + t.Fatalf("system parts len = %d, want at least 3", len(system.SystemParts)) + } + if system.SystemParts[0].PromptLayer != string(PromptLayerKernel) || + system.SystemParts[0].PromptSlot != string(PromptSlotIdentity) || + system.SystemParts[0].PromptSource != string(PromptSourceKernel) { + t.Fatalf("static system metadata = %#v, want kernel identity", system.SystemParts[0]) + } + + var hasRuntime, hasSummary bool + for _, part := range system.SystemParts { + switch part.PromptSource { + case string(PromptSourceRuntime): + hasRuntime = true + if part.CacheControl != nil { + t.Fatalf("runtime cache control = %#v, want nil", part.CacheControl) + } + case string(PromptSourceSummary): + hasSummary = true + if part.CacheControl != nil { + t.Fatalf("summary cache control = %#v, want nil", part.CacheControl) + } + } + } + if !hasRuntime { + t.Fatal("system parts missing runtime prompt metadata") + } + if !hasSummary { + t.Fatal("system parts missing summary prompt metadata") + } + + user := messages[1] + if user.PromptLayer != string(PromptLayerTurn) || + user.PromptSlot != string(PromptSlotMessage) || + user.PromptSource != string(PromptSourceUserMessage) { + t.Fatalf("user message metadata = %#v, want turn message", user) + } + + data, err := json.Marshal(messages) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if strings.Contains(string(data), "PromptSource") || + strings.Contains(string(data), "PromptLayer") || + strings.Contains(string(data), "PromptSlot") { + t.Fatalf("internal prompt metadata leaked into JSON: %s", data) + } +} + +func TestContextBuilder_CollectsToolDiscoveryContributor(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()).WithToolDiscovery(true, false) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"}) + system := messages[0] + if !strings.Contains(system.Content, "tool_search_tool_bm25") { + t.Fatalf("system prompt missing tool discovery rule: %q", system.Content) + } + + var found bool + for _, part := range system.SystemParts { + if part.PromptSource == string(PromptSourceToolDiscovery) { + found = true + if part.PromptLayer != string(PromptLayerCapability) || part.PromptSlot != string(PromptSlotTooling) { + t.Fatalf("tool discovery metadata = %#v, want capability/tooling", part) + } + if part.CacheControl == nil || part.CacheControl.Type != "ephemeral" { + t.Fatalf("tool discovery cache control = %#v, want ephemeral", part.CacheControl) + } + } + } + if !found { + t.Fatal("system parts missing tool discovery prompt metadata") + } +} + +func TestContextBuilder_CollectsMCPServerContributor(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()) + err := cb.RegisterPromptContributor(mcpServerPromptContributor{ + serverName: "GitHub Server", + toolCount: 3, + deferred: true, + }) + if err != nil { + t.Fatalf("RegisterPromptContributor() error = %v", err) + } + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"}) + system := messages[0] + if !strings.Contains(system.Content, "MCP server `GitHub Server` is connected") { + t.Fatalf("system prompt missing MCP contributor content: %q", system.Content) + } + + var found bool + for _, part := range system.SystemParts { + if part.PromptSource == "mcp:github_server" { + found = true + if part.PromptLayer != string(PromptLayerCapability) || part.PromptSlot != string(PromptSlotMCP) { + t.Fatalf("mcp metadata = %#v, want capability/mcp", part) + } + if part.CacheControl == nil || part.CacheControl.Type != "ephemeral" { + t.Fatalf("mcp cache control = %#v, want ephemeral", part.CacheControl) + } + } + } + if !found { + t.Fatal("system parts missing MCP prompt metadata") + } +} + +type testPromptContributor struct { + desc PromptSourceDescriptor + part PromptPart +} + +func (c testPromptContributor) PromptSource() PromptSourceDescriptor { + return c.desc +} + +func (c testPromptContributor) ContributePrompt(_ context.Context, _ PromptBuildRequest) ([]PromptPart, error) { + return []PromptPart{c.part}, nil +} + +func TestContextBuilder_CollectsRegisteredPromptContributors(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()) + + sourceID := PromptSourceID("test:contributor") + err := cb.RegisterPromptContributor(testPromptContributor{ + desc: PromptSourceDescriptor{ + ID: sourceID, + Owner: "test", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotMCP}}, + }, + part: PromptPart{ + ID: "capability.mcp.test", + Layer: PromptLayerCapability, + Slot: PromptSlotMCP, + Source: PromptSource{ID: sourceID, Name: "test"}, + Content: "registered contributor prompt", + }, + }) + if err != nil { + t.Fatalf("RegisterPromptContributor() error = %v", err) + } + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"}) + if !strings.Contains(messages[0].Content, "registered contributor prompt") { + t.Fatalf("system prompt missing contributor content: %q", messages[0].Content) + } +} diff --git a/pkg/agent/prompt_turn.go b/pkg/agent/prompt_turn.go new file mode 100644 index 000000000..588a8f00f --- /dev/null +++ b/pkg/agent/prompt_turn.go @@ -0,0 +1,129 @@ +package agent + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func promptBuildRequestForTurn( + ts *turnState, + history []providers.Message, + summary string, + currentMessage string, + media []string, +) PromptBuildRequest { + return PromptBuildRequest{ + History: history, + Summary: summary, + CurrentMessage: currentMessage, + Media: append([]string(nil), media...), + Channel: ts.channel, + ChatID: ts.chatID, + SenderID: ts.opts.Dispatch.SenderID(), + SenderDisplayName: ts.opts.SenderDisplayName, + ActiveSkills: activeSkillNames(ts.agent, ts.opts), + Overlays: promptOverlaysForOptions(ts.opts), + } +} + +func promptOverlaysForOptions(opts processOptions) []PromptPart { + systemPrompt := strings.TrimSpace(opts.SystemPromptOverride) + if systemPrompt == "" { + return nil + } + + return []PromptPart{ + { + ID: "instruction.subturn_profile", + Layer: PromptLayerInstruction, + Slot: PromptSlotWorkspace, + Source: PromptSource{ID: PromptSourceSubTurnProfile, Name: "subturn.profile"}, + Title: "SubTurn System Instructions", + Content: systemPrompt, + Stable: false, + Cache: PromptCacheNone, + }, + } +} + +func promptContentBlock(part PromptPart, cache *providers.CacheControl) providers.ContentBlock { + if cache == nil { + cache = cacheControlForPromptPart(part) + } + return providers.ContentBlock{ + Type: "text", + Text: part.Content, + CacheControl: cache, + PromptLayer: string(part.Layer), + PromptSlot: string(part.Slot), + PromptSource: string(part.Source.ID), + } +} + +func cacheControlForPromptPart(part PromptPart) *providers.CacheControl { + switch part.Cache { + case PromptCacheEphemeral: + return &providers.CacheControl{Type: "ephemeral"} + default: + return nil + } +} + +func promptMessageWithMetadata( + msg providers.Message, + layer PromptLayer, + slot PromptSlot, + source PromptSourceID, +) providers.Message { + msg.PromptLayer = string(layer) + msg.PromptSlot = string(slot) + msg.PromptSource = string(source) + return msg +} + +func promptMessageWithDefaultMetadata( + msg providers.Message, + layer PromptLayer, + slot PromptSlot, + source PromptSourceID, +) providers.Message { + if strings.TrimSpace(msg.PromptSource) != "" { + return msg + } + return promptMessageWithMetadata(msg, layer, slot, source) +} + +func userPromptMessage(content string, media []string) providers.Message { + msg := providers.Message{ + Role: "user", + Content: content, + } + if len(media) > 0 { + msg.Media = append([]string(nil), media...) + } + return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotMessage, PromptSourceUserMessage) +} + +func steeringPromptMessage(msg providers.Message) providers.Message { + return promptMessageWithDefaultMetadata(msg, PromptLayerTurn, PromptSlotSteering, PromptSourceSteering) +} + +func subTurnResultPromptMessage(content string) providers.Message { + return promptMessageWithMetadata( + providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}, + PromptLayerTurn, + PromptSlotSubTurn, + PromptSourceSubTurnResult, + ) +} + +func interruptPromptMessage(content string) providers.Message { + return promptMessageWithMetadata( + providers.Message{Role: "user", Content: content}, + PromptLayerTurn, + PromptSlotInterrupt, + PromptSourceInterrupt, + ) +} diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index 0e7973dc3..8aa11e37b 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -3,6 +3,7 @@ package agent import ( "sync" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" @@ -64,9 +65,9 @@ func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) { return agent, ok } -// ResolveRoute determines which agent handles the message. -func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute { - return r.resolver.ResolveRoute(input) +// ResolveRoute determines which agent handles the normalized inbound context. +func (r *AgentRegistry) ResolveRoute(inbound bus.InboundContext) routing.ResolvedRoute { + return r.resolver.ResolveRoute(inbound) } // ListAgentIDs returns all registered agent IDs. @@ -114,6 +115,18 @@ func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) { } } +// Close releases resources held by all registered agents. +func (r *AgentRegistry) Close() { + r.mu.RLock() + defer r.mu.RUnlock() + for _, agent := range r.agents { + if err := agent.Close(); err != nil { + logger.WarnCF("agent", "Failed to close agent", + map[string]any{"agent_id": agent.ID, "error": err.Error()}) + } + } +} + // GetDefaultAgent returns the default agent instance. func (r *AgentRegistry) GetDefaultAgent() *AgentInstance { r.mu.RLock() diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go index 518bb441f..b173ef967 100644 --- a/pkg/agent/registry_test.go +++ b/pkg/agent/registry_test.go @@ -29,7 +29,7 @@ func testCfg(agents []config.AgentConfig) *config.Config { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: "/tmp/picoclaw-test-registry", - Model: "gpt-4", + ModelName: "gpt-4", MaxTokens: 8192, MaxToolIterations: 10, }, diff --git a/pkg/agent/runtime_event_logger.go b/pkg/agent/runtime_event_logger.go new file mode 100644 index 000000000..1035ffe35 --- /dev/null +++ b/pkg/agent/runtime_event_logger.go @@ -0,0 +1,408 @@ +package agent + +import ( + "context" + "fmt" + "path" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const ( + runtimeEventLoggerBuffer = 256 + runtimeEventLoggerDrainTimeout = 2 * time.Second +) + +type runtimeEventLogger struct { + mu sync.RWMutex + cfg config.EventLoggingConfig +} + +func (al *AgentLoop) refreshRuntimeEventLogger(cfg *config.Config) { + if al == nil { + return + } + logCfg := config.EffectiveEventLoggingConfig(cfg) + + al.runtimeEventLogMu.Lock() + if !logCfg.Enabled { + oldSub := al.runtimeEventLogSub + al.runtimeEventLogger = nil + al.runtimeEventLogSub = nil + al.runtimeEventLogMu.Unlock() + closeRuntimeEventLoggerSubscription(oldSub) + return + } + + if al.runtimeEventLogger != nil && al.runtimeEventLogSub != nil { + al.runtimeEventLogger.updateConfig(logCfg) + al.runtimeEventLogMu.Unlock() + return + } + al.runtimeEventLogMu.Unlock() + + eventLogger := newRuntimeEventLoggerFromConfig(logCfg) + sub, err := eventLogger.subscribe(context.Background(), al.runtimeEvents) + if err != nil { + logger.WarnCF("events", "Failed to subscribe runtime event logger", map[string]any{"error": err.Error()}) + return + } + + al.runtimeEventLogMu.Lock() + oldSub := al.runtimeEventLogSub + al.runtimeEventLogger = eventLogger + al.runtimeEventLogSub = sub + al.runtimeEventLogMu.Unlock() + closeRuntimeEventLoggerSubscription(oldSub) +} + +func (al *AgentLoop) closeRuntimeEventLogger() { + if al == nil { + return + } + al.runtimeEventLogMu.Lock() + oldSub := al.runtimeEventLogSub + al.runtimeEventLogger = nil + al.runtimeEventLogSub = nil + al.runtimeEventLogMu.Unlock() + closeRuntimeEventLoggerSubscription(oldSub) +} + +func closeRuntimeEventLoggerSubscription(sub runtimeevents.Subscription) { + if sub == nil { + return + } + if err := sub.Close(); err != nil { + logger.WarnCF("events", "Failed to close runtime event logger subscription", map[string]any{ + "error": err.Error(), + }) + } + + timer := time.NewTimer(runtimeEventLoggerDrainTimeout) + defer timer.Stop() + select { + case <-sub.Done(): + case <-timer.C: + logger.WarnCF("events", "Timed out waiting for runtime event logger to drain", map[string]any{ + "timeout": runtimeEventLoggerDrainTimeout.String(), + }) + } +} + +func newRuntimeEventLogger(cfg *config.Config) *runtimeEventLogger { + logCfg := config.EffectiveEventLoggingConfig(cfg) + if !logCfg.Enabled { + return nil + } + return newRuntimeEventLoggerFromConfig(logCfg) +} + +func newRuntimeEventLoggerFromConfig(logCfg config.EventLoggingConfig) *runtimeEventLogger { + return &runtimeEventLogger{cfg: logCfg} +} + +func (l *runtimeEventLogger) updateConfig(cfg config.EventLoggingConfig) { + if l == nil { + return + } + l.mu.Lock() + l.cfg = cfg + l.mu.Unlock() +} + +func (l *runtimeEventLogger) configSnapshot() config.EventLoggingConfig { + if l == nil { + return config.EventLoggingConfig{} + } + l.mu.RLock() + defer l.mu.RUnlock() + return l.cfg +} + +func (l *runtimeEventLogger) subscribe( + ctx context.Context, + eventBus runtimeevents.Bus, +) (runtimeevents.Subscription, error) { + if l == nil || eventBus == nil { + return nil, nil + } + return eventBus.Channel().Subscribe(ctx, runtimeevents.SubscribeOptions{ + Name: "runtime-event-logger", + Buffer: runtimeEventLoggerBuffer, + Concurrency: runtimeevents.Locked, + Backpressure: runtimeevents.DropNewest, + PanicPolicy: runtimeevents.RecoverAndLog, + }, l.handle) +} + +func (l *runtimeEventLogger) handle(_ context.Context, evt runtimeevents.Event) error { + if l == nil || !l.shouldLog(evt) { + return nil + } + + fields := runtimeEventLogFields(evt) + if l.configSnapshot().IncludePayload && evt.Payload != nil { + fields["payload"] = evt.Payload + } + + logRuntimeEvent(evt, fields) + return nil +} + +func (l *runtimeEventLogger) shouldLog(evt runtimeevents.Event) bool { + if l == nil { + return false + } + cfg := l.configSnapshot() + if !cfg.Enabled { + return false + } + if runtimeEventSeverityRank(evt.Severity) < runtimeEventSeverityRank(parseRuntimeEventSeverity(cfg.MinSeverity)) { + return false + } + + kind := evt.Kind.String() + if !matchAnyRuntimeEventPattern(cfg.Include, kind, true) { + return false + } + return !matchAnyRuntimeEventPattern(cfg.Exclude, kind, false) +} + +func logRuntimeEvent(evt runtimeevents.Event, fields map[string]any) { + message := fmt.Sprintf("Runtime event: %s", evt.Kind.String()) + switch normalizeRuntimeEventSeverity(evt.Severity) { + case runtimeevents.SeverityDebug: + logger.DebugCF("events", message, fields) + case runtimeevents.SeverityWarn: + logger.WarnCF("events", message, fields) + case runtimeevents.SeverityError: + logger.ErrorCF("events", message, fields) + default: + logger.InfoCF("events", message, fields) + } +} + +func runtimeEventLogFields(evt runtimeevents.Event) map[string]any { + fields := map[string]any{ + "event_id": evt.ID, + "event_kind": evt.Kind.String(), + "severity": string(normalizeRuntimeEventSeverity(evt.Severity)), + } + if !evt.Time.IsZero() { + fields["event_time"] = evt.Time.Format(time.RFC3339Nano) + } + appendRuntimeEventSourceFields(fields, evt.Source) + appendRuntimeEventScopeFields(fields, evt.Scope) + appendRuntimeEventCorrelationFields(fields, evt.Correlation) + appendRuntimeEventAttrs(fields, evt.Attrs) + appendRuntimeEventPayloadSummary(fields, evt.Payload) + return fields +} + +func appendRuntimeEventSourceFields(fields map[string]any, source runtimeevents.Source) { + if source.Component != "" { + fields["source_component"] = source.Component + } + if source.Name != "" { + fields["source_name"] = source.Name + } +} + +func appendRuntimeEventScopeFields(fields map[string]any, scope runtimeevents.Scope) { + setStringField(fields, "runtime_id", scope.RuntimeID) + setStringField(fields, "agent_id", scope.AgentID) + setStringField(fields, "session_key", scope.SessionKey) + setStringField(fields, "turn_id", scope.TurnID) + setStringField(fields, "channel", scope.Channel) + setStringField(fields, "account", scope.Account) + setStringField(fields, "chat_id", scope.ChatID) + setStringField(fields, "topic_id", scope.TopicID) + setStringField(fields, "space_id", scope.SpaceID) + setStringField(fields, "space_type", scope.SpaceType) + setStringField(fields, "chat_type", scope.ChatType) + setStringField(fields, "sender_id", scope.SenderID) + setStringField(fields, "message_id", scope.MessageID) +} + +func appendRuntimeEventCorrelationFields(fields map[string]any, correlation runtimeevents.Correlation) { + setStringField(fields, "trace_id", correlation.TraceID) + setStringField(fields, "parent_turn_id", correlation.ParentTurnID) + setStringField(fields, "request_id", correlation.RequestID) + setStringField(fields, "reply_to_id", correlation.ReplyToID) +} + +func appendRuntimeEventAttrs(fields map[string]any, attrs map[string]any) { + for key, value := range attrs { + if key == "" || value == nil { + continue + } + if _, exists := fields[key]; exists { + fields["attr_"+key] = value + continue + } + fields[key] = value + } +} + +func appendRuntimeEventPayloadSummary(fields map[string]any, payload any) { + switch payload := payload.(type) { + case TurnStartPayload: + fields["user_len"] = len(payload.UserMessage) + fields["media_count"] = payload.MediaCount + case TurnEndPayload: + fields["status"] = payload.Status + fields["iterations_total"] = payload.Iterations + fields["duration_ms"] = payload.Duration.Milliseconds() + fields["final_len"] = payload.FinalContentLen + case LLMRequestPayload: + fields["model"] = payload.Model + fields["messages"] = payload.MessagesCount + fields["tools"] = payload.ToolsCount + fields["max_tokens"] = payload.MaxTokens + case LLMDeltaPayload: + fields["content_delta_len"] = payload.ContentDeltaLen + fields["reasoning_delta_len"] = payload.ReasoningDeltaLen + case LLMResponsePayload: + fields["content_len"] = payload.ContentLen + fields["tool_calls"] = payload.ToolCalls + fields["has_reasoning"] = payload.HasReasoning + case LLMRetryPayload: + fields["attempt"] = payload.Attempt + fields["max_retries"] = payload.MaxRetries + fields["reason"] = payload.Reason + fields["error"] = payload.Error + fields["backoff_ms"] = payload.Backoff.Milliseconds() + case ContextCompressPayload: + fields["reason"] = payload.Reason + fields["dropped_messages"] = payload.DroppedMessages + fields["remaining_messages"] = payload.RemainingMessages + case SessionSummarizePayload: + fields["summarized_messages"] = payload.SummarizedMessages + fields["kept_messages"] = payload.KeptMessages + fields["summary_len"] = payload.SummaryLen + fields["omitted_oversized"] = payload.OmittedOversized + case ToolExecStartPayload: + fields["tool"] = payload.Tool + fields["args_count"] = len(payload.Arguments) + case ToolExecEndPayload: + fields["tool"] = payload.Tool + fields["duration_ms"] = payload.Duration.Milliseconds() + fields["for_llm_len"] = payload.ForLLMLen + fields["for_user_len"] = payload.ForUserLen + fields["is_error"] = payload.IsError + fields["async"] = payload.Async + case ToolExecSkippedPayload: + fields["tool"] = payload.Tool + fields["reason"] = payload.Reason + case SteeringInjectedPayload: + fields["count"] = payload.Count + fields["total_content_len"] = payload.TotalContentLen + case FollowUpQueuedPayload: + fields["source_tool"] = payload.SourceTool + fields["content_len"] = payload.ContentLen + case InterruptReceivedPayload: + fields["interrupt_kind"] = payload.Kind + fields["role"] = payload.Role + fields["content_len"] = payload.ContentLen + fields["queue_depth"] = payload.QueueDepth + fields["hint_len"] = payload.HintLen + case SubTurnSpawnPayload: + fields["child_agent_id"] = payload.AgentID + fields["label"] = payload.Label + case SubTurnEndPayload: + fields["child_agent_id"] = payload.AgentID + fields["status"] = payload.Status + case SubTurnResultDeliveredPayload: + fields["target_channel"] = payload.TargetChannel + fields["target_chat_id"] = payload.TargetChatID + fields["content_len"] = payload.ContentLen + case SubTurnOrphanPayload: + fields["parent_turn_id"] = payload.ParentTurnID + fields["child_turn_id"] = payload.ChildTurnID + fields["reason"] = payload.Reason + case ErrorPayload: + fields["stage"] = payload.Stage + fields["error"] = payload.Message + } +} + +func setStringField(fields map[string]any, key, value string) { + if value != "" { + fields[key] = value + } +} + +func matchAnyRuntimeEventPattern(patterns []string, kind string, emptyMatches bool) bool { + if len(patterns) == 0 { + return emptyMatches + } + for _, pattern := range patterns { + if matchRuntimeEventPattern(pattern, kind) { + return true + } + } + return false +} + +func matchRuntimeEventPattern(pattern, kind string) bool { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + return false + } + if pattern == "*" { + return true + } + if strings.HasSuffix(pattern, ".*") { + return strings.HasPrefix(kind, strings.TrimSuffix(pattern, "*")) + } + matched, err := path.Match(pattern, kind) + if err == nil { + return matched + } + return pattern == kind +} + +func parseRuntimeEventSeverity(severity string) runtimeevents.Severity { + switch strings.ToLower(strings.TrimSpace(severity)) { + case "debug": + return runtimeevents.SeverityDebug + case "warn", "warning": + return runtimeevents.SeverityWarn + case "error": + return runtimeevents.SeverityError + default: + return runtimeevents.SeverityInfo + } +} + +func normalizeRuntimeEventSeverity(severity runtimeevents.Severity) runtimeevents.Severity { + switch severity { + case runtimeevents.SeverityDebug, + runtimeevents.SeverityInfo, + runtimeevents.SeverityWarn, + runtimeevents.SeverityError: + return severity + default: + return runtimeevents.SeverityInfo + } +} + +func runtimeEventSeverityRank(severity runtimeevents.Severity) int { + switch normalizeRuntimeEventSeverity(severity) { + case runtimeevents.SeverityDebug: + return 0 + case runtimeevents.SeverityInfo: + return 1 + case runtimeevents.SeverityWarn: + return 2 + case runtimeevents.SeverityError: + return 3 + default: + return 1 + } +} diff --git a/pkg/agent/runtime_event_logger_test.go b/pkg/agent/runtime_event_logger_test.go new file mode 100644 index 000000000..1c95b365c --- /dev/null +++ b/pkg/agent/runtime_event_logger_test.go @@ -0,0 +1,259 @@ +package agent + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +func TestRuntimeEventLoggerFiltering(t *testing.T) { + cfg := config.DefaultConfig() + eventLogger := newRuntimeEventLogger(cfg) + if eventLogger == nil { + t.Fatal("default runtime event logger is nil") + } + + if !eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindAgentTurnStart, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("default config should log agent events") + } + if eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindChannelLifecycleStarted, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("default config should not log non-agent events") + } + + cfg.Events.Logging.Include = []string{"*"} + cfg.Events.Logging.Exclude = []string{"mcp.*"} + eventLogger = newRuntimeEventLogger(cfg) + if !eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindGatewayReady, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("include * should log gateway events") + } + if eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindMCPServerConnected, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("exclude mcp.* should suppress MCP events") + } + + cfg.Events.Logging.Exclude = nil + cfg.Events.Logging.MinSeverity = "warn" + eventLogger = newRuntimeEventLogger(cfg) + if eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindGatewayReady, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("min severity warn should suppress info events") + } + if !eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindGatewayReloadFailed, + Severity: runtimeevents.SeverityError, + }) { + t.Fatal("min severity warn should allow error events") + } + + cfg.Events.Logging.Enabled = false + if newRuntimeEventLogger(cfg) != nil { + t.Fatal("disabled config should not create runtime event logger") + } +} + +func TestRuntimeEventLogFieldsSummarizeAgentPayload(t *testing.T) { + fields := runtimeEventLogFields(runtimeevents.Event{ + ID: "evt-test", + Kind: runtimeevents.KindAgentToolExecStart, + Severity: runtimeevents.SeverityInfo, + Source: runtimeevents.Source{ + Component: "agent", + Name: "main", + }, + Scope: runtimeevents.Scope{ + AgentID: "main", + SessionKey: "session-1", + TurnID: "turn-1", + }, + Payload: ToolExecStartPayload{ + Tool: "exec", + Arguments: map[string]any{ + "secret": "should-not-be-logged-by-default", + }, + }, + }) + + if fields["event_id"] != "evt-test" || fields["source_component"] != "agent" { + t.Fatalf("missing common event fields: %#v", fields) + } + if fields["tool"] != "exec" || fields["args_count"] != 1 { + t.Fatalf("missing safe agent payload summary fields: %#v", fields) + } + if _, ok := fields["payload"]; ok { + t.Fatalf("raw payload should not be included by runtimeEventLogFields: %#v", fields) + } +} + +func TestRuntimeEventLogFieldsIncludeSafeAttrs(t *testing.T) { + fields := runtimeEventLogFields(runtimeevents.Event{ + ID: "evt-gateway", + Kind: runtimeevents.KindGatewayReady, + Severity: runtimeevents.SeverityInfo, + Attrs: map[string]any{ + "duration_ms": 42, + "error": "startup failed", + "event_kind": "conflict", + }, + }) + + if fields["duration_ms"] != 42 || fields["error"] != "startup failed" { + t.Fatalf("missing safe attrs: %#v", fields) + } + if fields["event_kind"] != runtimeevents.KindGatewayReady.String() { + t.Fatalf("event_kind overwritten by attrs: %#v", fields) + } + if fields["attr_event_kind"] != "conflict" { + t.Fatalf("conflicting attr not preserved with prefix: %#v", fields) + } + if _, ok := fields["payload"]; ok { + t.Fatalf("raw payload should not be included by runtimeEventLogFields: %#v", fields) + } +} + +func runtimeEventLoggerStateForTest( + al *AgentLoop, +) (*runtimeEventLogger, runtimeevents.Subscription) { + al.runtimeEventLogMu.RLock() + defer al.runtimeEventLogMu.RUnlock() + return al.runtimeEventLogger, al.runtimeEventLogSub +} + +func TestReloadProviderAndConfigRefreshesRuntimeEventLogger(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = t.TempDir() + cfg.Events.Logging.Include = []string{"agent.*"} + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + defer al.Close() + + eventLogger, logSub := runtimeEventLoggerStateForTest(al) + if eventLogger == nil || logSub == nil { + t.Fatal("expected initial runtime event logger subscription") + } + if eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindGatewayReloadCompleted, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("initial agent-only logging should not log gateway reload events") + } + + reloaded := config.DefaultConfig() + reloaded.Agents.Defaults.Workspace = cfg.Agents.Defaults.Workspace + reloaded.Events.Logging.Include = []string{"gateway.*"} + if err := al.ReloadProviderAndConfig(context.Background(), &mockProvider{}, reloaded); err != nil { + t.Fatalf("ReloadProviderAndConfig() error = %v", err) + } + + eventLogger, logSub = runtimeEventLoggerStateForTest(al) + if eventLogger == nil || logSub == nil { + t.Fatal("expected runtime event logger subscription after reload") + } + if !eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindGatewayReloadCompleted, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("reloaded gateway logging should log gateway reload events") + } + if eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindAgentTurnStart, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("reloaded gateway-only logging should not log agent events") + } + + disabled := config.DefaultConfig() + disabled.Agents.Defaults.Workspace = cfg.Agents.Defaults.Workspace + disabled.Events.Logging.Enabled = false + if err := al.ReloadProviderAndConfig(context.Background(), &mockProvider{}, disabled); err != nil { + t.Fatalf("ReloadProviderAndConfig() with disabled logging error = %v", err) + } + eventLogger, logSub = runtimeEventLoggerStateForTest(al) + if eventLogger != nil || logSub != nil { + t.Fatal("expected runtime event logger to be disabled after reload") + } +} + +func TestCloseRuntimeEventLoggerSubscriptionWaitsForDrain(t *testing.T) { + eventBus := runtimeevents.NewBus() + defer func() { + if err := eventBus.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } + }() + + var handled atomic.Uint64 + firstStarted := make(chan struct{}) + releaseFirst := make(chan struct{}) + sub, err := eventBus.Channel().Subscribe( + context.Background(), + runtimeevents.SubscribeOptions{ + Name: "runtime-event-logger", + Buffer: 2, + Concurrency: runtimeevents.Locked, + }, + func(context.Context, runtimeevents.Event) error { + if handled.Add(1) == 1 { + close(firstStarted) + <-releaseFirst + } + return nil + }, + ) + if err != nil { + t.Fatalf("Subscribe failed: %v", err) + } + + first := eventBus.Publish(context.Background(), runtimeevents.Event{Kind: runtimeevents.Kind("test.first")}) + if first.Delivered != 1 { + t.Fatalf("first Publish = %+v, want one delivered event", first) + } + select { + case <-firstStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for first handler to start") + } + second := eventBus.Publish(context.Background(), runtimeevents.Event{Kind: runtimeevents.Kind("test.second")}) + if second.Delivered != 1 { + t.Fatalf("second Publish = %+v, want one delivered event", second) + } + + closeReturned := make(chan struct{}) + go func() { + closeRuntimeEventLoggerSubscription(sub) + close(closeReturned) + }() + + select { + case <-closeReturned: + t.Fatal("runtime event logger close returned before buffered events drained") + case <-time.After(50 * time.Millisecond): + } + + close(releaseFirst) + select { + case <-closeReturned: + case <-time.After(time.Second): + t.Fatal("timed out waiting for runtime event logger close to return") + } + if got := handled.Load(); got != 2 { + t.Fatalf("handled = %d, want 2", got) + } +} diff --git a/pkg/agent/runtime_event_test.go b/pkg/agent/runtime_event_test.go new file mode 100644 index 000000000..162ccf424 --- /dev/null +++ b/pkg/agent/runtime_event_test.go @@ -0,0 +1,103 @@ +package agent + +import ( + "testing" + "time" + + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +func subscribeRuntimeEventsForTest( + t *testing.T, + al *AgentLoop, + buffer int, + kinds ...runtimeevents.Kind, +) (<-chan runtimeevents.Event, func()) { + t.Helper() + + if al == nil { + t.Fatal("agent loop is nil") + } + channel := al.RuntimeEvents() + if channel == nil { + t.Fatal("runtime event channel is nil") + } + if len(kinds) > 0 { + channel = channel.OfKind(kinds...) + } + sub, ch, err := channel.SubscribeChan( + t.Context(), + runtimeevents.SubscribeOptions{Name: "agent-runtime-test", Buffer: buffer}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + return ch, func() { + if err := sub.Close(); err != nil { + t.Errorf("runtime subscription close failed: %v", err) + } + } +} + +func waitForRuntimeEvent( + t *testing.T, + ch <-chan runtimeevents.Event, + timeout time.Duration, + match func(runtimeevents.Event) bool, +) runtimeevents.Event { + t.Helper() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case evt, ok := <-ch: + if !ok { + t.Fatal("runtime event stream closed before expected event arrived") + } + if match(evt) { + return evt + } + case <-timer.C: + t.Fatal("timed out waiting for expected runtime event") + } + } +} + +func collectRuntimeEventStream(ch <-chan runtimeevents.Event) []runtimeevents.Event { + var events []runtimeevents.Event + for { + select { + case evt, ok := <-ch: + if !ok { + return events + } + events = append(events, evt) + default: + return events + } + } +} + +func findRuntimeEvent( + events []runtimeevents.Event, + kind runtimeevents.Kind, +) (runtimeevents.Event, bool) { + for _, evt := range events { + if evt.Kind == kind { + return evt, true + } + } + return runtimeevents.Event{}, false +} + +func filterRuntimeEvents(events []runtimeevents.Event, kind runtimeevents.Kind) []runtimeevents.Event { + var filtered []runtimeevents.Event + for _, evt := range events { + if evt.Kind == kind { + filtered = append(filtered, evt) + } + } + return filtered +} diff --git a/pkg/agent/steering.go b/pkg/agent/steering.go new file mode 100644 index 000000000..7bddbfc31 --- /dev/null +++ b/pkg/agent/steering.go @@ -0,0 +1,583 @@ +package agent + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/bus" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/tools" +) + +// SteeringMode controls how queued steering messages are dequeued. +type SteeringMode string + +const ( + // SteeringOneAtATime dequeues only the first queued message per poll. + SteeringOneAtATime SteeringMode = "one-at-a-time" + // SteeringAll drains the entire queue in a single poll. + SteeringAll SteeringMode = "all" + // MaxQueueSize number of possible messages in the Steering Queue + MaxQueueSize = 10 + // manualSteeringScope is the legacy fallback queue used when no active + // turn/session scope is available. + manualSteeringScope = "__manual__" +) + +// parseSteeringMode normalizes a config string into a SteeringMode. +func parseSteeringMode(s string) SteeringMode { + switch s { + case "all": + return SteeringAll + default: + return SteeringOneAtATime + } +} + +// steeringQueue is a thread-safe queue of user messages that can be injected +// into a running agent loop to interrupt it between tool calls. +type steeringQueue struct { + mu sync.Mutex + queues map[string][]providers.Message + mode SteeringMode +} + +func newSteeringQueue(mode SteeringMode) *steeringQueue { + return &steeringQueue{ + queues: make(map[string][]providers.Message), + mode: mode, + } +} + +func normalizeSteeringScope(scope string) string { + scope = strings.TrimSpace(scope) + if scope == "" { + return manualSteeringScope + } + return scope +} + +// push enqueues a steering message in the legacy fallback scope. +func (sq *steeringQueue) push(msg providers.Message) error { + return sq.pushScope(manualSteeringScope, msg) +} + +// pushScope enqueues a steering message for the provided scope. +func (sq *steeringQueue) pushScope(scope string, msg providers.Message) error { + sq.mu.Lock() + defer sq.mu.Unlock() + + scope = normalizeSteeringScope(scope) + queue := sq.queues[scope] + if len(queue) >= MaxQueueSize { + return fmt.Errorf("steering queue is full") + } + sq.queues[scope] = append(queue, msg) + return nil +} + +// dequeue removes and returns pending steering messages from the legacy +// fallback scope according to the configured mode. +func (sq *steeringQueue) dequeue() []providers.Message { + return sq.dequeueScope(manualSteeringScope) +} + +// dequeueScope removes and returns pending steering messages for the provided +// scope according to the configured mode. +func (sq *steeringQueue) dequeueScope(scope string) []providers.Message { + sq.mu.Lock() + defer sq.mu.Unlock() + + return sq.dequeueLocked(normalizeSteeringScope(scope)) +} + +// dequeueScopeWithFallback drains the scoped queue first and falls back to the +// legacy manual scope for backwards compatibility. +func (sq *steeringQueue) dequeueScopeWithFallback(scope string) []providers.Message { + sq.mu.Lock() + defer sq.mu.Unlock() + + scope = strings.TrimSpace(scope) + if scope != "" { + if msgs := sq.dequeueLocked(scope); len(msgs) > 0 { + return msgs + } + } + + return sq.dequeueLocked(manualSteeringScope) +} + +func (sq *steeringQueue) dequeueLocked(scope string) []providers.Message { + queue := sq.queues[scope] + if len(queue) == 0 { + return nil + } + + switch sq.mode { + case SteeringAll: + msgs := append([]providers.Message(nil), queue...) + delete(sq.queues, scope) + return msgs + default: + msg := queue[0] + queue[0] = providers.Message{} // Clear reference for GC + queue = queue[1:] + if len(queue) == 0 { + delete(sq.queues, scope) + } else { + sq.queues[scope] = queue + } + return []providers.Message{msg} + } +} + +// len returns the number of queued messages across all scopes. +func (sq *steeringQueue) len() int { + sq.mu.Lock() + defer sq.mu.Unlock() + + total := 0 + for _, queue := range sq.queues { + total += len(queue) + } + return total +} + +// lenScope returns the number of queued messages for a specific scope. +func (sq *steeringQueue) lenScope(scope string) int { + sq.mu.Lock() + defer sq.mu.Unlock() + return len(sq.queues[normalizeSteeringScope(scope)]) +} + +func (sq *steeringQueue) clearScope(scope string) int { + sq.mu.Lock() + defer sq.mu.Unlock() + + scope = normalizeSteeringScope(scope) + count := len(sq.queues[scope]) + if count > 0 { + delete(sq.queues, scope) + } + return count +} + +// setMode updates the steering mode. +func (sq *steeringQueue) setMode(mode SteeringMode) { + sq.mu.Lock() + defer sq.mu.Unlock() + sq.mode = mode +} + +// getMode returns the current steering mode. +func (sq *steeringQueue) getMode() SteeringMode { + sq.mu.Lock() + defer sq.mu.Unlock() + return sq.mode +} + +// Steer enqueues a user message to be injected into the currently running +// agent loop. The message will be picked up after the current tool finishes +// executing, causing any remaining tool calls in the batch to be skipped. +func (al *AgentLoop) Steer(msg providers.Message) error { + scope := "" + agentID := "" + if ts := al.getAnyActiveTurnState(); ts != nil { + scope = ts.sessionKey + agentID = ts.agentID + } + return al.enqueueSteeringMessage(scope, agentID, msg) +} + +func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers.Message) error { + if al.steering == nil { + return fmt.Errorf("steering queue is not initialized") + } + + msg = steeringPromptMessage(msg) + if err := al.steering.pushScope(scope, msg); err != nil { + logger.WarnCF("agent", "Failed to enqueue steering message", map[string]any{ + "error": err.Error(), + "role": msg.Role, + "scope": normalizeSteeringScope(scope), + }) + return err + } + + queueDepth := al.steering.lenScope(scope) + logger.DebugCF("agent", "Steering message enqueued", map[string]any{ + "role": msg.Role, + "content_len": len(msg.Content), + "media_count": len(msg.Media), + "queue_len": queueDepth, + "scope": normalizeSteeringScope(scope), + }) + + meta := HookMeta{ + Source: "Steer", + TracePath: "turn.interrupt.received", + } + if ts := al.getAnyActiveTurnState(); ts != nil { + meta = ts.eventMeta("Steer", "turn.interrupt.received") + } else { + if strings.TrimSpace(agentID) != "" { + meta.AgentID = agentID + } + normalizedScope := normalizeSteeringScope(scope) + if normalizedScope != manualSteeringScope { + meta.SessionKey = normalizedScope + } + if meta.AgentID == "" { + if registry := al.GetRegistry(); registry != nil { + if agent := registry.GetDefaultAgent(); agent != nil { + meta.AgentID = agent.ID + } + } + } + } + + al.emitEvent( + runtimeevents.KindAgentInterruptReceived, + meta, + InterruptReceivedPayload{ + Kind: InterruptKindSteering, + Role: msg.Role, + ContentLen: len(msg.Content), + QueueDepth: queueDepth, + }, + ) + + return nil +} + +// SteeringMode returns the current steering mode. +func (al *AgentLoop) SteeringMode() SteeringMode { + if al.steering == nil { + return SteeringOneAtATime + } + return al.steering.getMode() +} + +// SetSteeringMode updates the steering mode. +func (al *AgentLoop) SetSteeringMode(mode SteeringMode) { + if al.steering == nil { + return + } + al.steering.setMode(mode) +} + +// dequeueSteeringMessages is the internal method called by the agent loop +// to poll for steering messages in the legacy fallback scope. +func (al *AgentLoop) dequeueSteeringMessages() []providers.Message { + if al.steering == nil { + return nil + } + return al.steering.dequeue() +} + +func (al *AgentLoop) dequeueSteeringMessagesForScope(scope string) []providers.Message { + if al.steering == nil { + return nil + } + return al.steering.dequeueScope(scope) +} + +func (al *AgentLoop) dequeueSteeringMessagesForScopeWithFallback(scope string) []providers.Message { + if al.steering == nil { + return nil + } + return al.steering.dequeueScopeWithFallback(scope) +} + +func (al *AgentLoop) pendingSteeringCountForScope(scope string) int { + if al.steering == nil { + return 0 + } + return al.steering.lenScope(scope) +} + +func (al *AgentLoop) clearSteeringMessagesForScope(scope string) int { + if al.steering == nil { + return 0 + } + return al.steering.clearScope(scope) +} + +func (al *AgentLoop) continueWithSteeringMessages( + ctx context.Context, + agent *AgentInstance, + sessionKey, channel, chatID string, + scope *session.SessionScope, + steeringMsgs []providers.Message, +) (string, error) { + dispatch := DispatchRequest{ + SessionKey: sessionKey, + SessionScope: session.CloneScope(scope), + } + if channel != "" || chatID != "" { + dispatch.InboundContext = &bus.InboundContext{ + Channel: channel, + ChatID: chatID, + ChatType: inferChatTypeFromSessionScope(scope), + } + } + return al.runAgentLoop(ctx, agent, processOptions{ + Dispatch: dispatch, + DefaultResponse: defaultResponse, + EnableSummary: true, + SendResponse: false, + InitialSteeringMessages: steeringMsgs, + SkipInitialSteeringPoll: true, + }) +} + +func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance { + registry := al.GetRegistry() + if registry == nil { + return nil + } + + agentIDs := registry.ListAgentIDs() + sort.Strings(agentIDs) + for _, agentID := range agentIDs { + agent, ok := registry.GetAgent(agentID) + if !ok || agent == nil { + continue + } + resolvedAgentID := session.ResolveAgentID(agent.Sessions, sessionKey) + if resolvedAgentID == "" { + continue + } + if scopedAgent, ok := registry.GetAgent(resolvedAgentID); ok { + return scopedAgent + } + } + + return registry.GetDefaultAgent() +} + +// Continue resumes an idle agent by dequeuing any pending steering messages +// and running them through the agent loop. This is used when the agent's last +// message was from the assistant (i.e., it has stopped processing) and the +// user has since enqueued steering messages. +// +// If no steering messages are pending, it returns an empty string. +func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error) { + // Claim the session with a unique placeholder to prevent a TOCTOU race where two + // concurrent Continue calls for the same session both pass the active-turn + // check and create parallel turns. The placeholder is replaced by the real + // turnState inside continueWithSteeringMessages → runAgentLoop → registerActiveTurn. + placeholder := &turnState{ + turnID: "pending-continue-" + sessionKey + "-" + fmt.Sprintf("%d", al.turnSeq.Add(1)), + phase: TurnPhaseSetup, + } + if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded { + if active := al.GetActiveTurnBySession(sessionKey); active != nil { + return "", fmt.Errorf("turn %s is still active for session %q", active.TurnID, sessionKey) + } + // Another Continue just claimed the slot; let it handle the steering. + return "", nil + } + + if err := al.ensureHooksInitialized(ctx); err != nil { + al.activeTurnStates.Delete(sessionKey) + return "", err + } + if err := al.ensureMCPInitialized(ctx); err != nil { + al.activeTurnStates.Delete(sessionKey) + return "", err + } + + steeringMsgs := al.dequeueSteeringMessagesForScopeWithFallback(sessionKey) + if len(steeringMsgs) == 0 { + al.activeTurnStates.Delete(sessionKey) + return "", nil + } + + agent := al.agentForSession(sessionKey) + if agent == nil { + al.activeTurnStates.Delete(sessionKey) + return "", fmt.Errorf("no agent available for session %q", sessionKey) + } + + if tool, ok := agent.Tools.Get("message"); ok { + if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok { + resetter.ResetSentInRound(sessionKey) + } + } + + var scope *session.SessionScope + if metaStore, ok := agent.Sessions.(session.MetadataAwareSessionStore); ok { + scope = metaStore.GetSessionScope(sessionKey) + } + + return al.continueWithSteeringMessages(ctx, agent, sessionKey, channel, chatID, scope, steeringMsgs) +} + +func (al *AgentLoop) InterruptGraceful(hint string) error { + ts := al.getAnyActiveTurnState() + if ts == nil { + return fmt.Errorf("no active turn") + } + if !ts.requestGracefulInterrupt(hint) { + return fmt.Errorf("turn %s cannot accept graceful interrupt", ts.turnID) + } + + al.emitEvent( + runtimeevents.KindAgentInterruptReceived, + ts.eventMeta("InterruptGraceful", "turn.interrupt.received"), + InterruptReceivedPayload{ + Kind: InterruptKindGraceful, + HintLen: len(hint), + }, + ) + + return nil +} + +// InterruptHard aborts an arbitrary active turn. In parallel mode this may +// target the wrong session. Prefer HardAbort(sessionKey) instead. +// +// Deprecated: Use HardAbort(sessionKey) for session-safe aborts. +func (al *AgentLoop) InterruptHard() error { + ts := al.getAnyActiveTurnState() + if ts == nil { + return fmt.Errorf("no active turn") + } + if strings.HasPrefix(ts.turnID, "pending-") { + return fmt.Errorf("turn is still initializing for session %s", ts.sessionKey) + } + if !ts.requestHardAbort() { + return fmt.Errorf("turn %s is already aborting", ts.turnID) + } + + al.emitEvent( + runtimeevents.KindAgentInterruptReceived, + ts.eventMeta("InterruptHard", "turn.interrupt.received"), + InterruptReceivedPayload{ + Kind: InterruptKindHard, + }, + ) + + return nil +} + +// ====================== SubTurn Result Polling ====================== + +// dequeuePendingSubTurnResults polls the SubTurn result channel for the given +// session and returns all available results without blocking. +// Returns nil if no active turn state exists for this session. +func (al *AgentLoop) dequeuePendingSubTurnResults(sessionKey string) []*tools.ToolResult { + tsInterface, ok := al.activeTurnStates.Load(sessionKey) + if !ok { + return nil + } + ts, ok := tsInterface.(*turnState) + if !ok { + return nil + } + + var results []*tools.ToolResult + for { + select { + case result, ok := <-ts.pendingResults: + if !ok { + return results + } + if result != nil { + results = append(results, result) + } + default: + return results + } + } +} + +// ====================== Hard Abort ====================== + +// HardAbort immediately cancels the running agent loop for the given session, +// cascading the cancellation to all child SubTurns. This is a destructive operation +// that terminates execution without waiting for graceful cleanup. +// +// Use this when the user explicitly requests immediate termination (e.g., "stop now", "abort"). +// For graceful interruption that allows the agent to finish the current tool and summarize, +// use Steer() instead. +func (al *AgentLoop) HardAbort(sessionKey string) error { + tsInterface, ok := al.activeTurnStates.Load(sessionKey) + if !ok { + return fmt.Errorf("no active turn state found for session %s", sessionKey) + } + + ts, ok := tsInterface.(*turnState) + if !ok { + return fmt.Errorf("invalid turn state type for session %s", sessionKey) + } + + if strings.HasPrefix(ts.turnID, "pending-") { + return fmt.Errorf("turn is still initializing for session %s", sessionKey) + } + + logger.InfoCF("agent", "Hard abort triggered", map[string]any{ + "session_key": sessionKey, + "turn_id": ts.turnID, + "depth": ts.depth, + "initial_history_length": ts.initialHistoryLength, + }) + + // Cancel the active provider/tool turn contexts immediately so long-running + // execution stops as soon as possible on the root turn. + _ = ts.requestHardAbort() + + // IMPORTANT: Trigger cascading cancellation FIRST to stop all child SubTurns + // from adding more messages to the session. This prevents race conditions + // where rollback happens while children are still writing. + // Use isHardAbort=true for hard abort to immediately cancel all children. + ts.Finish(true) + + // Roll back session history to the state before the turn started. + if ts.session != nil { + history := ts.session.GetHistory(sessionKey) + if ts.initialHistoryLength < len(history) { + ts.session.SetHistory(sessionKey, history[:ts.initialHistoryLength]) + } + } + + return nil +} + +// ====================== Follow-Up Injection ====================== + +// InjectFollowUp enqueues a message to be automatically processed after the current +// turn completes. Unlike Steer(), which interrupts the current execution, InjectFollowUp +// waits for the current turn to finish naturally before processing the message. +// +// This is useful for: +// - Automated workflows that need to chain multiple turns +// - Background tasks that should run after the main task completes +// - Scheduled follow-up actions +// +// The message will be processed via Continue() when the agent becomes idle. +func (al *AgentLoop) InjectFollowUp(msg providers.Message) error { + // InjectFollowUp uses the same steering queue mechanism as Steer(), + // but the semantic difference is in when it's called: + // - Steer() is called during active execution to interrupt + // - InjectFollowUp() is called when planning future work + // + // Both end up in the same queue and are processed by Continue() + // when the agent is idle. + return al.Steer(msg) +} + +// ====================== API Aliases for Design Document Compatibility ====================== + +// InjectSteering is an alias for Steer() to match the design document naming. +// It injects a steering message into the currently running agent loop. +func (al *AgentLoop) InjectSteering(msg providers.Message) error { + return al.Steer(msg) +} diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go new file mode 100644 index 000000000..813013649 --- /dev/null +++ b/pkg/agent/steering_test.go @@ -0,0 +1,1896 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/tools" +) + +// --- steeringQueue unit tests --- + +func TestSteeringQueue_PushDequeue_OneAtATime(t *testing.T) { + sq := newSteeringQueue(SteeringOneAtATime) + + sq.push(providers.Message{Role: "user", Content: "msg1"}) + sq.push(providers.Message{Role: "user", Content: "msg2"}) + sq.push(providers.Message{Role: "user", Content: "msg3"}) + + if sq.len() != 3 { + t.Fatalf("expected 3 messages, got %d", sq.len()) + } + + msgs := sq.dequeue() + if len(msgs) != 1 { + t.Fatalf("expected 1 message in one-at-a-time mode, got %d", len(msgs)) + } + if msgs[0].Content != "msg1" { + t.Fatalf("expected 'msg1', got %q", msgs[0].Content) + } + if sq.len() != 2 { + t.Fatalf("expected 2 remaining, got %d", sq.len()) + } + + msgs = sq.dequeue() + if len(msgs) != 1 || msgs[0].Content != "msg2" { + t.Fatalf("expected 'msg2', got %v", msgs) + } + + msgs = sq.dequeue() + if len(msgs) != 1 || msgs[0].Content != "msg3" { + t.Fatalf("expected 'msg3', got %v", msgs) + } + + msgs = sq.dequeue() + if msgs != nil { + t.Fatalf("expected nil from empty queue, got %v", msgs) + } +} + +func TestSteeringQueue_PushDequeue_All(t *testing.T) { + sq := newSteeringQueue(SteeringAll) + + sq.push(providers.Message{Role: "user", Content: "msg1"}) + sq.push(providers.Message{Role: "user", Content: "msg2"}) + sq.push(providers.Message{Role: "user", Content: "msg3"}) + + msgs := sq.dequeue() + if len(msgs) != 3 { + t.Fatalf("expected 3 messages in all mode, got %d", len(msgs)) + } + if msgs[0].Content != "msg1" || msgs[1].Content != "msg2" || msgs[2].Content != "msg3" { + t.Fatalf("unexpected messages: %v", msgs) + } + + if sq.len() != 0 { + t.Fatalf("expected 0 remaining, got %d", sq.len()) + } + + msgs = sq.dequeue() + if msgs != nil { + t.Fatalf("expected nil from empty queue, got %v", msgs) + } +} + +func TestSteeringQueue_EmptyDequeue(t *testing.T) { + sq := newSteeringQueue(SteeringOneAtATime) + if msgs := sq.dequeue(); msgs != nil { + t.Fatalf("expected nil, got %v", msgs) + } +} + +func TestSteeringQueue_SetMode(t *testing.T) { + sq := newSteeringQueue(SteeringOneAtATime) + if sq.getMode() != SteeringOneAtATime { + t.Fatalf("expected one-at-a-time, got %v", sq.getMode()) + } + + sq.setMode(SteeringAll) + if sq.getMode() != SteeringAll { + t.Fatalf("expected all, got %v", sq.getMode()) + } + + // Push two messages and verify all-mode drains them + sq.push(providers.Message{Role: "user", Content: "a"}) + sq.push(providers.Message{Role: "user", Content: "b"}) + + msgs := sq.dequeue() + if len(msgs) != 2 { + t.Fatalf("expected 2 messages after mode switch, got %d", len(msgs)) + } +} + +func TestSteeringQueue_ConcurrentAccess(t *testing.T) { + sq := newSteeringQueue(SteeringOneAtATime) + + var wg sync.WaitGroup + const n = MaxQueueSize + + // Push from multiple goroutines + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + sq.push(providers.Message{Role: "user", Content: fmt.Sprintf("msg%d", i)}) + }(i) + } + wg.Wait() + + if sq.len() != n { + t.Fatalf("expected %d messages, got %d", n, sq.len()) + } + + // Drain from multiple goroutines + var drained int + var mu sync.Mutex + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if msgs := sq.dequeue(); len(msgs) > 0 { + mu.Lock() + drained += len(msgs) + mu.Unlock() + } + }() + } + wg.Wait() + + if drained != n { + t.Fatalf("expected to drain %d messages, got %d", n, drained) + } +} + +func TestSteeringQueue_Overflow(t *testing.T) { + sq := newSteeringQueue(SteeringOneAtATime) + + // Fill the queue up to its maximum capacity + for i := 0; i < MaxQueueSize; i++ { + err := sq.push(providers.Message{Role: "user", Content: fmt.Sprintf("msg%d", i)}) + if err != nil { + t.Fatalf("unexpected error pushing message %d: %v", i, err) + } + } + + // Sanity check: ensure the queue is actually full + if sq.len() != MaxQueueSize { + t.Fatalf("expected queue length %d, got %d", MaxQueueSize, sq.len()) + } + + // Attempt to push one more message, which MUST fail + err := sq.push(providers.Message{Role: "user", Content: "overflow_msg"}) + + // Assert the error happened and is the exact one we expect + if err == nil { + t.Fatal("expected an error when pushing to a full queue, but got nil") + } + + expectedErr := "steering queue is full" + if err.Error() != expectedErr { + t.Errorf("expected error message %q, got %q", expectedErr, err.Error()) + } +} + +func TestParseSteeringMode(t *testing.T) { + tests := []struct { + input string + expected SteeringMode + }{ + {"", SteeringOneAtATime}, + {"one-at-a-time", SteeringOneAtATime}, + {"all", SteeringAll}, + {"unknown", SteeringOneAtATime}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := parseSteeringMode(tt.input); got != tt.expected { + t.Fatalf("parseSteeringMode(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} + +// --- AgentLoop steering integration tests --- + +func TestAgentLoop_Steer_Enqueues(t *testing.T) { + al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) + defer cleanup() + + if cfg == nil { + t.Fatal("expected config to be initialized") + } + if msgBus == nil { + t.Fatal("expected message bus to be initialized") + } + if provider == nil { + t.Fatal("expected provider to be initialized") + } + + al.Steer(providers.Message{Role: "user", Content: "interrupt me"}) + + if al.steering.len() != 1 { + t.Fatalf("expected 1 steering message, got %d", al.steering.len()) + } + + msgs := al.dequeueSteeringMessages() + if len(msgs) != 1 || msgs[0].Content != "interrupt me" { + t.Fatalf("unexpected dequeued message: %v", msgs) + } +} + +func TestAgentLoop_SteeringMode_GetSet(t *testing.T) { + al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) + defer cleanup() + + if cfg == nil { + t.Fatal("expected config to be initialized") + } + if msgBus == nil { + t.Fatal("expected message bus to be initialized") + } + if provider == nil { + t.Fatal("expected provider to be initialized") + } + + if al.SteeringMode() != SteeringOneAtATime { + t.Fatalf("expected default mode one-at-a-time, got %v", al.SteeringMode()) + } + + al.SetSteeringMode(SteeringAll) + if al.SteeringMode() != SteeringAll { + t.Fatalf("expected all mode, got %v", al.SteeringMode()) + } +} + +func TestAgentLoop_SteeringMode_ConfiguredFromConfig(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + SteeringMode: "all", + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + if al.SteeringMode() != SteeringAll { + t.Fatalf("expected 'all' mode from config, got %v", al.SteeringMode()) + } +} + +func TestAgentLoop_Continue_NoMessages(t *testing.T) { + al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) + defer cleanup() + + if cfg == nil { + t.Fatal("expected config to be initialized") + } + if msgBus == nil { + t.Fatal("expected message bus to be initialized") + } + if provider == nil { + t.Fatal("expected provider to be initialized") + } + + resp, err := al.Continue(context.Background(), "test-session", "test", "chat1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp != "" { + t.Fatalf("expected empty response for no steering messages, got %q", resp) + } +} + +func TestAgentLoop_Continue_WithMessages(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &simpleMockProvider{response: "continued response"} + al := NewAgentLoop(cfg, msgBus, provider) + + al.Steer(providers.Message{Role: "user", Content: "new direction"}) + + resp, err := al.Continue(context.Background(), "test-session", "test", "chat1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp != "continued response" { + t.Fatalf("expected 'continued response', got %q", resp) + } +} + +// slowTool simulates a tool that takes some time to execute. +type slowTool struct { + name string + duration time.Duration + execCh chan struct{} // closed when Execute starts +} + +func (t *slowTool) Name() string { return t.name } +func (t *slowTool) Description() string { return "slow tool for testing" } +func (t *slowTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + } +} + +func (t *slowTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + if t.execCh != nil { + close(t.execCh) + } + time.Sleep(t.duration) + return tools.SilentResult(fmt.Sprintf("executed %s", t.name)) +} + +// toolCallProvider returns an LLM response with tool calls on the first call, +// then a direct response on subsequent calls. +type toolCallProvider struct { + mu sync.Mutex + calls int + toolCalls []providers.ToolCall + finalResp string +} + +func (m *toolCallProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls++ + + if m.calls == 1 && len(m.toolCalls) > 0 { + return &providers.LLMResponse{ + Content: "", + ToolCalls: m.toolCalls, + }, nil + } + + return &providers.LLMResponse{ + Content: m.finalResp, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *toolCallProvider) GetDefaultModel() string { + return "tool-call-mock" +} + +type gracefulCaptureProvider struct { + mu sync.Mutex + calls int + toolCalls []providers.ToolCall + finalResp string + terminalMessages []providers.Message + terminalToolsCount int +} + +func (p *gracefulCaptureProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.calls++ + + if p.calls == 1 { + return &providers.LLMResponse{ + ToolCalls: p.toolCalls, + }, nil + } + + p.terminalMessages = append([]providers.Message(nil), messages...) + p.terminalToolsCount = len(tools) + return &providers.LLMResponse{ + Content: p.finalResp, + }, nil +} + +func (p *gracefulCaptureProvider) GetDefaultModel() string { + return "graceful-capture-mock" +} + +type lateSteeringProvider struct { + mu sync.Mutex + calls int + firstCallStarted chan struct{} + releaseFirstCall chan struct{} + firstStartOnce sync.Once + secondCallMessages []providers.Message +} + +func (p *lateSteeringProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + p.calls++ + call := p.calls + p.mu.Unlock() + + if call == 1 { + p.firstStartOnce.Do(func() { close(p.firstCallStarted) }) + <-p.releaseFirstCall + return &providers.LLMResponse{Content: "first response"}, nil + } + + p.mu.Lock() + p.secondCallMessages = append([]providers.Message(nil), messages...) + p.mu.Unlock() + return &providers.LLMResponse{Content: "continued response"}, nil +} + +func (p *lateSteeringProvider) GetDefaultModel() string { + return "late-steering-mock" +} + +type blockingDirectProvider struct { + mu sync.Mutex + calls int + firstStarted chan struct{} + releaseFirst chan struct{} + firstResp string + finalResp string +} + +func (p *blockingDirectProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + p.calls++ + call := p.calls + firstStarted := p.firstStarted + releaseFirst := p.releaseFirst + firstResp := p.firstResp + finalResp := p.finalResp + if call == 1 && p.firstStarted != nil { + close(p.firstStarted) + p.firstStarted = nil + } + p.mu.Unlock() + + if call == 1 { + select { + case <-releaseFirst: + case <-ctx.Done(): + return nil, ctx.Err() + } + return &providers.LLMResponse{Content: firstResp}, nil + } + + _ = firstStarted + return &providers.LLMResponse{Content: finalResp}, nil +} + +func (p *blockingDirectProvider) GetDefaultModel() string { + return "blocking-direct-mock" +} + +type interruptibleTool struct { + name string + started chan struct{} + once sync.Once +} + +func (t *interruptibleTool) Name() string { return t.name } +func (t *interruptibleTool) Description() string { return "interruptible tool for testing" } +func (t *interruptibleTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + } +} + +func (t *interruptibleTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + if t.started != nil { + t.once.Do(func() { close(t.started) }) + } + <-ctx.Done() + return tools.ErrorResult(ctx.Err().Error()).WithError(ctx.Err()) +} + +func TestAgentLoop_Steering_SkipsRemainingTools(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + tool1ExecCh := make(chan struct{}) + tool1 := &slowTool{name: "tool_one", duration: 50 * time.Millisecond, execCh: tool1ExecCh} + tool2 := &slowTool{name: "tool_two", duration: 50 * time.Millisecond} + + provider := &toolCallProvider{ + toolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Name: "tool_one", + Function: &providers.FunctionCall{ + Name: "tool_one", + Arguments: "{}", + }, + Arguments: map[string]any{}, + }, + { + ID: "call_2", + Type: "function", + Name: "tool_two", + Function: &providers.FunctionCall{ + Name: "tool_two", + Arguments: "{}", + }, + Arguments: map[string]any{}, + }, + }, + finalResp: "steered response", + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + al.RegisterTool(tool1) + al.RegisterTool(tool2) + + // Start processing in a goroutine + type result struct { + resp string + err error + } + resultCh := make(chan result, 1) + + go func() { + resp, err := al.ProcessDirectWithChannel( + context.Background(), + "do something", + "test-session", + "test", + "chat1", + ) + resultCh <- result{resp, err} + }() + + // Wait for tool_one to start executing, then enqueue a steering message + select { + case <-tool1ExecCh: + // tool_one has started executing + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for tool_one to start") + } + + al.Steer(providers.Message{Role: "user", Content: "change course"}) + + // Get the result + select { + case r := <-resultCh: + if r.err != nil { + t.Fatalf("unexpected error: %v", r.err) + } + if r.resp != "steered response" { + t.Fatalf("expected 'steered response', got %q", r.resp) + } + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for agent loop to complete") + } + + // The provider should have been called twice: + // 1. first call returned tool calls + // 2. second call (after steering) returned the final response + provider.mu.Lock() + calls := provider.calls + provider.mu.Unlock() + if calls != 2 { + t.Fatalf("expected 2 provider calls, got %d", calls) + } +} + +func TestAgentLoop_Steering_InitialPoll(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + // Provider that captures messages it receives + var capturedMessages []providers.Message + var capMu sync.Mutex + provider := &capturingMockProvider{ + response: "ack", + captureFn: func(msgs []providers.Message) { + capMu.Lock() + capturedMessages = make([]providers.Message, len(msgs)) + copy(capturedMessages, msgs) + capMu.Unlock() + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + + // Enqueue a steering message before processing starts + al.Steer(providers.Message{Role: "user", Content: "pre-enqueued steering"}) + + // Process a normal message - the initial steering poll should inject the steering message + _, err = al.ProcessDirectWithChannel( + context.Background(), + "initial message", + "test-session", + "test", + "chat1", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // The steering message should have been injected into the conversation + capMu.Lock() + msgs := capturedMessages + capMu.Unlock() + + // Look for the steering message in the captured messages + found := false + for _, m := range msgs { + if m.Content == "pre-enqueued steering" { + found = true + break + } + } + if !found { + t.Fatal("expected steering message to be injected into conversation context") + } +} + +func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &lateSteeringProvider{ + firstCallStarted: make(chan struct{}), + releaseFirstCall: make(chan struct{}), + } + al := NewAgentLoop(cfg, msgBus, provider) + + runCtx, cancelRun := context.WithCancel(context.Background()) + defer cancelRun() + + runErrCh := make(chan error, 1) + go func() { + runErrCh <- al.Run(runCtx) + }() + + first := bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Content: "first message", + } + late := bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Content: "late append", + } + + pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer pubCancel() + if err := msgBus.PublishInbound(pubCtx, first); err != nil { + t.Fatalf("publish first inbound: %v", err) + } + + select { + case <-provider.firstCallStarted: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for first provider call to start") + } + + if err := msgBus.PublishInbound(pubCtx, late); err != nil { + t.Fatalf("publish late inbound: %v", err) + } + + close(provider.releaseFirstCall) + + subCtx, subCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer subCancel() + + var out1 bus.OutboundMessage + select { + case out1 = <-msgBus.OutboundChan(): + case <-subCtx.Done(): + t.Fatal("expected outbound response") + } + if out1.Content != "continued response" { + t.Fatalf("expected continued response, got %q", out1.Content) + } + + noExtraCtx, cancelNoExtra := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancelNoExtra() + select { + case out2 := <-msgBus.OutboundChan(): + t.Fatalf("expected stale direct response to be suppressed, got extra outbound %q", out2.Content) + case <-noExtraCtx.Done(): + } + + cancelRun() + select { + case err := <-runErrCh: + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for Run to stop") + } + + provider.mu.Lock() + calls := provider.calls + secondMessages := append([]providers.Message(nil), provider.secondCallMessages...) + provider.mu.Unlock() + + if calls != 2 { + t.Fatalf("expected 2 provider calls, got %d", calls) + } + + foundLateMessage := false + for _, msg := range secondMessages { + if msg.Role == "user" && msg.Content == "late append" { + foundLateMessage = true + break + } + } + if !foundLateMessage { + t.Fatal("expected queued late message to be processed in an automatic follow-up turn") + } +} + +func TestAgentLoop_Run_PendingStopStillContinuesQueuedFollowUp(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + MaxParallelTurns: 1, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &lateSteeringProvider{ + firstCallStarted: make(chan struct{}), + releaseFirstCall: make(chan struct{}), + } + al := NewAgentLoop(cfg, msgBus, provider) + + runCtx, cancelRun := context.WithCancel(context.Background()) + defer cancelRun() + + runErrCh := make(chan error, 1) + go func() { + runErrCh <- al.Run(runCtx) + }() + defer func() { + cancelRun() + select { + case err := <-runErrCh: + if err != nil { + t.Fatalf("Run() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for Run to stop") + } + }() + + blockerSessionKey := session.BuildOpaqueSessionKey("agent:main:test:blocker") + targetSessionKey := session.BuildOpaqueSessionKey("agent:main:test:target") + blockerCtx := bus.InboundContext{ + Channel: "test", + ChatID: "blocker-chat", + ChatType: "direct", + SenderID: "user1", + } + targetCtx := bus.InboundContext{ + Channel: "test", + ChatID: "target-chat", + ChatType: "direct", + SenderID: "user1", + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: blockerCtx, + Content: "block worker pool", + SessionKey: blockerSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(blocker) error = %v", err) + } + + select { + case <-provider.firstCallStarted: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for blocker turn to start") + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: targetCtx, + Content: "skip this turn", + SessionKey: targetSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(target start) error = %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for { + ts := al.getActiveTurnState(targetSessionKey) + if ts != nil && strings.HasPrefix(ts.turnID, pendingTurnPrefix) { + break + } + if time.Now().After(deadline) { + t.Fatal("timeout waiting for pending placeholder") + } + time.Sleep(10 * time.Millisecond) + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: targetCtx, + Content: "/stop", + SessionKey: targetSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(/stop) error = %v", err) + } + + deadline = time.Now().Add(2 * time.Second) + stopSeen := false + for !stopSeen { + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.ChatID == "target-chat" && outbound.Content == "Task stopped. Current task was canceled." { + stopSeen = true + } + case <-time.After(10 * time.Millisecond): + if time.Now().After(deadline) { + t.Fatal("timeout waiting for /stop reply") + } + } + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: targetCtx, + Content: "run this instead", + SessionKey: targetSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(follow-up) error = %v", err) + } + + deadline = time.Now().Add(2 * time.Second) + for al.pendingSteeringCountForScope(targetSessionKey) == 0 { + if time.Now().After(deadline) { + t.Fatal("timeout waiting for follow-up to enter scoped steering queue") + } + time.Sleep(10 * time.Millisecond) + } + + close(provider.releaseFirstCall) + + deadline = time.Now().Add(5 * time.Second) + followUpSeen := false + for !followUpSeen { + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.ChatID == "target-chat" && outbound.Content == "continued response" { + followUpSeen = true + } + case <-time.After(10 * time.Millisecond): + if time.Now().After(deadline) { + t.Fatal("timeout waiting for queued follow-up continuation") + } + } + } + + deadline = time.Now().Add(2 * time.Second) + for { + if al.GetActiveTurnBySession(targetSessionKey) == nil && + al.pendingSteeringCountForScope(targetSessionKey) == 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("timeout waiting for target session to go idle") + } + time.Sleep(10 * time.Millisecond) + } + + provider.mu.Lock() + calls := provider.calls + secondMessages := append([]providers.Message(nil), provider.secondCallMessages...) + provider.mu.Unlock() + + if calls != 2 { + t.Fatalf("expected 2 provider calls (blocker + continuation), got %d", calls) + } + + foundFollowUp := false + for _, msg := range secondMessages { + if msg.Role == "user" && msg.Content == "run this instead" { + foundFollowUp = true + } + if msg.Role == "user" && msg.Content == "skip this turn" { + t.Fatalf("unexpected canceled message in continuation context: %q", msg.Content) + } + } + if !foundFollowUp { + t.Fatal("expected queued follow-up to be processed after pending stop") + } +} + +func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) + provider := &blockingDirectProvider{ + firstStarted: make(chan struct{}), + releaseFirst: make(chan struct{}), + firstResp: "stale direct response", + finalResp: "fresh response after steering", + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + + resultCh := make(chan struct { + resp string + err error + }, 1) + go func() { + resp, err := al.ProcessDirectWithChannel( + context.Background(), + "initial request", + sessionKey, + "test", + "chat1", + ) + resultCh <- struct { + resp string + err error + }{resp: resp, err: err} + }() + + select { + case <-provider.firstStarted: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for first LLM call to start") + } + + if err := al.Steer(providers.Message{Role: "user", Content: "follow-up instruction"}); err != nil { + t.Fatalf("Steer failed: %v", err) + } + close(provider.releaseFirst) + + select { + case result := <-resultCh: + if result.err != nil { + t.Fatalf("unexpected error: %v", result.err) + } + if result.resp != "fresh response after steering" { + t.Fatalf("expected refreshed response, got %q", result.resp) + } + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for ProcessDirectWithChannel") + } + + provider.mu.Lock() + calls := provider.calls + provider.mu.Unlock() + if calls != 2 { + t.Fatalf("expected 2 provider calls, got %d", calls) + } + + if msgs := al.dequeueSteeringMessagesForScope(sessionKey); len(msgs) != 0 { + t.Fatalf("expected steering queue to be empty after continuation, got %v", msgs) + } +} + +func TestAgentLoop_AgentForSession_UsesStoredScopeMetadata(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + List: []config.AgentConfig{ + {ID: "sales", Default: true}, + {ID: "support"}, + }, + }, + } + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + support, ok := al.registry.GetAgent("support") + if !ok || support == nil { + t.Fatal("expected support agent") + } + + metaStore, ok := support.Sessions.(session.MetadataAwareSessionStore) + if !ok { + t.Fatal("support session store does not support metadata") + } + + alias := "agent:support:slack:channel:c001" + key := session.BuildOpaqueSessionKey(alias) + scope := &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "support", + Channel: "slack", + Account: "default", + Dimensions: []string{"chat"}, + Values: map[string]string{ + "chat": "channel:c001", + }, + } + metaStore.EnsureSessionMetadata(key, scope, []string{alias}) + + got := al.agentForSession(key) + if got == nil { + t.Fatal("agentForSession() returned nil") + } + if got.ID != "support" { + t.Fatalf("agentForSession() = %q, want %q", got.ID, "support") + } +} + +func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + store := media.NewFileMediaStore() + pngPath := filepath.Join(tmpDir, "steer.png") + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, + 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, + 0x90, 0x77, 0x53, 0xDE, + } + if err = os.WriteFile(pngPath, pngHeader, 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + ref, err := store.Store(pngPath, media.MediaMeta{Filename: "steer.png", ContentType: "image/png"}, "test") + if err != nil { + t.Fatalf("Store failed: %v", err) + } + + var capturedMessages []providers.Message + var capMu sync.Mutex + provider := &capturingMockProvider{ + response: "ack", + captureFn: func(msgs []providers.Message) { + capMu.Lock() + defer capMu.Unlock() + capturedMessages = append([]providers.Message(nil), msgs...) + }, + } + + sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + al.SetMediaStore(store) + + if err = al.Steer(providers.Message{ + Role: "user", + Content: "describe this image", + Media: []string{ref}, + }); err != nil { + t.Fatalf("Steer failed: %v", err) + } + + resp, err := al.Continue(context.Background(), sessionKey, "test", "chat1") + if err != nil { + t.Fatalf("Continue failed: %v", err) + } + if resp != "ack" { + t.Fatalf("expected ack, got %q", resp) + } + + capMu.Lock() + msgs := append([]providers.Message(nil), capturedMessages...) + capMu.Unlock() + + foundResolvedMedia := false + for _, msg := range msgs { + if msg.Role != "user" || !strings.Contains(msg.Content, "describe this image") { + continue + } + if strings.Contains(msg.Content, "[image:") { + foundResolvedMedia = true + break + } + } + if !foundResolvedMedia { + t.Fatal("expected continue path to inject image path tag into the provider request") + } + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + history := defaultAgent.Sessions.GetHistory(sessionKey) + foundOriginalRef := false + for _, msg := range history { + if msg.Role == "user" && len(msg.Media) == 1 && msg.Media[0] == ref { + foundOriginalRef = true + break + } + } + if !foundOriginalRef { + t.Fatal("expected original steering media ref to be preserved in session history") + } +} + +func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + tool1ExecCh := make(chan struct{}) + tool1 := &slowTool{name: "tool_one", duration: 50 * time.Millisecond, execCh: tool1ExecCh} + tool2 := &slowTool{name: "tool_two", duration: 50 * time.Millisecond} + + provider := &gracefulCaptureProvider{ + toolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Name: "tool_one", + Function: &providers.FunctionCall{ + Name: "tool_one", + Arguments: "{}", + }, + Arguments: map[string]any{}, + }, + { + ID: "call_2", + Type: "function", + Name: "tool_two", + Function: &providers.FunctionCall{ + Name: "tool_two", + Arguments: "{}", + }, + Arguments: map[string]any{}, + }, + }, + finalResp: "graceful summary", + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + al.RegisterTool(tool1) + al.RegisterTool(tool2) + sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentInterruptReceived, + runtimeevents.KindAgentTurnEnd, + ) + defer closeRuntimeEvents() + + type result struct { + resp string + err error + } + resultCh := make(chan result, 1) + go func() { + resp, err := al.ProcessDirectWithChannel( + context.Background(), + "do something", + sessionKey, + "test", + "chat1", + ) + resultCh <- result{resp: resp, err: err} + }() + + select { + case <-tool1ExecCh: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for tool_one to start") + } + + active := al.GetActiveTurn() + if active == nil { + t.Fatal("expected active turn while tool is running") + } + if active.SessionKey != sessionKey { + t.Fatalf("expected active session %q, got %q", sessionKey, active.SessionKey) + } + if active.Channel != "test" || active.ChatID != "chat1" { + t.Fatalf("unexpected active turn target: %#v", active) + } + + if err := al.InterruptGraceful("wrap it up"); err != nil { + t.Fatalf("InterruptGraceful failed: %v", err) + } + + select { + case r := <-resultCh: + if r.err != nil { + t.Fatalf("unexpected error: %v", r.err) + } + if r.resp != "graceful summary" { + t.Fatalf("expected graceful summary, got %q", r.resp) + } + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for graceful interrupt result") + } + + if active := al.GetActiveTurn(); active != nil { + t.Fatalf("expected no active turn after completion, got %#v", active) + } + + provider.mu.Lock() + terminalMessages := append([]providers.Message(nil), provider.terminalMessages...) + terminalToolsCount := provider.terminalToolsCount + calls := provider.calls + provider.mu.Unlock() + + if calls != 2 { + t.Fatalf("expected 2 provider calls, got %d", calls) + } + if terminalToolsCount != 0 { + t.Fatalf("expected graceful terminal call to disable tools, got %d tool defs", terminalToolsCount) + } + + foundHint := false + foundSkipped := false + expectedHint := "Interrupt requested. Stop scheduling tools and provide a short final summary.\n\n" + + "Interrupt hint: wrap it up" + for _, msg := range terminalMessages { + if msg.Role == "user" && msg.Content == expectedHint { + foundHint = true + } + if msg.Role == "tool" && msg.ToolCallID == "call_2" && msg.Content == "Skipped due to graceful interrupt." { + foundSkipped = true + } + } + if !foundHint { + t.Fatal("expected graceful terminal call to include interrupt hint message") + } + if !foundSkipped { + t.Fatal("expected remaining tool to be marked as skipped after graceful interrupt") + } + + events := collectRuntimeEventStream(runtimeCh) + interruptEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentInterruptReceived) + if !ok { + t.Fatal("expected interrupt received event") + } + interruptPayload, ok := interruptEvt.Payload.(InterruptReceivedPayload) + if !ok { + t.Fatalf("expected InterruptReceivedPayload, got %T", interruptEvt.Payload) + } + if interruptPayload.Kind != InterruptKindGraceful { + t.Fatalf("expected graceful interrupt payload, got %q", interruptPayload.Kind) + } + + turnEndEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentTurnEnd) + if !ok { + t.Fatal("expected turn end event") + } + turnEndPayload, ok := turnEndEvt.Payload.(TurnEndPayload) + if !ok { + t.Fatalf("expected TurnEndPayload, got %T", turnEndEvt.Payload) + } + if turnEndPayload.Status != TurnEndStatusCompleted { + t.Fatalf("expected completed turn after graceful interrupt, got %q", turnEndPayload.Status) + } +} + +func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolCallProvider{ + toolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Name: "cancel_tool", + Function: &providers.FunctionCall{ + Name: "cancel_tool", + Arguments: "{}", + }, + Arguments: map[string]any{}, + }, + }, + finalResp: "should not happen", + } + + al := NewAgentLoop(cfg, msgBus, provider) + started := make(chan struct{}) + al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started}) + sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + originalHistory := []providers.Message{ + {Role: "user", Content: "before"}, + {Role: "assistant", Content: "after"}, + } + defaultAgent.Sessions.SetHistory(sessionKey, originalHistory) + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentInterruptReceived, + runtimeevents.KindAgentTurnEnd, + ) + defer closeRuntimeEvents() + + type result struct { + resp string + err error + } + resultCh := make(chan result, 1) + go func() { + resp, err := al.ProcessDirectWithChannel( + context.Background(), + "do work", + sessionKey, + "test", + "chat1", + ) + resultCh <- result{resp: resp, err: err} + }() + + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for interruptible tool to start") + } + + if active := al.GetActiveTurn(); active == nil { + t.Fatal("expected active turn before hard abort") + } + + if err := al.InterruptHard(); err != nil { + t.Fatalf("InterruptHard failed: %v", err) + } + + select { + case r := <-resultCh: + if r.err != nil { + t.Fatalf("unexpected error: %v", r.err) + } + if r.resp != "" { + t.Fatalf("expected no final response after hard abort, got %q", r.resp) + } + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for hard abort result") + } + + if active := al.GetActiveTurn(); active != nil { + t.Fatalf("expected no active turn after hard abort, got %#v", active) + } + + finalHistory := defaultAgent.Sessions.GetHistory(sessionKey) + if !reflect.DeepEqual(finalHistory, originalHistory) { + t.Fatalf("expected history rollback after hard abort, got %#v", finalHistory) + } + + events := collectRuntimeEventStream(runtimeCh) + interruptEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentInterruptReceived) + if !ok { + t.Fatal("expected interrupt received event") + } + interruptPayload, ok := interruptEvt.Payload.(InterruptReceivedPayload) + if !ok { + t.Fatalf("expected InterruptReceivedPayload, got %T", interruptEvt.Payload) + } + if interruptPayload.Kind != InterruptKindHard { + t.Fatalf("expected hard interrupt payload, got %q", interruptPayload.Kind) + } + + turnEndEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentTurnEnd) + if !ok { + t.Fatal("expected turn end event") + } + turnEndPayload, ok := turnEndEvt.Payload.(TurnEndPayload) + if !ok { + t.Fatalf("expected TurnEndPayload, got %T", turnEndEvt.Payload) + } + if turnEndPayload.Status != TurnEndStatusAborted { + t.Fatalf("expected aborted turn, got %q", turnEndPayload.Status) + } +} + +func TestAgentLoop_StopCommand_AbortsActiveTurnAndClearsQueuedSteering(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolCallProvider{ + toolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Name: "cancel_tool", + Function: &providers.FunctionCall{ + Name: "cancel_tool", + Arguments: "{}", + }, + Arguments: map[string]any{}, + }, + }, + finalResp: "should not continue", + } + + al := NewAgentLoop(cfg, msgBus, provider) + started := make(chan struct{}) + al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started}) + sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) + + runCtx, cancelRun := context.WithCancel(context.Background()) + defer cancelRun() + + runErrCh := make(chan error, 1) + go func() { + runErrCh <- al.Run(runCtx) + }() + defer func() { + cancelRun() + select { + case err := <-runErrCh: + if err != nil { + t.Fatalf("Run() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for Run to stop") + } + }() + + baseMsg := testInboundMessage(bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + SessionKey: sessionKey, + }) + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: baseMsg.Context, + Content: "do work", + SessionKey: sessionKey, + }); err != nil { + t.Fatalf("PublishInbound(start) error = %v", err) + } + + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for interruptible tool to start") + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: baseMsg.Context, + Content: "follow up after cancel", + SessionKey: sessionKey, + }); err != nil { + t.Fatalf("PublishInbound(follow-up) error = %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for al.pendingSteeringCountForScope(sessionKey) == 0 { + if time.Now().After(deadline) { + t.Fatal("timeout waiting for follow-up message to enter steering queue") + } + time.Sleep(10 * time.Millisecond) + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: baseMsg.Context, + Content: "/stop", + SessionKey: sessionKey, + }); err != nil { + t.Fatalf("PublishInbound(/stop) error = %v", err) + } + + select { + case outbound := <-msgBus.OutboundChan(): + want := "Task stopped. \"do work\" was canceled." + if outbound.Content != want { + t.Fatalf("stop reply = %q, want %q", outbound.Content, want) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for /stop reply") + } + + deadline = time.Now().Add(5 * time.Second) + for al.GetActiveTurnBySession(sessionKey) != nil { + if time.Now().After(deadline) { + t.Fatal("timeout waiting for active turn to stop") + } + time.Sleep(10 * time.Millisecond) + } + + if got := al.pendingSteeringCountForScope(sessionKey); got != 0 { + t.Fatalf("expected cleared steering queue, got %d pending message(s)", got) + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("unexpected outbound after stop: %q", outbound.Content) + case <-time.After(300 * time.Millisecond): + } + + provider.mu.Lock() + calls := provider.calls + provider.mu.Unlock() + if calls != 1 { + t.Fatalf("expected provider to stop before follow-up turn, got %d calls", calls) + } +} + +// capturingMockProvider captures messages sent to Chat for inspection. +type capturingMockProvider struct { + response string + calls int + captureFn func([]providers.Message) +} + +func (m *capturingMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.captureFn != nil { + m.captureFn(messages) + } + return &providers.LLMResponse{ + Content: m.response, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *capturingMockProvider) GetDefaultModel() string { + return "capturing-mock" +} + +func TestAgentLoop_Steering_SkippedToolsHaveErrorResults(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + execCh := make(chan struct{}) + tool1 := &slowTool{name: "slow_tool", duration: 50 * time.Millisecond, execCh: execCh} + tool2 := &slowTool{name: "skipped_tool", duration: 50 * time.Millisecond} + + // Provider that captures messages on the second call (after tools) + var secondCallMessages []providers.Message + var capMu sync.Mutex + callCount := 0 + + provider := &toolCallProvider{ + toolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Name: "slow_tool", + Function: &providers.FunctionCall{ + Name: "slow_tool", + Arguments: "{}", + }, + Arguments: map[string]any{}, + }, + { + ID: "call_2", + Type: "function", + Name: "skipped_tool", + Function: &providers.FunctionCall{ + Name: "skipped_tool", + Arguments: "{}", + }, + Arguments: map[string]any{}, + }, + }, + finalResp: "done", + } + + // Wrap provider to capture messages on second call + wrappedProvider := &wrappingProvider{ + inner: provider, + onChat: func(msgs []providers.Message) { + capMu.Lock() + callCount++ + if callCount >= 2 { + secondCallMessages = make([]providers.Message, len(msgs)) + copy(secondCallMessages, msgs) + } + capMu.Unlock() + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, wrappedProvider) + al.RegisterTool(tool1) + al.RegisterTool(tool2) + + resultCh := make(chan string, 1) + go func() { + resp, _ := al.ProcessDirectWithChannel( + context.Background(), "go", "test-session", "test", "chat1", + ) + resultCh <- resp + }() + + <-execCh + al.Steer(providers.Message{Role: "user", Content: "interrupt!"}) + + select { + case <-resultCh: + case <-time.After(5 * time.Second): + t.Fatal("timeout") + } + + // Check that the skipped tool result message is in the conversation + capMu.Lock() + msgs := secondCallMessages + capMu.Unlock() + + foundSkipped := false + for _, m := range msgs { + if m.Role == "tool" && m.ToolCallID == "call_2" && m.Content == "Skipped due to queued user message." { + foundSkipped = true + break + } + } + if !foundSkipped { + // Log what we actually got + for i, m := range msgs { + t.Logf("msg[%d]: role=%s toolCallID=%s content=%s", i, m.Role, m.ToolCallID, truncate(m.Content, 80)) + } + t.Fatal("expected skipped tool result for call_2") + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// wrappingProvider wraps another provider to hook into Chat calls. +type wrappingProvider struct { + inner providers.LLMProvider + onChat func([]providers.Message) +} + +func (w *wrappingProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + if w.onChat != nil { + w.onChat(messages) + } + return w.inner.Chat(ctx, messages, tools, model, opts) +} + +func (w *wrappingProvider) GetDefaultModel() string { + return w.inner.GetDefaultModel() +} + +// Ensure NormalizeToolCall handles our test tool calls. +func init() { + // This is a no-op init; we just need the tool call tests to work + // with the proper argument serialization. + _ = json.Marshal +} diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go new file mode 100644 index 000000000..86617d02f --- /dev/null +++ b/pkg/agent/subturn.go @@ -0,0 +1,704 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" + "github.com/sipeed/picoclaw/pkg/tools" +) + +// ====================== Config & Constants ====================== +const ( + // Default values for SubTurn configuration (used when config is not set or is zero) + defaultMaxSubTurnDepth = 3 + defaultMaxConcurrentSubTurns = 5 + defaultConcurrencyTimeout = 30 * time.Second + defaultSubTurnTimeout = 5 * time.Minute + // maxEphemeralHistorySize limits the number of messages stored in ephemeral sessions. + // This prevents memory accumulation in long-running sub-turns. + maxEphemeralHistorySize = 50 +) + +var ( + ErrDepthLimitExceeded = errors.New("sub-turn depth limit exceeded") + ErrInvalidSubTurnConfig = errors.New("invalid sub-turn config") + ErrConcurrencyTimeout = errors.New("timeout waiting for concurrency slot") +) + +// getSubTurnConfig returns the effective SubTurn configuration with defaults applied. +func (al *AgentLoop) getSubTurnConfig() subTurnRuntimeConfig { + cfg := al.cfg.Agents.Defaults.SubTurn + + maxDepth := cfg.MaxDepth + if maxDepth <= 0 { + maxDepth = defaultMaxSubTurnDepth + } + + maxConcurrent := cfg.MaxConcurrent + if maxConcurrent <= 0 { + maxConcurrent = defaultMaxConcurrentSubTurns + } + + concurrencyTimeout := time.Duration(cfg.ConcurrencyTimeoutSec) * time.Second + if concurrencyTimeout <= 0 { + concurrencyTimeout = defaultConcurrencyTimeout + } + + defaultTimeout := time.Duration(cfg.DefaultTimeoutMinutes) * time.Minute + if defaultTimeout <= 0 { + defaultTimeout = defaultSubTurnTimeout + } + + return subTurnRuntimeConfig{ + maxDepth: maxDepth, + maxConcurrent: maxConcurrent, + concurrencyTimeout: concurrencyTimeout, + defaultTimeout: defaultTimeout, + defaultTokenBudget: cfg.DefaultTokenBudget, + } +} + +// subTurnRuntimeConfig holds the effective runtime configuration for SubTurn execution. +type subTurnRuntimeConfig struct { + maxDepth int + maxConcurrent int + concurrencyTimeout time.Duration + defaultTimeout time.Duration + defaultTokenBudget int +} + +// ====================== SubTurn Config ====================== + +// SubTurnConfig configures the execution of a child sub-turn. +// +// Usage Examples: +// +// Synchronous sub-turn (Async=false): +// +// cfg := SubTurnConfig{ +// Model: "gpt-4o-mini", +// SystemPrompt: "Analyze this code", +// Async: false, // Result returned immediately +// } +// result, err := SpawnSubTurn(ctx, cfg) +// // Use result directly here +// processResult(result) +// +// Asynchronous sub-turn (Async=true): +// +// cfg := SubTurnConfig{ +// Model: "gpt-4o-mini", +// SystemPrompt: "Background analysis", +// Async: true, // Result delivered to channel +// } +// result, err := SpawnSubTurn(ctx, cfg) +// // Result also available in parent's pendingResults channel +// // Parent turn will poll and process it in a later iteration +type SubTurnConfig struct { + Model string + Tools []tools.Tool + SystemPrompt string + MaxTokens int + + // Async controls the result delivery mechanism: + // + // When Async = false (synchronous sub-turn): + // - The caller blocks until the sub-turn completes + // - The result is ONLY returned via the function return value + // - The result is NOT delivered to the parent's pendingResults channel + // - This prevents double delivery: caller gets result immediately, no need for channel + // - Use case: When the caller needs the result immediately to continue execution + // - Example: A tool that needs to process the sub-turn result before returning + // + // When Async = true (asynchronous sub-turn): + // - The sub-turn runs in the background (still blocks the caller, but semantically async) + // - The result is delivered to the parent's pendingResults channel + // - The result is ALSO returned via the function return value (for consistency) + // - The parent turn can poll pendingResults in later iterations to process results + // - Use case: Fire-and-forget operations, or when results are processed in batches + // - Example: Spawning multiple sub-turns in parallel and collecting results later + // + // IMPORTANT: The Async flag does NOT make the call non-blocking. It only controls + // whether the result is delivered via the channel. For true non-blocking execution, + // the caller must spawn the sub-turn in a separate goroutine. + Async bool + + // Critical indicates this SubTurn's result is important and should continue + // running even after the parent turn finishes gracefully. + // + // When parent finishes gracefully (Finish(false)): + // - Critical=true: SubTurn continues running, delivers result as orphan + // - Critical=false: SubTurn exits gracefully without error + // + // When parent finishes with hard abort (Finish(true)): + // - All SubTurns are canceled regardless of Critical flag + Critical bool + + // Timeout is the maximum duration for this SubTurn. + // If the SubTurn runs longer than this, it will be canceled. + // Default is 5 minutes (defaultSubTurnTimeout) if not specified. + Timeout time.Duration + + // MaxContextRunes limits the context size (in runes) passed to the SubTurn. + // This prevents context window overflow by truncating message history before LLM calls. + // + // Values: + // 0 = Auto-calculate based on model's ContextWindow * 0.75 (default, recommended) + // -1 = No limit (disable soft truncation, rely only on hard context errors) + // >0 = Use specified rune limit + // + // The soft limit acts as a first line of defense before hitting the provider's + // hard context window limit. When exceeded, older messages are intelligently + // truncated while preserving system messages and recent context. + MaxContextRunes int + + // ActualSystemPrompt is injected as the true 'system' role message for the childAgent. + // The legacy SystemPrompt field is actually used as the first 'user' message (task description). + ActualSystemPrompt string + + // InitialMessages preloads the ephemeral session history before the agent loop starts. + // Used by evaluator-optimizer patterns to pass the full worker context across multiple iterations. + InitialMessages []providers.Message + + // InitialTokenBudget is a shared atomic counter for tracking remaining tokens. + // If set, the SubTurn will inherit this budget and deduct tokens after each LLM call. + // If nil, the SubTurn will inherit the parent's tokenBudget (if any). + // Used by team tool to enforce token limits across all team members. + InitialTokenBudget *atomic.Int64 + + // TargetAgentID, when set, runs the sub-turn as the specified agent. + // The target agent's workspace, model, tools, and system prompt are used + // instead of the caller's. If empty, the sub-turn runs as the parent agent. + TargetAgentID string +} + +// ====================== Context Keys ====================== +type agentLoopKeyType struct{} + +var agentLoopKey = agentLoopKeyType{} + +// WithAgentLoop injects AgentLoop into context for tool access +func WithAgentLoop(ctx context.Context, al *AgentLoop) context.Context { + return context.WithValue(ctx, agentLoopKey, al) +} + +// AgentLoopFromContext retrieves AgentLoop from context +func AgentLoopFromContext(ctx context.Context) *AgentLoop { + al, _ := ctx.Value(agentLoopKey).(*AgentLoop) + return al +} + +// ====================== Helper Functions ====================== + +func (al *AgentLoop) generateSubTurnID() string { + return fmt.Sprintf("subturn-%d", al.subTurnCounter.Add(1)) +} + +// ====================== Core Function: spawnSubTurn ====================== + +// AgentLoopSpawner implements tools.SubTurnSpawner interface. +// This allows tools to spawn sub-turns without circular dependency. +type AgentLoopSpawner struct { + al *AgentLoop +} + +// SpawnSubTurn implements tools.SubTurnSpawner interface. +func (s *AgentLoopSpawner) SpawnSubTurn( + ctx context.Context, + cfg tools.SubTurnConfig, +) (*tools.ToolResult, error) { + parentTS := turnStateFromContext(ctx) + if parentTS == nil { + return nil, errors.New( + "parent turnState not found in context - cannot spawn sub-turn outside of a turn", + ) + } + + // Convert tools.SubTurnConfig to agent.SubTurnConfig + agentCfg := SubTurnConfig{ + Model: cfg.Model, + Tools: cfg.Tools, + SystemPrompt: cfg.SystemPrompt, + ActualSystemPrompt: cfg.ActualSystemPrompt, + InitialMessages: cfg.InitialMessages, + InitialTokenBudget: cfg.InitialTokenBudget, + MaxTokens: cfg.MaxTokens, + Async: cfg.Async, + Critical: cfg.Critical, + Timeout: cfg.Timeout, + MaxContextRunes: cfg.MaxContextRunes, + TargetAgentID: cfg.TargetAgentID, + } + + return spawnSubTurn(ctx, s.al, parentTS, agentCfg) +} + +// NewSubTurnSpawner creates a SubTurnSpawner for the given AgentLoop. +func NewSubTurnSpawner(al *AgentLoop) *AgentLoopSpawner { + return &AgentLoopSpawner{al: al} +} + +// SpawnSubTurn is the exported entry point for tools to spawn sub-turns. +// It retrieves AgentLoop and parent turnState from context and delegates to spawnSubTurn. +func SpawnSubTurn(ctx context.Context, cfg SubTurnConfig) (*tools.ToolResult, error) { + al := AgentLoopFromContext(ctx) + if al == nil { + return nil, errors.New( + "AgentLoop not found in context - ensure context is properly initialized", + ) + } + + parentTS := turnStateFromContext(ctx) + if parentTS == nil { + return nil, errors.New( + "parent turnState not found in context - cannot spawn sub-turn outside of a turn", + ) + } + + return spawnSubTurn(ctx, al, parentTS, cfg) +} + +func spawnSubTurn( + ctx context.Context, + al *AgentLoop, + parentTS *turnState, + cfg SubTurnConfig, +) (result *tools.ToolResult, err error) { + // Get effective SubTurn configuration + rtCfg := al.getSubTurnConfig() + + // 0. Acquire concurrency semaphore FIRST to ensure it's released even if early validation fails. + // Blocks if parent already has maxConcurrentSubTurns running, with a timeout to prevent indefinite blocking. + // Also respects context cancellation so we don't block forever if parent is aborted. + // NOTE: The semaphore is released immediately after runTurn completes (not in a defer) to + // ensure it is freed before the cleanup phase (async result delivery), which may block on + // a full pendingResults channel. Holding the semaphore through cleanup would allow the + // parent's goroutine to be blocked waiting for a semaphore slot while child turns are + // blocked delivering results — a deadlock. + var semAcquired bool + if parentTS.concurrencySem != nil { + // Create a timeout context for semaphore acquisition + timeoutCtx, cancel := context.WithTimeout(ctx, rtCfg.concurrencyTimeout) + defer cancel() + + select { + case parentTS.concurrencySem <- struct{}{}: + semAcquired = true + defer func() { + if semAcquired { + <-parentTS.concurrencySem + } + }() + case <-timeoutCtx.Done(): + // Check parent context first - if it was canceled, propagate that error + if ctx.Err() != nil { + return nil, ctx.Err() + } + // Otherwise it's our timeout + return nil, fmt.Errorf("%w: all %d slots occupied for %v", + ErrConcurrencyTimeout, rtCfg.maxConcurrent, rtCfg.concurrencyTimeout) + } + } + + // 1. Depth limit check + if parentTS.depth >= rtCfg.maxDepth { + logger.WarnCF("subturn", "Depth limit exceeded", map[string]any{ + "parent_id": parentTS.turnID, + "depth": parentTS.depth, + "max_depth": rtCfg.maxDepth, + }) + return nil, ErrDepthLimitExceeded + } + + // 2. Config validation: Model is required unless TargetAgentID is set + // (the target agent provides its own model). + if cfg.Model == "" && cfg.TargetAgentID == "" { + return nil, ErrInvalidSubTurnConfig + } + + // 3. Determine timeout for child SubTurn + timeout := cfg.Timeout + if timeout <= 0 { + timeout = rtCfg.defaultTimeout + } + + // 4. Create INDEPENDENT child context (not derived from parent ctx). + // This allows the child to continue running after parent finishes gracefully. + // The child has its own timeout for self-protection. + childCtx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + childID := al.generateSubTurnID() + + // Resolve the agent instance for the child turn. + // When TargetAgentID is set, look up that agent from the registry so the + // child runs with the target's workspace, model, tools, and system prompt. + // Otherwise fall back to the parent's agent (existing behavior). + var baseAgent *AgentInstance + if cfg.TargetAgentID != "" { + var ok bool + baseAgent, ok = al.registry.GetAgent(cfg.TargetAgentID) + if !ok { + return nil, fmt.Errorf("target agent %q not found in registry", cfg.TargetAgentID) + } + } else { + baseAgent = parentTS.agent + if baseAgent == nil { + baseAgent = al.registry.GetDefaultAgent() + } + } + if baseAgent == nil { + return nil, errors.New("parent turnState has no agent instance") + } + ephemeralStore := newEphemeralSession(nil) + agent := *baseAgent // shallow copy + agent.Sessions = ephemeralStore + // Clone the tool registry so child turn's tool registrations + // don't pollute the parent's registry. + if baseAgent.Tools != nil { + agent.Tools = baseAgent.Tools.Clone() + } + + // Create processOptions for the child turn + dispatch := DispatchRequest{ + SessionKey: childID, + UserMessage: cfg.SystemPrompt, + Media: nil, + InboundContext: cloneInboundContext(parentTS.opts.Dispatch.InboundContext), + } + opts := processOptions{ + Dispatch: dispatch, + SenderID: parentTS.opts.Dispatch.SenderID(), + SenderDisplayName: parentTS.opts.SenderDisplayName, + SystemPromptOverride: cfg.ActualSystemPrompt, + InitialSteeringMessages: cfg.InitialMessages, + DefaultResponse: "", + EnableSummary: false, + SendResponse: false, + NoHistory: true, // SubTurns don't use session history + SkipInitialSteeringPoll: true, + } + + // Create event scope for the child turn + scope := al.newTurnEventScope( + agent.ID, + childID, + newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope), + ) + + // Create child turnState using the new API + childTS := newTurnState(&agent, opts, scope) + + // Set SubTurn-specific fields + childTS.cancelFunc = cancel + childTS.critical = cfg.Critical + childTS.depth = parentTS.depth + 1 + childTS.parentTurnID = parentTS.turnID + childTS.parentTurnState = parentTS + childTS.pendingResults = make(chan *tools.ToolResult, 16) + childTS.concurrencySem = make(chan struct{}, rtCfg.maxConcurrent) + childTS.al = al // back-ref for hard abort cascade + childTS.session = ephemeralStore // same store as agent.Sessions + + // Token budget initialization/inheritance + // If InitialTokenBudget is explicitly provided (e.g., by team tool), use it. + // Otherwise, inherit from parent's tokenBudget (for nested SubTurns). + if cfg.InitialTokenBudget != nil { + childTS.tokenBudget = cfg.InitialTokenBudget + } else if parentTS.tokenBudget != nil { + childTS.tokenBudget = parentTS.tokenBudget + } else if rtCfg.defaultTokenBudget > 0 { + // Apply default token budget from config if no budget is set + budget := &atomic.Int64{} + budget.Store(int64(rtCfg.defaultTokenBudget)) + childTS.tokenBudget = budget + } + + // IMPORTANT: Put childTS into childCtx so that code inside runTurn can retrieve it + childCtx = withTurnState(childCtx, childTS) + childCtx = WithAgentLoop(childCtx, al) // Propagate AgentLoop to child turn + + childTS.ctx = childCtx + + // Register child turn state so GetAllActiveTurns/Subagents can find it + al.activeTurnStates.Store(childID, childTS) + defer al.activeTurnStates.Delete(childID) + + // 5. Establish parent-child relationship (thread-safe) + parentTS.mu.Lock() + parentTS.childTurnIDs = append(parentTS.childTurnIDs, childID) + parentTS.mu.Unlock() + + // 6. Emit Spawn event + al.emitEvent(runtimeevents.KindAgentSubTurnSpawn, + childTS.eventMeta("spawnSubTurn", "subturn.spawn"), + SubTurnSpawnPayload{ + AgentID: childTS.agentID, + Label: childID, + ParentTurnID: parentTS.turnID, + }, + ) + + // 7. Defer cleanup: deliver result (for async), emit End event, and recover from panics + defer func() { + if r := recover(); r != nil { + logger.RecoverPanicNoExit(r) + err = fmt.Errorf("subturn panicked: %v", r) + result = nil + logger.ErrorCF("subturn", "SubTurn panicked", map[string]any{ + "child_id": childID, + "parent_id": parentTS.turnID, + "panic": r, + }) + } + + // Result Delivery Strategy (Async vs Sync) + if cfg.Async { + deliverSubTurnResult(al, parentTS, childID, result) + } + + status := "completed" + if err != nil { + status = "error" + } + al.emitEvent(runtimeevents.KindAgentSubTurnEnd, + childTS.eventMeta("spawnSubTurn", "subturn.end"), + SubTurnEndPayload{ + AgentID: childTS.agentID, + Status: status, + }, + ) + }() + + // 8. Execute sub-turn via the real agent loop. + pipeline := NewPipeline(al) + turnRes, turnErr := al.runTurn(childCtx, childTS, pipeline) + + // Release the concurrency semaphore immediately after runTurn completes, + // before the cleanup defer runs. This prevents a deadlock where: + // - All semaphore slots are held by sub-turns in their cleanup phase + // - Cleanup blocks on a full pendingResults channel + // - The parent goroutine is blocked waiting for a semaphore slot + // - The parent cannot consume pendingResults because it is blocked on the semaphore + if semAcquired { + <-parentTS.concurrencySem + semAcquired = false // prevent the defer from double-releasing + } + + // Convert turnResult to tools.ToolResult + if turnErr != nil { + err = turnErr + result = &tools.ToolResult{ + Err: turnErr, + ForLLM: fmt.Sprintf("SubTurn failed: %v", turnErr), + } + } else { + result = &tools.ToolResult{ + ForLLM: turnRes.finalContent, + ForUser: turnRes.finalContent, + } + } + + return result, err +} + +// ====================== Result Delivery ====================== + +// deliverSubTurnResult delivers a sub-turn result to the parent turn's pendingResults channel. +// +// IMPORTANT: This function is ONLY called for asynchronous sub-turns (Async=true). +// For synchronous sub-turns (Async=false), results are returned directly via the function +// return value to avoid double delivery. +// +// Delivery behavior: +// - If parent turn is still running: attempts to deliver to pendingResults channel +// - If channel is full: emits agent.subturn.orphan (result is lost from channel but tracked) +// - If parent turn has finished: emits agent.subturn.orphan (late arrival) +// +// Thread safety: +// - Reads parent state under lock, then releases lock before channel send +// - Small race window exists but is acceptable (worst case: result becomes orphan) +// +// Event emissions: +// - agent.subturn.result_delivered: successful delivery to channel +// - agent.subturn.orphan: delivery failed (parent finished or channel full) +func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, result *tools.ToolResult) { + // Let GC clean up the pendingResults channel; parent Finish will no longer close it. + // We use defer/recover to catch any unlikely channel panics if it were ever closed. + defer func() { + if r := recover(); r != nil { + logger.RecoverPanicNoExit(r) + logger.WarnCF("subturn", "recovered panic sending to pendingResults", map[string]any{ + "parent_id": parentTS.turnID, + "child_id": childID, + "recover": r, + }) + if result != nil && al != nil { + al.emitEvent(runtimeevents.KindAgentSubTurnOrphan, + parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), + SubTurnOrphanPayload{ParentTurnID: parentTS.turnID, ChildTurnID: childID, Reason: "panic"}, + ) + } + } + }() + parentTS.mu.Lock() + isFinished := parentTS.isFinished.Load() + resultChan := parentTS.pendingResults + parentTS.mu.Unlock() + + // If parent turn has already finished, treat this as an orphan result + if isFinished || resultChan == nil { + if result != nil && al != nil { + al.emitEvent(runtimeevents.KindAgentSubTurnOrphan, + parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), + SubTurnOrphanPayload{ParentTurnID: parentTS.turnID, ChildTurnID: childID, Reason: "parent_finished"}, + ) + } + return + } + + // Parent Turn is still running → attempt to deliver result + // We use a select statement with parentTS.Finished() to ensure that if the + // parent turn finishes while we are waiting to send the result (e.g. channel + // is full), we don't leak this goroutine by blocking forever. + select { + case resultChan <- result: + // Successfully delivered + if al != nil { + al.emitEvent(runtimeevents.KindAgentSubTurnResultDelivered, + parentTS.eventMeta("deliverSubTurnResult", "subturn.result_delivered"), + SubTurnResultDeliveredPayload{ContentLen: len(result.ForLLM)}, + ) + } + case <-parentTS.Finished(): + // Parent finished while we were waiting to deliver. + // The result cannot be delivered to the LLM, so it becomes an orphan. + logger.WarnCF("subturn", "parent finished before result could be delivered", map[string]any{ + "parent_id": parentTS.turnID, + "child_id": childID, + }) + if result != nil && al != nil { + al.emitEvent( + runtimeevents.KindAgentSubTurnOrphan, + parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), + SubTurnOrphanPayload{ + ParentTurnID: parentTS.turnID, + ChildTurnID: childID, + Reason: "parent_finished_waiting", + }, + ) + } + } +} + +// ====================== Other Types ====================== + +// ephemeralSessionStore is an in-memory session.SessionStore used by SubTurns. +// It does not persist to disk and auto-truncates history to maxEphemeralHistorySize. +type ephemeralSessionStore struct { + mu sync.Mutex + history []providers.Message + summary string +} + +func newEphemeralSession(initial []providers.Message) ephemeralSessionStoreIface { + s := &ephemeralSessionStore{} + if len(initial) > 0 { + s.history = append(s.history, initial...) + } + return s +} + +// ephemeralSessionStoreIface is satisfied by *ephemeralSessionStore. +// Declared so newEphemeralSession can return a typed interface. +type ephemeralSessionStoreIface interface { + AddMessage(sessionKey, role, content string) + AddFullMessage(sessionKey string, msg providers.Message) + GetHistory(key string) []providers.Message + GetSummary(key string) string + SetSummary(key, summary string) + SetHistory(key string, history []providers.Message) + TruncateHistory(key string, keepLast int) + Save(key string) error + ListSessions() []string + Close() error +} + +func (e *ephemeralSessionStore) AddMessage(_, role, content string) { + e.mu.Lock() + defer e.mu.Unlock() + e.history = append(e.history, providers.Message{Role: role, Content: content}) + e.truncateLocked() +} + +func (e *ephemeralSessionStore) AddFullMessage(_ string, msg providers.Message) { + if messageutil.IsTransientAssistantThoughtMessage(msg) { + return + } + + e.mu.Lock() + defer e.mu.Unlock() + e.history = append(e.history, msg) + e.truncateLocked() +} + +func (e *ephemeralSessionStore) GetHistory(_ string) []providers.Message { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]providers.Message, len(e.history)) + copy(out, e.history) + return out +} + +func (e *ephemeralSessionStore) GetSummary(_ string) string { + e.mu.Lock() + defer e.mu.Unlock() + return e.summary +} + +func (e *ephemeralSessionStore) SetSummary(_, summary string) { + e.mu.Lock() + defer e.mu.Unlock() + e.summary = summary +} + +func (e *ephemeralSessionStore) SetHistory(_ string, history []providers.Message) { + e.mu.Lock() + defer e.mu.Unlock() + history = messageutil.FilterInvalidHistoryMessages(history) + e.history = make([]providers.Message, len(history)) + copy(e.history, history) + e.truncateLocked() +} + +func (e *ephemeralSessionStore) TruncateHistory(_ string, keepLast int) { + e.mu.Lock() + defer e.mu.Unlock() + if keepLast <= 0 { + e.history = nil + return + } + + if keepLast >= len(e.history) { + return + } + e.history = e.history[len(e.history)-keepLast:] +} + +func (e *ephemeralSessionStore) Save(_ string) error { return nil } +func (e *ephemeralSessionStore) Close() error { return nil } +func (e *ephemeralSessionStore) ListSessions() []string { return nil } + +func (e *ephemeralSessionStore) truncateLocked() { + if len(e.history) > maxEphemeralHistorySize { + e.history = e.history[len(e.history)-maxEphemeralHistorySize:] + } +} diff --git a/pkg/agent/subturn_test.go b/pkg/agent/subturn_test.go new file mode 100644 index 000000000..e9f557c82 --- /dev/null +++ b/pkg/agent/subturn_test.go @@ -0,0 +1,2330 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" +) + +// Test constants (use defaults from subturn.go) +const ( + testMaxConcurrentSubTurns = defaultMaxConcurrentSubTurns +) + +// ====================== Test Helper: Event Collector ====================== +type eventCollector struct { + mu sync.Mutex + events []runtimeevents.Event +} + +func newEventCollector(t *testing.T, al *AgentLoop) (*eventCollector, func()) { + t.Helper() + c := &eventCollector{} + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentSubTurnSpawn, + runtimeevents.KindAgentSubTurnEnd, + runtimeevents.KindAgentSubTurnResultDelivered, + runtimeevents.KindAgentSubTurnOrphan, + ) + done := make(chan struct{}) + go func() { + defer close(done) + for evt := range runtimeCh { + c.mu.Lock() + c.events = append(c.events, evt) + c.mu.Unlock() + } + }() + cleanup := func() { + closeRuntimeEvents() + <-done + } + return c, cleanup +} + +func (c *eventCollector) hasEventOfKind(kind runtimeevents.Kind) bool { + c.mu.Lock() + defer c.mu.Unlock() + for _, e := range c.events { + if e.Kind == kind { + return true + } + } + return false +} + +// ====================== Main Test Function ====================== +func TestSpawnSubTurn(t *testing.T) { + tests := []struct { + name string + parentDepth int + config SubTurnConfig + wantErr error + wantSpawn bool + wantEnd bool + wantDepthFail bool + }{ + { + name: "Basic success path - Single layer sub-turn", + parentDepth: 0, + config: SubTurnConfig{ + Model: "gpt-4o-mini", + Tools: []tools.Tool{}, // At least one tool + }, + wantErr: nil, + wantSpawn: true, + wantEnd: true, + }, + { + name: "Nested 2 layers - Normal", + parentDepth: 1, + config: SubTurnConfig{ + Model: "gpt-4o-mini", + Tools: []tools.Tool{}, + }, + wantErr: nil, + wantSpawn: true, + wantEnd: true, + }, + { + name: "Depth limit triggered - 4th layer fails", + parentDepth: 3, + config: SubTurnConfig{ + Model: "gpt-4o-mini", + Tools: []tools.Tool{}, + }, + wantErr: ErrDepthLimitExceeded, + wantSpawn: false, + wantEnd: false, + wantDepthFail: true, + }, + { + name: "Invalid config - Empty Model", + parentDepth: 0, + config: SubTurnConfig{ + Model: "", + Tools: []tools.Tool{}, + }, + wantErr: ErrInvalidSubTurnConfig, + wantSpawn: false, + wantEnd: false, + }, + } + + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Prepare parent Turn + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-1", + depth: tt.parentDepth, + childTurnIDs: []string{}, + pendingResults: make(chan *tools.ToolResult, 10), + session: &ephemeralSessionStore{}, + agent: al.registry.GetDefaultAgent(), + } + + // Subscribe to runtime events to capture sub-turn lifecycle. + collector, collectCleanup := newEventCollector(t, al) + defer collectCleanup() + + // Execute spawnSubTurn + result, err := spawnSubTurn(context.Background(), al, parent, tt.config) + + // Assert errors + if tt.wantErr != nil { + if err == nil || err != tt.wantErr { + t.Errorf("expected error %v, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Verify result + if result == nil { + t.Error("expected non-nil result") + } + + // Verify event emission + time.Sleep(10 * time.Millisecond) // let event goroutine flush + if tt.wantSpawn { + if !collector.hasEventOfKind(runtimeevents.KindAgentSubTurnSpawn) { + t.Error("SubTurnSpawnEvent not emitted") + } + } + if tt.wantEnd { + if !collector.hasEventOfKind(runtimeevents.KindAgentSubTurnEnd) { + t.Error("SubTurnEndEvent not emitted") + } + } + + // Verify turn tree + if len(parent.childTurnIDs) == 0 && !tt.wantDepthFail { + t.Error("child Turn not added to parent.childTurnIDs") + } + + // For synchronous calls (Async=false, the default), result is returned directly + // and should NOT be in pendingResults. The result was already verified above. + // Only async calls (Async=true) would place results in pendingResults. + }) + } +} + +// ====================== Extra Independent Test: Ephemeral Session Isolation ====================== +func TestSpawnSubTurn_EphemeralSessionIsolation(t *testing.T) { + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + // Parent uses its own ephemeral store pre-seeded with one message + parentSession := &ephemeralSessionStore{} + parentSession.AddMessage("", "user", "parent msg") + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-1", + depth: 0, + pendingResults: make(chan *tools.ToolResult, 4), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + session: parentSession, + } + + cfg := SubTurnConfig{Model: "gpt-4o-mini", Tools: []tools.Tool{}} + + originalParentLen := len(parentSession.GetHistory("")) + + _, _ = spawnSubTurn(context.Background(), al, parent, cfg) + + // Parent session must be untouched — child used its own store + if got := len(parentSession.GetHistory("")); got != originalParentLen { + t.Errorf("parent session polluted: expected %d messages, got %d", originalParentLen, got) + } + + // The child's agent.Sessions must NOT be the same pointer as the parent's session. + // We verify this indirectly: spawnSubTurn stores childTS in activeTurnStates during + // execution (deleted on return), so we can't easily grab childTS after the call. + // Instead, confirm that the child session is a distinct ephemeralSessionStore by + // checking the parent session key is only used by the parent store. + // If isolation is correct, parent.session.GetHistory(childID) is always empty + // (the child never wrote to the parent store). + al.activeTurnStates.Range(func(k, v any) bool { + // No active turns should remain after spawnSubTurn returns + t.Errorf("unexpected active turn state left after spawnSubTurn: key=%v", k) + return true + }) +} + +// ====================== Extra Independent Test: Result Delivery Path (Async) ====================== +func TestSpawnSubTurn_ResultDelivery(t *testing.T) { + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-1", + depth: 0, + pendingResults: make(chan *tools.ToolResult, 1), + session: &ephemeralSessionStore{}, + } + + // Set Async=true to test async result delivery via pendingResults channel + cfg := SubTurnConfig{Model: "gpt-4o-mini", Tools: []tools.Tool{}, Async: true} + + _, _ = spawnSubTurn(context.Background(), al, parent, cfg) + + // Check if pendingResults received the result (only for async calls) + select { + case res := <-parent.pendingResults: + if res == nil { + t.Error("received nil result in pendingResults") + } + default: + t.Error("result did not enter pendingResults for async call") + } +} + +// ====================== Extra Independent Test: Result Delivery Path (Sync) ====================== +func TestSpawnSubTurn_ResultDeliverySync(t *testing.T) { + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-sync-1", + depth: 0, + pendingResults: make(chan *tools.ToolResult, 1), + session: &ephemeralSessionStore{}, + } + + // Sync call (Async=false, the default) - result should be returned directly + cfg := SubTurnConfig{Model: "gpt-4o-mini", Tools: []tools.Tool{}, Async: false} + + result, err := spawnSubTurn(context.Background(), al, parent, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Result should be returned directly + if result == nil { + t.Error("expected non-nil result from sync call") + } + + // pendingResults should NOT contain the result (no double delivery) + select { + case <-parent.pendingResults: + t.Error("sync call should not place result in pendingResults (double delivery)") + default: + // Expected - channel should be empty + } +} + +// ====================== Extra Independent Test: Orphan Result Routing ====================== +func TestSpawnSubTurn_OrphanResultRouting(t *testing.T) { + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + collector, collectCleanup := newEventCollector(t, al) + defer collectCleanup() + + parentCtx, cancelParent := context.WithCancel(context.Background()) + parent := &turnState{ + ctx: parentCtx, + cancelFunc: cancelParent, + turnID: "parent-1", + depth: 0, + pendingResults: make(chan *tools.ToolResult, 1), + session: &ephemeralSessionStore{}, + } + + // Simulate parent finishing before child delivers result + parent.Finish(false) + + // Call deliverSubTurnResult directly to simulate a delayed child + deliverSubTurnResult(al, parent, "delayed-child", &tools.ToolResult{ForLLM: "late result"}) + + time.Sleep(10 * time.Millisecond) // let event goroutine flush + // Verify Orphan event is emitted + if !collector.hasEventOfKind(runtimeevents.KindAgentSubTurnOrphan) { + t.Error("agent.subturn.orphan not emitted for finished parent") + } + + // Verify history is NOT polluted + if len(parent.session.GetHistory("")) != 0 { + t.Error("Parent history was polluted by orphan result") + } +} + +// ====================== Extra Independent Test: Result Channel Registration ====================== +func TestSubTurnResultChannelRegistration(t *testing.T) { + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-reg-1", + depth: 0, + pendingResults: make(chan *tools.ToolResult, 4), + session: &ephemeralSessionStore{}, + } + + cfg := SubTurnConfig{Model: "gpt-4o-mini", Tools: []tools.Tool{}} + + // Before spawn: channel should not be registered + if results := al.dequeuePendingSubTurnResults(parent.turnID); results != nil { + t.Error("expected no channel before spawnSubTurn") + } + + _, _ = spawnSubTurn(context.Background(), al, parent, cfg) +} + +// ====================== Extra Independent Test: Dequeue Pending SubTurn Results ====================== +func TestDequeuePendingSubTurnResults(t *testing.T) { + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + sessionKey := "test-session-dequeue" + + // Empty (no turnState registered) returns nil + if results := al.dequeuePendingSubTurnResults(sessionKey); len(results) != 0 { + t.Errorf("expected empty results, got %d", len(results)) + } + + // Register a turnState so dequeuePendingSubTurnResults can find it + ts := &turnState{ + ctx: context.Background(), + turnID: sessionKey, + depth: 0, + session: &ephemeralSessionStore{}, + pendingResults: make(chan *tools.ToolResult, 4), + } + al.activeTurnStates.Store(sessionKey, ts) + defer al.activeTurnStates.Delete(sessionKey) + + // Put 3 results in + ts.pendingResults <- &tools.ToolResult{ForLLM: "result-1"} + ts.pendingResults <- &tools.ToolResult{ForLLM: "result-2"} + ts.pendingResults <- &tools.ToolResult{ForLLM: "result-3"} + + results := al.dequeuePendingSubTurnResults(sessionKey) + if len(results) != 3 { + t.Errorf("expected 3 results, got %d", len(results)) + } + if results[0].ForLLM != "result-1" || results[2].ForLLM != "result-3" { + t.Error("results order or content mismatch") + } + + // Channel should be drained now + if results := al.dequeuePendingSubTurnResults(sessionKey); len(results) != 0 { + t.Errorf("expected empty after drain, got %d", len(results)) + } + + // After removing from activeTurnStates, returns nil + al.activeTurnStates.Delete(sessionKey) + if results := al.dequeuePendingSubTurnResults(sessionKey); results != nil { + t.Error("expected nil for unregistered session") + } +} + +// ====================== Extra Independent Test: Concurrency Semaphore ====================== +func TestSubTurnConcurrencySemaphore(t *testing.T) { + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-concurrency", + depth: 0, + pendingResults: make(chan *tools.ToolResult, 10), + session: &ephemeralSessionStore{}, + concurrencySem: make(chan struct{}, 2), // Only allow 2 concurrent children + } + + cfg := SubTurnConfig{Model: "gpt-4o-mini", Tools: []tools.Tool{}} + + // Spawn 2 children — should succeed immediately + done := make(chan bool, 3) + for i := 0; i < 2; i++ { + go func() { + _, _ = spawnSubTurn(context.Background(), al, parent, cfg) + done <- true + }() + } + + // Wait a bit to ensure the first 2 are running + // (In real scenario they'd be blocked in runTurn, but mockProvider returns immediately) + // So we just verify the semaphore doesn't block when under limit + <-done + <-done + + // Verify semaphore is now full (2/2 slots used, but they already released) + // Since mockProvider returns immediately, semaphore is already released + // So we can't easily test blocking without a real long-running operation + + // Instead, verify that semaphore exists and has correct capacity + if cap(parent.concurrencySem) != 2 { + t.Errorf("expected semaphore capacity 2, got %d", cap(parent.concurrencySem)) + } +} + +// ====================== Extra Independent Test: Hard Abort Cascading ====================== +func TestHardAbortCascading(t *testing.T) { + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + sessionKey := "test-session-abort" + + // Root turn with its own independent context (not derived from child) + rootCtx, rootCancel := context.WithCancel(context.Background()) + rootTS := &turnState{ + ctx: rootCtx, + cancelFunc: rootCancel, + turnID: sessionKey, + depth: 0, + session: &ephemeralSessionStore{}, + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, 5), + al: al, + } + al.activeTurnStates.Store(sessionKey, rootTS) + defer al.activeTurnStates.Delete(sessionKey) + + // Child turn with an INDEPENDENT context (simulates spawnSubTurn behavior: + // context.WithTimeout(context.Background(), ...) — NOT derived from parent). + // Cascade must therefore happen via childTurnIDs traversal, not Go context tree. + childCtx, childCancel := context.WithCancel(context.Background()) + childID := "child-independent" + childTS := &turnState{ + ctx: childCtx, + cancelFunc: childCancel, + turnID: childID, + pendingResults: make(chan *tools.ToolResult, 4), + al: al, + } + al.activeTurnStates.Store(childID, childTS) + defer al.activeTurnStates.Delete(childID) + + // Wire child into root's childTurnIDs (as spawnSubTurn would do) + rootTS.childTurnIDs = append(rootTS.childTurnIDs, childID) + + // Verify neither context is canceled yet + select { + case <-rootTS.ctx.Done(): + t.Fatal("root context should not be canceled yet") + default: + } + select { + case <-childTS.ctx.Done(): + t.Fatal("child context should not be canceled yet (independent context)") + default: + } + + // Trigger Hard Abort via al.HardAbort (goes through steering.go → Finish(true)) + err := al.HardAbort(sessionKey) + if err != nil { + t.Fatalf("HardAbort failed: %v", err) + } + + // Root context must be canceled + select { + case <-rootTS.ctx.Done(): + default: + t.Error("root context should be canceled after HardAbort") + } + + // Child context must be canceled via childTurnIDs cascade, NOT via Go context tree + select { + case <-childTS.ctx.Done(): + default: + t.Error("child context should be canceled via childTurnIDs cascade") + } + + // HardAbort on non-existent session should return an error + if err := al.HardAbort("non-existent-session"); err == nil { + t.Error("expected error for non-existent session") + } +} + +// TestHardAbortSessionRollback verifies that HardAbort rolls back session history +// to the state before the turn started, discarding all messages added during the turn. +func TestHardAbortSessionRollback(t *testing.T) { + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + // Create a session with initial history + sess := &ephemeralSessionStore{ + history: []providers.Message{ + {Role: "user", Content: "initial message 1"}, + {Role: "assistant", Content: "initial response 1"}, + }, + } + + // Create a root turnState with initialHistoryLength = 2 + rootTS := &turnState{ + ctx: context.Background(), + turnID: "test-session", + depth: 0, + session: sess, + initialHistoryLength: 2, // Snapshot: 2 messages + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, 5), + } + + // Register the turn state + al.activeTurnStates.Store("test-session", rootTS) + + // Simulate adding messages during the turn (e.g., user input + assistant response) + sess.AddMessage("", "user", "new user message") + sess.AddMessage("", "assistant", "new assistant response") + + // Verify history grew to 4 messages + if len(sess.GetHistory("")) != 4 { + t.Fatalf("expected 4 messages before abort, got %d", len(sess.GetHistory(""))) + } + + // Trigger HardAbort + err := al.HardAbort("test-session") + if err != nil { + t.Fatalf("HardAbort failed: %v", err) + } + + // Verify history rolled back to initial 2 messages + finalHistory := sess.GetHistory("") + if len(finalHistory) != 2 { + t.Errorf("expected history to rollback to 2 messages, got %d", len(finalHistory)) + } + + // Verify the content matches the initial state + if finalHistory[0].Content != "initial message 1" || finalHistory[1].Content != "initial response 1" { + t.Error("history content does not match initial state after rollback") + } +} + +// TestNestedSubTurnHierarchy verifies that nested SubTurns maintain correct +// parent-child relationships and depth tracking when recursively calling runAgentLoop. +func TestNestedSubTurnHierarchy(t *testing.T) { + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + // Track spawned turns and their depths + type turnInfo struct { + parentID string + childID string + } + var spawnedTurns []turnInfo + var mu sync.Mutex + + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentSubTurnSpawn, + ) + defer closeRuntimeEvents() + go func() { + for evt := range runtimeCh { + if evt.Kind == runtimeevents.KindAgentSubTurnSpawn { + p, _ := evt.Payload.(SubTurnSpawnPayload) + mu.Lock() + spawnedTurns = append(spawnedTurns, turnInfo{ + parentID: p.ParentTurnID, + childID: p.Label, + }) + mu.Unlock() + } + } + }() + + // Create a root turn + rootSession := &ephemeralSessionStore{} + rootTS := &turnState{ + ctx: context.Background(), + turnID: "root-turn", + depth: 0, + session: rootSession, + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, 5), + } + + // Spawn a child (depth 1) + childCfg := SubTurnConfig{Model: "gpt-4o-mini"} + _, err := spawnSubTurn(context.Background(), al, rootTS, childCfg) + if err != nil { + t.Fatalf("failed to spawn child: %v", err) + } + + time.Sleep(10 * time.Millisecond) // let event goroutine flush + + // Verify we captured the spawn event + mu.Lock() + if len(spawnedTurns) != 1 { + t.Fatalf("expected 1 spawn event, got %d", len(spawnedTurns)) + } + if spawnedTurns[0].parentID != "root-turn" { + t.Errorf("expected parent ID 'root-turn', got %s", spawnedTurns[0].parentID) + } + mu.Unlock() + + // Verify root turn has the child in its childTurnIDs + rootTS.mu.Lock() + if len(rootTS.childTurnIDs) != 1 { + t.Errorf("expected root to have 1 child, got %d", len(rootTS.childTurnIDs)) + } + rootTS.mu.Unlock() +} + +// TestDeliverSubTurnResultNoDeadlock verifies that deliverSubTurnResult doesn't +// deadlock when multiple goroutines are accessing the parent turnState concurrently. +func TestDeliverSubTurnResultNoDeadlock(t *testing.T) { + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-deadlock-test", + depth: 0, + pendingResults: make(chan *tools.ToolResult, 2), // Small buffer to test blocking + } + + // Simulate multiple child turns delivering results concurrently + var wg sync.WaitGroup + numChildren := 10 + + for i := 0; i < numChildren; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + result := &tools.ToolResult{ForLLM: fmt.Sprintf("result-%d", id)} + deliverSubTurnResult(nil, parent, fmt.Sprintf("child-%d", id), result) + }(i) + } + + // Concurrently read from the channel to prevent blocking + // and to actually retrieve the matched number of results + go func() { + for i := 0; i < numChildren; i++ { + select { + case <-parent.pendingResults: + case <-time.After(5 * time.Second): + t.Error("timeout waiting for result") + return + } + } + }() + + // Wait for all deliveries to complete (with timeout) + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + // Success - no deadlock + case <-time.After(3 * time.Second): + t.Fatal("deadlock detected: deliverSubTurnResult blocked") + } +} + +// TestHardAbortOrderOfOperations verifies that HardAbort calls Finish() before +// rolling back session history, minimizing the race window where new messages +// could be added after rollback. +func TestHardAbortOrderOfOperations(t *testing.T) { + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + sess := &ephemeralSessionStore{ + history: []providers.Message{ + {Role: "user", Content: "initial message"}, + {Role: "assistant", Content: "response 1"}, + {Role: "user", Content: "follow-up"}, + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + rootTS := &turnState{ + ctx: ctx, + cancelFunc: cancel, + turnID: "test-session-order", + depth: 0, + session: sess, + initialHistoryLength: 1, // Snapshot: 1 message + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, 5), + } + + al.activeTurnStates.Store("test-session-order", rootTS) + + // Trigger HardAbort + err := al.HardAbort("test-session-order") + if err != nil { + t.Fatalf("HardAbort failed: %v", err) + } + + // Verify context was canceled (Finish() was called) + select { + case <-rootTS.ctx.Done(): + // Good - context was canceled + default: + t.Error("expected context to be canceled after HardAbort") + } + + // Verify history was rolled back + finalHistory := sess.GetHistory("") + if len(finalHistory) != 1 { + t.Errorf("expected history to rollback to 1 message, got %d", len(finalHistory)) + } + + if finalHistory[0].Content != "initial message" { + t.Error("history content does not match initial state after rollback") + } +} + +// TestFinishedChannelClosedState verifies that Finish() closes the Finished() channel +// so that child turns can safely abort waiting. +func TestFinishedChannelClosedState(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ts := &turnState{ + ctx: ctx, + cancelFunc: cancel, + turnID: "test-finished-channel", + depth: 0, + pendingResults: make(chan *tools.ToolResult, 2), + } + + // Verify Finished channel is blocking initially + select { + case <-ts.Finished(): + t.Fatal("finished channel should block initially") + default: + // Good + } + + // Call Finish() with graceful finish + ts.Finish(false) + + // Verify Finished channel is closed + select { + case _, ok := <-ts.Finished(): + if ok { + t.Error("expected Finished() channel to be closed after Finish()") + } + default: + t.Fatal("expected <-ts.Finished() to not block") + } + + // Verify Finish() is idempotent + ts.Finish(false) // Should not panic + + // Verify deliverSubTurnResult correctly uses Finished() channel and treats as orphan + result := &tools.ToolResult{ForLLM: "late result"} + deliverSubTurnResult(nil, ts, "child-1", result) // Will emit orphan due to <-ts.Finished() case +} + +// TestFinalPollCapturesLateResults verifies that the final poll before Finish() +// captures results that arrive after the last iteration poll. +func TestFinalPollCapturesLateResults(t *testing.T) { + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + sessionKey := "test-session-final-poll" + + // Register a turnState + ts := &turnState{ + ctx: context.Background(), + turnID: sessionKey, + depth: 0, + session: &ephemeralSessionStore{}, + pendingResults: make(chan *tools.ToolResult, 4), + } + al.activeTurnStates.Store(sessionKey, ts) + defer al.activeTurnStates.Delete(sessionKey) + + // Simulate results arriving after last iteration poll + ts.pendingResults <- &tools.ToolResult{ForLLM: "result 1"} + ts.pendingResults <- &tools.ToolResult{ForLLM: "result 2"} + + // Dequeue should capture both results + results := al.dequeuePendingSubTurnResults(sessionKey) + + if len(results) != 2 { + t.Errorf("expected 2 results, got %d", len(results)) + } + + // Verify channel is now empty + results = al.dequeuePendingSubTurnResults(sessionKey) + if len(results) != 0 { + t.Errorf("expected 0 results on second poll, got %d", len(results)) + } +} + +// TestSpawnSubTurn_PanicRecovery verifies that even if runTurn panics, +// the result is still delivered for async calls and SubTurnEndEvent is emitted. +func TestSpawnSubTurn_PanicRecovery(t *testing.T) { + // Create a panic provider + panicProvider := &panicMockProvider{} + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + al := NewAgentLoop(cfg, bus.NewMessageBus(), panicProvider) + + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-panic", + depth: 0, + pendingResults: make(chan *tools.ToolResult, 1), + session: &ephemeralSessionStore{}, + } + + collector, collectCleanup := newEventCollector(t, al) + defer collectCleanup() + + // Test async call - result should still be delivered via channel + asyncCfg := SubTurnConfig{Model: "gpt-4o-mini", Tools: []tools.Tool{}, Async: true} + result, err := spawnSubTurn(context.Background(), al, parent, asyncCfg) + + // Should return error from panic recovery + if err == nil { + t.Error("expected error from panic recovery") + } + + // Result should be nil because panic occurred before runTurn could return + if result != nil { + t.Error("expected nil result after panic") + } + + time.Sleep(10 * time.Millisecond) // let event goroutine flush + // SubTurnEndEvent should still be emitted + if !collector.hasEventOfKind(runtimeevents.KindAgentSubTurnEnd) { + t.Error("SubTurnEndEvent not emitted after panic") + } + + // For async call, result should still be delivered to channel (even if nil) + select { + case res := <-parent.pendingResults: + // Result was delivered (nil due to panic) + _ = res + default: + t.Error("async result should be delivered to channel even after panic") + } +} + +// panicMockProvider is a mock provider that always panics +type panicMockProvider struct{} + +func (m *panicMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + panic("intentional panic for testing") +} + +func (m *panicMockProvider) GetDefaultModel() string { + return "panic-model" +} + +// ====================== Public API Tests ====================== + +// simpleMockProviderAPI for testing public APIs +type simpleMockProviderAPI struct { + response string +} + +func (m *simpleMockProviderAPI) Chat( + ctx context.Context, + messages []providers.Message, + toolDefs []providers.ToolDefinition, + model string, + options map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{ + Content: m.response, + }, nil +} + +func (m *simpleMockProviderAPI) GetDefaultModel() string { + return "gpt-4o-mini" +} + +// TestGetActiveTurn verifies that GetActiveTurn returns correct turn information +func TestGetActiveTurn(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "gpt-4o-mini", + Provider: "mock", + }, + }, + } + al := NewAgentLoop(cfg, nil, &simpleMockProviderAPI{response: "ok"}) + + // Create a root turn state + rootCtx := context.Background() + rootTS := &turnState{ + ctx: rootCtx, + turnID: "root-turn", + parentTurnID: "", + depth: 0, + childTurnIDs: []string{}, + session: newEphemeralSession(nil), + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + } + + sessionKey := "test-session" + al.activeTurnStates.Store(sessionKey, rootTS) + defer al.activeTurnStates.Delete(sessionKey) + + // Test: GetActiveTurn should return turn info + info := al.GetActiveTurnBySession(sessionKey) + if info == nil { + t.Fatal("GetActiveTurn returned nil for active session") + } + + if info.TurnID != "root-turn" { + t.Errorf("Expected TurnID 'root-turn', got %q", info.TurnID) + } + + if info.Depth != 0 { + t.Errorf("Expected Depth 0, got %d", info.Depth) + } + + if info.ParentTurnID != "" { + t.Errorf("Expected empty ParentTurnID, got %q", info.ParentTurnID) + } + + if len(info.ChildTurnIDs) != 0 { + t.Errorf("Expected 0 child turns, got %d", len(info.ChildTurnIDs)) + } + + // Test: GetActiveTurn should return nil for non-existent session + nonExistentInfo := al.GetActiveTurnBySession("non-existent-session") + if nonExistentInfo != nil { + t.Error("GetActiveTurn should return nil for non-existent session") + } +} + +// TestGetActiveTurn_WithChildren verifies that child turn IDs are correctly reported +func TestGetActiveTurn_WithChildren(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "gpt-4o-mini", + Provider: "mock", + }, + }, + } + al := NewAgentLoop(cfg, nil, &simpleMockProviderAPI{response: "ok"}) + + rootCtx := context.Background() + rootTS := &turnState{ + ctx: rootCtx, + turnID: "root-turn", + parentTurnID: "", + depth: 0, + childTurnIDs: []string{"child-1", "child-2"}, + session: newEphemeralSession(nil), + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + } + + sessionKey := "test-session-with-children" + al.activeTurnStates.Store(sessionKey, rootTS) + defer al.activeTurnStates.Delete(sessionKey) + + info := al.GetActiveTurnBySession(sessionKey) + if info == nil { + t.Fatal("GetActiveTurn returned nil") + } + + if len(info.ChildTurnIDs) != 2 { + t.Fatalf("Expected 2 child turns, got %d", len(info.ChildTurnIDs)) + } + + if info.ChildTurnIDs[0] != "child-1" || info.ChildTurnIDs[1] != "child-2" { + t.Errorf("Child turn IDs mismatch: got %v", info.ChildTurnIDs) + } +} + +// TestTurnStateInfo_ThreadSafety verifies that Info() is thread-safe +func TestTurnStateInfo_ThreadSafety(t *testing.T) { + rootCtx := context.Background() + ts := &turnState{ + ctx: rootCtx, + turnID: "test-turn", + parentTurnID: "parent", + depth: 1, + childTurnIDs: []string{}, + session: newEphemeralSession(nil), + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + } + + // Concurrently read Info() and modify childTurnIDs + done := make(chan bool) + go func() { + for i := 0; i < 100; i++ { + ts.mu.Lock() + ts.childTurnIDs = append(ts.childTurnIDs, "child") + ts.mu.Unlock() + } + done <- true + }() + + go func() { + for i := 0; i < 100; i++ { + info := ts.snapshot() + if info.TurnID == "" { + t.Error("snapshot() returned empty TurnID") + } + } + done <- true + }() + + <-done + <-done +} + +// TestInjectFollowUp verifies that InjectFollowUp enqueues messages +func TestInjectFollowUp(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "gpt-4o-mini", + Provider: "mock", + }, + }, + } + + al := NewAgentLoop(cfg, nil, &simpleMockProviderAPI{response: "ok"}) + + msg := providers.Message{ + Role: "user", + Content: "Follow-up task", + } + + err := al.InjectFollowUp(msg) + if err != nil { + t.Fatalf("InjectFollowUp failed: %v", err) + } + + // Verify message was enqueued + if al.steering.len() != 1 { + t.Errorf("Expected 1 message in queue, got %d", al.steering.len()) + } +} + +// TestAPIAliases verifies that API aliases work correctly +func TestAPIAliases(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "gpt-4o-mini", + Provider: "mock", + }, + }, + } + + al := NewAgentLoop(cfg, nil, &simpleMockProviderAPI{response: "ok"}) + + msg := providers.Message{ + Role: "user", + Content: "Test message", + } + + // Test InterruptGraceful: requires active turn, so error is expected here + _ = al.InterruptGraceful(msg.Content) + + // Test InjectSteering (enqueues a steering message) + err := al.InjectSteering(msg) + if err != nil { + t.Errorf("InjectSteering failed: %v", err) + } + + // Also enqueue via Steer to verify second message + err = al.Steer(msg) + if err != nil { + t.Errorf("Steer failed: %v", err) + } + + // Verify both messages were enqueued + if al.steering.len() != 2 { + t.Errorf("Expected 2 messages in queue, got %d", al.steering.len()) + } +} + +// TestInterruptHard_Alias verifies that InterruptHard is an alias for HardAbort +func TestInterruptHard_Alias(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "gpt-4o-mini", + Provider: "mock", + }, + }, + } + al := NewAgentLoop(cfg, nil, &simpleMockProviderAPI{response: "ok"}) + + rootCtx := context.Background() + rootTS := &turnState{ + ctx: rootCtx, + turnID: "test-turn", + depth: 0, + session: newEphemeralSession(nil), + initialHistoryLength: 0, + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + } + + sessionKey := "test-session-interrupt" + al.activeTurnStates.Store(sessionKey, rootTS) + + // Test InterruptHard (alias for HardAbort) + err := al.InterruptHard() + if err != nil { + t.Errorf("InterruptHard failed: %v", err) + } + + // Verify turn was finished (removed from activeTurnStates) + info := al.GetActiveTurnBySession(sessionKey) + _ = info // turn may still be in map briefly; hard abort sets isFinished on the state +} + +// TestFinish_ConcurrentCalls verifies that calling Finish() concurrently from multiple +// goroutines is safe and doesn't cause panics or double-close errors. +func TestFinish_ConcurrentCalls(t *testing.T) { + ctx := context.Background() + parentTS := &turnState{ + ctx: ctx, + turnID: "parent-concurrent-finish", + depth: 0, + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + } + parentTS.ctx, parentTS.cancelFunc = context.WithCancel(ctx) + + // Launch multiple goroutines that all call Finish() concurrently + const numGoroutines = 10 + var wg sync.WaitGroup + wg.Add(numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func() { + defer wg.Done() + // This should not panic, even when called concurrently + parentTS.Finish(false) + }() + } + + wg.Wait() + + // Verify the Finished() channel is closed + select { + case _, ok := <-parentTS.Finished(): + if ok { + t.Error("Expected Finished() channel to be closed") + } + default: + t.Error("Expected Finished() channel to be closed and readable without blocking") + } + + // Verify isFinished is set + parentTS.mu.Lock() + if !parentTS.isFinished.Load() { + t.Error("Expected isFinished to be true") + } + parentTS.mu.Unlock() +} + +// TestDeliverSubTurnResult_RaceWithFinish verifies that deliverSubTurnResult handles +// the race condition where Finish() is called while results are being delivered. +func TestDeliverSubTurnResult_RaceWithFinish(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled + defer cleanup() + + var mu sync.Mutex + var deliveredCount, orphanCount int + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 64, + runtimeevents.KindAgentSubTurnResultDelivered, + runtimeevents.KindAgentSubTurnOrphan, + ) + defer closeRuntimeEvents() + go func() { + for evt := range runtimeCh { + mu.Lock() + switch evt.Kind { + case runtimeevents.KindAgentSubTurnResultDelivered: + deliveredCount++ + case runtimeevents.KindAgentSubTurnOrphan: + orphanCount++ + } + mu.Unlock() + } + }() + + ctx := context.Background() + parentTS := &turnState{ + ctx: ctx, + turnID: "parent-race-test", + depth: 0, + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + } + parentTS.ctx, parentTS.cancelFunc = context.WithCancel(ctx) + + // Launch goroutines that deliver results while another goroutine calls Finish() + const numResults = 20 + var wg sync.WaitGroup + wg.Add(numResults + 1) + + // Goroutine that calls Finish() after a short delay + go func() { + defer wg.Done() + time.Sleep(5 * time.Millisecond) + parentTS.Finish(false) + }() + + // Goroutines that deliver results + for i := 0; i < numResults; i++ { + go func(id int) { + defer wg.Done() + result := &tools.ToolResult{ + ForLLM: fmt.Sprintf("result-%d", id), + } + // This should not panic, even if Finish() is called concurrently + deliverSubTurnResult(al, parentTS, fmt.Sprintf("child-%d", id), result) + }(i) + } + + wg.Wait() + time.Sleep(20 * time.Millisecond) // let event goroutine flush + + // Get final counts + mu.Lock() + finalDelivered := deliveredCount + finalOrphan := orphanCount + mu.Unlock() + + t.Logf("Delivered: %d, Orphan: %d, Total: %d", finalDelivered, finalOrphan, finalDelivered+finalOrphan) + + // With the new drainPendingResults behavior, the total events may be >= numResults + // because Finish() drains remaining results from the channel and emits them as orphans. + // So we expect: + // - Some results were delivered successfully (before Finish()) + // - Some results became orphans (after Finish() or channel full) + // - Some results were in the channel when Finish() was called and got drained as orphans + // The total should be at least numResults (could be more due to drain) + if finalDelivered+finalOrphan < numResults { + t.Errorf("Expected at least %d total events, got %d delivered + %d orphan = %d", + numResults, finalDelivered, finalOrphan, finalDelivered+finalOrphan) + } + + // Should have at least some orphan results (those that arrived after Finish() or were drained) + if finalOrphan == 0 { + t.Error("Expected at least some orphan results after Finish()") + } +} + +// TestConcurrencySemaphore_Timeout verifies that spawning sub-turns times out +// when all concurrency slots are occupied for too long. +// Note: This test uses a shorter timeout by temporarily modifying the constant. +func TestConcurrencySemaphore_Timeout(t *testing.T) { + // This test would take 30 seconds with the default timeout. + // Instead, we'll test the mechanism by verifying the timeout context is created correctly. + // A full integration test with actual timeout would be too slow for unit tests. + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Provider: "mock", + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &simpleMockProviderAPI{} + al := NewAgentLoop(cfg, msgBus, provider) + + ctx := context.Background() + parentTS := &turnState{ + ctx: ctx, + turnID: "parent-timeout-test", + depth: 0, + session: newEphemeralSession(nil), + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + } + parentTS.ctx, parentTS.cancelFunc = context.WithCancel(ctx) + defer parentTS.Finish(false) + + // Fill all concurrency slots + for i := 0; i < testMaxConcurrentSubTurns; i++ { + parentTS.concurrencySem <- struct{}{} + } + + // Create a context with a very short timeout for testing + testCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + + // Now try to spawn a sub-turn with the short timeout context + subTurnCfg := SubTurnConfig{ + Model: "gpt-4o-mini", + Async: false, + } + + start := time.Now() + _, err := spawnSubTurn(testCtx, al, parentTS, subTurnCfg) + elapsed := time.Since(start) + + // Should get a timeout error (either from our timeout context or the internal one) + if err == nil { + t.Error("Expected timeout error, got nil") + } + + // The error should be related to context cancellation or timeout + if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, ErrConcurrencyTimeout) { + t.Logf("Got error: %v (type: %T)", err, err) + // This is acceptable - the error might be wrapped + } + + // Should timeout quickly (within a reasonable margin) + if elapsed > 2*time.Second { + t.Errorf("Timeout took too long: %v", elapsed) + } + + t.Logf("Timeout occurred after %v with error: %v", elapsed, err) + + // Clean up - drain the semaphore + for i := 0; i < testMaxConcurrentSubTurns; i++ { + <-parentTS.concurrencySem + } +} + +// TestEphemeralSession_AutoTruncate verifies that ephemeral sessions automatically +// truncate their history to prevent memory accumulation. +func TestEphemeralSession_AutoTruncate(t *testing.T) { + store := newEphemeralSession(nil).(*ephemeralSessionStore) + + // Add more messages than the limit + for i := 0; i < maxEphemeralHistorySize+20; i++ { + store.AddMessage("test", "user", fmt.Sprintf("message-%d", i)) + } + + // Verify history is truncated to the limit + history := store.GetHistory("test") + if len(history) != maxEphemeralHistorySize { + t.Errorf("Expected history length %d, got %d", maxEphemeralHistorySize, len(history)) + } + + // Verify we kept the most recent messages + lastMsg := history[len(history)-1] + expectedContent := fmt.Sprintf("message-%d", maxEphemeralHistorySize+20-1) + if lastMsg.Content != expectedContent { + t.Errorf("Expected last message to be %q, got %q", expectedContent, lastMsg.Content) + } + + // Verify the oldest messages were discarded + firstMsg := history[0] + expectedFirstContent := fmt.Sprintf("message-%d", 20) // First 20 were discarded + if firstMsg.Content != expectedFirstContent { + t.Errorf("Expected first message to be %q, got %q", expectedFirstContent, firstMsg.Content) + } +} + +// TestContextWrapping_SingleLayer verifies that we only create one context layer +// in spawnSubTurn, not multiple redundant layers. +func TestContextWrapping_SingleLayer(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Provider: "mock", + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &simpleMockProviderAPI{} + al := NewAgentLoop(cfg, msgBus, provider) + + ctx := context.Background() + parentTS := &turnState{ + ctx: ctx, + turnID: "parent-context-test", + depth: 0, + session: newEphemeralSession(nil), + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + } + parentTS.ctx, parentTS.cancelFunc = context.WithCancel(ctx) + defer parentTS.Finish(false) + + // Spawn a sub-turn + subTurnCfg := SubTurnConfig{ + Model: "gpt-4o-mini", + Async: false, + } + + result, err := spawnSubTurn(ctx, al, parentTS, subTurnCfg) + if err != nil { + t.Fatalf("spawnSubTurn failed: %v", err) + } + + if result == nil { + t.Error("Expected non-nil result") + } + + // Verify the child turn was created with a cancel function + // (This is implicit - if the test passes without hanging, the context management is correct) + t.Log("Context wrapping test passed - no redundant layers detected") +} + +// TestSyncSubTurn_NoChannelDelivery verifies that synchronous sub-turns +// do NOT deliver results to the pendingResults channel (only return directly). +func TestSyncSubTurn_NoChannelDelivery(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Provider: "mock", + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &simpleMockProviderAPI{} + al := NewAgentLoop(cfg, msgBus, provider) + + ctx := context.Background() + parentTS := &turnState{ + ctx: ctx, + turnID: "parent-sync-test", + depth: 0, + session: newEphemeralSession(nil), + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + } + parentTS.ctx, parentTS.cancelFunc = context.WithCancel(ctx) + defer parentTS.Finish(false) + + // Spawn a SYNCHRONOUS sub-turn (Async=false) + subTurnCfg := SubTurnConfig{ + Model: "gpt-4o-mini", + Async: false, // Synchronous - should NOT deliver to channel + } + + result, err := spawnSubTurn(ctx, al, parentTS, subTurnCfg) + if err != nil { + t.Fatalf("spawnSubTurn failed: %v", err) + } + + if result == nil { + t.Error("Expected non-nil result from synchronous sub-turn") + } + + // Verify the pendingResults channel is EMPTY + // (synchronous sub-turns should not deliver to channel) + select { + case r := <-parentTS.pendingResults: + t.Errorf("Expected empty channel for sync sub-turn, but got result: %v", r) + default: + // Expected: channel is empty + t.Log("Verified: synchronous sub-turn did not deliver to channel") + } + + // Verify channel length is 0 + if len(parentTS.pendingResults) != 0 { + t.Errorf("Expected channel length 0, got %d", len(parentTS.pendingResults)) + } +} + +// TestAsyncSubTurn_ChannelDelivery verifies that asynchronous sub-turns +// DO deliver results to the pendingResults channel. +func TestAsyncSubTurn_ChannelDelivery(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Provider: "mock", + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &simpleMockProviderAPI{} + al := NewAgentLoop(cfg, msgBus, provider) + + ctx := context.Background() + parentTS := &turnState{ + ctx: ctx, + turnID: "parent-async-test", + depth: 0, + session: newEphemeralSession(nil), + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + } + parentTS.ctx, parentTS.cancelFunc = context.WithCancel(ctx) + defer parentTS.Finish(false) + + // Spawn an ASYNCHRONOUS sub-turn (Async=true) + subTurnCfg := SubTurnConfig{ + Model: "gpt-4o-mini", + Async: true, // Asynchronous - SHOULD deliver to channel + } + + result, err := spawnSubTurn(ctx, al, parentTS, subTurnCfg) + if err != nil { + t.Fatalf("spawnSubTurn failed: %v", err) + } + + if result == nil { + t.Error("Expected non-nil result from asynchronous sub-turn") + } + + // Verify the pendingResults channel has the result + select { + case r := <-parentTS.pendingResults: + if r == nil { + t.Error("Expected non-nil result from channel") + } + t.Log("Verified: asynchronous sub-turn delivered to channel") + case <-time.After(100 * time.Millisecond): + t.Error("Expected result in channel for async sub-turn, but channel was empty") + } +} + +// TestGrandchildAbort_CascadingCancellation verifies that when a grandparent turn +// is hard aborted, the cancellation cascades down to grandchild turns. +func TestGrandchildAbort_CascadingCancellation(t *testing.T) { + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + // Three independent contexts — none derived from another. + // Cascade must happen exclusively through childTurnIDs traversal in Finish(true). + gpCtx, gpCancel := context.WithCancel(context.Background()) + parentCtx, parentCancel := context.WithCancel(context.Background()) + childCtx, childCancel := context.WithCancel(context.Background()) + + childTS := &turnState{ + ctx: childCtx, + cancelFunc: childCancel, + turnID: "grandchild", + al: al, + } + parentTS := &turnState{ + ctx: parentCtx, + cancelFunc: parentCancel, + turnID: "parent", + childTurnIDs: []string{"grandchild"}, + al: al, + } + grandparentTS := &turnState{ + ctx: gpCtx, + cancelFunc: gpCancel, + turnID: "grandparent", + depth: 0, + session: newEphemeralSession(nil), + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + childTurnIDs: []string{"parent"}, + al: al, + } + + al.activeTurnStates.Store("grandparent", grandparentTS) + al.activeTurnStates.Store("parent", parentTS) + al.activeTurnStates.Store("grandchild", childTS) + defer al.activeTurnStates.Delete("grandparent") + defer al.activeTurnStates.Delete("parent") + defer al.activeTurnStates.Delete("grandchild") + + // All contexts must be active before the abort + for _, ctx := range []context.Context{gpCtx, parentCtx, childCtx} { + select { + case <-ctx.Done(): + t.Fatal("context should not be canceled yet") + default: + } + } + + // Hard abort the grandparent — should cascade to parent and grandchild + grandparentTS.Finish(true) + + time.Sleep(10 * time.Millisecond) + + select { + case <-gpCtx.Done(): + t.Log("Grandparent context canceled (expected)") + default: + t.Error("Grandparent context should be canceled") + } + select { + case <-parentCtx.Done(): + t.Log("Parent context canceled via cascade (expected)") + default: + t.Error("Parent context should be canceled via childTurnIDs cascade") + } + select { + case <-childCtx.Done(): + t.Log("Grandchild context canceled via cascade (expected)") + default: + t.Error("Grandchild context should be canceled via childTurnIDs cascade") + } +} + +func TestNestedSubTurn_GracefulFinishSignalsDirectChildren(t *testing.T) { + parentCtx := context.Background() + parentTS := &turnState{ + ctx: parentCtx, + turnID: "parent-graceful", + depth: 1, + pendingResults: make(chan *tools.ToolResult, 16), + } + parentTS.ctx, parentTS.cancelFunc = context.WithCancel(parentCtx) + + childTS := &turnState{ + ctx: context.Background(), + turnID: "child-graceful", + depth: 2, + parentTurnState: parentTS, + pendingResults: make(chan *tools.ToolResult, 16), + } + + if childTS.IsParentEnded() { + t.Fatal("IsParentEnded should be false before parent finishes") + } + + parentTS.Finish(false) + + if !parentTS.parentEnded.Load() { + t.Fatal("parentEnded should be true after graceful finish") + } + if !childTS.IsParentEnded() { + t.Fatal("nested child should observe parent graceful finish") + } +} + +// TestSpawnDuringAbort_RaceCondition verifies behavior when trying to spawn +// a sub-turn while the parent is being aborted. +func TestSpawnDuringAbort_RaceCondition(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Provider: "mock", + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &simpleMockProviderAPI{} + al := NewAgentLoop(cfg, msgBus, provider) + + ctx := context.Background() + parentTS := &turnState{ + ctx: ctx, + turnID: "parent-abort-race", + depth: 0, + session: newEphemeralSession(nil), + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + } + parentTS.ctx, parentTS.cancelFunc = context.WithCancel(ctx) + + var wg sync.WaitGroup + wg.Add(2) + + var spawnErr error + + // Goroutine 1: Try to spawn a sub-turn + go func() { + defer wg.Done() + subTurnCfg := SubTurnConfig{ + Model: "gpt-4o-mini", + Async: false, + } + _, err := spawnSubTurn(parentTS.ctx, al, parentTS, subTurnCfg) + spawnErr = err + }() + + // Goroutine 2: Abort the parent almost immediately + go func() { + defer wg.Done() + time.Sleep(1 * time.Millisecond) + parentTS.Finish(false) + }() + + wg.Wait() + + // The spawn should either succeed (if it started before abort) + // or fail with context canceled error (if abort happened first) + if spawnErr != nil { + if errors.Is(spawnErr, context.Canceled) { + t.Logf("Spawn failed with expected context cancellation: %v", spawnErr) + } else { + t.Logf("Spawn failed with error: %v", spawnErr) + } + } else { + t.Log("Spawn succeeded before abort") + } + + // The important thing is that it doesn't panic or deadlock + t.Log("Race condition handled gracefully - no panic or deadlock") +} + +// ====================== Slow SubTurn Cancellation Test ====================== + +// slowMockProvider simulates a slow LLM call that takes a long time to complete. +// This is used to test the scenario where a parent turn finishes before the child SubTurn. +type slowMockProvider struct { + delay time.Duration +} + +func (m *slowMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + toolDefs []providers.ToolDefinition, + model string, + options map[string]any, +) (*providers.LLMResponse, error) { + select { + case <-time.After(m.delay): + // Completed normally after delay + return &providers.LLMResponse{ + Content: "slow response completed", + }, nil + case <-ctx.Done(): + // Context was canceled while waiting + return nil, ctx.Err() + } +} + +func (m *slowMockProvider) GetDefaultModel() string { + return "slow-model" +} + +// TestAsyncSubTurn_ParentFinishesEarly simulates the scenario where: +// 1. Parent spawns an async SubTurn that takes a long time +// 2. Parent finishes quickly +// 3. SubTurn should be canceled with context canceled error +func TestAsyncSubTurn_ParentFinishesEarly(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Provider: "mock", + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &slowMockProvider{delay: 5 * time.Second} // SubTurn takes 5 seconds + al := NewAgentLoop(cfg, msgBus, provider) + + var mu sync.Mutex + var events []runtimeevents.Event + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentSubTurnSpawn, + runtimeevents.KindAgentSubTurnEnd, + runtimeevents.KindAgentSubTurnResultDelivered, + runtimeevents.KindAgentSubTurnOrphan, + ) + defer closeRuntimeEvents() + go func() { + for evt := range runtimeCh { + mu.Lock() + events = append(events, evt) + mu.Unlock() + } + }() + + ctx := context.Background() + parentTS := &turnState{ + ctx: ctx, + turnID: "parent-fast", + depth: 0, + session: newEphemeralSession(nil), + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + } + parentTS.ctx, parentTS.cancelFunc = context.WithCancel(ctx) + + var subTurnErr error + var subTurnResult *tools.ToolResult + var wg sync.WaitGroup + + // Spawn async SubTurn in a goroutine (it will be slow) + wg.Add(1) + go func() { + defer wg.Done() + subTurnCfg := SubTurnConfig{ + Model: "slow-model", + Async: true, // Asynchronous SubTurn + } + subTurnResult, subTurnErr = spawnSubTurn(parentTS.ctx, al, parentTS, subTurnCfg) + }() + + // Parent finishes quickly (after 100ms), while SubTurn is still running + time.Sleep(100 * time.Millisecond) + t.Log("Parent finishing early...") + parentTS.Finish(false) + + // Wait for SubTurn to complete (or be canceled) + wg.Wait() + + // Check the result + t.Logf("SubTurn error: %v", subTurnErr) + t.Logf("SubTurn result: %v", subTurnResult) + + if subTurnErr != nil { + if errors.Is(subTurnErr, context.Canceled) { + t.Log("✓ SubTurn was canceled as expected (context canceled)") + } else { + t.Logf("SubTurn failed with other error: %v", subTurnErr) + } + } else { + t.Log("SubTurn completed before parent finished (unlikely but possible)") + } + + // Log captured events + mu.Lock() + t.Logf("Captured %d events:", len(events)) + for i, e := range events { + t.Logf(" Event %d: %s", i+1, e.Kind) + } + mu.Unlock() +} + +// TestAsyncSubTurn_ParentWaitsForChild simulates the scenario where: +// 1. Parent spawns an async SubTurn that takes some time +// 2. Parent WAITS for SubTurn to complete before finishing +// 3. Both should complete successfully +func TestAsyncSubTurn_ParentWaitsForChild(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Provider: "mock", + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &slowMockProvider{delay: 200 * time.Millisecond} // SubTurn takes 200ms + al := NewAgentLoop(cfg, msgBus, provider) + + ctx := context.Background() + parentTS := &turnState{ + ctx: ctx, + turnID: "parent-wait", + depth: 0, + session: newEphemeralSession(nil), + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + } + parentTS.ctx, parentTS.cancelFunc = context.WithCancel(ctx) + + var subTurnErr error + var subTurnResult *tools.ToolResult + var wg sync.WaitGroup + + // Spawn async SubTurn in a goroutine + wg.Add(1) + go func() { + defer wg.Done() + subTurnCfg := SubTurnConfig{ + Model: "slow-model", + Async: true, + } + subTurnResult, subTurnErr = spawnSubTurn(parentTS.ctx, al, parentTS, subTurnCfg) + }() + + // Parent WAITS for SubTurn to complete + t.Log("Parent waiting for SubTurn...") + wg.Wait() + t.Log("SubTurn completed, parent now finishing") + + // Now parent can finish safely + parentTS.Finish(false) + + // Check the result + if subTurnErr != nil { + if errors.Is(subTurnErr, context.Canceled) { + t.Errorf("SubTurn should NOT have been canceled: %v", subTurnErr) + } else { + t.Logf("SubTurn failed with error: %v", subTurnErr) + } + } else { + t.Log("✓ SubTurn completed successfully") + if subTurnResult != nil { + t.Logf("SubTurn result: %s", subTurnResult.ForLLM) + } + } + + // Check channel delivery + select { + case r := <-parentTS.pendingResults: + if r != nil { + t.Logf("✓ Result delivered to channel: %s", r.ForLLM) + } + case <-time.After(100 * time.Millisecond): + t.Log("No result in channel (expected since we waited)") + } +} + +// ====================== Graceful vs Hard Finish Tests ====================== + +// TestFinish_GracefulVsHard verifies the behavior difference between: +// - Finish(false): graceful finish, signals parentEnded but doesn't cancel children +// - Finish(true): hard abort, immediately cancels all children +func TestFinish_GracefulVsHard(t *testing.T) { + // Test 1: Graceful finish should set parentEnded but not cancel context + t.Run("Graceful_SetsParentEnded", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ts := &turnState{ + ctx: ctx, + turnID: "graceful-test", + depth: 0, + pendingResults: make(chan *tools.ToolResult, 16), + } + ts.ctx, ts.cancelFunc = context.WithCancel(ctx) + + // Finish gracefully + ts.Finish(false) + + // Verify parentEnded is set + if !ts.parentEnded.Load() { + t.Error("parentEnded should be true after graceful finish") + } + + // Verify context is NOT canceled (for graceful finish, children continue) + // Note: In graceful mode, we don't call cancelFunc() + // But since we're using WithCancel on the same ctx, it might be canceled + // Let's check that the context is still valid for a moment + time.Sleep(10 * time.Millisecond) + // Context might be canceled by the deferred cancel() in test, which is fine + }) + + // Test 2: Hard abort should cancel context immediately + t.Run("Hard_CancelsContext", func(t *testing.T) { + ctx := context.Background() + + ts := &turnState{ + ctx: ctx, + turnID: "hard-test", + depth: 0, + pendingResults: make(chan *tools.ToolResult, 16), + } + ts.ctx, ts.cancelFunc = context.WithCancel(ctx) + + // Finish with hard abort + ts.Finish(true) + + // Verify context is canceled + select { + case <-ts.ctx.Done(): + t.Log("✓ Context canceled after hard abort") + default: + t.Error("Context should be canceled after hard abort") + } + }) + + // Test 3: IsParentEnded returns correct value + t.Run("IsParentEnded", func(t *testing.T) { + ctx := context.Background() + + parentTS := &turnState{ + ctx: ctx, + turnID: "parent-isended-test", + depth: 0, + pendingResults: make(chan *tools.ToolResult, 16), + } + parentTS.ctx, parentTS.cancelFunc = context.WithCancel(ctx) + + childTS := &turnState{ + ctx: ctx, + turnID: "child-isended-test", + depth: 1, + parentTurnState: parentTS, + pendingResults: make(chan *tools.ToolResult, 16), + } + + // Before parent finishes + if childTS.IsParentEnded() { + t.Error("IsParentEnded should be false before parent finishes") + } + + // Finish parent gracefully + parentTS.Finish(false) + + // After parent finishes + if !childTS.IsParentEnded() { + t.Error("IsParentEnded should be true after parent finishes gracefully") + } + }) +} + +// TestSubTurn_IndependentContext verifies that SubTurns use independent contexts +// that don't get canceled when the parent finishes gracefully. +func TestSubTurn_IndependentContext(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Provider: "mock", + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &slowMockProvider{delay: 500 * time.Millisecond} + al := NewAgentLoop(cfg, msgBus, provider) + + ctx := context.Background() + parentTS := &turnState{ + ctx: ctx, + turnID: "parent-independent", + depth: 0, + session: newEphemeralSession(nil), + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + } + parentTS.ctx, parentTS.cancelFunc = context.WithCancel(ctx) + + var subTurnErr error + var wg sync.WaitGroup + + // Spawn SubTurn with Critical=true (should continue after parent finishes) + wg.Add(1) + go func() { + defer wg.Done() + subTurnCfg := SubTurnConfig{ + Model: "slow-model", + Async: true, + Critical: true, // Critical SubTurn should continue + } + _, subTurnErr = spawnSubTurn(parentTS.ctx, al, parentTS, subTurnCfg) + }() + + // Let SubTurn start + time.Sleep(50 * time.Millisecond) + + // Parent finishes gracefully (should NOT cancel SubTurn) + parentTS.Finish(false) + t.Log("Parent finished gracefully, SubTurn should continue") + + // Wait for SubTurn to complete + wg.Wait() + + // SubTurn should complete without context canceled error + // (because it uses independent context now) + if subTurnErr != nil { + t.Logf("SubTurn error: %v", subTurnErr) + // The error might be context.DeadlineExceeded if timeout is too short + // but should NOT be context.Canceled from parent + if errors.Is(subTurnErr, context.Canceled) { + t.Error("SubTurn should not be canceled by parent's graceful finish") + } + } else { + t.Log("✓ SubTurn completed successfully (independent context)") + } +} + +// ====================== TargetAgentID Tests ====================== + +// modelRecordingProvider captures the model passed to Chat for test assertions. +type modelRecordingProvider struct { + mu sync.Mutex + lastModel string +} + +func (rp *modelRecordingProvider) Chat( + _ context.Context, + _ []providers.Message, + _ []providers.ToolDefinition, + model string, + _ map[string]any, +) (*providers.LLMResponse, error) { + rp.mu.Lock() + rp.lastModel = model + rp.mu.Unlock() + return &providers.LLMResponse{Content: "Mock response"}, nil +} + +func (rp *modelRecordingProvider) GetDefaultModel() string { return "mock-model" } + +func (rp *modelRecordingProvider) getLastModel() string { + rp.mu.Lock() + defer rp.mu.Unlock() + return rp.lastModel +} + +// newMultiAgentLoop creates an AgentLoop with two named agents for testing +// cross-agent delegation via TargetAgentID. +func newMultiAgentLoop(t *testing.T, provider providers.LLMProvider) (*AgentLoop, func()) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "multiagent-test-*") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + + alphaDir := filepath.Join(tmpDir, "alpha") + betaDir := filepath.Join(tmpDir, "beta") + os.MkdirAll(alphaDir, 0o755) + os.MkdirAll(betaDir, 0o755) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "default-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + List: []config.AgentConfig{ + { + ID: "alpha", + Workspace: alphaDir, + Model: &config.AgentModelConfig{Primary: "model-alpha"}, + }, + { + ID: "beta", + Workspace: betaDir, + Model: &config.AgentModelConfig{Primary: "model-beta"}, + }, + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + + return al, func() { os.RemoveAll(tmpDir) } +} + +func TestSpawnSubTurn_TargetAgentID_UsesTargetAgent(t *testing.T) { + rp := &modelRecordingProvider{} + al, cleanup := newMultiAgentLoop(t, rp) + defer cleanup() + + alphaAgent, ok := al.registry.GetAgent("alpha") + if !ok { + t.Fatal("alpha agent not in registry") + } + + // Parent is alpha, target is beta + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-alpha", + depth: 0, + childTurnIDs: []string{}, + pendingResults: make(chan *tools.ToolResult, 4), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + session: &ephemeralSessionStore{}, + agent: alphaAgent, + } + + result, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{ + TargetAgentID: "beta", + SystemPrompt: "task for beta", + }) + if err != nil { + t.Fatalf("spawnSubTurn failed: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + + // The recording provider captures the model passed to Chat(). + // If TargetAgentID works correctly, the child turn should have + // used beta's model, not alpha's. + if got := rp.getLastModel(); got != "model-beta" { + t.Errorf("child turn used model %q, want %q", got, "model-beta") + } +} + +func TestSpawnSubTurn_TargetAgentID_NotFound(t *testing.T) { + al, cleanup := newMultiAgentLoop(t, &mockProvider{}) + defer cleanup() + + alphaAgent, _ := al.registry.GetAgent("alpha") + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-alpha", + depth: 0, + childTurnIDs: []string{}, + pendingResults: make(chan *tools.ToolResult, 4), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + session: &ephemeralSessionStore{}, + agent: alphaAgent, + } + + _, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{ + TargetAgentID: "nonexistent", + SystemPrompt: "task", + }) + + if err == nil { + t.Fatal("expected error for nonexistent agent") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error should mention 'not found', got: %v", err) + } +} + +func TestSpawnSubTurn_TargetAgentID_EmptyModelAccepted(t *testing.T) { + al, cleanup := newMultiAgentLoop(t, &mockProvider{}) + defer cleanup() + + alphaAgent, _ := al.registry.GetAgent("alpha") + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-alpha", + depth: 0, + childTurnIDs: []string{}, + pendingResults: make(chan *tools.ToolResult, 4), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + session: &ephemeralSessionStore{}, + agent: alphaAgent, + } + + // Model is empty but TargetAgentID is set — should NOT fail validation + result, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{ + Model: "", // intentionally empty + TargetAgentID: "beta", + SystemPrompt: "task for beta", + }) + if err != nil { + t.Fatalf("should accept empty Model when TargetAgentID is set, got: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestDelegateToolNotRegistered_SingleAgent(t *testing.T) { + // Single-agent setup: delegate should not be registered + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("default agent should exist") + } + if _, has := agent.Tools.Get("delegate"); has { + t.Error("delegate tool should not be registered in single-agent setup") + } +} + +func TestDelegateToolRegistered_MultiAgent(t *testing.T) { + al, cleanup := newMultiAgentLoop(t, &mockProvider{}) + defer cleanup() + + // Both agents should have the delegate tool + for _, id := range []string{"alpha", "beta"} { + agent, ok := al.registry.GetAgent(id) + if !ok { + t.Fatalf("agent %q not found", id) + } + if _, has := agent.Tools.Get("delegate"); !has { + t.Errorf("agent %q should have delegate tool in multi-agent setup", id) + } + } +} diff --git a/pkg/agent/turn_context.go b/pkg/agent/turn_context.go new file mode 100644 index 000000000..c675590ce --- /dev/null +++ b/pkg/agent/turn_context.go @@ -0,0 +1,92 @@ +package agent + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" +) + +// TurnContext carries normalized turn-scoped facts that can be shared across +// events, hooks, and other runtime observers without re-parsing legacy fields. +type TurnContext struct { + Inbound *bus.InboundContext `json:"inbound,omitempty"` + Route *routing.ResolvedRoute `json:"route,omitempty"` + Scope *session.SessionScope `json:"scope,omitempty"` +} + +func newTurnContext( + inbound *bus.InboundContext, + route *routing.ResolvedRoute, + scope *session.SessionScope, +) *TurnContext { + if inbound == nil && route == nil && scope == nil { + return nil + } + return &TurnContext{ + Inbound: cloneInboundContext(inbound), + Route: cloneResolvedRoute(route), + Scope: session.CloneScope(scope), + } +} + +func cloneTurnContext(ctx *TurnContext) *TurnContext { + if ctx == nil { + return nil + } + cloned := *ctx + cloned.Inbound = cloneInboundContext(ctx.Inbound) + cloned.Route = cloneResolvedRoute(ctx.Route) + cloned.Scope = session.CloneScope(ctx.Scope) + return &cloned +} + +func cloneInboundContext(ctx *bus.InboundContext) *bus.InboundContext { + if ctx == nil { + return nil + } + cloned := *ctx + cloned.ReplyHandles = cloneStringMap(ctx.ReplyHandles) + cloned.Raw = cloneStringMap(ctx.Raw) + return &cloned +} + +func cloneStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + cloned := make(map[string]string, len(src)) + for k, v := range src { + cloned[k] = v + } + return cloned +} + +func cloneHookMeta(meta HookMeta) HookMeta { + meta.turnContext = cloneTurnContext(meta.turnContext) + return meta +} + +func cloneResolvedRoute(route *routing.ResolvedRoute) *routing.ResolvedRoute { + if route == nil { + return nil + } + cloned := *route + cloned.SessionPolicy = routing.SessionPolicy{ + Dimensions: append([]string(nil), route.SessionPolicy.Dimensions...), + IdentityLinks: cloneIdentityLinks(route.SessionPolicy.IdentityLinks), + } + return &cloned +} + +func cloneIdentityLinks(src map[string][]string) map[string][]string { + if len(src) == 0 { + return nil + } + cloned := make(map[string][]string, len(src)) + for canonical, ids := range src { + dup := make([]string, len(ids)) + copy(dup, ids) + cloned[canonical] = dup + } + return cloned +} diff --git a/pkg/agent/turn_coord.go b/pkg/agent/turn_coord.go new file mode 100644 index 000000000..2826e662c --- /dev/null +++ b/pkg/agent/turn_coord.go @@ -0,0 +1,634 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipeline) (turnResult, error) { + turnCtx, turnCancel := context.WithCancel(ctx) + defer turnCancel() + ts.setTurnCancel(turnCancel) + + // Inject turnState and AgentLoop into context so tools (e.g. spawn) can retrieve them. + turnCtx = withTurnState(turnCtx, ts) + turnCtx = WithAgentLoop(turnCtx, al) + + al.registerActiveTurn(ts) + defer al.clearActiveTurn(ts) + + if al.takePendingStop(ts.sessionKey) { + _ = ts.requestHardAbort() + } + + turnStatus := TurnEndStatusCompleted + defer func() { + al.emitEvent( + runtimeevents.KindAgentTurnEnd, + ts.eventMeta("runTurn", "turn.end"), + TurnEndPayload{ + Status: turnStatus, + Iterations: ts.currentIteration(), + Duration: time.Since(ts.startedAt), + FinalContentLen: ts.finalContentLen(), + }, + ) + }() + + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + + al.emitEvent( + runtimeevents.KindAgentTurnStart, + ts.eventMeta("runTurn", "turn.start"), + TurnStartPayload{ + UserMessage: ts.userMessage, + MediaCount: len(ts.media), + }, + ) + + // SetupTurn extracts the one-time initialization phase. + exec, err := pipeline.SetupTurn(turnCtx, ts) + if err != nil { + return turnResult{}, err + } + + // Convenience references to exec fields used throughout the turn loop. + messages := exec.messages + pendingMessages := exec.pendingMessages + maxMediaSize := pipeline.Cfg.Agents.Defaults.GetMaxMediaSize() + finalContent := exec.finalContent + + for ts.currentIteration() < ts.agent.MaxIterations || len(exec.pendingMessages) > 0 || func() bool { + graceful, _ := ts.gracefulInterruptRequested() + return graceful + }() { + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + + iteration := ts.currentIteration() + 1 + ts.setIteration(iteration) + ts.setPhase(TurnPhaseRunning) + + if iteration > 1 { + // For subsequent iterations, read from exec.pendingMessages which + // is where ExecuteTools (or initial poll) deposits steering. + // We do NOT call dequeueSteeringMessagesForScope here because + // steering was already consumed from al.steering by ExecuteTools. + if len(exec.pendingMessages) > 0 { + pendingMessages = append(pendingMessages, exec.pendingMessages...) + exec.pendingMessages = nil + } + } else if !ts.opts.SkipInitialSteeringPoll { + if steerMsgs := al.dequeueSteeringMessagesForScopeWithFallback(ts.sessionKey); len(steerMsgs) > 0 { + pendingMessages = append(pendingMessages, steerMsgs...) + } + } + + // Check if parent turn has ended (SubTurn support from HEAD) + if ts.parentTurnState != nil && ts.IsParentEnded() { + if !ts.critical { + logger.InfoCF("agent", "Parent turn ended, non-critical SubTurn exiting gracefully", map[string]any{ + "agent_id": ts.agentID, + "iteration": iteration, + "turn_id": ts.turnID, + }) + break + } + logger.InfoCF("agent", "Parent turn ended, critical SubTurn continues running", map[string]any{ + "agent_id": ts.agentID, + "iteration": iteration, + "turn_id": ts.turnID, + }) + } + + // Poll for pending SubTurn results (from HEAD) + if ts.pendingResults != nil { + select { + case result, ok := <-ts.pendingResults: + if ok && result != nil && result.ForLLM != "" { + content := al.cfg.FilterSensitiveData(result.ForLLM) + msg := subTurnResultPromptMessage(content) + pendingMessages = append(pendingMessages, msg) + } + default: + // No results available + } + } + + // Inject pending steering messages + if len(pendingMessages) > 0 { + resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize) + totalContentLen := 0 + for i, pm := range pendingMessages { + messages = append(messages, resolvedPending[i]) + totalContentLen += len(pm.Content) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, pm) + ts.recordPersistedMessage(pm) + ts.ingestMessage(turnCtx, al, pm) + } + logger.InfoCF("agent", "Injected steering message into context", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "content_len": len(pm.Content), + "media_count": len(pm.Media), + }) + } + al.emitEvent( + runtimeevents.KindAgentSteeringInjected, + ts.eventMeta("runTurn", "turn.steering.injected"), + SteeringInjectedPayload{ + Count: len(pendingMessages), + TotalContentLen: totalContentLen, + }, + ) + // Clear exec.pendingMessages after injection so InitialSteeringMessages + // are not re-injected on subsequent iterations (Issue 2 fix). + exec.pendingMessages = nil + } + // Always sync messages into exec.messages so CallLLM sees the updated state + exec.messages = messages + + logger.DebugCF("agent", "LLM iteration", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "max": ts.agent.MaxIterations, + }) + + // Execute LLM call via Pipeline + ts.setPhase(TurnPhaseRunning) + ctrl, callErr := pipeline.CallLLM(ctx, turnCtx, ts, exec, iteration) + if callErr != nil { + turnStatus = TurnEndStatusError + return turnResult{}, callErr + } + messages = exec.messages + pendingMessages = exec.pendingMessages + finalContent = exec.finalContent + + switch ctrl { + case ControlContinue: + continue + case ControlBreak: + // Hard abort: delegate to abortTurn (sets TurnEndStatusAborted) + if exec.abortedByHardAbort { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + // Hook abort (HookActionAbortTurn): sets TurnEndStatusError, returns error + if exec.abortedByHook { + turnStatus = TurnEndStatusError + return turnResult{}, fmt.Errorf("hook requested turn abort") + } + // Ensure empty response falls back to DefaultResponse + if finalContent == "" { + finalContent = ts.opts.DefaultResponse + } + return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent) + case ControlToolLoop: + // Execute tools via Pipeline + toolCtrl := pipeline.ExecuteTools(ctx, turnCtx, ts, exec, iteration) + switch toolCtrl { + case ToolControlContinue: + // Re-read exec.messages since ExecuteTools may have updated it + // (added tool results/skipped messages) before returning ControlContinue + messages = exec.messages + continue + case ToolControlBreak: + // Hard abort: delegate to abortTurn (sets TurnEndStatusAborted) + if exec.abortedByHardAbort { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + // Hook abort (HookActionAbortTurn): sets TurnEndStatusError, returns error + if exec.abortedByHook { + turnStatus = TurnEndStatusError + return turnResult{}, fmt.Errorf("hook requested turn abort") + } + // ExecuteTools returned ControlBreak: + // - allResponsesHandled=true: finalize without DefaultResponse (exec.finalContent empty) + // - allResponsesHandled=false: coordinator applies DefaultResponse before finalize + if exec.allResponsesHandled { + finalContent = "" + } + return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent) + } + } + } + + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + + if finalContent == "" { + if ts.currentIteration() >= ts.agent.MaxIterations && ts.agent.MaxIterations > 0 { + finalContent = toolLimitResponse + } else { + finalContent = ts.opts.DefaultResponse + } + } + + // Check hard abort before finalizing (may have been set during tool execution) + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + + return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent) +} + +func (al *AgentLoop) abortTurn(ts *turnState) (turnResult, error) { + ts.setPhase(TurnPhaseAborted) + if !ts.opts.NoHistory { + if err := ts.restoreSession(ts.agent); err != nil { + al.emitEvent( + runtimeevents.KindAgentError, + ts.eventMeta("abortTurn", "turn.error"), + ErrorPayload{ + Stage: "session_restore", + Message: err.Error(), + }, + ) + return turnResult{}, err + } + } + return turnResult{status: TurnEndStatusAborted}, nil +} + +func (al *AgentLoop) selectCandidates( + agent *AgentInstance, + userMsg string, + history []providers.Message, +) (candidates []providers.FallbackCandidate, model string, usedLight bool) { + if agent.Router == nil || len(agent.LightCandidates) == 0 { + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false + } + + _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) + if !usedLight { + logger.DebugCF("agent", "Model routing: primary model selected", + map[string]any{ + "agent_id": agent.ID, + "score": score, + "threshold": agent.Router.Threshold(), + }) + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false + } + + logger.InfoCF("agent", "Model routing: light model selected", + map[string]any{ + "agent_id": agent.ID, + "light_model": agent.Router.LightModel(), + "score": score, + "threshold": agent.Router.Threshold(), + }) + return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true +} + +func (al *AgentLoop) resolveContextManager() ContextManager { + name := al.cfg.Agents.Defaults.ContextManager + if name == "" || name == "legacy" { + return &legacyContextManager{al: al} + } + factory, ok := lookupContextManager(name) + if !ok { + logger.WarnCF("agent", "Unknown context manager, falling back to legacy", map[string]any{ + "name": name, + }) + return &legacyContextManager{al: al} + } + cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al) + if err != nil { + logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{ + "name": name, + "error": err.Error(), + }) + return &legacyContextManager{al: al} + } + return cm +} + +func (al *AgentLoop) askSideQuestion( + ctx context.Context, + agent *AgentInstance, + opts *processOptions, + question string, +) (string, error) { + if agent == nil { + return "", fmt.Errorf("askSideQuestion: no agent available for /btw") + } + + question = strings.TrimSpace(question) + if question == "" { + return "", fmt.Errorf("askSideQuestion: %w", fmt.Errorf("Usage: /btw ")) + } + + if opts != nil { + normalizeProcessOptionsInPlace(opts) + } + + var media []string + var channel, chatID, senderID, senderDisplayName string + if opts != nil { + media = opts.Media + channel = opts.Channel + chatID = opts.ChatID + senderID = opts.SenderID + senderDisplayName = opts.SenderDisplayName + } + + // Build messages with context but WITHOUT adding to session history + var history []providers.Message + var summary string + if opts != nil && !opts.NoHistory { + if resp, err := al.contextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: opts.SessionKey, + Budget: agent.ContextWindow, + MaxTokens: agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary + } + } + + messages := agent.ContextBuilder.BuildMessages( + history, + summary, + question, + media, + channel, + chatID, + senderID, + senderDisplayName, + ) + + maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize() + messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) + + activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages) + selectedModelName := sideQuestionModelName(agent, usedLight) + + llmOpts := map[string]any{ + "max_tokens": agent.MaxTokens, + "temperature": agent.Temperature, + "prompt_cache_key": agent.ID + ":btw", + } + + hookModelChanged := false + callProvider := func( + ctx context.Context, + candidate providers.FallbackCandidate, + model string, + forceModel bool, + callMessages []providers.Message, + ) (*providers.LLMResponse, error) { + provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(agent, selectedModelName, candidate) + if err != nil { + return nil, err + } + defer cleanup() + if !forceModel || strings.TrimSpace(model) == "" { + model = providerModel + } + callOpts := llmOpts + if _, exists := callOpts["thinking_level"]; !exists && agent.ThinkingLevel != ThinkingOff { + if tc, ok := provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { + callOpts = shallowCloneLLMOptions(llmOpts) + callOpts["thinking_level"] = string(agent.ThinkingLevel) + } + } + return provider.Chat(ctx, callMessages, nil, model, callOpts) + } + + turnCtx := newTurnContext(nil, nil, nil) + if opts != nil { + turnCtx = newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope) + } + llmModel := activeModel + if al.hooks != nil { + llmReq, decision := al.hooks.BeforeLLM(ctx, &LLMHookRequest{ + Meta: HookMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.request", + turnContext: cloneTurnContext(turnCtx), + }, + Context: cloneTurnContext(turnCtx), + Model: llmModel, + Messages: messages, + Tools: nil, + Options: llmOpts, + GracefulTerminal: false, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmReq != nil { + if strings.TrimSpace(llmReq.Model) != "" && llmReq.Model != llmModel { + hookModelChanged = true + } + llmModel = llmReq.Model + messages = llmReq.Messages + llmOpts = llmReq.Options + } + case HookActionAbortTurn: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) + case HookActionHardAbort: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) + } + } + if hookModelChanged { + // Hook-selected models must not continue through the pre-hook fallback + // candidate list, otherwise fallback execution would call the original + // candidate model and silently ignore the hook decision. + activeCandidates = nil + } + + callSideLLM := func(callMessages []providers.Message) (*providers.LLMResponse, error) { + if len(activeCandidates) > 1 && al.fallback != nil { + fbResult, err := al.fallback.Execute( + ctx, + activeCandidates, + func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) { + candidate := providers.FallbackCandidate{Provider: providerName, Model: model} + for _, activeCandidate := range activeCandidates { + if activeCandidate.Provider == providerName && activeCandidate.Model == model { + candidate = activeCandidate + break + } + } + return callProvider(ctx, candidate, model, false, callMessages) + }, + ) + if err != nil { + return nil, err + } + return fbResult.Response, nil + } + + var candidate providers.FallbackCandidate + if len(activeCandidates) > 0 { + candidate = activeCandidates[0] + } + return callProvider(ctx, candidate, llmModel, hookModelChanged, callMessages) + } + + // Retry without media if vision is unsupported + // Note: Vision retry is only applied to the initial call. If fallback chain + // is used, vision errors from fallback providers will not trigger retry. + var resp *providers.LLMResponse + var err error + resp, err = callSideLLM(messages) + if err != nil && hasMediaRefs(messages) && isVisionUnsupportedError(err) { + al.emitEvent( + runtimeevents.KindAgentLLMRetry, + HookMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.retry", + turnContext: cloneTurnContext(turnCtx), + }, + LLMRetryPayload{ + Attempt: 1, + MaxRetries: 1, + Reason: "vision_unsupported", + Error: err.Error(), + Backoff: 0, + }, + ) + messagesWithoutMedia := stripMessageMedia(messages) + resp, err = callSideLLM(messagesWithoutMedia) + } + if err != nil { + return "", err + } + if resp == nil { + return "", nil + } + + // Apply after_llm hooks + if al.hooks != nil { + llmResp, decision := al.hooks.AfterLLM(ctx, &LLMHookResponse{ + Meta: HookMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.response", + turnContext: cloneTurnContext(turnCtx), + }, + Context: cloneTurnContext(turnCtx), + Model: llmModel, + Response: resp, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmResp != nil && llmResp.Response != nil { + resp = llmResp.Response + } + case HookActionAbortTurn, HookActionHardAbort: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during after_llm: %s", reason) + } + } + + return sideQuestionResponseContent(resp), nil +} + +func (al *AgentLoop) isolatedSideQuestionProvider( + agent *AgentInstance, + baseModelName string, + candidate providers.FallbackCandidate, +) (providers.LLMProvider, string, func(), error) { + if agent == nil { + return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: no agent available for /btw") + } + + modelCfg, err := al.sideQuestionModelConfig(agent, baseModelName, candidate) + if err != nil { + return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) + } + + factory := al.providerFactory + if factory == nil { + factory = providers.CreateProviderFromConfig + } + provider, modelID, err := factory(modelCfg) + if err != nil { + return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) + } + + cleanup := func() { + closeProviderIfStateful(provider) + } + return provider, modelID, cleanup, nil +} + +func (al *AgentLoop) sideQuestionModelConfig( + agent *AgentInstance, + baseModelName string, + candidate providers.FallbackCandidate, +) (*config.ModelConfig, error) { + if agent == nil { + return nil, fmt.Errorf("sideQuestionModelConfig: no agent available for /btw") + } + + // If candidate has an identity key, use that + if name := modelNameFromIdentityKey(candidate.IdentityKey); name != "" { + modelCfg, err := resolvedModelConfig(al.GetConfig(), name, agent.Workspace) + if err == nil { + return modelCfg, nil + } + // Fallback: create a minimal config if lookup fails + } + + // Otherwise, clean up the base model name and use it + baseModelName = strings.TrimSpace(baseModelName) + modelCfg, err := resolvedModelConfig(al.GetConfig(), baseModelName, agent.Workspace) + if err != nil { + // Fallback: create a minimal config for test scenarios + model := strings.TrimSpace(baseModelName) + if candidate.Model != "" { + model = candidate.Model + } + if candidate.Provider != "" && candidate.Model != "" { + model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model + } else { + model = ensureProtocolModel(model) + } + return &config.ModelConfig{ + ModelName: baseModelName, + Model: model, + Workspace: agent.Workspace, + }, nil + } + + // If candidate specifies a different provider/model, override + clone := *modelCfg + if candidate.Provider != "" && candidate.Model != "" { + clone.Model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model + } + return &clone, nil +} diff --git a/pkg/agent/turn_coord_test.go b/pkg/agent/turn_coord_test.go new file mode 100644 index 000000000..898ae3931 --- /dev/null +++ b/pkg/agent/turn_coord_test.go @@ -0,0 +1,782 @@ +package agent + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// ============================================================================= +// Mock Providers for turn_coord Tests +// ============================================================================= + +// simpleConvProvider returns a simple text response without tools +type simpleConvProvider struct{} + +func (p *simpleConvProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{ + Content: "Hello! How can I help you today?", + FinishReason: "stop", + }, nil +} + +func (p *simpleConvProvider) GetDefaultModel() string { + return "simple-model" +} + +type nativeSearchCaptureProvider struct { + lastOpts map[string]any +} + +func (p *nativeSearchCaptureProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.lastOpts = make(map[string]any, len(opts)) + for k, v := range opts { + p.lastOpts[k] = v + } + return &providers.LLMResponse{ + Content: "Using native search", + FinishReason: "stop", + }, nil +} + +func (p *nativeSearchCaptureProvider) GetDefaultModel() string { + return "native-search-model" +} + +func (p *nativeSearchCaptureProvider) SupportsNativeSearch() bool { + return true +} + +// toolCallRespProvider returns a tool call response +type toolCallRespProvider struct { + toolName string + toolArgs map[string]any + response string + callCount int + mu sync.Mutex +} + +func (p *toolCallRespProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + p.callCount++ + count := p.callCount + p.mu.Unlock() + + // First call returns a tool call, subsequent calls return final response + if count == 1 { + return &providers.LLMResponse{ + Content: "Let me search for that information.", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Name: p.toolName, + Arguments: p.toolArgs, + }, + }, + FinishReason: "tool_calls", + }, nil + } + return &providers.LLMResponse{ + Content: p.response, + FinishReason: "stop", + }, nil +} + +func (p *toolCallRespProvider) GetDefaultModel() string { + return "tool-model" +} + +// errorProvider simulates various error conditions +type errorProvider struct { + errType string + callCount int + mu sync.Mutex +} + +func (p *errorProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + p.callCount++ + p.mu.Unlock() + + switch p.errType { + case "timeout": + return nil, context.DeadlineExceeded + case "context_length": + return nil, errors.New("context_length_exceeded") + case "vision": + return nil, errors.New("vision_unsupported") + case "connection_reset": + return nil, errors.New("connection reset by peer") + case "broken_pipe": + return nil, errors.New("broken pipe") + case "read_tcp": + return nil, errors.New("read tcp 127.0.0.1:8080: connection reset") + case "eof": + return nil, errors.New("EOF") + case "connection_refused": + return nil, errors.New("connection refused") + default: + return nil, errors.New("unknown error") + } +} + +func (p *errorProvider) GetDefaultModel() string { + return "error-model" +} + +// ============================================================================= +// Test Helper Functions +// ============================================================================= + +func newTurnCoordTestLoop(t *testing.T, provider providers.LLMProvider) (*AgentLoop, *AgentInstance, func()) { + t.Helper() + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + return al, agent, func() { + al.Close() + } +} + +func makeTestProcessOpts(sessionKey string) processOptions { + return processOptions{ + SessionKey: sessionKey, + Channel: "cli", + ChatID: "test-chat", + UserMessage: "test message", + DefaultResponse: "I couldn't process your request.", + EnableSummary: false, + SendResponse: false, + NoHistory: false, + } +} + +// ============================================================================= +// Pipeline Method Tests: SetupTurn +// ============================================================================= + +func TestPipeline_SetupTurn_BasicInitialization(t *testing.T) { + al, agent, cleanup := newTurnCoordTestLoop(t, &simpleConvProvider{}) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + if exec == nil { + t.Fatal("expected non-nil turnExecution") + } + if len(exec.messages) == 0 { + t.Error("expected messages to be populated") + } + if exec.iteration != 0 { + t.Errorf("expected iteration 0, got %d", exec.iteration) + } +} + +// ============================================================================= +// Pipeline Method Tests: CallLLM +// ============================================================================= + +func TestPipeline_CallLLM_SimpleResponse(t *testing.T) { + al, agent, cleanup := newTurnCoordTestLoop(t, &simpleConvProvider{}) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + if ctrl != ControlBreak { + t.Errorf("expected ControlBreak, got %v", ctrl) + } + if exec.response == nil { + t.Fatal("expected non-nil response") + } + if exec.response.Content == "" { + t.Error("expected non-empty content") + } +} + +func TestPipeline_CallLLM_WithToolCall(t *testing.T) { + provider := &toolCallRespProvider{ + toolName: "web_search", + toolArgs: map[string]any{"query": "test"}, + response: "Found information about test.", + } + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + if ctrl != ControlToolLoop { + t.Errorf("expected ControlToolLoop, got %v", ctrl) + } + if len(exec.normalizedToolCalls) == 0 { + t.Fatal("expected tool calls") + } + if exec.normalizedToolCalls[0].Name != "web_search" { + t.Errorf("expected tool name 'web_search', got %q", exec.normalizedToolCalls[0].Name) + } +} + +func TestPipeline_CallLLM_UsesNativeSearchWithoutClientWebSearchTool(t *testing.T) { + provider := &nativeSearchCaptureProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + if _, ok := agent.Tools.Get("web_search"); ok { + t.Fatal("expected no client-side web_search tool to be registered") + } + + al.cfg.Tools.Web.Enabled = true + al.cfg.Tools.Web.PreferNative = true + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + if ctrl != ControlBreak { + t.Fatalf("expected ControlBreak, got %v", ctrl) + } + if got, _ := provider.lastOpts["native_search"].(bool); !got { + t.Fatalf("expected native_search=true, got %#v", provider.lastOpts["native_search"]) + } +} + +func TestPipeline_CallLLM_TimeoutRetry(t *testing.T) { + errorPrv := &errorProvider{errType: "timeout"} + al, agent, cleanup := newTurnCoordTestLoop(t, errorPrv) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + // Should retry and eventually fail after max retries + _, err = pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err == nil { + t.Error("expected error after retries") + } +} + +func TestPipeline_CallLLM_ContextLengthError(t *testing.T) { + errorPrv := &errorProvider{errType: "context_length"} + al, agent, cleanup := newTurnCoordTestLoop(t, errorPrv) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + // Should trigger context compression and retry + _, err = pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + // May succeed after compression or fail - either is acceptable + t.Logf("CallLLM result after context error: err=%v", err) +} + +func TestPipeline_CallLLM_NetworkErrorRetry(t *testing.T) { + testCases := []struct { + name string + errType string + }{ + {"connection_reset", "connection_reset"}, + {"broken_pipe", "broken_pipe"}, + {"read_tcp", "read_tcp"}, + {"eof", "eof"}, + {"connection_refused", "connection_refused"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + errorPrv := &errorProvider{errType: tc.errType} + al, agent, cleanup := newTurnCoordTestLoop(t, errorPrv) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + _, err = pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err == nil { + t.Error("expected error after network error retries") + } + }) + } +} + +func TestPipeline_CallLLM_RetryConfigRespected(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + MaxLLMRetries: 3, + LLMRetryBackoffSecs: 1, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &errorProvider{errType: "connection_reset"} + al := NewAgentLoop(cfg, msgBus, provider) + defer al.Close() + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + start := time.Now() + _, err = pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + elapsed := time.Since(start) + + if err == nil { + t.Error("expected error after retries") + } + + expectedMinTime := 3 * time.Second + if elapsed < expectedMinTime { + t.Errorf("expected at least %v of backoff, got %v", expectedMinTime, elapsed) + } +} + +func TestPipeline_CallLLM_RetryCountLimit(t *testing.T) { + tmpDir := t.TempDir() + + counterPrv := &countingErrorProvider{errType: "connection_reset", targetCalls: 5} + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + MaxLLMRetries: 2, + LLMRetryBackoffSecs: 0, + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, counterPrv) + defer al.Close() + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + _, err = pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err == nil { + t.Error("expected error after retries") + } + + if counterPrv.callCount != 3 { + t.Errorf("expected exactly 3 calls (1 initial + 2 retries), got %d", counterPrv.callCount) + } +} + +type countingErrorProvider struct { + errType string + targetCalls int + callCount int + mu sync.Mutex +} + +func (p *countingErrorProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + p.callCount++ + p.mu.Unlock() + return nil, errors.New("connection reset by peer") +} + +func (p *countingErrorProvider) GetDefaultModel() string { + return "counting-error-model" +} + +// ============================================================================= +// Pipeline Method Tests: ExecuteTools +// ============================================================================= + +func TestPipeline_ExecuteTools_NoTools(t *testing.T) { + // Provider returns no tool calls, so ExecuteTools should not be called + // This test verifies the ControlBreak path from CallLLM + provider := &simpleConvProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + // First CallLLM returns ControlBreak (no tools) + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + + if ctrl != ControlBreak { + t.Fatalf("expected ControlBreak, got %v", ctrl) + } + // No tools to execute, Finalize should be called directly +} + +// ============================================================================= +// runTurn Integration Tests +// ============================================================================= + +func TestRunTurn_SimpleConversation(t *testing.T) { + provider := &simpleConvProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-simple") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-simple", + context: newTurnContext(nil, nil, nil), + }) + + result, err := al.runTurn(context.Background(), ts, pipeline) + if err != nil { + t.Fatalf("runTurn failed: %v", err) + } + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } + if result.finalContent == "" { + t.Error("expected non-empty finalContent") + } +} + +func TestRunTurn_MaxIterations(t *testing.T) { + // Provider always returns tool calls, should hit max iterations + provider := &toolCallRespProvider{ + toolName: "search", + toolArgs: map[string]any{"q": "x"}, + response: "done", + } + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + // Override max iterations to 2 + agent.MaxIterations = 2 + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-maxiter") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-maxiter", + context: newTurnContext(nil, nil, nil), + }) + + result, err := al.runTurn(context.Background(), ts, pipeline) + if err != nil { + t.Fatalf("runTurn failed: %v", err) + } + // Should complete due to max iterations + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } +} + +func TestRunTurn_HardAbort(t *testing.T) { + // Provider simulates a slow response, but we'll abort mid-turn + slowProvider := &slowMockProvider{delay: 10 * time.Second} + al, agent, cleanup := newTurnCoordTestLoop(t, slowProvider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-abort") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-abort", + context: newTurnContext(nil, nil, nil), + }) + + // Run in goroutine with abort after short delay + done := make(chan struct{}) + + go func() { + al.runTurn(context.Background(), ts, pipeline) + close(done) + }() + + // Give it a moment to start + time.Sleep(50 * time.Millisecond) + + // Request hard abort + ts.requestHardAbort() + + // Wait for runTurn to complete + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("runTurn did not complete after abort") + } +} + +func TestRunTurn_SteeringMessageInjection(t *testing.T) { + provider := &simpleConvProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-steering") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-steering", + context: newTurnContext(nil, nil, nil), + }) + + // Enqueue steering message before runTurn + steeringMsg := providers.Message{ + Role: "user", + Content: "Steering message", + } + al.Steer(steeringMsg) + + result, err := al.runTurn(context.Background(), ts, pipeline) + if err != nil { + t.Fatalf("runTurn failed: %v", err) + } + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } + // Steering message should have been injected +} + +func TestRunTurn_GracefulInterrupt(t *testing.T) { + provider := &toolCallRespProvider{ + toolName: "search", + toolArgs: map[string]any{"q": "test"}, + response: "Final response after interrupt", + } + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-graceful") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-graceful", + context: newTurnContext(nil, nil, nil), + }) + + // Run in goroutine with graceful interrupt after first iteration + done := make(chan struct{}) + var result turnResult + + go func() { + result, _ = al.runTurn(context.Background(), ts, pipeline) + close(done) + }() + + // Give it a moment to start first iteration + time.Sleep(50 * time.Millisecond) + + // Request graceful interrupt + ts.requestGracefulInterrupt("Please stop") + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("runTurn did not complete after graceful interrupt") + } + + // Should complete gracefully + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } +} + +// ============================================================================= +// turnState Tests +// ============================================================================= + +func TestTurnState_GracefulInterruptRequested(t *testing.T) { + ts := &turnState{ + gracefulInterrupt: false, + gracefulInterruptHint: "", + } + + // Initially should not be requested + requested, _ := ts.gracefulInterruptRequested() + if requested { + t.Error("expected no interrupt initially") + } + + // Request interrupt + ts.requestGracefulInterrupt("test hint") + + requested, hint := ts.gracefulInterruptRequested() + if !requested { + t.Error("expected interrupt to be requested") + } + if hint != "test hint" { + t.Errorf("expected hint 'test hint', got %q", hint) + } +} + +func TestTurnState_HardAbortRequested(t *testing.T) { + ts := &turnState{ + hardAbort: false, + } + + if ts.hardAbortRequested() { + t.Error("expected no hard abort initially") + } + + ts.requestHardAbort() + + if !ts.hardAbortRequested() { + t.Error("expected hard abort to be requested") + } +} diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go new file mode 100644 index 000000000..b769ebcd0 --- /dev/null +++ b/pkg/agent/turn_state.go @@ -0,0 +1,649 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "reflect" + "sync" + "sync/atomic" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/tools" +) + +// ============================================================================= +// TurnPhase - represents the current phase of a turn +// ============================================================================= + +type TurnPhase string + +const ( + TurnPhaseSetup TurnPhase = "setup" + TurnPhaseRunning TurnPhase = "running" + TurnPhaseTools TurnPhase = "tools" + TurnPhaseFinalizing TurnPhase = "finalizing" + TurnPhaseCompleted TurnPhase = "completed" + TurnPhaseAborted TurnPhase = "aborted" +) + +// ============================================================================= +// Control signals - returned from Pipeline methods to drive runTurn's coordinator loop +// ============================================================================= + +type Control int + +const ( + // ControlContinue tells the coordinator to jump back to the top of the turn loop + // (equivalent to the original "goto turnLoop"). + ControlContinue Control = iota + // ControlBreak tells the coordinator to exit the turn loop and proceed to Finalize. + ControlBreak + // ControlToolLoop tells the coordinator to execute the tool loop. + ControlToolLoop +) + +// ToolControl signals returned from ExecuteTools to drive tool loop iteration. +type ToolControl int + +const ( + // ToolControlContinue tells the tool loop to jump to the next iteration + // (pendingMessages arrived, SubTurn results, etc.). + ToolControlContinue ToolControl = iota + // ToolControlBreak tells the tool loop to exit and return to the coordinator. + ToolControlBreak + // ToolControlFinalize tells the coordinator that all tool responses were + // handled and the turn should finalize without another LLM call. + ToolControlFinalize +) + +// LLMPhase indicates which phase the turn is executing in. +type LLMPhase int + +const ( + LLMPhaseSetup LLMPhase = iota + LLMPhasePreLLM + LLMPhaseLLMCall + LLMPhaseProcessing + LLMPhaseToolLoop + LLMPhaseTools + LLMPhaseFinalizing + LLMPhaseCompleted + LLMPhaseAborted +) + +// ============================================================================= +// turnResult - returned from runTurn +// ============================================================================= + +type turnResult struct { + finalContent string + status TurnEndStatus + followUps []bus.InboundMessage +} + +// ============================================================================= +// ActiveTurnInfo - public info about an active turn +// ============================================================================= + +type ActiveTurnInfo struct { + TurnID string + AgentID string + SessionKey string + Channel string + ChatID string + UserMessage string + Phase TurnPhase + Iteration int + StartedAt time.Time + Depth int + ParentTurnID string + ChildTurnIDs []string +} + +// ============================================================================= +// turnExecution - mutable state that persists across turn loop iterations +// ============================================================================= + +type turnExecution struct { + // Core message state (accumulates throughout the turn) + messages []providers.Message // built from ContextBuilder, grows per-iteration + pendingMessages []providers.Message // steering/SubTurn messages awaiting injection + history []providers.Message // from ContextManager.Assemble + summary string + + // Turn output + finalContent string + + // Iteration tracking + iteration int + + // Per-iteration state set by Pipeline.PreLLM + activeCandidates []providers.FallbackCandidate + activeModel string + activeProvider providers.LLMProvider + usedLight bool + + // LLM call per-iteration state + response *providers.LLMResponse + normalizedToolCalls []providers.ToolCall + allResponsesHandled bool + callMessages []providers.Message + providerToolDefs []providers.ToolDefinition + llmModel string + llmOpts map[string]any + gracefulTerminal bool + useNativeSearch bool + + // Phase tracking + phase LLMPhase + + // Abort signaling for coordinator (set by Pipeline methods) + abortedByHardAbort bool // true when hard abort triggered during LLM/tools + abortedByHook bool // true when HookActionAbortTurn triggered +} + +// newTurnExecution creates a turnExecution initialized from turnState and options. +func newTurnExecution( + agent *AgentInstance, + opts processOptions, + history []providers.Message, + summary string, + messages []providers.Message, +) *turnExecution { + return &turnExecution{ + history: history, + summary: summary, + messages: messages, + pendingMessages: append([]providers.Message(nil), opts.InitialSteeringMessages...), + iteration: 0, + phase: LLMPhaseSetup, + } +} + +// ============================================================================= +// turnState - the full state for a turn, constructed once per turn +// ============================================================================= + +type turnState struct { + mu sync.RWMutex + + agent *AgentInstance + opts processOptions + scope turnEventScope + + turnID string + agentID string + sessionKey string + turnCtx *TurnContext + + channel string + chatID string + userMessage string + media []string + + phase TurnPhase + iteration int + startedAt time.Time + finalContent string + + followUps []bus.InboundMessage + + gracefulInterrupt bool + gracefulInterruptHint string + gracefulTerminalUsed bool + hardAbort bool + providerCancel context.CancelFunc + turnCancel context.CancelFunc + + restorePointHistory []providers.Message + restorePointSummary string + persistedMessages []providers.Message + + // SubTurn support (from HEAD) + depth int // SubTurn depth (0 for root turn) + parentTurnID string // Parent turn ID (empty for root turn) + childTurnIDs []string // Child turn IDs + pendingResults chan *tools.ToolResult // Channel for SubTurn results + concurrencySem chan struct{} // Semaphore for limiting concurrent SubTurns + isFinished atomic.Bool // Whether this turn has finished + session session.SessionStore // Session store reference + initialHistoryLength int // Snapshot of history length at turn start + + // Additional SubTurn fields + ctx context.Context // Context for this turn + cancelFunc context.CancelFunc // Cancel function for this turn's context + critical bool // Whether this SubTurn should continue after parent ends + parentTurnState *turnState // Reference to parent turnState + parentEnded atomic.Bool // Whether parent has ended + closeOnce sync.Once // Ensures pendingResults channel is closed once + finishedChan chan struct{} // Closed when turn finishes + + // Token budget tracking + tokenBudget *atomic.Int64 // Shared token budget counter + lastFinishReason string // Last LLM finish_reason + lastUsage *providers.UsageInfo // Last LLM usage info + + // Back-reference to the owning AgentLoop (set for SubTurns only, used for hard abort cascade) + al *AgentLoop +} + +// ============================================================================= +// turnState constructors and active turn management +// ============================================================================= + +func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScope) *turnState { + ts := &turnState{ + agent: agent, + opts: opts, + scope: scope, + turnID: scope.turnID, + agentID: agent.ID, + sessionKey: opts.Dispatch.SessionKey, + turnCtx: cloneTurnContext(scope.context), + channel: opts.Dispatch.Channel(), + chatID: opts.Dispatch.ChatID(), + userMessage: opts.Dispatch.UserMessage, + media: append([]string(nil), opts.Dispatch.Media...), + phase: TurnPhaseSetup, + startedAt: time.Now(), + } + + // Bind session store and capture initial history length for rollback logic + if agent != nil && agent.Sessions != nil { + ts.session = agent.Sessions + history := agent.Sessions.GetHistory(opts.Dispatch.SessionKey) + ts.initialHistoryLength = len(history) + ts.restorePointHistory = append([]providers.Message(nil), history...) + ts.restorePointSummary = agent.Sessions.GetSummary(opts.Dispatch.SessionKey) + } + + return ts +} + +func (al *AgentLoop) registerActiveTurn(ts *turnState) { + al.activeTurnStates.Store(ts.sessionKey, ts) +} + +func (al *AgentLoop) clearActiveTurn(ts *turnState) { + al.activeTurnStates.Delete(ts.sessionKey) +} + +func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState { + if val, ok := al.activeTurnStates.Load(sessionKey); ok { + if ts, ok := val.(*turnState); ok { + return ts + } + // Unexpected non-*turnState value — treat as "no active turn" to avoid + // panics. This should not happen under normal operation. + } + return nil +} + +// getAnyActiveTurnState returns any active turn state (for backward compatibility) +func (al *AgentLoop) getAnyActiveTurnState() *turnState { + var firstTS *turnState + al.activeTurnStates.Range(func(key, value any) bool { + if ts, ok := value.(*turnState); ok { + firstTS = ts + return false + } + return true + }) + return firstTS +} + +func (al *AgentLoop) GetActiveTurn() *ActiveTurnInfo { + // For backward compatibility, return the first active turn found + // In the new architecture, there can be multiple concurrent turns + var firstTS *turnState + al.activeTurnStates.Range(func(key, value any) bool { + if ts, ok := value.(*turnState); ok { + firstTS = ts + return false + } + return true + }) + if firstTS == nil { + return nil + } + info := firstTS.snapshot() + return &info +} + +func (al *AgentLoop) GetActiveTurnBySession(sessionKey string) *ActiveTurnInfo { + ts := al.getActiveTurnState(sessionKey) + if ts == nil { + return nil + } + info := ts.snapshot() + return &info +} + +// ============================================================================= +// turnState - getters and setters +// ============================================================================= + +func (ts *turnState) snapshot() ActiveTurnInfo { + ts.mu.RLock() + defer ts.mu.RUnlock() + + return ActiveTurnInfo{ + TurnID: ts.turnID, + AgentID: ts.agentID, + SessionKey: ts.sessionKey, + Channel: ts.channel, + ChatID: ts.chatID, + UserMessage: ts.userMessage, + Phase: ts.phase, + Iteration: ts.iteration, + StartedAt: ts.startedAt, + Depth: ts.depth, + ParentTurnID: ts.parentTurnID, + ChildTurnIDs: append([]string(nil), ts.childTurnIDs...), + } +} + +func (ts *turnState) setPhase(phase TurnPhase) { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.phase = phase +} + +func (ts *turnState) setIteration(iteration int) { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.iteration = iteration +} + +func (ts *turnState) currentIteration() int { + ts.mu.RLock() + defer ts.mu.RUnlock() + return ts.iteration +} + +func (ts *turnState) setFinalContent(content string) { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.finalContent = content +} + +func (ts *turnState) finalContentLen() int { + ts.mu.RLock() + defer ts.mu.RUnlock() + return len(ts.finalContent) +} + +func (ts *turnState) setTurnCancel(cancel context.CancelFunc) { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.turnCancel = cancel +} + +func (ts *turnState) setProviderCancel(cancel context.CancelFunc) { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.providerCancel = cancel +} + +func (ts *turnState) clearProviderCancel(_ context.CancelFunc) { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.providerCancel = nil +} + +func (ts *turnState) requestGracefulInterrupt(hint string) bool { + ts.mu.Lock() + defer ts.mu.Unlock() + if ts.hardAbort { + return false + } + ts.gracefulInterrupt = true + ts.gracefulInterruptHint = hint + return true +} + +func (ts *turnState) gracefulInterruptRequested() (bool, string) { + ts.mu.RLock() + defer ts.mu.RUnlock() + return ts.gracefulInterrupt && !ts.gracefulTerminalUsed, ts.gracefulInterruptHint +} + +func (ts *turnState) markGracefulTerminalUsed() { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.gracefulTerminalUsed = true +} + +func (ts *turnState) requestHardAbort() bool { + ts.mu.Lock() + if ts.hardAbort { + ts.mu.Unlock() + return false + } + ts.hardAbort = true + turnCancel := ts.turnCancel + providerCancel := ts.providerCancel + ts.mu.Unlock() + + if providerCancel != nil { + providerCancel() + } + if turnCancel != nil { + turnCancel() + } + return true +} + +func (ts *turnState) hardAbortRequested() bool { + ts.mu.RLock() + defer ts.mu.RUnlock() + return ts.hardAbort +} + +func (ts *turnState) eventMeta(source, tracePath string) HookMeta { + snap := ts.snapshot() + return HookMeta{ + AgentID: snap.AgentID, + TurnID: snap.TurnID, + SessionKey: snap.SessionKey, + Iteration: snap.Iteration, + Source: source, + TracePath: tracePath, + turnContext: cloneTurnContext(ts.turnCtx), + } +} + +func (ts *turnState) captureRestorePoint(history []providers.Message, summary string) { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.restorePointHistory = append([]providers.Message(nil), history...) + ts.restorePointSummary = summary +} + +func (ts *turnState) recordPersistedMessage(msg providers.Message) { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.persistedMessages = append(ts.persistedMessages, msg) +} + +func (ts *turnState) refreshRestorePointFromSession(agent *AgentInstance) { + history := agent.Sessions.GetHistory(ts.sessionKey) + summary := agent.Sessions.GetSummary(ts.sessionKey) + + ts.mu.RLock() + persisted := append([]providers.Message(nil), ts.persistedMessages...) + ts.mu.RUnlock() + + if matched := matchingTurnMessageTail(history, persisted); matched > 0 { + history = append([]providers.Message(nil), history[:len(history)-matched]...) + } + + ts.captureRestorePoint(history, summary) +} + +// ingestMessage calls the ContextManager's Ingest method for a persisted message. +// Errors are logged but never block the turn. +func (ts *turnState) ingestMessage(ctx context.Context, al *AgentLoop, msg providers.Message) { + if al.contextManager == nil { + return + } + if err := al.contextManager.Ingest(ctx, &IngestRequest{ + SessionKey: ts.sessionKey, + Message: msg, + }); err != nil { + logger.WarnCF("agent", "Context manager ingest failed", map[string]any{ + "session_key": ts.sessionKey, + "error": err.Error(), + }) + } +} + +func (ts *turnState) restoreSession(agent *AgentInstance) error { + ts.mu.RLock() + history := append([]providers.Message(nil), ts.restorePointHistory...) + summary := ts.restorePointSummary + ts.mu.RUnlock() + + agent.Sessions.SetHistory(ts.sessionKey, history) + agent.Sessions.SetSummary(ts.sessionKey, summary) + return agent.Sessions.Save(ts.sessionKey) +} + +func matchingTurnMessageTail(history, persisted []providers.Message) int { + maxMatch := min(len(history), len(persisted)) + for size := maxMatch; size > 0; size-- { + if reflect.DeepEqual(history[len(history)-size:], persisted[len(persisted)-size:]) { + return size + } + } + return 0 +} + +func (ts *turnState) interruptHintMessage() providers.Message { + _, hint := ts.gracefulInterruptRequested() + content := "Interrupt requested. Stop scheduling tools and provide a short final summary." + if hint != "" { + content += "\n\nInterrupt hint: " + hint + } + return interruptPromptMessage(content) +} + +// ============================================================================= +// SubTurn-related methods +// ============================================================================= + +// Finish marks the turn as finished and closes the pendingResults channel +func (ts *turnState) Finish(isHardAbort bool) { + ts.isFinished.Store(true) + + // Close pendingResults channel exactly once + ts.closeOnce.Do(func() { + if ts.pendingResults != nil { + close(ts.pendingResults) + } + ts.mu.Lock() + if ts.finishedChan == nil { + ts.finishedChan = make(chan struct{}) + } + close(ts.finishedChan) + ts.mu.Unlock() + }) + + // Any graceful finish must signal direct children so nested SubTurns can + // observe parent completion and decide whether to stop or continue. + if !isHardAbort { + ts.parentEnded.Store(true) + } + + // Cancel the turn context + if ts.cancelFunc != nil { + ts.cancelFunc() + } + + // Hard abort cascades to all child turns + if isHardAbort && ts.al != nil { + ts.mu.RLock() + children := append([]string(nil), ts.childTurnIDs...) + ts.mu.RUnlock() + for _, childID := range children { + if val, ok := ts.al.activeTurnStates.Load(childID); ok { + if child, ok := val.(*turnState); ok { + child.Finish(true) + } + } + } + } +} + +// Finished returns whether the turn has finished +func (ts *turnState) Finished() chan struct{} { + ts.mu.Lock() + defer ts.mu.Unlock() + if ts.finishedChan == nil { + ts.finishedChan = make(chan struct{}) + } + return ts.finishedChan +} + +// IsParentEnded checks if the parent turn has ended +func (ts *turnState) IsParentEnded() bool { + if ts.parentTurnState == nil { + return false + } + return ts.parentTurnState.parentEnded.Load() +} + +// GetLastFinishReason returns the last LLM finish_reason +func (ts *turnState) GetLastFinishReason() string { + ts.mu.RLock() + defer ts.mu.RUnlock() + return ts.lastFinishReason +} + +// SetLastFinishReason sets the last LLM finish_reason +func (ts *turnState) SetLastFinishReason(reason string) { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.lastFinishReason = reason +} + +// GetLastUsage returns the last LLM usage info +func (ts *turnState) GetLastUsage() *providers.UsageInfo { + ts.mu.RLock() + defer ts.mu.RUnlock() + return ts.lastUsage +} + +// SetLastUsage sets the last LLM usage info +func (ts *turnState) SetLastUsage(usage *providers.UsageInfo) { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.lastUsage = usage +} + +// ============================================================================= +// Context helper functions for turnState +// ============================================================================= + +type turnStateKeyType struct{} + +var turnStateKey = turnStateKeyType{} + +func withTurnState(ctx context.Context, ts *turnState) context.Context { + return context.WithValue(ctx, turnStateKey, ts) +} + +func turnStateFromContext(ctx context.Context) *turnState { + ts, _ := ctx.Value(turnStateKey).(*turnState) + return ts +} + +// TurnStateFromContext retrieves turnState from context (exported for tools) +func TurnStateFromContext(ctx context.Context) *turnState { + return turnStateFromContext(ctx) +} diff --git a/pkg/audio/asr/README.md b/pkg/audio/asr/README.md new file mode 100644 index 000000000..99d2a8c90 --- /dev/null +++ b/pkg/audio/asr/README.md @@ -0,0 +1,167 @@ +# ASR (Automatic Speech Recognition) + +This package handles speech-to-text for PicoClaw voice input. + +If you are new to ASR setup, the simplest mental model is: + +1. Add one or more ASR-capable entries to `model_list`. +2. Point `voice.model_name` at the one you want to use. +3. Put the API key in `.security.yml`. + +## Quick Recommendation + +For most new users, start with one of these: + +| Provider | Example model | Why start here | +| --- | --- | --- | +| [Groq](https://console.groq.com/keys) | `groq/whisper-large-v3-turbo` | Fast Whisper-style transcription and a straightforward OpenAI-compatible API. Groq currently advertises a free tier plan for 2000 reqs/day. | +| [ElevenLabs](https://elevenlabs.io/pricing) | `elevenlabs/scribe_v1` | Easy setup and strong speech-to-text quality. ElevenLabs currently advertises a free plan that includes speech-to-text usage. | + +Pricing and free-plan limits can change, so check the linked pricing pages before depending on them in production. + +## How ASR Configuration Works + +PicoClaw does not keep ASR API keys inside the `voice` section. + +Instead: + +- `voice.model_name` chooses a named entry from `model_list`. +- The matching `model_list` entry describes the actual provider and model. +- `.security.yml` stores the API key for that named model entry. + +This is the recommended pattern because it is explicit, reusable, and consistent with the rest of PicoClaw's model configuration. + +## Recommended Setup + +### Option A: Groq Whisper + +`config.json` + +```json +{ + "voice": { + "model_name": "groq-asr", + "echo_transcription": true + }, + "model_list": [ + { + "model_name": "groq-asr", + "model": "groq/whisper-large-v3-turbo" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + groq-asr: + api_keys: + - "gsk_your_groq_key" +``` + +Notes: + +- You can omit `api_base` and PicoClaw will use Groq's default API base automatically. +- If you set `api_base` manually for Groq Whisper, both of these forms work: + - `https://api.groq.com/openai/v1` + - `https://api.groq.com/openai/v1/audio/transcriptions` +- Any OpenAI-compatible Whisper model name containing `whisper` can use the Whisper transcription path, not only `whisper-large-v3-turbo`. + +### Option B: ElevenLabs + +`config.json` + +```json +{ + "voice": { + "model_name": "elevenlabs-asr", + "echo_transcription": true + }, + "model_list": [ + { + "model_name": "elevenlabs-asr", + "provider": "elevenlabs", + "model": "scribe_v1" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + elevenlabs-asr: + api_keys: + - "sk-elevenlabs-your-key" +``` + +### Option C: OpenAI Whisper + +`config.json` + +```json +{ + "voice": { + "model_name": "openai-asr" + }, + "model_list": [ + { + "model_name": "openai-asr", + "model": "openai/whisper-1" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + openai-asr: + api_keys: + - "sk-openai-your-key" +``` + +## Other ASR-Capable Model Types + +PicoClaw currently supports three main ASR routes: + +| Route | Example models | Behavior | +| --- | --- | --- | +| ElevenLabs ASR | `provider: elevenlabs`, `model: scribe_v1` | Uses the ElevenLabs transcription API. | +| Whisper endpoint models | `openai/whisper-1`, `groq/whisper-large-v3` | Uses an OpenAI-compatible `/audio/transcriptions` endpoint. | +| Audio-capable chat models **(Under construction)** | `openai/gpt-4o-audio-preview`, `gemini/gemini-2.5-flash` | Sends audio to a multimodal chat model and asks it to transcribe. | + +If you are unsure which one to pick, choose Groq Whisper or ElevenLabs first. + +## How PicoClaw Chooses a Transcriber + +`DetectTranscriber` resolves ASR in this order: + +1. **Preferred path**: resolve `voice.model_name` against `model_list`. +2. If that resolved model is: + - an `elevenlabs` provider model, PicoClaw uses the ElevenLabs transcriber. + - an OpenAI-compatible Whisper model, PicoClaw uses the Whisper transcriber. + - an audio-capable chat model, PicoClaw uses `AudioModelTranscriber`. +3. **Fallback path**: if `voice.model_name` is not set, PicoClaw performs a compatibility scan through `model_list` for legacy auto-detected ASR entries. + +Fallback scanning exists for backward compatibility. New configurations should set `voice.model_name` explicitly. + +## Common Mistakes + +- Defining an ASR model in `model_list` but forgetting to set `voice.model_name`. +- Putting the API key in `voice` instead of `.security.yml`. +- Using a non-ASR model and expecting Whisper-style transcription behavior. +- Setting a custom `api_base` that points to the wrong provider endpoint. + +## Minimal Checklist + +Before testing voice input, make sure: + +- `voice.model_name` matches a `model_list[].model_name`. +- The matching `.security.yml` entry contains a valid API key. +- The selected model is actually ASR-capable. +- Voice input is enabled for the channel you are using. diff --git a/pkg/audio/asr/README.zh.md b/pkg/audio/asr/README.zh.md new file mode 100644 index 000000000..670698cb8 --- /dev/null +++ b/pkg/audio/asr/README.zh.md @@ -0,0 +1,167 @@ +# ASR(自动语音识别) + +这个目录负责 PicoClaw 的语音转文字能力。 + +如果你是第一次配置 ASR,可以参考如下步骤: + +1. 在 `model_list` 里添加一个或多个支持 ASR 的模型条目。 +2. 用 `voice.model_name` 指向你想使用的那个条目。 +3. 在 `.security.yml` 里配置对应的 API Key。 + +## 快速推荐 + +对于大多数新用户,建议先从下面两种开始: + +| 提供商 | 示例模型 | 推荐理由 | +| --- | --- | --- | +| [Groq](https://console.groq.com/keys) | `groq/whisper-large-v3-turbo` | Whisper 风格转录速度快,并且提供 OpenAI 兼容接口,配置比较直接。Groq 目前官方提供2000请求每日的免费套餐。 | +| [ElevenLabs](https://elevenlabs.io/pricing) | `elevenlabs/scribe_v1` | 上手简单,语音转文字质量也不错。ElevenLabs 目前官方免费套餐包含 STT 用量。 | + +价格和免费额度可能会变化,正式使用前请以官网定价页为准。 + +## ASR 配置是如何工作的 + +PicoClaw 不会把 ASR 的 API Key 放在 `voice` 配置里。 + +推荐的方式是: + +- `voice.model_name` 用来选择 `model_list` 里的某个命名模型。 +- `model_list` 条目描述真实的提供商和模型。 +- `.security.yml` 负责保存该模型条目的 API Key。 + +这种方式更明确、更安全,也和 PicoClaw 其他模型配置方式保持一致。 + +## 推荐配置方式 + +### 方案 A:Groq Whisper + +`config.json` + +```json +{ + "voice": { + "model_name": "groq-asr", + "echo_transcription": true + }, + "model_list": [ + { + "model_name": "groq-asr", + "model": "groq/whisper-large-v3-turbo" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + groq-asr: + api_keys: + - "gsk_your_groq_key" +``` + +说明: + +- 你可以不写 `api_base`,PicoClaw 会自动使用 Groq 默认接口地址。 +- 如果你手动设置 Groq Whisper 的 `api_base`,下面两种写法都可以: + - `https://api.groq.com/openai/v1` + - `https://api.groq.com/openai/v1/audio/transcriptions` +- 只要是 OpenAI 兼容、并且模型名里包含 `whisper` 的模型,都可以走 Whisper 转录路径,不仅限于 `whisper-large-v3-turbo`。 + +### 方案 B:ElevenLabs + +`config.json` + +```json +{ + "voice": { + "model_name": "elevenlabs-asr", + "echo_transcription": true + }, + "model_list": [ + { + "model_name": "elevenlabs-asr", + "provider": "elevenlabs", + "model": "scribe_v1" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + elevenlabs-asr: + api_keys: + - "sk-elevenlabs-your-key" +``` + +### 方案 C:OpenAI Whisper + +`config.json` + +```json +{ + "voice": { + "model_name": "openai-asr" + }, + "model_list": [ + { + "model_name": "openai-asr", + "model": "openai/whisper-1" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + openai-asr: + api_keys: + - "sk-openai-your-key" +``` + +## 其他支持 ASR 的模型类型 + +PicoClaw 目前主要支持三种 ASR 路径: + +| 路径 | 示例模型 | 行为说明 | +| --- | --- | --- | +| ElevenLabs ASR | `provider: elevenlabs`,`model: scribe_v1` | 使用 ElevenLabs 的语音转录接口。 | +| Whisper 接口模型 | `openai/whisper-1`、`groq/whisper-large-v3` | 使用 OpenAI 兼容的 `/audio/transcriptions` 接口。 | +| 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview`、`gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 | + +如果你不确定该选哪种,建议优先使用 Groq Whisper 或 ElevenLabs。 + +## PicoClaw 如何选择转录器 + +`DetectTranscriber` 会按下面顺序选择 ASR: + +1. **首选路径**:根据 `voice.model_name` 在 `model_list` 中找到对应模型。 +2. 如果找到的模型属于以下类型: + - `provider=elevenlabs` 的模型,则使用 ElevenLabs transcriber。 + - OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。 + - 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`。 +3. **回退路径**:如果没有设置 `voice.model_name`,PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。 + +回退扫描只是为了兼容旧行为。新配置建议始终显式设置 `voice.model_name`。 + +## 常见错误 + +- 在 `model_list` 里定义了 ASR 模型,但忘了设置 `voice.model_name`。 +- 把 API Key 写进了 `voice`,而不是 `.security.yml`。 +- 选择了不支持 ASR 的模型,却期望得到 Whisper 风格的转录结果。 +- 自定义了错误的 `api_base`,导致请求打到错误的接口地址。 + +## 最小检查清单 + +在测试语音输入前,请确认: + +- `voice.model_name` 能正确匹配某个 `model_list[].model_name`。 +- `.security.yml` 中对应条目已经配置了有效 API Key。 +- 你选择的模型确实支持 ASR。 +- 你当前使用的频道已经启用了语音输入能力。 diff --git a/pkg/audio/asr/agent.go b/pkg/audio/asr/agent.go new file mode 100644 index 000000000..c483a0778 --- /dev/null +++ b/pkg/audio/asr/agent.go @@ -0,0 +1,253 @@ +package asr + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/pion/rtp" + "github.com/pion/webrtc/v3/pkg/media/oggwriter" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type speechAccumulator struct { + writer *oggwriter.OggWriter + file string + lastAudioAt time.Time + mu sync.Mutex + closed bool + chatID string + speakerID string + sessionID string + channel string +} + +func (a *speechAccumulator) Push(chunk bus.AudioChunk) { + a.mu.Lock() + defer a.mu.Unlock() + + if a.closed { + return + } + + a.lastAudioAt = time.Now() + + pkt := &rtp.Packet{ + Header: rtp.Header{ + SequenceNumber: uint16(chunk.Sequence), + Timestamp: chunk.Timestamp, + SSRC: 1, // Stable arbitrary dummy + }, + Payload: chunk.Data, + } + + if err := a.writer.WriteRTP(pkt); err != nil { + logger.ErrorCF("voice-agent", "Failed to write RTP", map[string]any{"error": err}) + } +} + +func (a *speechAccumulator) Close() { + a.mu.Lock() + defer a.mu.Unlock() + if !a.closed { + a.writer.Close() + a.closed = true + } +} + +type Agent struct { + bus *bus.MessageBus + transcriber Transcriber + + mu sync.Mutex + sessions map[string]*speechAccumulator // keyed by sessionID_speakerID +} + +func NewAgent(mb *bus.MessageBus, t Transcriber) *Agent { + return &Agent{ + bus: mb, + transcriber: t, + sessions: make(map[string]*speechAccumulator), + } +} + +func (a *Agent) Start(ctx context.Context) { + logger.InfoCF("voice-agent", "Started Voice Agent orchestrator", nil) + go a.listenChunks(ctx) + go a.vadTick(ctx) + + // Cleanup sessions on shutdown + go func() { + <-ctx.Done() + a.mu.Lock() + for key, acc := range a.sessions { + acc.Close() + os.Remove(acc.file) + delete(a.sessions, key) + } + a.mu.Unlock() + logger.InfoCF("voice-agent", "Cleaned up voice sessions on shutdown", nil) + }() +} + +func (a *Agent) listenChunks(ctx context.Context) { + chunks := a.bus.AudioChunksChan() + for { + select { + case <-ctx.Done(): + return + case chunk, ok := <-chunks: + if !ok { + return + } + a.handleChunk(chunk) + } + } +} + +func (a *Agent) handleChunk(chunk bus.AudioChunk) { + // Only accept Opus-encoded audio + if chunk.Format != "opus" { + logger.DebugCF("voice-agent", "Ignoring unsupported audio format", map[string]any{"format": chunk.Format}) + return + } + + key := fmt.Sprintf("%s_%s", chunk.SessionID, chunk.SpeakerID) + + a.mu.Lock() + acc, exists := a.sessions[key] + if !exists { + filename := filepath.Join(os.TempDir(), fmt.Sprintf("voice_%s_%d.ogg", key, time.Now().UnixNano())) + writer, err := oggwriter.New(filename, uint32(chunk.SampleRate), uint16(chunk.Channels)) + if err != nil { + a.mu.Unlock() + logger.ErrorCF("voice-agent", "Failed to create OggWriter", map[string]any{"error": err}) + return + } + + acc = &speechAccumulator{ + writer: writer, + file: filename, + lastAudioAt: time.Now(), + chatID: chunk.ChatID, + speakerID: chunk.SpeakerID, + sessionID: chunk.SessionID, + channel: chunk.Channel, + } + a.sessions[key] = acc + logger.DebugCF("voice-agent", "Started accumulating voice", map[string]any{"key": key, "file": filename}) + } + a.mu.Unlock() + + acc.Push(chunk) +} + +func (a *Agent) vadTick(ctx context.Context) { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.checkSilence(ctx) + } + } +} + +func (a *Agent) checkSilence(ctx context.Context) { + a.mu.Lock() + now := time.Now() + var finished []*speechAccumulator + + for key, acc := range a.sessions { + acc.mu.Lock() + last := acc.lastAudioAt + acc.mu.Unlock() + + if now.Sub(last) > 1500*time.Millisecond { + acc.Close() + delete(a.sessions, key) + finished = append(finished, acc) + } + } + a.mu.Unlock() + + for _, acc := range finished { + go a.processUtterance(ctx, acc) + } +} + +func (a *Agent) processUtterance(ctx context.Context, acc *speechAccumulator) { + defer os.Remove(acc.file) + + logger.InfoCF("voice-agent", "User finished speaking, transcribing...", map[string]any{"file": acc.file}) + + if a.transcriber == nil { + logger.ErrorCF("voice-agent", "No STT configured!", nil) + return + } + + res, err := a.transcriber.Transcribe(ctx, acc.file) + if err != nil { + logger.ErrorCF("voice-agent", "Transcription failed", map[string]any{"error": err}) + return + } + + if res.Text == "" { + logger.DebugCF("voice-agent", "Ignored empty transcription", map[string]any{"file": acc.file}) + return + } + + logger.InfoCF("voice-agent", "Transcription result", map[string]any{"text": res.Text, "duration": res.Duration}) + + channelType := acc.channel + if channelType == "" { + channelType = "discord" // fallback for legacy chunks + } + + text := strings.ToLower(strings.TrimSpace(res.Text)) + if strings.Contains(text, "leave the voice channel") || strings.Contains(text, "leave voice") || + strings.Contains(text, "disconnect voice") || strings.Contains(text, "leave the channel") || + strings.Contains(text, "leave channel") { + logger.InfoCF("voice-agent", "Voice command triggered: leave", nil) + if err := a.bus.PublishVoiceControl(ctx, bus.VoiceControl{ + SessionID: acc.sessionID, + Type: "command", + Action: "leave", + }); err != nil { + logger.ErrorCF("voice-agent", "Failed to publish leave control", map[string]any{"error": err}) + } + if err := a.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Context: bus.NewOutboundContext(channelType, acc.chatID, ""), + Content: "Goodbye! Leaving the voice channel.", + }); err != nil { + logger.ErrorCF("voice-agent", "Failed to publish goodbye message", map[string]any{"error": err}) + } + return + } + + oralPrompt := "\n\n[SYSTEM]: The user just spoke this to you over voice chat. Please reply in a highly concise, conversational, oral style suitable for text-to-speech. Do not use markdown, emojis, asterisks, or code blocks. Speak naturally." + + if err := a.bus.PublishInbound(ctx, bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: channelType, + ChatID: acc.chatID, + ChatType: "channel", + SenderID: acc.speakerID, + Raw: map[string]string{ + "is_voice": "true", + }, + }, + Content: res.Text + oralPrompt, + }); err != nil { + logger.ErrorCF("voice-agent", "Failed to publish inbound message", map[string]any{"error": err}) + } +} diff --git a/pkg/audio/asr/agent_test.go b/pkg/audio/asr/agent_test.go new file mode 100644 index 000000000..0f9bcb3b2 --- /dev/null +++ b/pkg/audio/asr/agent_test.go @@ -0,0 +1,196 @@ +package asr + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/pion/webrtc/v3/pkg/media/oggwriter" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +type fakeTranscriber struct { + text string + err error + lastPath string +} + +func (f *fakeTranscriber) Name() string { return "fake" } + +func (f *fakeTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { + f.lastPath = audioFilePath + if f.err != nil { + return nil, f.err + } + return &TranscriptionResponse{Text: f.text}, nil +} + +func waitForFileRemoval(t *testing.T, path string, timeout time.Duration) { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); os.IsNotExist(err) { + return + } + time.Sleep(10 * time.Millisecond) + } + if _, err := os.Stat(path); err == nil { + t.Fatalf("expected file to be removed: %s", path) + } +} + +func TestAgentHandleChunkCreatesSession(t *testing.T) { + t.Parallel() + + mb := bus.NewMessageBus() + defer mb.Close() + + agent := NewAgent(mb, &fakeTranscriber{}) + + chunk := bus.AudioChunk{ + SessionID: "sess", + SpeakerID: "speaker", + ChatID: "chat", + Channel: "discord", + Sequence: 1, + Timestamp: 1, + SampleRate: 48000, + Channels: 2, + Format: "opus", + Data: []byte{0xF8, 0xFF, 0xFE}, + } + + agent.handleChunk(chunk) + + key := "sess_speaker" + agent.mu.Lock() + acc, ok := agent.sessions[key] + agent.mu.Unlock() + if !ok { + t.Fatal("expected session to be created") + } + + acc.Close() + _ = os.Remove(acc.file) +} + +func TestAgentHandleChunkIgnoresUnsupportedFormat(t *testing.T) { + t.Parallel() + + mb := bus.NewMessageBus() + defer mb.Close() + + agent := NewAgent(mb, &fakeTranscriber{}) + + chunk := bus.AudioChunk{Format: "pcm"} + agent.handleChunk(chunk) + + agent.mu.Lock() + count := len(agent.sessions) + agent.mu.Unlock() + if count != 0 { + t.Fatalf("expected no sessions, got %d", count) + } +} + +func TestAgentProcessUtteranceLeaveCommand(t *testing.T) { + t.Parallel() + + mb := bus.NewMessageBus() + defer mb.Close() + + tr := &fakeTranscriber{text: "please leave the voice channel now"} + agent := NewAgent(mb, tr) + + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "voice.ogg") + if err := os.WriteFile(filePath, []byte("data"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + acc := &speechAccumulator{ + file: filePath, + chatID: "chat", + speakerID: "speaker", + sessionID: "sess", + channel: "discord", + } + + agent.processUtterance(context.Background(), acc) + + select { + case ctrl := <-mb.VoiceControlsChan(): + if ctrl.Action != "leave" || ctrl.Type != "command" || ctrl.SessionID != "sess" { + t.Fatalf("unexpected voice control: %#v", ctrl) + } + case <-time.After(250 * time.Millisecond): + t.Fatal("expected voice control publish") + } + + select { + case out := <-mb.OutboundChan(): + if !strings.Contains(out.Content, "Leaving the voice channel") { + t.Fatalf("unexpected outbound content: %q", out.Content) + } + case <-time.After(250 * time.Millisecond): + t.Fatal("expected outbound publish") + } + + if _, err := os.Stat(filePath); !os.IsNotExist(err) { + t.Fatalf("expected temp file to be removed") + } +} + +func TestAgentCheckSilencePublishesInboundAndCleansUp(t *testing.T) { + t.Parallel() + + mb := bus.NewMessageBus() + defer mb.Close() + + tr := &fakeTranscriber{text: "hello there"} + agent := NewAgent(mb, tr) + + filePath := filepath.Join(t.TempDir(), "voice.ogg") + writer, err := oggwriter.New(filePath, 48000, 2) + if err != nil { + t.Fatalf("create ogg writer: %v", err) + } + + acc := &speechAccumulator{ + writer: writer, + file: filePath, + lastAudioAt: time.Now().Add(-2 * time.Second), + chatID: "chat", + speakerID: "speaker", + sessionID: "sess", + channel: "slack", + } + + agent.mu.Lock() + agent.sessions["sess_speaker"] = acc + agent.mu.Unlock() + + agent.checkSilence(context.Background()) + + select { + case msg := <-mb.InboundChan(): + if msg.Channel != "slack" { + t.Fatalf("unexpected inbound channel: %q", msg.Channel) + } + if !strings.Contains(msg.Content, "hello there") { + t.Fatalf("unexpected inbound content: %q", msg.Content) + } + if msg.Context.Raw["is_voice"] != "true" { + t.Fatalf("expected is_voice metadata, got %#v", msg.Context.Raw) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("expected inbound publish") + } + + waitForFileRemoval(t, filePath, 500*time.Millisecond) +} diff --git a/pkg/audio/asr/asr.go b/pkg/audio/asr/asr.go new file mode 100644 index 000000000..a7c93e578 --- /dev/null +++ b/pkg/audio/asr/asr.go @@ -0,0 +1,146 @@ +package asr + +import ( + "context" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +const elevenLabsSupportedModelID = "scribe_v1" + +func ElevenLabsSupportedModelID() string { + return elevenLabsSupportedModelID +} + +type Transcriber interface { + Name() string + Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) +} + +type TranscriptionResponse struct { + Text string `json:"text"` + Language string `json:"language,omitempty"` + Duration float64 `json:"duration,omitempty"` +} + +func supportsAudioTranscription(modelCfg *config.ModelConfig) bool { + protocol, _ := providers.ExtractProtocol(modelCfg) + + switch protocol { + case "openai", "azure", "azure-openai", + "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", + "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", + "vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", + "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", + "coding-plan", "alibaba-coding", "qwen-coding", "zai": + // These protocols all go through the OpenAI-compatible or Azure provider path in + // providers.CreateProviderFromConfig, so they are the only ones that can supply + // the audio media payload shape expected by NewAudioModelTranscriber. + + // TODO: Further restrict this by modelID, since not every model under these + // protocols supports audio transcription. + return true + default: + return false + } +} + +func supportsWhisperTranscription(modelCfg *config.ModelConfig) bool { + protocol, _ := providers.ExtractProtocol(modelCfg) + + switch protocol { + case "openai", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", + "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", + "vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", + "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", + "coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo": + return true + default: + return false + } +} + +func whisperModelID(modelCfg *config.ModelConfig) string { + if modelCfg == nil || modelCfg.APIKey() == "" { + return "" + } + + if !supportsWhisperTranscription(modelCfg) { + return "" + } + + _, modelID := providers.ExtractProtocol(modelCfg) + if strings.Contains(strings.ToLower(modelID), "whisper") { + return modelID + } + return "" +} + +func isElevenLabsTranscriptionModel(modelCfg *config.ModelConfig) bool { + if modelCfg == nil || modelCfg.APIKey() == "" { + return false + } + + protocol, _ := providers.ExtractProtocol(modelCfg) + return protocol == "elevenlabs" +} + +func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber { + if modelCfg == nil { + return nil + } + + if isElevenLabsTranscriptionModel(modelCfg) { + _, modelID := providers.ExtractProtocol(modelCfg) + return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID) + } + if modelID := whisperModelID(modelCfg); modelID != "" { + return NewWhisperTranscriber(modelCfg) + } + if supportsAudioTranscription(modelCfg) { + return NewAudioModelTranscriber(modelCfg) + } + return nil +} + +func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber { + if modelCfg == nil { + return nil + } + + if isElevenLabsTranscriptionModel(modelCfg) { + _, modelID := providers.ExtractProtocol(modelCfg) + return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID) + } + if modelID := whisperModelID(modelCfg); modelID != "" { + return NewWhisperTranscriber(modelCfg) + } + return nil +} + +// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or +// nil if no supported transcription provider is configured. +func DetectTranscriber(cfg *config.Config) Transcriber { + if cfg == nil { + return nil + } + + if modelName := strings.TrimSpace(cfg.Voice.ModelName); modelName != "" { + modelCfg, err := cfg.GetModelConfig(modelName) + if err == nil { + if tr := transcriberFromModelConfig(modelCfg); tr != nil { + return tr + } + } + } + + // Fall back to compatibility scanning for legacy auto-detected ASR providers. + for _, mc := range cfg.ModelList { + if tr := fallbackTranscriberFromModelConfig(mc); tr != nil { + return tr + } + } + return nil +} diff --git a/pkg/audio/asr/asr_test.go b/pkg/audio/asr/asr_test.go new file mode 100644 index 000000000..f877b1198 --- /dev/null +++ b/pkg/audio/asr/asr_test.go @@ -0,0 +1,243 @@ +package asr + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestDetectTranscriber(t *testing.T) { + tests := []struct { + name string + cfg *config.Config + wantNil bool + wantName string + }{ + { + name: "no config", + cfg: &config.Config{}, + wantNil: true, + }, + { + name: "voice model name selects audio model transcriber", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "voice-gemini"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "voice-gemini", + Model: "gemini/gemini-2.5-flash", + APIKeys: config.SimpleSecureStrings("sk-gemini-model"), + }, + }, + }, + wantName: "audio-model", + }, + { + name: "voice model name alias selects elevenlabs transcriber", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "my-asr-model"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "my-asr-model", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }, + }, + }, + wantName: "elevenlabs", + }, + { + name: "explicit elevenlabs provider selects elevenlabs transcriber", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "my-asr-model"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "my-asr-model", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }, + }, + }, + wantName: "elevenlabs", + }, + { + name: "voice model name alias selects whisper transcriber for groq", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "my-asr-model"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "my-asr-model", + Model: "groq/whisper-large-v3", + APIKeys: config.SimpleSecureStrings("sk-groq-model"), + }, + }, + }, + wantName: "whisper", + }, + { + name: "openai whisper alias selects whisper transcriber", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "my-asr-model"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "my-asr-model", + Model: "openai/whisper-1", + APIKeys: config.SimpleSecureStrings("sk-openai-model"), + }, + }, + }, + wantName: "whisper", + }, + { + name: "whisper via model list fallback", + cfg: &config.Config{ + ModelList: []*config.ModelConfig{ + {ModelName: "openai", Model: "openai/gpt-4o", APIKeys: config.SimpleSecureStrings("sk-openai")}, + { + ModelName: "groq", + Model: "groq/whisper-large-v3-turbo", + APIKeys: config.SimpleSecureStrings("sk-groq-model"), + }, + }, + }, + wantName: "whisper", + }, + { + name: "voice model name alias selects non-gemini audio model transcriber", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "my-asr-model"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "my-asr-model", + Model: "openai/gpt-4o-audio-preview", + APIKeys: config.SimpleSecureStrings("sk-openai"), + }, + }, + }, + wantName: "audio-model", + }, + { + name: "voice model name selects azure audio model transcriber", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "voice-azure-audio"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "voice-azure-audio", + Model: "azure/my-audio-deployment", APIKeys: config.SimpleSecureStrings("sk-azure"), + APIBase: "https://example.openai.azure.com", + }, + }, + }, + wantName: "audio-model", + }, + { + name: "voice model name with non openai compatible protocol does not select audio model transcriber", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "voice-anthropic"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "voice-anthropic", + Model: "anthropic/claude-sonnet-4.6", + APIKeys: config.SimpleSecureStrings("sk-anthropic"), + }, + }, + }, + wantNil: true, + }, + { + name: "groq model list entry without key is skipped", + cfg: &config.Config{ + ModelList: []*config.ModelConfig{ + {Model: "groq/whisper-large-v3"}, + }, + }, + wantNil: true, + }, + { + name: "provider key takes priority over model list", + cfg: &config.Config{ + ModelList: []*config.ModelConfig{ + { + ModelName: "groq", + Model: "groq/whisper-large-v3", + APIKeys: config.SimpleSecureStrings("sk-groq-model"), + }, + }, + }, + wantName: "whisper", + }, + { + name: "missing voice model name config returns nil", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "missing"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "other", + Model: "gemini/gemini-2.5-flash", + APIKeys: config.SimpleSecureStrings("sk-other-model"), + }, + }, + }, + wantNil: true, + }, + { + name: "elevenlabs voice config key", + cfg: &config.Config{ + ModelList: []*config.ModelConfig{ + {Model: "elevenlabs/scribe_v1", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test")}, + }, + }, + wantName: "elevenlabs", + }, + { + name: "elevenlabs takes priority over groq model list", + cfg: &config.Config{ + ModelList: []*config.ModelConfig{ + {Model: "elevenlabs/scribe_v1", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test")}, + { + ModelName: "groq", + Model: "groq/llama-3.3-70b", + APIKeys: config.SimpleSecureStrings("sk-groq-model"), + }, + }, + }, + wantName: "elevenlabs", + }, + { + name: "voice model name takes priority over elevenlabs", + cfg: &config.Config{ + Voice: config.VoiceConfig{ + ModelName: "voice-gemini", + }, + ModelList: []*config.ModelConfig{ + {Model: "elevenlabs", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test")}, + { + ModelName: "voice-gemini", + Model: "gemini/gemini-2.5-flash", + APIKeys: config.SimpleSecureStrings("sk-gemini-model"), + }, + }, + }, + wantName: "audio-model", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tr := DetectTranscriber(tc.cfg) + if tc.wantNil { + if tr != nil { + t.Errorf("DetectTranscriber() = %v, want nil", tr) + } + return + } + if tr == nil { + t.Fatal("DetectTranscriber() = nil, want non-nil") + } + if got := tr.Name(); got != tc.wantName { + t.Errorf("Name() = %q, want %q", got, tc.wantName) + } + }) + } +} diff --git a/pkg/audio/asr/audio_model_transcriber.go b/pkg/audio/asr/audio_model_transcriber.go new file mode 100644 index 000000000..e8ded15dd --- /dev/null +++ b/pkg/audio/asr/audio_model_transcriber.go @@ -0,0 +1,95 @@ +package asr + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/utils" +) + +type AudioModelTranscriber struct { + provider providers.LLMProvider + modelID string + prompt string +} + +const ( + defaultTranscriptionPrompt = "Transcribe this audio." +) + +func NewAudioModelTranscriber(modelCfg *config.ModelConfig) *AudioModelTranscriber { + if modelCfg == nil { + return nil + } + + logger.DebugCF("voice", "Creating audio model transcriber", map[string]any{ + "has_api_key": modelCfg.APIKey() != "", + "api_base": modelCfg.APIBase, + "model": modelCfg.Model, + }) + + provider, modelID, err := providers.CreateProviderFromConfig(modelCfg) + if err != nil { + logger.ErrorCF("voice", "Failed to create audio model provider", map[string]any{"error": err}) + return nil + } + + return &AudioModelTranscriber{ + provider: provider, + modelID: modelID, + prompt: defaultTranscriptionPrompt, + } +} + +func (t *AudioModelTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { + logger.InfoCF("voice", "Starting audio model transcription", map[string]any{ + "audio_file": audioFilePath, + "model": t.modelID, + }) + + audioBytes, err := os.ReadFile(audioFilePath) + if err != nil { + logger.ErrorCF("voice", "Failed to read audio file", map[string]any{"path": audioFilePath, "error": err}) + return nil, fmt.Errorf("failed to read audio file: %w", err) + } + + format, err := utils.AudioFormat(audioFilePath) + if err != nil { + logger.ErrorCF("voice", "Failed to detect audio format", map[string]any{"path": audioFilePath, "error": err}) + return nil, err + } + + resp, err := t.provider.Chat(ctx, []providers.Message{ + { + Role: "user", + Content: t.prompt, + Media: []string{ + fmt.Sprintf("data:audio/%s;base64,%s", format, base64.StdEncoding.EncodeToString(audioBytes)), + }, + }, + }, nil, t.modelID, map[string]any{ + "temperature": 0, + }) + if err != nil { + logger.ErrorCF("voice", "Audio model transcription request failed", map[string]any{"error": err}) + return nil, fmt.Errorf("transcription request failed: %w", err) + } + + text := strings.TrimSpace(resp.Content) + logger.InfoCF("voice", "Audio model transcription completed successfully", map[string]any{ + "text_length": len(text), + "transcription_preview": utils.Truncate(text, 50), + }) + + return &TranscriptionResponse{Text: text}, nil +} + +func (t *AudioModelTranscriber) Name() string { + return "audio-model" +} diff --git a/pkg/audio/asr/audio_model_transcriber_test.go b/pkg/audio/asr/audio_model_transcriber_test.go new file mode 100644 index 000000000..5aaa82061 --- /dev/null +++ b/pkg/audio/asr/audio_model_transcriber_test.go @@ -0,0 +1,203 @@ +package asr + +import ( + "context" + "encoding/base64" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +var _ Transcriber = (*AudioModelTranscriber)(nil) + +type fakeLLMProvider struct { + chatFunc func( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + options map[string]any, + ) (*providers.LLMResponse, error) +} + +func (p *fakeLLMProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + options map[string]any, +) (*providers.LLMResponse, error) { + if p.chatFunc == nil { + return nil, nil + } + return p.chatFunc(ctx, messages, tools, model, options) +} + +func (p *fakeLLMProvider) GetDefaultModel() string { + return "" +} + +func TestAudioModelTranscriberName(t *testing.T) { + tr := &AudioModelTranscriber{} + if got := tr.Name(); got != "audio-model" { + t.Errorf("Name() = %q, want %q", got, "audio-model") + } +} + +func TestNewAudioModelTranscriberInvalidConfig(t *testing.T) { + tests := []struct { + name string + cfg *config.ModelConfig + }{ + { + name: "nil config", + cfg: nil, + }, + { + name: "missing api key", + cfg: &config.ModelConfig{ + Model: "gemini/gemini-2.5-flash", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tr := NewAudioModelTranscriber(tt.cfg); tr != nil { + t.Fatalf("NewAudioModelTranscriber() = %#v, want nil", tr) + } + }) + } +} + +func TestAudioModelTranscriberTranscribe(t *testing.T) { + tmpDir := t.TempDir() + audioPath := filepath.Join(tmpDir, "clip.ogg") + audioData := []byte("fake-audio-data") + if err := os.WriteFile(audioPath, audioData, 0o644); err != nil { + t.Fatalf("failed to write fake audio file: %v", err) + } + + t.Run("success", func(t *testing.T) { + tr := &AudioModelTranscriber{ + provider: &fakeLLMProvider{ + chatFunc: func( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + options map[string]any, + ) (*providers.LLMResponse, error) { + if ctx == nil { + t.Fatal("context should not be nil") + } + if tools != nil { + t.Fatalf("tools = %#v, want nil", tools) + } + if model != "gemini-2.5-flash" { + t.Fatalf("model = %q, want %q", model, "gemini-2.5-flash") + } + if len(messages) != 1 { + t.Fatalf("len(messages) = %d, want 1", len(messages)) + } + msg := messages[0] + if msg.Role != "user" { + t.Fatalf("role = %q, want %q", msg.Role, "user") + } + if msg.Content != defaultTranscriptionPrompt { + t.Fatalf("prompt = %q, want %q", msg.Content, defaultTranscriptionPrompt) + } + if len(msg.Media) != 1 { + t.Fatalf("len(media) = %d, want 1", len(msg.Media)) + } + wantMedia := "data:audio/ogg;base64," + base64.StdEncoding.EncodeToString(audioData) + if msg.Media[0] != wantMedia { + t.Fatalf("media = %q, want %q", msg.Media[0], wantMedia) + } + if len(options) != 1 { + t.Fatalf("options = %#v, want only temperature", options) + } + if got := options["temperature"]; got != 0 { + t.Fatalf("temperature = %#v, want 0", got) + } + + return &providers.LLMResponse{Content: " hello from gemini \n"}, nil + }, + }, + modelID: "gemini-2.5-flash", + prompt: defaultTranscriptionPrompt, + } + + resp, err := tr.Transcribe(context.Background(), audioPath) + if err != nil { + t.Fatalf("Transcribe() error: %v", err) + } + if resp.Text != "hello from gemini" { + t.Fatalf("Text = %q, want %q", resp.Text, "hello from gemini") + } + }) + + t.Run("provider error", func(t *testing.T) { + tr := &AudioModelTranscriber{ + provider: &fakeLLMProvider{ + chatFunc: func( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + options map[string]any, + ) (*providers.LLMResponse, error) { + return nil, errors.New("upstream failure") + }, + }, + modelID: "gemini-2.5-flash", + prompt: defaultTranscriptionPrompt, + } + + _, err := tr.Transcribe(context.Background(), audioPath) + if err == nil { + t.Fatal("expected error for provider failure, got nil") + } + if got := err.Error(); got != "transcription request failed: upstream failure" { + t.Fatalf("error = %q, want %q", got, "transcription request failed: upstream failure") + } + }) + + t.Run("missing file", func(t *testing.T) { + tr := &AudioModelTranscriber{ + provider: &fakeLLMProvider{}, + modelID: "gemini-2.5-flash", + prompt: defaultTranscriptionPrompt, + } + + _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg")) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } + }) + + t.Run("unsupported audio format", func(t *testing.T) { + badPath := filepath.Join(tmpDir, "clip.txt") + if err := os.WriteFile(badPath, []byte("not-audio"), 0o644); err != nil { + t.Fatalf("failed to write fake file: %v", err) + } + + tr := &AudioModelTranscriber{ + provider: &fakeLLMProvider{}, + modelID: "gemini-2.5-flash", + prompt: defaultTranscriptionPrompt, + } + + _, err := tr.Transcribe(context.Background(), badPath) + if err == nil { + t.Fatal("expected error for unsupported audio format, got nil") + } + if got := err.Error(); got != `unsupported audio format for "`+badPath+`"` { + t.Fatalf("error = %q, want unsupported format error", got) + } + }) +} diff --git a/pkg/voice/transcriber.go b/pkg/audio/asr/elevenlabs_transcriber.go similarity index 54% rename from pkg/voice/transcriber.go rename to pkg/audio/asr/elevenlabs_transcriber.go index e949d7a22..a89d62848 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/audio/asr/elevenlabs_transcriber.go @@ -1,4 +1,4 @@ -package voice +package asr import ( "bytes" @@ -10,46 +10,42 @@ import ( "net/http" "os" "path/filepath" - "strings" "time" - "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) -type Transcriber interface { - Name() string - Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) -} - -type GroqTranscriber struct { +// ElevenLabsTranscriber uses the ElevenLabs Scribe API for speech-to-text. +type ElevenLabsTranscriber struct { apiKey string apiBase string + modelID string httpClient *http.Client } -type TranscriptionResponse struct { - Text string `json:"text"` - Language string `json:"language,omitempty"` - Duration float64 `json:"duration,omitempty"` -} +func NewElevenLabsTranscriber(apiKey, apiBase, modelID string) *ElevenLabsTranscriber { + logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""}) -func NewGroqTranscriber(apiKey string) *GroqTranscriber { - logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""}) + if apiBase == "" { + apiBase = "https://api.elevenlabs.io" + } + if modelID == "" || modelID != ElevenLabsSupportedModelID() { + modelID = ElevenLabsSupportedModelID() + } - apiBase := "https://api.groq.com/openai/v1" - return &GroqTranscriber{ + return &ElevenLabsTranscriber{ apiKey: apiKey, apiBase: apiBase, + modelID: modelID, httpClient: &http.Client{ - Timeout: 60 * time.Second, + Timeout: 120 * time.Second, }, } } -func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { - logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath}) +func (t *ElevenLabsTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { + logger.InfoCF("voice", "Starting ElevenLabs transcription", map[string]any{"audio_file": audioFilePath}) audioFile, err := os.Open(audioFilePath) if err != nil { @@ -78,22 +74,13 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) return nil, fmt.Errorf("failed to create form file: %w", err) } - copied, err := io.Copy(part, audioFile) - if err != nil { + if _, err = io.Copy(part, audioFile); err != nil { logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err}) return nil, fmt.Errorf("failed to copy file content: %w", err) } - logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied}) - - if err = writer.WriteField("model", "whisper-large-v3"); err != nil { - logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to write model field: %w", err) - } - - if err = writer.WriteField("response_format", "json"); err != nil { - logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to write response_format field: %w", err) + if err = writer.WriteField("model_id", t.modelID); err != nil { + return nil, fmt.Errorf("failed to write model_id field: %w", err) } if err = writer.Close(); err != nil { @@ -101,7 +88,7 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) return nil, fmt.Errorf("failed to close multipart writer: %w", err) } - url := t.apiBase + "/audio/transcriptions" + url := t.apiBase + "/v1/speech-to-text" req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody) if err != nil { logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err}) @@ -109,9 +96,9 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) } req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", "Bearer "+t.apiKey) + req.Header.Set("Xi-Api-Key", t.apiKey) - logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{ + logger.DebugCF("voice", "Sending transcription request to ElevenLabs API", map[string]any{ "url": url, "request_size_bytes": requestBody.Len(), "file_size_bytes": fileInfo.Size(), @@ -131,14 +118,14 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) } if resp.StatusCode != http.StatusOK { - logger.ErrorCF("voice", "API error", map[string]any{ + logger.ErrorCF("voice", "ElevenLabs API error", map[string]any{ "status_code": resp.StatusCode, "response": string(body), }) - return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("ElevenLabs API error (status %d): %s", resp.StatusCode, string(body)) } - logger.DebugCF("voice", "Received response from Groq API", map[string]any{ + logger.DebugCF("voice", "Received response from ElevenLabs API", map[string]any{ "status_code": resp.StatusCode, "response_size_bytes": len(body), }) @@ -149,32 +136,15 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) return nil, fmt.Errorf("failed to unmarshal response: %w", err) } - logger.InfoCF("voice", "Transcription completed successfully", map[string]any{ + logger.InfoCF("voice", "ElevenLabs transcription completed successfully", map[string]any{ "text_length": len(result.Text), "language": result.Language, - "duration_seconds": result.Duration, "transcription_preview": utils.Truncate(result.Text, 50), }) return &result, nil } -func (t *GroqTranscriber) Name() string { - return "groq" -} - -// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or -// nil if no supported transcription provider is configured. -func DetectTranscriber(cfg *config.Config) Transcriber { - // Direct Groq provider config takes priority. - if key := cfg.Providers.Groq.APIKey; key != "" { - return NewGroqTranscriber(key) - } - // Fall back to any model-list entry that uses the groq/ protocol. - for _, mc := range cfg.ModelList { - if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" { - return NewGroqTranscriber(mc.APIKey) - } - } - return nil +func (t *ElevenLabsTranscriber) Name() string { + return "elevenlabs" } diff --git a/pkg/audio/asr/elevenlabs_transcriber_test.go b/pkg/audio/asr/elevenlabs_transcriber_test.go new file mode 100644 index 000000000..bbc827578 --- /dev/null +++ b/pkg/audio/asr/elevenlabs_transcriber_test.go @@ -0,0 +1,160 @@ +package asr + +import ( + "context" + "encoding/json" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// Ensure ElevenLabsTranscriber satisfies the Transcriber interface at compile time. +var _ Transcriber = (*ElevenLabsTranscriber)(nil) + +func TestElevenLabsTranscriberName(t *testing.T) { + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") + if got := tr.Name(); got != "elevenlabs" { + t.Errorf("Name() = %q, want %q", got, "elevenlabs") + } +} + +func TestElevenLabsTranscribe(t *testing.T) { + tmpDir := t.TempDir() + audioPath := filepath.Join(tmpDir, "clip.ogg") + if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil { + t.Fatalf("failed to write fake audio file: %v", err) + } + + t.Run("success", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/speech-to-text" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Xi-Api-Key") != "sk_test" { + t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key")) + } + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ParseMediaType() error = %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content-type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(r.Body, params["boundary"]) + var gotModelID string + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart() error = %v", err) + } + if part.FormName() != "model_id" { + continue + } + body, err := io.ReadAll(part) + if err != nil { + t.Fatalf("ReadAll(part) error = %v", err) + } + gotModelID = strings.TrimSpace(string(body)) + } + if gotModelID != "scribe_v1" { + t.Fatalf("model_id = %q, want %q", gotModelID, "scribe_v1") + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(TranscriptionResponse{ + Text: "hello from elevenlabs", + Language: "en", + }) + })) + defer srv.Close() + + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") + tr.apiBase = srv.URL + + resp, err := tr.Transcribe(context.Background(), audioPath) + if err != nil { + t.Fatalf("Transcribe() error: %v", err) + } + if resp.Text != "hello from elevenlabs" { + t.Errorf("Text = %q, want %q", resp.Text, "hello from elevenlabs") + } + if resp.Language != "en" { + t.Errorf("Language = %q, want %q", resp.Language, "en") + } + }) + + t.Run("api error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized) + })) + defer srv.Close() + + tr := NewElevenLabsTranscriber("sk_bad", "", "scribe_v1") + tr.apiBase = srv.URL + + _, err := tr.Transcribe(context.Background(), audioPath) + if err == nil { + t.Fatal("expected error for non-200 response, got nil") + } + }) + + t.Run("missing file", func(t *testing.T) { + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") + _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg")) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } + }) + + t.Run("unsupported model falls back to scribe_v1", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ParseMediaType() error = %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content-type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(r.Body, params["boundary"]) + var gotModelID string + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart() error = %v", err) + } + if part.FormName() != "model_id" { + continue + } + body, err := io.ReadAll(part) + if err != nil { + t.Fatalf("ReadAll(part) error = %v", err) + } + gotModelID = strings.TrimSpace(string(body)) + } + if gotModelID != "scribe_v1" { + t.Fatalf("model_id = %q, want runtime fallback to %q", gotModelID, "scribe_v1") + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(TranscriptionResponse{Text: "ok"}) + })) + defer srv.Close() + + tr := NewElevenLabsTranscriber("sk_test", "", "unsupported-model") + tr.apiBase = srv.URL + + if _, err := tr.Transcribe(context.Background(), audioPath); err != nil { + t.Fatalf("Transcribe() error: %v", err) + } + }) +} diff --git a/pkg/audio/asr/whisper_transcriber.go b/pkg/audio/asr/whisper_transcriber.go new file mode 100644 index 000000000..fc1101e1c --- /dev/null +++ b/pkg/audio/asr/whisper_transcriber.go @@ -0,0 +1,245 @@ +package asr + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/utils" +) + +type WhisperTranscriber struct { + apiKey string + apiBase string + modelID string + providerName string + httpClient *http.Client +} + +func NewWhisperTranscriber(modelCfg *config.ModelConfig) *WhisperTranscriber { + if modelCfg == nil { + return nil + } + + protocol, modelID := providers.ExtractProtocol(modelCfg) + if modelID == "" { + modelID = strings.TrimSpace(modelCfg.Model) + } + + tr := newWhisperTranscriber( + modelCfg.APIKey(), + providers.ResolveAPIBase(modelCfg), + modelID, + protocol, + ) + if tr == nil { + return nil + } + + logger.DebugCF("voice", "Creating whisper transcriber", map[string]any{ + "api_base": tr.apiBase, + "has_key": tr.apiKey != "", + "model": tr.modelID, + "provider": tr.providerName, + }) + return tr +} + +func NewGroqTranscriber(apiKey, modelID string) *WhisperTranscriber { + return newWhisperTranscriber(apiKey, "https://api.groq.com/openai/v1", modelID, "groq") +} + +func newWhisperTranscriber(apiKey, apiBase, modelID, providerName string) *WhisperTranscriber { + if modelID == "" { + return nil + } + if providerName == "" { + providerName = "whisper" + } + return &WhisperTranscriber{ + apiKey: apiKey, + apiBase: strings.TrimRight(apiBase, "/"), + modelID: modelID, + providerName: providerName, + httpClient: &http.Client{ + Timeout: 60 * time.Second, + }, + } +} + +func (t *WhisperTranscriber) transcriptionURL() string { + base := strings.TrimRight(t.apiBase, "/") + if strings.HasSuffix(base, "/audio/transcriptions") { + return base + } + return base + "/audio/transcriptions" +} + +func (t *WhisperTranscriber) TranscribeData( + ctx context.Context, + data []byte, + filename string, +) (*TranscriptionResponse, error) { + logger.InfoCF("voice", "Starting whisper transcription from memory", map[string]any{ + "bytes": len(data), + "filename": filename, + "model": t.modelID, + "provider": t.providerName, + }) + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + part, err := writer.CreateFormFile("file", filename) + if err != nil { + logger.ErrorCF("voice", "Failed to create whisper form file", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to create form file: %w", err) + } + + if _, copyErr := io.Copy(part, bytes.NewReader(data)); copyErr != nil { + logger.ErrorCF("voice", "Failed to copy whisper file content", map[string]any{"error": copyErr}) + return nil, fmt.Errorf("failed to copy file content: %w", copyErr) + } + + if err = writer.WriteField("model", t.modelID); err != nil { + logger.ErrorCF("voice", "Failed to write whisper model field", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to write model field: %w", err) + } + + if err = writer.WriteField("response_format", "json"); err != nil { + logger.ErrorCF("voice", "Failed to write whisper response_format field", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to write response_format field: %w", err) + } + + if err = writer.Close(); err != nil { + logger.ErrorCF("voice", "Failed to close whisper multipart writer", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to close multipart writer: %w", err) + } + + return t.doRequest(ctx, &requestBody, writer.FormDataContentType(), int64(len(data))) +} + +func (t *WhisperTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { + logger.InfoCF("voice", "Starting whisper transcription", map[string]any{ + "audio_file": audioFilePath, + "model": t.modelID, + "provider": t.providerName, + }) + + audioFile, err := os.Open(audioFilePath) + if err != nil { + return nil, fmt.Errorf("failed to open audio file %s: %w", audioFilePath, err) + } + defer audioFile.Close() + + fileInfo, err := audioFile.Stat() + if err != nil { + return nil, fmt.Errorf("failed to stat audio file %s: %w", audioFilePath, err) + } + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath)) + if err != nil { + return nil, fmt.Errorf("failed to create form file: %w", err) + } + + if _, copyErr := io.Copy(part, audioFile); copyErr != nil { + return nil, fmt.Errorf("failed to copy audio data: %w", copyErr) + } + + if err = writer.WriteField("model", t.modelID); err != nil { + return nil, fmt.Errorf("failed to write model field: %w", err) + } + + if err = writer.WriteField("response_format", "json"); err != nil { + return nil, fmt.Errorf("failed to write response_format field: %w", err) + } + + if err = writer.Close(); err != nil { + return nil, fmt.Errorf("failed to close multipart writer: %w", err) + } + + return t.doRequest(ctx, &requestBody, writer.FormDataContentType(), fileInfo.Size()) +} + +func (t *WhisperTranscriber) doRequest( + ctx context.Context, + requestBody *bytes.Buffer, + contentType string, + fileSize int64, +) (*TranscriptionResponse, error) { + url := t.transcriptionURL() + req, err := http.NewRequestWithContext(ctx, "POST", url, requestBody) + if err != nil { + logger.ErrorCF("voice", "Failed to create whisper request", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", contentType) + if t.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+t.apiKey) + } + + logger.DebugCF("voice", "Sending whisper transcription request", map[string]any{ + "file_size_bytes": fileSize, + "model": t.modelID, + "provider": t.providerName, + "request_size_bytes": requestBody.Len(), + "url": url, + }) + + resp, err := t.httpClient.Do(req) + if err != nil { + logger.ErrorCF("voice", "Failed to send whisper request", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + logger.ErrorCF("voice", "Failed to read whisper response", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + logger.ErrorCF("voice", "Whisper API error", map[string]any{ + "provider": t.providerName, + "response": string(body), + "status_code": resp.StatusCode, + }) + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var result TranscriptionResponse + if err := json.Unmarshal(body, &result); err != nil { + logger.ErrorCF("voice", "Failed to unmarshal whisper response", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + logger.InfoCF("voice", "Whisper transcription completed successfully", map[string]any{ + "duration_seconds": result.Duration, + "language": result.Language, + "provider": t.providerName, + "text_length": len(result.Text), + "transcription_preview": utils.Truncate(result.Text, 50), + }) + + return &result, nil +} + +func (t *WhisperTranscriber) Name() string { + return "whisper" +} diff --git a/pkg/audio/asr/whisper_transcriber_test.go b/pkg/audio/asr/whisper_transcriber_test.go new file mode 100644 index 000000000..a2a5178d1 --- /dev/null +++ b/pkg/audio/asr/whisper_transcriber_test.go @@ -0,0 +1,102 @@ +package asr + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestWhisperTranscriberTranscribeDataUsesConfiguredModel(t *testing.T) { + var gotModel string + var gotPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if got := r.Header.Get("Authorization"); got != "Bearer sk-openai-test" { + t.Errorf("Authorization = %q, want %q", got, "Bearer sk-openai-test") + } + + reader, err := r.MultipartReader() + if err != nil { + t.Fatalf("MultipartReader() error: %v", err) + } + + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart() error: %v", err) + } + + data, err := io.ReadAll(part) + if err != nil { + t.Fatalf("ReadAll() error: %v", err) + } + + if part.FormName() == "model" { + gotModel = string(data) + } + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(TranscriptionResponse{Text: "hello from whisper"}); err != nil { + t.Fatalf("Encode() error: %v", err) + } + })) + defer server.Close() + + tr := NewWhisperTranscriber(&config.ModelConfig{ + Model: "openai/whisper-1", + APIBase: server.URL, + APIKeys: config.SimpleSecureStrings("sk-openai-test"), + }) + tr.httpClient = server.Client() + + resp, err := tr.TranscribeData(context.Background(), []byte("audio"), "clip.ogg") + if err != nil { + t.Fatalf("TranscribeData() error: %v", err) + } + if resp.Text != "hello from whisper" { + t.Errorf("Text = %q, want %q", resp.Text, "hello from whisper") + } + if gotModel != "whisper-1" { + t.Errorf("model field = %q, want %q", gotModel, "whisper-1") + } + if gotPath != "/audio/transcriptions" { + t.Errorf("path = %q, want %q", gotPath, "/audio/transcriptions") + } +} + +func TestWhisperTranscriberUsesEndpointAPIBaseWithoutDoubleAppend(t *testing.T) { + var gotPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(TranscriptionResponse{Text: "ok"}); err != nil { + t.Fatalf("Encode() error: %v", err) + } + })) + defer server.Close() + + tr := NewWhisperTranscriber(&config.ModelConfig{ + Model: "groq/whisper-large-v3", + APIBase: server.URL + "/audio/transcriptions", + APIKeys: config.SimpleSecureStrings("sk-groq-test"), + }) + tr.httpClient = server.Client() + + if _, err := tr.TranscribeData(context.Background(), []byte("audio"), "clip.ogg"); err != nil { + t.Fatalf("TranscribeData() error: %v", err) + } + if gotPath != "/audio/transcriptions" { + t.Errorf("path = %q, want %q", gotPath, "/audio/transcriptions") + } +} diff --git a/pkg/audio/ogg.go b/pkg/audio/ogg.go new file mode 100644 index 000000000..f0055a574 --- /dev/null +++ b/pkg/audio/ogg.go @@ -0,0 +1,57 @@ +package audio + +import ( + "bytes" + "fmt" + "io" +) + +// DecodeOggOpus reads an Ogg format stream and extracts individual Opus payloads. +// It calls onFrame for every complete Opus frame found in the stream. +func DecodeOggOpus(r io.Reader, onFrame func([]byte) error) error { + var packet bytes.Buffer + header := make([]byte, 27) + segment := make([]byte, 255) + + for { + if _, err := io.ReadFull(r, header); err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + return nil + } + return fmt.Errorf("failed to read ogg header: %w", err) + } + if string(header[:4]) != "OggS" { + return fmt.Errorf("invalid ogg magic string") + } + + pageSegments := int(header[26]) + segmentTable := make([]byte, pageSegments) + if _, err := io.ReadFull(r, segmentTable); err != nil { + return fmt.Errorf("failed to read segment table: %w", err) + } + + for _, lacing := range segmentTable { + if _, err := io.ReadFull(r, segment[:lacing]); err != nil { + return fmt.Errorf("failed to read segment data: %w", err) + } + + packet.Write(segment[:lacing]) + + // If lacing is less than 255, the packet is complete + if lacing < 255 { + if packet.Len() > 0 { + packetBytes := packet.Bytes() + // Ignore Ogg Opus headers + if !bytes.HasPrefix(packetBytes, []byte("OpusHead")) && + !bytes.HasPrefix(packetBytes, []byte("OpusTags")) { + if err := onFrame(packetBytes); err != nil { + return err + } + } + // Start new packet + packet.Reset() + } + } + } + } +} diff --git a/pkg/audio/ogg_test.go b/pkg/audio/ogg_test.go new file mode 100644 index 000000000..8d5e5ac2a --- /dev/null +++ b/pkg/audio/ogg_test.go @@ -0,0 +1,146 @@ +package audio + +import ( + "bytes" + "reflect" + "strings" + "testing" +) + +// buildOggPage helper creates an Ogg page for testing. +// lacingVals specifies the segment table, and data is the payload. +func buildOggPage(lacingVals []byte, data []byte) []byte { + var buf bytes.Buffer + // 27-byte Ogg header + header := make([]byte, 27) + copy(header[:4], "OggS") + header[5] = 0 // type flag + // For testing, we only care about OggS magic and page_segments (byte 26) + header[26] = byte(len(lacingVals)) + buf.Write(header) + buf.Write(lacingVals) + buf.Write(data) + return buf.Bytes() +} + +func TestDecodeOggOpus_ValidParsing(t *testing.T) { + var b bytes.Buffer + + // Packet 1: Single segment, length 50 + pkt1 := bytes.Repeat([]byte{1}, 50) + // Packet 2: Multi-segment (255 + 10 = 265 bytes) + pkt2Part1 := bytes.Repeat([]byte{2}, 255) + pkt2Part2 := bytes.Repeat([]byte{2}, 10) + // Packet 3: Continued across pages. Page 1 gets 255, Page 2 gets 20. Total 275 bytes. + pkt3Part1 := bytes.Repeat([]byte{3}, 255) + pkt3Part2 := bytes.Repeat([]byte{3}, 20) + + // Page 1: OpusHead (skip), OpusTags (skip), pkt1, pkt2, pkt3Part1 + page1Lacing := []byte{8, 8, 50, 255, 10, 255} + page1Data := bytes.Join([][]byte{ + []byte("OpusHead"), + []byte("OpusTags"), + pkt1, + pkt2Part1, pkt2Part2, + pkt3Part1, + }, nil) + + // Page 2: pkt3Part2, pkt4 (length 10) + pkt4 := bytes.Repeat([]byte{4}, 10) + page2Lacing := []byte{20, 10} + page2Data := bytes.Join([][]byte{ + pkt3Part2, + pkt4, + }, nil) + + b.Write(buildOggPage(page1Lacing, page1Data)) + b.Write(buildOggPage(page2Lacing, page2Data)) + + var frames [][]byte + err := DecodeOggOpus(&b, func(frame []byte) error { + // making a copy to store as DecodeOggOpus might reuse backing array + cpy := make([]byte, len(frame)) + copy(cpy, frame) + frames = append(frames, cpy) + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedFrames := [][]byte{ + pkt1, + append(pkt2Part1, pkt2Part2...), + append(pkt3Part1, pkt3Part2...), + pkt4, + } + + if len(frames) != len(expectedFrames) { + t.Fatalf("expected %d frames, got %d", len(expectedFrames), len(frames)) + } + + for i, expected := range expectedFrames { + if !reflect.DeepEqual(frames[i], expected) { + t.Errorf("frame %d mismatch:\nexp: %v\ngot: %v", i, expected, frames[i]) + } + } +} + +func TestDecodeOggOpus_Errors(t *testing.T) { + tests := []struct { + name string + data []byte + errContains string + }{ + { + name: "invalid magic string", + data: []byte( + "OggX\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + ), + errContains: "invalid ogg magic string", + }, + { + name: "short header", + data: []byte("Ogg"), + errContains: "failed to read ogg header", + }, + { + name: "eof in segment table", + data: func() []byte { + h := make([]byte, 27) + copy(h, "OggS") + h[26] = 5 // expects 5 bytes of segment table, but none provided + return h + }(), + errContains: "failed to read segment table", + }, + { + name: "eof in segment data", + data: func() []byte { + h := make([]byte, 27, 28) + copy(h, "OggS") + h[26] = 1 + return append(h, 100) // expects 100 bytes of data, but none provided + }(), + errContains: "failed to read segment data", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := DecodeOggOpus(bytes.NewReader(tt.data), func(b []byte) error { return nil }) + if tt.name == "short header" { + if err != nil { + t.Errorf("expected no error (io.EOF/ErrUnexpectedEOF swallowed), got %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.errContains) + } + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("expected error to contain %q, got: %q", tt.errContains, err.Error()) + } + }) + } +} diff --git a/pkg/audio/sentence.go b/pkg/audio/sentence.go new file mode 100644 index 000000000..89b9ac03e --- /dev/null +++ b/pkg/audio/sentence.go @@ -0,0 +1,96 @@ +package audio + +import ( + "strings" + "unicode" +) + +// SplitSentences splits text into sentence-sized chunks suitable for TTS synthesis. +// It splits on sentence-ending punctuation (.!?\n, as well as CJK 。, !, ?) while avoiding false splits +// on decimal numbers. Very short fragments are merged with +// the next sentence to prevent choppy playback. +func SplitSentences(text string) []string { + if text == "" { + return nil + } + + var sentences []string + var current strings.Builder + runes := []rune(text) + + for i := 0; i < len(runes); i++ { + r := runes[i] + if r == '\n' { + s := strings.TrimSpace(current.String()) + if s != "" { + sentences = append(sentences, s) + } + current.Reset() + continue + } + + current.WriteRune(r) + + if r == '.' || r == '!' || r == '?' || r == '。' || r == '!' || r == '?' { + // Avoid splitting on decimal numbers like "3.14" + if r == '.' && i > 0 && unicode.IsDigit(runes[i-1]) && + i+1 < len(runes) && unicode.IsDigit(runes[i+1]) { + continue + } + + // Consume contiguous punctuation clusters (e.g., "..." or "?!"). + for i+1 < len(runes) && (runes[i+1] == '.' || runes[i+1] == '!' || runes[i+1] == '?' || runes[i+1] == '。' || runes[i+1] == '!' || runes[i+1] == '?') { + i++ + current.WriteRune(runes[i]) + } + + s := strings.TrimSpace(current.String()) + if s != "" { + sentences = append(sentences, s) + } + current.Reset() + } + } + + // Flush remaining text + if s := strings.TrimSpace(current.String()); s != "" { + sentences = append(sentences, s) + } + + // Merge very short fragments with the next sentence + return mergeShorties(sentences, 15) +} + +// mergeShorties merges sentences shorter than minLen characters with the following sentence. +func mergeShorties(sentences []string, minLen int) []string { + if len(sentences) <= 1 { + return sentences + } + + var merged []string + var buf string + + for _, s := range sentences { + if buf != "" { + buf += " " + s + if len([]rune(buf)) >= minLen { + merged = append(merged, buf) + buf = "" + } + } else if len([]rune(s)) < minLen { + buf = s + } else { + merged = append(merged, s) + } + } + + if buf != "" { + if len(merged) > 0 { + merged[len(merged)-1] += " " + buf + } else { + merged = append(merged, buf) + } + } + + return merged +} diff --git a/pkg/audio/sentence_test.go b/pkg/audio/sentence_test.go new file mode 100644 index 000000000..54d69e4a6 --- /dev/null +++ b/pkg/audio/sentence_test.go @@ -0,0 +1,69 @@ +package audio + +import ( + "reflect" + "testing" +) + +func TestSplitSentences(t *testing.T) { + tests := []struct { + name string + in string + want []string + }{ + { + name: "empty input", + in: "", + want: nil, + }, + { + name: "single sentence", + in: "Hello world.", + want: []string{"Hello world."}, + }, + { + name: "decimal numbers do not split", + in: "The value is 3.14 today. Keep watching closely.", + want: []string{"The value is 3.14 today.", "Keep watching closely."}, + }, + { + name: "newline boundary", + in: "This is line number one\nThis is line number two", + want: []string{"This is line number one", "This is line number two"}, + }, + { + name: "newline with surrounding spaces", + in: " This is the first line \n This is the second line ", + want: []string{"This is the first line", "This is the second line"}, + }, + { + name: "trailing punctuation consumed", + in: "Please wait a moment... What on earth?! That is perfectly fine.", + want: []string{"Please wait a moment...", "What on earth?!", "That is perfectly fine."}, + }, + { + name: "short leading fragment merges with next", + in: "Hi. This is a longer sentence.", + want: []string{"Hi. This is a longer sentence."}, + }, + { + name: "consecutive short fragments keep merging", + in: "A. B. C. This is the real sentence.", + want: []string{"A. B. C. This is the real sentence."}, + }, + { + name: "short trailing fragment merges back", + in: "This sentence is long enough. End.", + want: []string{"This sentence is long enough. End."}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := SplitSentences(tc.in) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("SplitSentences(%q) = %#v, want %#v", tc.in, got, tc.want) + } + }) + } +} diff --git a/pkg/audio/tts/README.md b/pkg/audio/tts/README.md new file mode 100644 index 000000000..ab8491da6 --- /dev/null +++ b/pkg/audio/tts/README.md @@ -0,0 +1,137 @@ +# TTS (Text-to-Speech) + +This package handles speech synthesis for PicoClaw. + +If you are new to TTS setup, the simplest workflow is: + +1. Add a TTS-capable entry to `model_list`. +2. Point `voice.tts_model_name` at that entry. +3. Put the API key in `.security.yml`. + +## Quick Recommendation + +For most users, these are the best starting points: + +| Provider | Why start here | +| --- | --- | +| [OpenAI](https://platform.openai.com/docs/guides/text-to-speech) | Best-supported path in PicoClaw today. The current TTS implementation is built around the OpenAI-compatible `/audio/speech` API shape, and OpenAI is the safest default. | +| [Xiaomi MiMo](https://platform.xiaomimimo.com) | A good second option if you want an OpenAI-compatible provider endpoint and are already using MiMo models in the rest of your stack. | + +## How TTS Configuration Works + +PicoClaw does not keep TTS API keys inside `voice`. + +Instead: + +- `voice.tts_model_name` selects a named entry from `model_list`. +- That `model_list` entry provides the provider, model ID, API base, and proxy settings. +- `.security.yml` stores the API key for the same named model entry. + +This is the recommended and supported configuration pattern. + +## Recommended Setup + +### Option A: OpenAI + +`config.json` + +```json +{ + "voice": { + "tts_model_name": "openai-tts" + }, + "model_list": [ + { + "model_name": "openai-tts", + "model": "openai/tts-1" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + openai-tts: + api_keys: + - "sk-openai-your-key" +``` + +### Option B: Xiaomi MiMo + +`config.json` + +```json +{ + "voice": { + "tts_model_name": "mimo-tts" + }, + "model_list": [ + { + "model_name": "mimo-tts", + "model": "mimo/mimo-v2-tts" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + mimo-tts: + api_keys: + - "your-mimo-key" +``` + +If you use a custom MiMo endpoint, you can also set `api_base` explicitly. Otherwise PicoClaw will use the provider default. + +## What PicoClaw Sends Today + +The current TTS runtime uses an OpenAI-compatible speech request with these defaults: + +- Endpoint: `/audio/speech` +- Response format: `opus` +- Voice: `alloy` +- Model: taken from the selected `model_list` entry + +That means: + +- `openai/tts-1` works naturally. +- Other OpenAI-compatible providers can work if they accept the same request format. +- PicoClaw currently does not expose a user-facing config field for changing the TTS voice from `alloy`. + +## How PicoClaw Chooses a TTS Provider + +`DetectTTS` resolves TTS in this order: + +1. **Preferred path**: resolve `voice.tts_model_name` against `model_list`. +2. If a matching model entry exists and has an API key, PicoClaw creates an OpenAI-compatible TTS provider using that model's settings. +3. **Fallback path**: if `voice.tts_model_name` is not set or cannot be resolved, PicoClaw scans `model_list` for the first entry whose model string contains `tts` and has an API key. + +Fallback scanning exists for compatibility. New configs should set `voice.tts_model_name` explicitly. + +## Notes About API Base Handling + +PicoClaw normalizes the configured base URL for TTS: + +- For OpenAI, a base like `https://api.openai.com` or `https://api.openai.com/v1` becomes `https://api.openai.com/v1/audio/speech`. +- For other OpenAI-compatible providers, PicoClaw preserves the configured base path and ensures it ends with `/audio/speech`. +- If `api_base` is omitted, PicoClaw uses the provider default base when the model prefix is known. + +## Common Mistakes + +- Setting `voice.tts_model_name` to a name that does not exist in `model_list`. +- Adding a TTS model but forgetting to put its API key in `.security.yml`. +- Assuming PicoClaw will automatically use provider-specific custom voices. +- Using a provider endpoint that is not compatible with the OpenAI `/audio/speech` request format. + +## Minimal Checklist + +Before testing `send_tts`, make sure: + +- `voice.tts_model_name` matches a `model_list[].model_name`. +- The matching `.security.yml` entry contains a valid API key. +- The chosen provider supports an OpenAI-compatible speech synthesis endpoint. +- Your selected model is actually a TTS-capable model. diff --git a/pkg/audio/tts/README.zh.md b/pkg/audio/tts/README.zh.md new file mode 100644 index 000000000..a48b612a9 --- /dev/null +++ b/pkg/audio/tts/README.zh.md @@ -0,0 +1,137 @@ +# TTS(文本转语音) + +这个目录负责 PicoClaw 的语音合成能力。 + +如果你是第一次配置 TTS,可以参照下面这个流程: + +1. 在 `model_list` 里添加一个支持 TTS 的模型。 +2. 用 `voice.tts_model_name` 指向这个模型。 +3. 在 `.security.yml` 里配置对应的 API Key。 + +## 快速推荐 + +对于大多数用户,建议优先从下面两种开始: + +| 提供商 | 推荐理由 | +| --- | --- | +| [OpenAI](https://platform.openai.com/docs/guides/text-to-speech) | 这是 PicoClaw 当前最稳定、最直接的 TTS 路径。当前实现就是围绕 OpenAI 兼容的 `/audio/speech` 接口格式构建的,所以 OpenAI 是最稳妥的默认选择。 | +| [Xiaomi MiMo](https://platform.xiaomimimo.com) | 由于响应速度和语音音色对于中国用户更友好,MiMo 是一个不错的第二选择。 | + +## TTS 配置是如何工作的 + +PicoClaw 不会把 TTS 的 API Key 放在 `voice` 配置里。 + +推荐方式是: + +- `voice.tts_model_name` 用来选择 `model_list` 里的某个命名模型。 +- 对应的 `model_list` 条目提供真实的 provider、model ID、`api_base` 和代理配置。 +- `.security.yml` 负责保存该模型条目的 API Key。 + +这是当前推荐且受支持的配置方式。 + +## 推荐配置方式 + +### 方案 A:OpenAI + +`config.json` + +```json +{ + "voice": { + "tts_model_name": "openai-tts" + }, + "model_list": [ + { + "model_name": "openai-tts", + "model": "openai/tts-1" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + openai-tts: + api_keys: + - "sk-openai-your-key" +``` + +### 方案 B:Xiaomi MiMo + +`config.json` + +```json +{ + "voice": { + "tts_model_name": "mimo-tts" + }, + "model_list": [ + { + "model_name": "mimo-tts", + "model": "mimo/mimo-v2-tts" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + mimo-tts: + api_keys: + - "your-mimo-key" +``` + +如果你使用自定义的 MiMo 接口地址,也可以显式设置 `api_base`。如果不设置,PicoClaw 会自动使用该 provider 的默认地址。 + +## PicoClaw 当前实际发送的 TTS 请求 + +当前 TTS 运行时使用的是 OpenAI 兼容的语音合成请求,并带有以下默认值: + +- Endpoint:`/audio/speech` +- 返回格式:`opus` +- Voice:`alloy` +- Model:来自你所选中的 `model_list` 条目 + +这意味着: + +- `openai/tts-1` 可以自然工作。 +- 其他 OpenAI 兼容 provider 也可能可用,前提是它们接受相同的请求格式。 +- PicoClaw 目前还没有对用户暴露一个配置项来修改 TTS voice,当前固定为 `alloy`。 + +## PicoClaw 如何选择 TTS Provider + +`DetectTTS` 会按下面顺序选择 TTS: + +1. **首选路径**:根据 `voice.tts_model_name` 在 `model_list` 中找到对应模型。 +2. 如果找到了匹配条目,并且它有 API Key,PicoClaw 就会使用这个模型条目的配置创建一个 OpenAI 兼容的 TTS provider。 +3. **回退路径**:如果没有设置 `voice.tts_model_name`,或者该名字无法解析,PicoClaw 会扫描 `model_list`,选中第一个模型字符串里包含 `tts` 且带有 API Key 的条目。 + +回退扫描只是为了兼容旧行为。新配置建议始终显式设置 `voice.tts_model_name`。 + +## 关于 API Base 的处理方式 + +PicoClaw 会对 TTS 的 `api_base` 做规范化处理: + +- 对 OpenAI 来说,像 `https://api.openai.com` 或 `https://api.openai.com/v1` 这样的地址,会自动变成 `https://api.openai.com/v1/audio/speech`。 +- 对其他 OpenAI 兼容 provider,PicoClaw 会尽量保留你提供的基础路径,只确保它最终以 `/audio/speech` 结尾。 +- 如果没有设置 `api_base`,并且模型前缀是已知 provider,PicoClaw 会自动使用该 provider 的默认地址。 + +## 常见错误 + +- `voice.tts_model_name` 指向了一个不存在的 `model_list` 名称。 +- 在 `model_list` 里定义了 TTS 模型,但忘了在 `.security.yml` 中配置对应 API Key。 +- 误以为 PicoClaw 会自动支持 provider 自定义 voice 参数。 +- 使用了不兼容 OpenAI `/audio/speech` 请求格式的接口地址。 + +## 最小检查清单 + +在测试 `send_tts` 之前,请确认: + +- `voice.tts_model_name` 能正确匹配某个 `model_list[].model_name`。 +- `.security.yml` 中对应条目已经配置了有效 API Key。 +- 你所选的 provider 支持 OpenAI 兼容的语音合成接口。 +- 你选择的模型本身确实支持 TTS。 diff --git a/pkg/audio/tts/mimo_tts.go b/pkg/audio/tts/mimo_tts.go new file mode 100644 index 000000000..a8aee6b8c --- /dev/null +++ b/pkg/audio/tts/mimo_tts.go @@ -0,0 +1,162 @@ +package tts + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +type MimoTTSProvider struct { + apiKey string + apiBase string + voice string + format string + model string + httpClient *http.Client +} + +func NewMimoTTSProvider(apiKey string, apiBase string, model string, proxyURL string) *MimoTTSProvider { + if apiBase == "" { + apiBase = "https://api.xiaomimimo.com/v1/chat/completions" + } else { + if u, err := url.Parse(apiBase); err == nil && u.Scheme != "" && u.Host != "" { + path := u.Path + if u.Host == "api.xiaomimimo.com" { + if path == "" || path == "/" || path == "/v1" || path == "/v1/" { + path = "/v1/chat/completions" + } else { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if !strings.HasPrefix(path, "/v1/") { + path = "/v1" + strings.TrimSuffix(path, "/") + } + if !strings.HasSuffix(path, "/chat/completions") { + path = strings.TrimSuffix(path, "/") + "/chat/completions" + } + } + } else { + if !strings.HasSuffix(path, "/chat/completions") { + path = strings.TrimSuffix(path, "/") + "/chat/completions" + } + } + u.Path = path + apiBase = u.String() + } else { + if apiBase == "https://api.xiaomimimo.com/v1" { + apiBase = "https://api.xiaomimimo.com/v1/chat/completions" + } else if !strings.HasSuffix(apiBase, "/chat/completions") { + apiBase = strings.TrimSuffix(apiBase, "/") + "/chat/completions" + } + } + } + + model = strings.TrimSpace(model) + if model == "" { + model = "mimo-v2-tts" + } + + client := &http.Client{Timeout: 60 * time.Second} + if proxyURL != "" { + if pURL, err := url.Parse(proxyURL); err == nil { + client.Transport = &http.Transport{Proxy: http.ProxyURL(pURL)} + } else { + logger.WarnF( + "NewMimoTTSProvider: invalid proxy URL; proceeding without proxy", + map[string]any{"proxyURL": proxyURL, "error": err}, + ) + } + } + + return &MimoTTSProvider{ + apiKey: apiKey, + apiBase: apiBase, + voice: "default_zh", // mimo_default now seems to be an alias for default_en, which is not working for Chinese TTS. default_zh seems to work fine with both English and Chinese, and is likely the intended default for TTS. + format: "mp3", + model: model, + httpClient: client, + } +} + +func (t *MimoTTSProvider) Name() string { + return "mimo-tts" +} + +func (t *MimoTTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) { + logger.DebugCF("voice-tts", "Starting TTS synthesis", map[string]any{"text_len": len(text), "provider": t.Name()}) + + reqBody := map[string]any{ + "model": t.model, + "messages": []map[string]string{ + {"role": "assistant", "content": text}, + }, + "audio": map[string]string{ + "format": t.format, + "voice": t.voice, + }, + "stream": false, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", t.apiBase, bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Api-Key", t.apiKey) + + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var payload struct { + Choices []struct { + Message struct { + Audio struct { + Data string `json:"data"` + } `json:"audio"` + } `json:"message"` + } `json:"choices"` + } + + err = json.Unmarshal(body, &payload) + if err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if len(payload.Choices) == 0 || payload.Choices[0].Message.Audio.Data == "" { + return nil, fmt.Errorf("invalid TTS response: missing audio data") + } + + audioBytes, err := base64.StdEncoding.DecodeString(payload.Choices[0].Message.Audio.Data) + if err != nil { + return nil, fmt.Errorf("failed to decode audio data: %w", err) + } + + return io.NopCloser(bytes.NewReader(audioBytes)), nil +} diff --git a/pkg/audio/tts/openai_tts.go b/pkg/audio/tts/openai_tts.go new file mode 100644 index 000000000..786414873 --- /dev/null +++ b/pkg/audio/tts/openai_tts.go @@ -0,0 +1,126 @@ +package tts + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers/common" +) + +type OpenAITTSProvider struct { + apiKey string + apiBase string + voice string + model string + httpClient *http.Client +} + +func NewOpenAITTSProvider(apiKey string, apiBase string, proxyURL string, model string) *OpenAITTSProvider { + // Normalize apiBase to avoid malformed endpoints like + // "https://api.openai.com/audio/speech" when "/v1" is required. + if apiBase == "" { + apiBase = "https://api.openai.com/v1/audio/speech" + } else { + if u, err := url.Parse(apiBase); err == nil && u.Scheme != "" && u.Host != "" { + path := u.Path + if u.Host == "api.openai.com" { + // For the official OpenAI host, ensure exactly one /v1 prefix and + // that the path ends with /audio/speech. + if path == "" || path == "/" || path == "/v1" { + path = "/v1/audio/speech" + } else { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if !strings.HasPrefix(path, "/v1/") { + path = "/v1" + strings.TrimSuffix(path, "/") + } + if !strings.HasSuffix(path, "/audio/speech") { + path = strings.TrimSuffix(path, "/") + "/audio/speech" + } + } + } else { + // For non-OpenAI hosts (e.g., proxies), preserve the existing base + // path and only ensure it ends with /audio/speech. + if !strings.HasSuffix(path, "/audio/speech") { + path = strings.TrimSuffix(path, "/") + "/audio/speech" + } + } + u.Path = path + apiBase = u.String() + } else { + // Fallback to the previous string-based behavior if parsing fails. + if apiBase == "https://api.openai.com/v1" { + apiBase = "https://api.openai.com/v1/audio/speech" + } else if !strings.HasSuffix(apiBase, "/audio/speech") { + // Just in case they provide openrouter base or standard base + apiBase = strings.TrimSuffix(apiBase, "/") + "/audio/speech" + } + } + } + + client := common.NewHTTPClient(proxyURL) + client.Timeout = 60 * time.Second + + model = strings.TrimSpace(model) + if model == "" { + model = "tts-1" + } + + return &OpenAITTSProvider{ + apiKey: apiKey, + apiBase: apiBase, + voice: "alloy", + model: model, + httpClient: client, + } +} + +func (t *OpenAITTSProvider) Name() string { + return "openai-tts" +} + +func (t *OpenAITTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) { + logger.DebugCF("voice-tts", "Starting TTS synthesis", map[string]any{"text_len": len(text)}) + + reqBody := map[string]any{ + "model": t.model, + "input": text, + "voice": t.voice, + "response_format": "opus", + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", t.apiBase, bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+t.apiKey) + + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + return resp.Body, nil +} diff --git a/pkg/audio/tts/tts.go b/pkg/audio/tts/tts.go new file mode 100644 index 000000000..7ae85c8da --- /dev/null +++ b/pkg/audio/tts/tts.go @@ -0,0 +1,151 @@ +package tts + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" +) + +type TTSProvider interface { + Name() string + Synthesize(ctx context.Context, text string) (io.ReadCloser, error) +} + +func providerFromModelConfig(mc *config.ModelConfig) TTSProvider { + if mc == nil || mc.APIKey() == "" { + return nil + } + + protocol, modelID := providers.ExtractProtocol(mc) + if modelID == "" { + modelID = strings.TrimSpace(mc.Model) + } + + switch protocol { + case "mimo": + return NewMimoTTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), modelID, mc.Proxy) + default: + return NewOpenAITTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), mc.Proxy, modelID) + } +} + +func DetectTTS(cfg *config.Config) TTSProvider { + if cfg == nil { + return nil + } + + if modelName := strings.TrimSpace(cfg.Voice.TTSModelName); modelName != "" { + if mc, err := cfg.GetModelConfig(modelName); err == nil { + if provider := providerFromModelConfig(mc); provider != nil { + return provider + } + } + } + + for _, mc := range cfg.ModelList { + if strings.Contains(strings.ToLower(mc.Model), "tts") && mc.APIKey() != "" { + if provider := providerFromModelConfig(mc); provider != nil { + return provider + } + } + } + return nil +} + +// SynthesizeAndStore synthesizes text to speech and registers it in the media store, returning the media reference. +func SynthesizeAndStore( + ctx context.Context, + provider TTSProvider, + store media.MediaStore, + text string, + filename string, + channel string, + chatID string, +) (string, error) { + if provider == nil { + return "", fmt.Errorf("tts provider is not configured") + } + if store == nil { + return "", fmt.Errorf("media store not configured") + } + if channel == "" || chatID == "" { + return "", fmt.Errorf("no target channel/chat available") + } + if strings.TrimSpace(text) == "" { + return "", fmt.Errorf("text is required") + } + + stream, err := provider.Synthesize(ctx, text) + if err != nil { + return "", fmt.Errorf("tts synthesize failed: %w", err) + } + defer stream.Close() + + err = os.MkdirAll(media.TempDir(), 0o700) + if err != nil { + return "", fmt.Errorf("failed to create media temp dir: %w", err) + } + + fileExt := ".ogg" + contentType := "audio/ogg" + if provider.Name() == "mimo-tts" { + fileExt = ".mp3" + contentType = "audio/mpeg" + } + + file, err := os.CreateTemp(media.TempDir(), "tts-*"+fileExt) + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + + removeTemp := true + defer func() { + if removeTemp { + _ = os.Remove(file.Name()) + } + }() + + _, err = io.Copy(file, stream) + if err != nil { + file.Close() + return "", fmt.Errorf("failed to write tts audio: %w", err) + } + + err = file.Close() + if err != nil { + return "", fmt.Errorf("failed to close tts audio file: %w", err) + } + + filename = strings.TrimSpace(filename) + if filename == "" { + filename = fmt.Sprintf("tts-%d%s", time.Now().Unix(), fileExt) + } + + ext := strings.ToLower(filepath.Ext(filename)) + if ext == "" { + filename += fileExt + } else if ext != fileExt { + filename = strings.TrimSuffix(filename, filepath.Ext(filename)) + fileExt + } + + scope := fmt.Sprintf("tool:send_tts:%s:%s:%d", channel, chatID, time.Now().UnixNano()) + ref, err := store.Store(file.Name(), media.MediaMeta{ + Filename: filename, + ContentType: contentType, + Source: "tool:send_tts", + }, scope) + if err != nil { + return "", fmt.Errorf("failed to register audio: %w", err) + } + removeTemp = false + + return ref, nil +} diff --git a/pkg/audio/tts/tts_test.go b/pkg/audio/tts/tts_test.go new file mode 100644 index 000000000..053aa7220 --- /dev/null +++ b/pkg/audio/tts/tts_test.go @@ -0,0 +1,247 @@ +package tts + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" +) + +func TestNewOpenAITTSProvider_APIBaseNormalization(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + expect string + }{ + { + name: "empty base", + input: "", + expect: "https://api.openai.com/v1/audio/speech", + }, + { + name: "official host no path", + input: "https://api.openai.com", + expect: "https://api.openai.com/v1/audio/speech", + }, + { + name: "official host v1", + input: "https://api.openai.com/v1", + expect: "https://api.openai.com/v1/audio/speech", + }, + { + name: "official host v1 slash", + input: "https://api.openai.com/v1/", + expect: "https://api.openai.com/v1/audio/speech", + }, + { + name: "non-openai host preserves base path", + input: "https://proxy.example.com/base", + expect: "https://proxy.example.com/base/audio/speech", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + provider := NewOpenAITTSProvider("key", tc.input, "", "") + if provider.apiBase != tc.expect { + t.Fatalf("apiBase mismatch: got %q, want %q", provider.apiBase, tc.expect) + } + }) + } +} + +func TestOpenAITTSProvider_SynthesizeSuccess(t *testing.T) { + t.Parallel() + + var gotPath string + var gotAuth string + var gotContentType string + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + gotContentType = r.Header.Get("Content-Type") + + bodyBytes, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + _ = json.Unmarshal(bodyBytes, &gotBody) + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("audio-bytes")) + })) + defer server.Close() + + provider := NewOpenAITTSProvider("k123", server.URL, "", "") + stream, err := provider.Synthesize(context.Background(), "hello") + if err != nil { + t.Fatalf("Synthesize failed: %v", err) + } + defer stream.Close() + + data, err := io.ReadAll(stream) + if err != nil { + t.Fatalf("read stream failed: %v", err) + } + + if gotPath != "/audio/speech" { + t.Fatalf("request path mismatch: got %q", gotPath) + } + if gotAuth != "Bearer k123" { + t.Fatalf("authorization mismatch: got %q", gotAuth) + } + if gotContentType != "application/json" { + t.Fatalf("content-type mismatch: got %q", gotContentType) + } + if gotBody["model"] != "tts-1" || gotBody["voice"] != "alloy" || gotBody["response_format"] != "opus" || + gotBody["input"] != "hello" { + bodyJSON, _ := json.Marshal(gotBody) + t.Fatalf("request body mismatch: %s", string(bodyJSON)) + } + if string(data) != "audio-bytes" { + t.Fatalf("response body mismatch: got %q", string(data)) + } +} + +func TestOpenAITTSProvider_SynthesizeNon200(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("nope")) + })) + defer server.Close() + + provider := NewOpenAITTSProvider("k123", server.URL, "", "") + _, err := provider.Synthesize(context.Background(), "hello") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "API error (status 500): nope") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewOpenAITTSProvider_UsesConfiguredModel(t *testing.T) { + t.Parallel() + + provider := NewOpenAITTSProvider("key", "https://api.xiaomimimo.com/v1", "", "mimo-v2-tts") + if provider.model != "mimo-v2-tts" { + t.Fatalf("model mismatch: got %q, want %q", provider.model, "mimo-v2-tts") + } + if provider.apiBase != "https://api.xiaomimimo.com/v1/audio/speech" { + t.Fatalf("apiBase mismatch: got %q", provider.apiBase) + } +} + +func TestDetectTTS_UsesMimoProviderForMimoModels(t *testing.T) { + t.Parallel() + + provider := DetectTTS(&config.Config{ + Voice: config.VoiceConfig{TTSModelName: "mimo-tts"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "mimo-tts", + Model: "mimo/mimo-v2-tts", + APIKeys: config.SimpleSecureStrings("sk-mimo"), + }, + }, + }) + + ttsProvider, ok := provider.(*MimoTTSProvider) + if !ok { + t.Fatalf("DetectTTS() type = %T, want *MimoTTSProvider", provider) + } + if ttsProvider.model != "mimo-v2-tts" { + t.Fatalf("model mismatch: got %q, want %q", ttsProvider.model, "mimo-v2-tts") + } + if ttsProvider.apiBase != "https://api.xiaomimimo.com/v1/chat/completions" { + t.Fatalf("apiBase mismatch: got %q", ttsProvider.apiBase) + } +} + +type stubTTSProvider struct { + name string +} + +func (s stubTTSProvider) Name() string { + return s.name +} + +func (s stubTTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("audio")), nil +} + +func TestSynthesizeAndStore_UsesOggMetadataByDefault(t *testing.T) { + t.Parallel() + + store := media.NewFileMediaStore() + ref, err := SynthesizeAndStore( + context.Background(), + stubTTSProvider{name: "openai-tts"}, + store, + "hello", + "", + "discord", + "chat123", + ) + if err != nil { + t.Fatalf("SynthesizeAndStore failed: %v", err) + } + + path, meta, err := store.ResolveWithMeta(ref) + if err != nil { + t.Fatalf("ResolveWithMeta failed: %v", err) + } + if meta.ContentType != "audio/ogg" { + t.Fatalf("ContentType = %q, want %q", meta.ContentType, "audio/ogg") + } + if filepath.Ext(path) != ".ogg" { + t.Fatalf("stored file extension = %q, want %q", filepath.Ext(path), ".ogg") + } + if filepath.Ext(meta.Filename) != ".ogg" { + t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".ogg") + } +} + +func TestSynthesizeAndStore_UsesMp3MetadataForMimo(t *testing.T) { + t.Parallel() + + store := media.NewFileMediaStore() + ref, err := SynthesizeAndStore( + context.Background(), + stubTTSProvider{name: "mimo-tts"}, + store, + "hello", + "", + "discord", + "chat123", + ) + if err != nil { + t.Fatalf("SynthesizeAndStore failed: %v", err) + } + + path, meta, err := store.ResolveWithMeta(ref) + if err != nil { + t.Fatalf("ResolveWithMeta failed: %v", err) + } + if meta.ContentType != "audio/mpeg" { + t.Fatalf("ContentType = %q, want %q", meta.ContentType, "audio/mpeg") + } + if filepath.Ext(path) != ".mp3" { + t.Fatalf("stored file extension = %q, want %q", filepath.Ext(path), ".mp3") + } + if filepath.Ext(meta.Filename) != ".mp3" { + t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".mp3") + } +} diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index c48dc747e..c7871d2a6 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -34,6 +34,15 @@ type OAuthProviderConfig struct { Port int } +type LoginBrowserOptions struct { + NoBrowser bool +} + +var ( + openBrowserFunc = OpenBrowser + browserLoginInput io.Reader = os.Stdin +) + func OpenAIOAuthConfig() OAuthProviderConfig { return OAuthProviderConfig{ Issuer: "https://auth.openai.com", @@ -92,6 +101,10 @@ func GenerateState() (string, error) { } func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { + return LoginBrowserWithOptions(cfg, LoginBrowserOptions{}) +} + +func LoginBrowserWithOptions(cfg OAuthProviderConfig, opts LoginBrowserOptions) (*AuthCredential, error) { pkce, err := GeneratePKCE() if err != nil { return nil, fmt.Errorf("generating PKCE: %w", err) @@ -102,55 +115,45 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { return nil, fmt.Errorf("generating state: %w", err) } - redirectURI := fmt.Sprintf("http://localhost:%d/auth/callback", cfg.Port) + redirectURI := oauthCallbackRedirectURI(cfg.Port) + callbackPort := cfg.Port + var resultCh <-chan callbackResult + + if !opts.NoBrowser { + callbackResultCh := make(chan callbackResult, 1) + listener, actualPort, err := listenOAuthCallback(cfg.Port) + if err != nil { + return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err) + } + + redirectURI = oauthCallbackRedirectURI(actualPort) + callbackPort = actualPort + resultCh = callbackResultCh + + server := &http.Server{Handler: oauthCallbackHandler(state, callbackResultCh)} + go func() { + _ = server.Serve(listener) + }() + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + }() + } authURL := buildAuthorizeURL(cfg, pkce, state, redirectURI) - resultCh := make(chan callbackResult, 1) - - mux := http.NewServeMux() - mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("state") != state { - resultCh <- callbackResult{err: fmt.Errorf("state mismatch")} - http.Error(w, "State mismatch", http.StatusBadRequest) - return - } - - code := r.URL.Query().Get("code") - if code == "" { - errMsg := r.URL.Query().Get("error") - resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)} - http.Error(w, "No authorization code received", http.StatusBadRequest) - return - } - - w.Header().Set("Content-Type", "text/html") - fmt.Fprint(w, "

Authentication successful!

You can close this window.

") - resultCh <- callbackResult{code: code} - }) - - listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.Port)) - if err != nil { - return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err) - } - - server := &http.Server{Handler: mux} - go server.Serve(listener) - defer func() { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - server.Shutdown(ctx) - }() - fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL) - if err := OpenBrowser(authURL); err != nil { + if opts.NoBrowser { + fmt.Println("Browser auto-open disabled. Open the URL manually to continue.") + } else if err := openBrowserFunc(authURL); err != nil { fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL) } fmt.Printf( "Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n", - cfg.Port, + callbackPort, ) fmt.Println( "please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.", @@ -158,11 +161,16 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { fmt.Println("Waiting for authentication (browser or manual paste)...") // Start manual input in a goroutine - manualCh := make(chan string) + manualCh := make(chan string, 1) + manualDone := make(chan struct{}) + defer close(manualDone) go func() { - reader := bufio.NewReader(os.Stdin) + reader := bufio.NewReader(browserLoginInput) input, _ := reader.ReadString('\n') - manualCh <- strings.TrimSpace(input) + select { + case manualCh <- strings.TrimSpace(input): + case <-manualDone: + } }() select { @@ -192,6 +200,49 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { } } +func oauthCallbackRedirectURI(port int) string { + return fmt.Sprintf("http://localhost:%d/auth/callback", port) +} + +func oauthCallbackHandler(state string, resultCh chan<- callbackResult) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("state") != state { + resultCh <- callbackResult{err: fmt.Errorf("state mismatch")} + http.Error(w, "State mismatch", http.StatusBadRequest) + return + } + + code := r.URL.Query().Get("code") + if code == "" { + errMsg := r.URL.Query().Get("error") + resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)} + http.Error(w, "No authorization code received", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, "

Authentication successful!

You can close this window.

") + resultCh <- callbackResult{code: code} + }) + return mux +} + +func listenOAuthCallback(port int) (net.Listener, int, error) { + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, 0, err + } + + tcpAddr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + _ = listener.Close() + return nil, 0, fmt.Errorf("unexpected listener address type %T", listener.Addr()) + } + + return listener, tcpAddr.Port, nil +} + type callbackResult struct { code string err error @@ -558,13 +609,11 @@ func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) { AuthMethod: "oauth", } - if accountID := extractAccountID(tokenResp.IDToken); accountID != "" { - cred.AccountID = accountID - } else if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" { - cred.AccountID = accountID - } else if accountID := extractAccountID(tokenResp.IDToken); accountID != "" { - // Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims. - cred.AccountID = accountID + // Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims. + if id := extractAccountID(tokenResp.IDToken); id != "" { + cred.AccountID = id + } else if id := extractAccountID(tokenResp.AccessToken); id != "" { + cred.AccountID = id } return cred, nil diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go index 230ac7c2a..b318934f9 100644 --- a/pkg/auth/oauth_test.go +++ b/pkg/auth/oauth_test.go @@ -3,6 +3,7 @@ package auth import ( "encoding/base64" "encoding/json" + "net" "net/http" "net/http/httptest" "net/url" @@ -373,3 +374,118 @@ func TestParseDeviceCodeResponseInvalidInterval(t *testing.T) { t.Fatal("expected error for invalid interval") } } + +func TestLoginBrowserWithOptionsNoBrowserDoesNotRequireCallbackPort(t *testing.T) { + server := newMockOAuthTokenServer() + defer server.Close() + reservedListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen() error: %v", err) + } + defer reservedListener.Close() + + reservedPort := reservedListener.Addr().(*net.TCPAddr).Port + origOpenBrowserFunc := openBrowserFunc + origBrowserLoginInput := browserLoginInput + t.Cleanup(func() { + openBrowserFunc = origOpenBrowserFunc + browserLoginInput = origBrowserLoginInput + }) + + var openCalls int + openBrowserFunc = func(string) error { + openCalls++ + return nil + } + browserLoginInput = strings.NewReader("manual-code\n") + + cfg := OAuthProviderConfig{ + Issuer: server.URL, + ClientID: "test-client", + Scopes: "openid", + Port: reservedPort, + } + + cred, err := LoginBrowserWithOptions(cfg, LoginBrowserOptions{NoBrowser: true}) + if err != nil { + t.Fatalf("LoginBrowserWithOptions() error: %v", err) + } + + if openCalls != 0 { + t.Fatalf("openBrowserFunc call count = %d, want 0", openCalls) + } + if cred.AccessToken != "mock-access-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "mock-access-token") + } +} + +func TestLoginBrowserWithOptionsAutoOpensByDefault(t *testing.T) { + server := newMockOAuthTokenServer() + defer server.Close() + + origOpenBrowserFunc := openBrowserFunc + origBrowserLoginInput := browserLoginInput + t.Cleanup(func() { + openBrowserFunc = origOpenBrowserFunc + browserLoginInput = origBrowserLoginInput + }) + + var ( + openCalls int + browserURL string + ) + openBrowserFunc = func(url string) error { + openCalls++ + browserURL = url + return nil + } + browserLoginInput = strings.NewReader("manual-code\n") + + cfg := OAuthProviderConfig{ + Issuer: server.URL, + ClientID: "test-client", + Scopes: "openid", + Port: 0, + } + + _, err := LoginBrowserWithOptions(cfg, LoginBrowserOptions{}) + if err != nil { + t.Fatalf("LoginBrowserWithOptions() error: %v", err) + } + + if openCalls != 1 { + t.Fatalf("openBrowserFunc call count = %d, want 1", openCalls) + } + + parsedBrowserURL, err := url.Parse(browserURL) + if err != nil { + t.Fatalf("url.Parse(browserURL) error: %v", err) + } + + redirectURI, err := url.Parse(parsedBrowserURL.Query().Get("redirect_uri")) + if err != nil { + t.Fatalf("url.Parse(redirectURI) error: %v", err) + } + if redirectURI.Port() == "" { + t.Fatal("redirectURI port is empty") + } + if redirectURI.Port() == "0" { + t.Fatalf("redirectURI port = %q, want dynamically assigned port", redirectURI.Port()) + } +} + +func newMockOAuthTokenServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth/token" { + http.Error(w, "not found", http.StatusNotFound) + return + } + + resp := map[string]any{ + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_in": 3600, + } + _ = json.NewEncoder(w).Encode(resp) + })) +} diff --git a/pkg/auth/store.go b/pkg/auth/store.go index 2e55d4877..0e6567a03 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -4,8 +4,10 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "time" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/fileutil" ) @@ -24,6 +26,11 @@ type AuthStore struct { Credentials map[string]*AuthCredential `json:"credentials"` } +const ( + providerGoogleAntigravity = "google-antigravity" + providerAntigravityAlias = "antigravity" +) + func (c *AuthCredential) IsExpired() bool { if c.ExpiresAt.IsZero() { return false @@ -39,11 +46,126 @@ func (c *AuthCredential) NeedsRefresh() bool { } func authFilePath() string { - if home := os.Getenv("PICOCLAW_HOME"); home != "" { - return filepath.Join(home, "auth.json") + return filepath.Join(config.GetHome(), "auth.json") +} + +func canonicalProvider(provider string) string { + normalized := strings.ToLower(strings.TrimSpace(provider)) + switch normalized { + case providerAntigravityAlias: + return providerGoogleAntigravity + default: + return normalized } - home, _ := os.UserHomeDir() - return filepath.Join(home, ".picoclaw", "auth.json") +} + +func cloneCredential(cred *AuthCredential) *AuthCredential { + if cred == nil { + return nil + } + cp := *cred + return &cp +} + +func mergeCredentials(primary, secondary *AuthCredential) *AuthCredential { + if primary == nil { + return cloneCredential(secondary) + } + + merged := *primary + if secondary == nil { + return &merged + } + if merged.AccessToken == "" { + merged.AccessToken = secondary.AccessToken + } + if merged.RefreshToken == "" { + merged.RefreshToken = secondary.RefreshToken + } + if merged.AccountID == "" { + merged.AccountID = secondary.AccountID + } + if merged.ExpiresAt.IsZero() { + merged.ExpiresAt = secondary.ExpiresAt + } + if merged.Provider == "" { + merged.Provider = secondary.Provider + } + if merged.AuthMethod == "" { + merged.AuthMethod = secondary.AuthMethod + } + if merged.Email == "" { + merged.Email = secondary.Email + } + if merged.ProjectID == "" { + merged.ProjectID = secondary.ProjectID + } + + return &merged +} + +func shouldPreferCredential( + candidate *AuthCredential, + candidateCanonical bool, + current *AuthCredential, + currentCanonical bool, +) bool { + if candidate == nil { + return false + } + if current == nil { + return true + } + + switch { + case candidate.ExpiresAt.After(current.ExpiresAt): + return true + case current.ExpiresAt.After(candidate.ExpiresAt): + return false + case candidateCanonical != currentCanonical: + return candidateCanonical + default: + return false + } +} + +func normalizeStore(store *AuthStore) { + if store == nil { + return + } + if store.Credentials == nil { + store.Credentials = make(map[string]*AuthCredential) + return + } + + normalized := make(map[string]*AuthCredential, len(store.Credentials)) + canonicalFlags := make(map[string]bool, len(store.Credentials)) + + for provider, cred := range store.Credentials { + normalizedProvider := strings.ToLower(strings.TrimSpace(provider)) + canonical := canonicalProvider(provider) + normalizedCred := cloneCredential(cred) + if normalizedCred != nil { + normalizedCred.Provider = canonicalProvider(normalizedCred.Provider) + if normalizedCred.Provider == "" { + normalizedCred.Provider = canonical + } + } + + current := normalized[canonical] + currentCanonical := canonicalFlags[canonical] + candidateCanonical := normalizedProvider == canonical + + if shouldPreferCredential(normalizedCred, candidateCanonical, current, currentCanonical) { + normalized[canonical] = mergeCredentials(normalizedCred, current) + canonicalFlags[canonical] = candidateCanonical + continue + } + + normalized[canonical] = mergeCredentials(current, normalizedCred) + } + + store.Credentials = normalized } func LoadStore() (*AuthStore, error) { @@ -60,9 +182,7 @@ func LoadStore() (*AuthStore, error) { if err := json.Unmarshal(data, &store); err != nil { return nil, err } - if store.Credentials == nil { - store.Credentials = make(map[string]*AuthCredential) - } + normalizeStore(&store) return &store, nil } @@ -82,7 +202,7 @@ func GetCredential(provider string) (*AuthCredential, error) { if err != nil { return nil, err } - cred, ok := store.Credentials[provider] + cred, ok := store.Credentials[canonicalProvider(provider)] if !ok { return nil, nil } @@ -94,7 +214,17 @@ func SetCredential(provider string, cred *AuthCredential) error { if err != nil { return err } - store.Credentials[provider] = cred + + canonical := canonicalProvider(provider) + normalized := cloneCredential(cred) + if normalized != nil { + normalized.Provider = canonicalProvider(normalized.Provider) + if normalized.Provider == "" { + normalized.Provider = canonical + } + } + + store.Credentials[canonical] = normalized return SaveStore(store) } @@ -103,7 +233,7 @@ func DeleteCredential(provider string) error { if err != nil { return err } - delete(store.Credentials, provider) + delete(store.Credentials, canonicalProvider(provider)) return SaveStore(store) } diff --git a/pkg/auth/store_test.go b/pkg/auth/store_test.go index f6793cfce..578ed4ead 100644 --- a/pkg/auth/store_test.go +++ b/pkg/auth/store_test.go @@ -1,12 +1,24 @@ package auth import ( + "encoding/json" "os" "path/filepath" + "runtime" "testing" "time" + + "github.com/sipeed/picoclaw/pkg/config" ) +func setTestAuthHome(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw")) + return tmpDir +} + func TestAuthCredentialIsExpired(t *testing.T) { tests := []struct { name string @@ -51,10 +63,7 @@ func TestAuthCredentialNeedsRefresh(t *testing.T) { } func TestStoreRoundtrip(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) cred := &AuthCredential{ AccessToken: "test-access-token", @@ -88,10 +97,7 @@ func TestStoreRoundtrip(t *testing.T) { } func TestStoreFilePermissions(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + tmpDir := setTestAuthHome(t) cred := &AuthCredential{ AccessToken: "secret-token", @@ -108,16 +114,16 @@ func TestStoreFilePermissions(t *testing.T) { t.Fatalf("Stat() error: %v", err) } perm := info.Mode().Perm() + if runtime.GOOS == "windows" { + return + } if perm != 0o600 { t.Errorf("file permissions = %o, want 0600", perm) } } func TestStoreMultiProvider(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"} anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"} @@ -147,10 +153,7 @@ func TestStoreMultiProvider(t *testing.T) { } func TestDeleteCredential(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"} if err := SetCredential("openai", cred); err != nil { @@ -171,10 +174,7 @@ func TestDeleteCredential(t *testing.T) { } func TestLoadStoreEmpty(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) store, err := LoadStore() if err != nil { @@ -187,3 +187,319 @@ func TestLoadStoreEmpty(t *testing.T) { t.Errorf("expected empty credentials, got %d", len(store.Credentials)) } } + +func TestGetCredentialCanonicalizesLegacyAntigravityProvider(t *testing.T) { + tmpDir := setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "project_id": "project-1", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cred, err := GetCredential("google-antigravity") + if err != nil { + t.Fatalf("GetCredential() error: %v", err) + } + if cred == nil { + t.Fatal("GetCredential() returned nil") + } + if cred.Provider != "google-antigravity" { + t.Fatalf("Provider = %q, want %q", cred.Provider, "google-antigravity") + } + if !cred.ExpiresAt.Equal(expiresAt) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, expiresAt) + } +} + +func TestLoadStoreMergesAntigravityAliasesPreferringNewerExpiry(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + refreshedExpiry := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "refresh_token": "legacy-refresh", + "expires_at": legacyExpiry.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "email": "legacy@example.com", + }, + "google-antigravity": map[string]any{ + "access_token": "fresh-token", + "expires_at": refreshedExpiry.Format(time.RFC3339), + "provider": "google-antigravity", + "auth_method": "oauth", + "project_id": "project-2", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if cred.RefreshToken != "legacy-refresh" { + t.Fatalf("RefreshToken = %q, want %q", cred.RefreshToken, "legacy-refresh") + } + if cred.Email != "legacy@example.com" { + t.Fatalf("Email = %q, want %q", cred.Email, "legacy@example.com") + } + if cred.ProjectID != "project-2" { + t.Fatalf("ProjectID = %q, want %q", cred.ProjectID, "project-2") + } + if !cred.ExpiresAt.Equal(refreshedExpiry) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, refreshedExpiry) + } +} + +func TestLoadStorePrefersCanonicalKeyWhenExpiryMatchesAlias(t *testing.T) { + tmpDir := setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "refresh_token": "legacy-refresh", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "email": "legacy@example.com", + }, + " Google-Antigravity ": map[string]any{ + "access_token": "fresh-token", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": " Google-Antigravity ", + "auth_method": "oauth", + "project_id": "project-2", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if cred.RefreshToken != "legacy-refresh" { + t.Fatalf("RefreshToken = %q, want %q", cred.RefreshToken, "legacy-refresh") + } + if cred.Email != "legacy@example.com" { + t.Fatalf("Email = %q, want %q", cred.Email, "legacy@example.com") + } + if cred.ProjectID != "project-2" { + t.Fatalf("ProjectID = %q, want %q", cred.ProjectID, "project-2") + } +} + +func TestSetCredentialReplacesLegacyAntigravityEntry(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC).Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + }, + }, + } + data, err := json.Marshal(legacyStore) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC) + err = SetCredential("google-antigravity", &AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: refreshedExpiry, + Provider: "google-antigravity", + AuthMethod: "oauth", + }) + if err != nil { + t.Fatalf("SetCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if !cred.ExpiresAt.Equal(refreshedExpiry) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, refreshedExpiry) + } +} + +func TestDeleteCredentialRemovesLegacyAntigravityAlias(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "provider": "antigravity", + "auth_method": "oauth", + }, + }, + } + data, err := json.Marshal(legacyStore) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err = DeleteCredential(" google-antigravity ") + if err != nil { + t.Fatalf("DeleteCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 0 { + t.Fatalf("credential count = %d, want 0", len(loaded.Credentials)) + } +} + +func TestSetCredentialCanonicalizesTrimmedMixedCaseProvider(t *testing.T) { + setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 13, 0, 0, 0, time.UTC) + if err := SetCredential(" AnTiGrAvItY ", &AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: expiresAt, + Provider: " AnTiGrAvItY ", + AuthMethod: "oauth", + }); err != nil { + t.Fatalf("SetCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.Provider != "google-antigravity" { + t.Fatalf("Provider = %q, want %q", cred.Provider, "google-antigravity") + } + if !cred.ExpiresAt.Equal(expiresAt) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, expiresAt) + } + + got, err := GetCredential(" GoOgLe-AnTiGrAvItY ") + if err != nil { + t.Fatalf("GetCredential() error: %v", err) + } + if got == nil { + t.Fatal("GetCredential() returned nil") + } + if got.Provider != "google-antigravity" { + t.Fatalf("GetCredential provider = %q, want %q", got.Provider, "google-antigravity") + } +} diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index f5ff9587d..dee67d87c 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -3,22 +3,59 @@ package bus import ( "context" "errors" + "sync" "sync/atomic" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" ) // ErrBusClosed is returned when publishing to a closed MessageBus. var ErrBusClosed = errors.New("message bus closed") +var ( + ErrMissingInboundContext = errors.New("inbound message context is required") + ErrMissingOutboundContext = errors.New("outbound message context is required") + ErrMissingOutboundMediaContext = errors.New("outbound media context is required") +) + const defaultBusBufferSize = 64 +// StreamDelegate is implemented by the channel Manager to provide streaming +// capabilities to the agent loop without tight coupling. +type StreamDelegate interface { + // GetStreamer returns a Streamer for the given channel+chatID if the channel + // supports streaming. Returns nil, false if streaming is unavailable. + GetStreamer(ctx context.Context, channel, chatID string) (Streamer, bool) +} + +// Streamer pushes incremental content to a streaming-capable channel. +// Defined here so the agent loop can use it without importing pkg/channels. +type Streamer interface { + Update(ctx context.Context, content string) error + Finalize(ctx context.Context, content string) error + Cancel(ctx context.Context) +} + type MessageBus struct { inbound chan InboundMessage outbound chan OutboundMessage outboundMedia chan OutboundMediaMessage - done chan struct{} - closed atomic.Bool + audioChunks chan AudioChunk + voiceControls chan VoiceControl + + closeOnce sync.Once + done chan struct{} + closed atomic.Bool + wg sync.WaitGroup + streamDelegate atomic.Value // stores StreamDelegate + eventPublisher atomic.Value // stores EventPublisher +} + +// EventPublisher is the minimal runtime event publisher used by MessageBus. +type EventPublisher interface { + Publish(ctx context.Context, evt runtimeevents.Event) runtimeevents.PublishResult + PublishNonBlocking(evt runtimeevents.Event) runtimeevents.PublishResult } func NewMessageBus() *MessageBus { @@ -26,132 +63,177 @@ func NewMessageBus() *MessageBus { inbound: make(chan InboundMessage, defaultBusBufferSize), outbound: make(chan OutboundMessage, defaultBusBufferSize), outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize), + audioChunks: make(chan AudioChunk, defaultBusBufferSize*4), // Audio chunks need more buffer. + voiceControls: make(chan VoiceControl, defaultBusBufferSize), done: make(chan struct{}), } } -func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error { +func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error { + // check bus closed before acquiring wg, to avoid unnecessary wg.Add and potential deadlock if mb.closed.Load() { return ErrBusClosed } - if err := ctx.Err(); err != nil { - return err - } + + // check again,before sending message, to avoid sending to closed channel select { - case mb.inbound <- msg: - return nil - case <-mb.done: - return ErrBusClosed case <-ctx.Done(): return ctx.Err() + case <-mb.done: + return ErrBusClosed + default: + } + + mb.wg.Add(1) + defer mb.wg.Done() + + select { + case ch <- msg: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-mb.done: + return ErrBusClosed } } -func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) { - select { - case msg, ok := <-mb.inbound: - return msg, ok - case <-mb.done: - return InboundMessage{}, false - case <-ctx.Done(): - return InboundMessage{}, false +func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error { + msg = NormalizeInboundMessage(msg) + if msg.Context.isZero() { + mb.publishFailure("inbound", runtimeScopeFromInboundContext(msg.Context), ErrMissingInboundContext) + return ErrMissingInboundContext } + if err := publish(ctx, mb, mb.inbound, msg); err != nil { + mb.publishFailure("inbound", runtimeScopeFromInboundContext(msg.Context), err) + return err + } + return nil +} + +func (mb *MessageBus) InboundChan() <-chan InboundMessage { + return mb.inbound } func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error { - if mb.closed.Load() { - return ErrBusClosed + msg = NormalizeOutboundMessage(msg) + if msg.Context.isZero() { + mb.publishFailure("outbound", runtimeScopeFromInboundContext(msg.Context), ErrMissingOutboundContext) + return ErrMissingOutboundContext } - if err := ctx.Err(); err != nil { + if err := publish(ctx, mb, mb.outbound, msg); err != nil { + mb.publishFailure("outbound", runtimeScopeFromInboundContext(msg.Context), err) return err } - select { - case mb.outbound <- msg: - return nil - case <-mb.done: - return ErrBusClosed - case <-ctx.Done(): - return ctx.Err() - } + return nil } -func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) { - select { - case msg, ok := <-mb.outbound: - return msg, ok - case <-mb.done: - return OutboundMessage{}, false - case <-ctx.Done(): - return OutboundMessage{}, false - } +func (mb *MessageBus) OutboundChan() <-chan OutboundMessage { + return mb.outbound } func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error { - if mb.closed.Load() { - return ErrBusClosed + msg = NormalizeOutboundMediaMessage(msg) + if msg.Context.isZero() { + mb.publishFailure("outbound_media", runtimeScopeFromInboundContext(msg.Context), ErrMissingOutboundMediaContext) + return ErrMissingOutboundMediaContext } - if err := ctx.Err(); err != nil { + if err := publish(ctx, mb, mb.outboundMedia, msg); err != nil { + mb.publishFailure("outbound_media", runtimeScopeFromInboundContext(msg.Context), err) return err } - select { - case mb.outboundMedia <- msg: - return nil - case <-mb.done: - return ErrBusClosed - case <-ctx.Done(): - return ctx.Err() - } + return nil } -func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMediaMessage, bool) { - select { - case msg, ok := <-mb.outboundMedia: - return msg, ok - case <-mb.done: - return OutboundMediaMessage{}, false - case <-ctx.Done(): - return OutboundMediaMessage{}, false +func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage { + return mb.outboundMedia +} + +func (mb *MessageBus) PublishAudioChunk(ctx context.Context, chunk AudioChunk) error { + if err := publish(ctx, mb, mb.audioChunks, chunk); err != nil { + mb.publishFailure("audio_chunk", runtimeScopeFromAudioChunk(chunk), err) + return err } + return nil +} + +func (mb *MessageBus) AudioChunksChan() <-chan AudioChunk { + return mb.audioChunks +} + +func (mb *MessageBus) PublishVoiceControl(ctx context.Context, ctrl VoiceControl) error { + if err := publish(ctx, mb, mb.voiceControls, ctrl); err != nil { + mb.publishFailure("voice_control", runtimeScopeFromVoiceControl(ctrl), err) + return err + } + return nil +} + +func (mb *MessageBus) VoiceControlsChan() <-chan VoiceControl { + return mb.voiceControls +} + +// SetStreamDelegate registers a StreamDelegate (typically the channel Manager). +func (mb *MessageBus) SetStreamDelegate(d StreamDelegate) { + mb.streamDelegate.Store(d) +} + +// SetEventPublisher registers a runtime event publisher for bus errors and lifecycle events. +func (mb *MessageBus) SetEventPublisher(p EventPublisher) { + mb.eventPublisher.Store(p) +} + +// GetStreamer returns a Streamer for the given channel+chatID via the delegate. +func (mb *MessageBus) GetStreamer(ctx context.Context, channel, chatID string) (Streamer, bool) { + if d, ok := mb.streamDelegate.Load().(StreamDelegate); ok && d != nil { + return d.GetStreamer(ctx, channel, chatID) + } + return nil, false } func (mb *MessageBus) Close() { - if mb.closed.CompareAndSwap(false, true) { + mb.closeOnce.Do(func() { + mb.publishCloseEvent(runtimeevents.KindBusCloseStarted, 0) + // notify all blocked publishers to exit close(mb.done) - // Drain buffered channels so messages aren't silently lost. - // Channels are NOT closed to avoid send-on-closed panics from concurrent publishers. + // because every publisher will check mb.closed before acquiring wg + // so we can be sure that new publishers will not be added new messages after this point + mb.closed.Store(true) + + // wait for all ongoing Publish calls to finish, ensuring all messages have been sent to channels or exited + mb.wg.Wait() + + // close channels safely + close(mb.inbound) + close(mb.outbound) + close(mb.outboundMedia) + close(mb.audioChunks) + close(mb.voiceControls) + + // clean up any remaining messages in channels drained := 0 - for { - select { - case <-mb.inbound: - drained++ - default: - goto doneInbound - } + for range mb.inbound { + drained++ } - doneInbound: - for { - select { - case <-mb.outbound: - drained++ - default: - goto doneOutbound - } + for range mb.outbound { + drained++ } - doneOutbound: - for { - select { - case <-mb.outboundMedia: - drained++ - default: - goto doneMedia - } + for range mb.outboundMedia { + drained++ } - doneMedia: + for range mb.audioChunks { + drained++ + } + for range mb.voiceControls { + drained++ + } + if drained > 0 { logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{ "count": drained, }) + mb.publishCloseEvent(runtimeevents.KindBusCloseDrained, drained) } - } + mb.publishCloseEvent(runtimeevents.KindBusCloseCompleted, drained) + }) } diff --git a/pkg/bus/bus_test.go b/pkg/bus/bus_test.go index e07b8c7fe..a0a9e1e14 100644 --- a/pkg/bus/bus_test.go +++ b/pkg/bus/bus_test.go @@ -5,6 +5,8 @@ import ( "sync" "testing" "time" + + runtimeevents "github.com/sipeed/picoclaw/pkg/events" ) func TestPublishConsume(t *testing.T) { @@ -14,17 +16,20 @@ func TestPublishConsume(t *testing.T) { ctx := context.Background() msg := InboundMessage{ - Channel: "test", - SenderID: "user1", - ChatID: "chat1", - Content: "hello", + Context: InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Content: "hello", } if err := mb.PublishInbound(ctx, msg); err != nil { t.Fatalf("PublishInbound failed: %v", err) } - got, ok := mb.ConsumeInbound(ctx) + got, ok := <-mb.InboundChan() if !ok { t.Fatal("ConsumeInbound returned ok=false") } @@ -34,6 +39,218 @@ func TestPublishConsume(t *testing.T) { if got.Channel != "test" { t.Fatalf("expected channel 'test', got %q", got.Channel) } + if got.Context.Channel != "test" { + t.Fatalf("expected context channel 'test', got %q", got.Context.Channel) + } + if got.Context.ChatID != "chat1" { + t.Fatalf("expected context chat ID 'chat1', got %q", got.Context.ChatID) + } + if got.Context.SenderID != "user1" { + t.Fatalf("expected context sender ID 'user1', got %q", got.Context.SenderID) + } +} + +func TestPublishInbound_NormalizesContext(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + msg := InboundMessage{ + Context: InboundContext{ + Channel: "slack", + Account: "workspace-a", + ChatID: "C456/1712", + ChatType: "group", + TopicID: "1712", + SpaceID: "T001", + SpaceType: "team", + SenderID: "U123", + MessageID: "1712.01", + ReplyToMessageID: "1700.01", + Mentioned: true, + }, + Content: "hello", + } + + if err := mb.PublishInbound(context.Background(), msg); err != nil { + t.Fatalf("PublishInbound failed: %v", err) + } + + got := <-mb.InboundChan() + if got.Context.Channel != "slack" { + t.Fatalf("expected context channel slack, got %q", got.Context.Channel) + } + if got.Context.Account != "workspace-a" { + t.Fatalf("expected context account workspace-a, got %q", got.Context.Account) + } + if got.Context.ChatType != "group" { + t.Fatalf("expected context chat type group, got %q", got.Context.ChatType) + } + if got.Context.TopicID != "1712" { + t.Fatalf("expected topic 1712, got %q", got.Context.TopicID) + } + if got.Context.SpaceType != "team" || got.Context.SpaceID != "T001" { + t.Fatalf("expected team space T001, got %q/%q", got.Context.SpaceType, got.Context.SpaceID) + } + if !got.Context.Mentioned { + t.Fatal("expected mentioned=true in context") + } + if got.Context.ReplyToMessageID != "1700.01" { + t.Fatalf("expected reply_to_message_id 1700.01, got %q", got.Context.ReplyToMessageID) + } +} + +func TestPublishInbound_MirrorsContextIntoConvenienceFields(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + msg := InboundMessage{ + Context: InboundContext{ + Channel: "telegram", + Account: "bot-a", + ChatID: "-1001", + ChatType: "group", + TopicID: "42", + SpaceID: "guild-9", + SpaceType: "guild", + SenderID: "user-1", + MessageID: "777", + Mentioned: true, + ReplyToMessageID: "666", + }, + Content: "hi", + } + + if err := mb.PublishInbound(context.Background(), msg); err != nil { + t.Fatalf("PublishInbound failed: %v", err) + } + + got := <-mb.InboundChan() + if got.Channel != "telegram" { + t.Fatalf("expected legacy channel telegram, got %q", got.Channel) + } + if got.ChatID != "-1001" { + t.Fatalf("expected legacy chat ID -1001, got %q", got.ChatID) + } + if got.SenderID != "user-1" { + t.Fatalf("expected legacy sender ID user-1, got %q", got.SenderID) + } + if got.MessageID != "777" { + t.Fatalf("expected legacy message ID 777, got %q", got.MessageID) + } + if got.Context.Account != "bot-a" || got.Context.SpaceID != "guild-9" || got.Context.TopicID != "42" { + t.Fatalf("unexpected normalized context: %+v", got.Context) + } +} + +func TestPublishInbound_BackfillsContextFromLegacyFields(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + msg := InboundMessage{ + Channel: "pico", + ChatID: "session-1", + SenderID: "user-1", + MessageID: "msg-1", + Content: "hello", + } + + if err := mb.PublishInbound(context.Background(), msg); err != nil { + t.Fatalf("PublishInbound failed: %v", err) + } + + got := <-mb.InboundChan() + if got.Context.Channel != "pico" { + t.Fatalf("expected context channel pico, got %q", got.Context.Channel) + } + if got.Context.ChatID != "session-1" { + t.Fatalf("expected context chat ID session-1, got %q", got.Context.ChatID) + } + if got.Context.SenderID != "user-1" { + t.Fatalf("expected context sender ID user-1, got %q", got.Context.SenderID) + } + if got.Context.MessageID != "msg-1" { + t.Fatalf("expected context message ID msg-1, got %q", got.Context.MessageID) + } +} + +func TestMessageBusPublishesRuntimeFailureAndCloseEvents(t *testing.T) { + eventBus := runtimeevents.NewBus() + defer func() { + if err := eventBus.Close(); err != nil { + t.Errorf("event bus close failed: %v", err) + } + }() + + _, eventsCh, err := eventBus.Channel().OfKind( + runtimeevents.KindBusPublishFailed, + runtimeevents.KindBusCloseStarted, + runtimeevents.KindBusCloseDrained, + runtimeevents.KindBusCloseCompleted, + ).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "bus-events", Buffer: 4}) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + mb := NewMessageBus() + mb.SetEventPublisher(eventBus) + + if err := mb.PublishInbound(context.Background(), InboundMessage{}); err == nil { + t.Fatal("expected PublishInbound to fail") + } + failed := receiveBusRuntimeEvent(t, eventsCh) + if failed.Kind != runtimeevents.KindBusPublishFailed || + failed.Source.Name != "inbound" || + failed.Severity != runtimeevents.SeverityError { + t.Fatalf("publish failed event = %+v", failed) + } + if failed.Attrs["stream"] != "inbound" || failed.Attrs["error"] == "" { + t.Fatalf("publish failed attrs = %#v, want stream and error", failed.Attrs) + } + + if err := mb.PublishOutbound(context.Background(), OutboundMessage{ + Context: NewOutboundContext("telegram", "chat-1", ""), + Content: "queued", + }); err != nil { + t.Fatalf("PublishOutbound failed: %v", err) + } + mb.Close() + + seen := map[runtimeevents.Kind]bool{} + var drainedAttrs map[string]any + for range 3 { + evt := receiveBusRuntimeEvent(t, eventsCh) + seen[evt.Kind] = true + if evt.Kind == runtimeevents.KindBusCloseDrained { + drainedAttrs = evt.Attrs + } + } + for _, kind := range []runtimeevents.Kind{ + runtimeevents.KindBusCloseStarted, + runtimeevents.KindBusCloseDrained, + runtimeevents.KindBusCloseCompleted, + } { + if !seen[kind] { + t.Fatalf("missing %s event, seen=%v", kind, seen) + } + } + if drainedAttrs["drained"] != 1 { + t.Fatalf("bus close drained attrs = %#v, want drained count", drainedAttrs) + } +} + +func receiveBusRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event { + t.Helper() + + select { + case evt, ok := <-ch: + if !ok { + t.Fatal("runtime event channel closed before expected event") + } + return evt + case <-time.After(time.Second): + t.Fatal("timed out waiting for runtime event") + return runtimeevents.Event{} + } } func TestPublishOutboundSubscribe(t *testing.T) { @@ -43,8 +260,10 @@ func TestPublishOutboundSubscribe(t *testing.T) { ctx := context.Background() msg := OutboundMessage{ - Channel: "telegram", - ChatID: "123", + Context: InboundContext{ + Channel: "telegram", + ChatID: "123", + }, Content: "world", } @@ -52,13 +271,229 @@ func TestPublishOutboundSubscribe(t *testing.T) { t.Fatalf("PublishOutbound failed: %v", err) } - got, ok := mb.SubscribeOutbound(ctx) + got, ok := <-mb.OutboundChan() if !ok { t.Fatal("SubscribeOutbound returned ok=false") } if got.Content != "world" { t.Fatalf("expected content 'world', got %q", got.Content) } + if got.Context.Channel != "telegram" || got.Context.ChatID != "123" { + t.Fatalf("expected normalized outbound context, got %+v", got.Context) + } +} + +func TestPublishOutbound_MirrorsContextToLegacyFields(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + msg := OutboundMessage{ + Context: InboundContext{ + Channel: "telegram", + ChatID: "chat-42", + ReplyToMessageID: "msg-9", + }, + AgentID: "main", + SessionKey: "sk_v1_123", + Scope: &OutboundScope{ + Version: 1, + AgentID: "main", + Channel: "telegram", + Account: "bot-a", + Dimensions: []string{"chat", "sender"}, + Values: map[string]string{ + "chat": "direct:chat-42", + "sender": "user-1", + }, + }, + Content: "reply", + } + + if err := mb.PublishOutbound(context.Background(), msg); err != nil { + t.Fatalf("PublishOutbound failed: %v", err) + } + + got := <-mb.OutboundChan() + if got.Channel != "telegram" { + t.Fatalf("expected legacy channel telegram, got %q", got.Channel) + } + if got.ChatID != "chat-42" { + t.Fatalf("expected legacy chat ID chat-42, got %q", got.ChatID) + } + if got.ReplyToMessageID != "msg-9" { + t.Fatalf("expected mirrored reply_to_message_id msg-9, got %q", got.ReplyToMessageID) + } + if got.AgentID != "main" || got.SessionKey != "sk_v1_123" { + t.Fatalf("unexpected outbound turn metadata: agent=%q session=%q", got.AgentID, got.SessionKey) + } + if got.Scope == nil || got.Scope.AgentID != "main" || got.Scope.Values["chat"] != "direct:chat-42" { + t.Fatalf("unexpected outbound scope: %+v", got.Scope) + } + if got.Context.Channel != "telegram" || got.Context.ChatID != "chat-42" { + t.Fatalf("unexpected outbound context: %+v", got.Context) + } +} + +func TestPublishOutbound_PreservesExplicitReplyToMessageID(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + msg := OutboundMessage{ + Context: InboundContext{ + Channel: "telegram", + ChatID: "chat-42", + }, + ReplyToMessageID: "msg-9", + Content: "reply", + } + + if err := mb.PublishOutbound(context.Background(), msg); err != nil { + t.Fatalf("PublishOutbound failed: %v", err) + } + + got := <-mb.OutboundChan() + if got.ReplyToMessageID != "msg-9" { + t.Fatalf("expected mirrored reply_to_message_id msg-9, got %q", got.ReplyToMessageID) + } + if got.Context.ReplyToMessageID != "msg-9" { + t.Fatalf("expected context reply_to_message_id msg-9, got %q", got.Context.ReplyToMessageID) + } +} + +func TestPublishOutbound_PreservesExplicitReplyToMessageIDWhenContextReplyIsBlank(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + msg := OutboundMessage{ + Context: InboundContext{ + Channel: "telegram", + ChatID: "chat-42", + ReplyToMessageID: " ", + }, + ReplyToMessageID: "msg-9", + Content: "reply", + } + + if err := mb.PublishOutbound(context.Background(), msg); err != nil { + t.Fatalf("PublishOutbound failed: %v", err) + } + + got := <-mb.OutboundChan() + if got.ReplyToMessageID != "msg-9" { + t.Fatalf("expected mirrored reply_to_message_id msg-9, got %q", got.ReplyToMessageID) + } + if got.Context.ReplyToMessageID != "msg-9" { + t.Fatalf("expected context reply_to_message_id msg-9, got %q", got.Context.ReplyToMessageID) + } +} + +func TestPublishOutboundMedia_MirrorsContextToLegacyFields(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + msg := OutboundMediaMessage{ + Context: InboundContext{ + Channel: "slack", + ChatID: "C001", + }, + AgentID: "support", + SessionKey: "sk_v1_media", + Scope: &OutboundScope{ + Version: 1, + AgentID: "support", + Channel: "slack", + Dimensions: []string{"chat"}, + Values: map[string]string{ + "chat": "channel:c001", + }, + }, + Parts: []MediaPart{{Type: "image", Ref: "media://1"}}, + } + + if err := mb.PublishOutboundMedia(context.Background(), msg); err != nil { + t.Fatalf("PublishOutboundMedia failed: %v", err) + } + + got := <-mb.OutboundMediaChan() + if got.Channel != "slack" { + t.Fatalf("expected legacy channel slack, got %q", got.Channel) + } + if got.ChatID != "C001" { + t.Fatalf("expected legacy chat ID C001, got %q", got.ChatID) + } + if got.AgentID != "support" || got.SessionKey != "sk_v1_media" { + t.Fatalf("unexpected outbound media turn metadata: agent=%q session=%q", got.AgentID, got.SessionKey) + } + if got.Scope == nil || got.Scope.Values["chat"] != "channel:c001" { + t.Fatalf("unexpected outbound media scope: %+v", got.Scope) + } + if got.Context.Channel != "slack" || got.Context.ChatID != "C001" { + t.Fatalf("unexpected outbound media context: %+v", got.Context) + } +} + +func TestPublishAudioChunkSubscribe(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + chunk := AudioChunk{ + SessionID: "voice-1", + SpeakerID: "speaker-1", + ChatID: "chat-1", + Channel: "discord", + Sequence: 7, + Format: "opus", + Data: []byte{0x01, 0x02}, + } + + if err := mb.PublishAudioChunk(context.Background(), chunk); err != nil { + t.Fatalf("PublishAudioChunk failed: %v", err) + } + + got, ok := <-mb.AudioChunksChan() + if !ok { + t.Fatal("AudioChunksChan returned ok=false") + } + if got.SessionID != "voice-1" || got.Sequence != 7 { + t.Fatalf("unexpected audio chunk: %+v", got) + } +} + +func TestPublishVoiceControlSubscribe(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + ctrl := VoiceControl{ + SessionID: "voice-1", + ChatID: "chat-1", + Type: "command", + Action: "start", + } + + if err := mb.PublishVoiceControl(context.Background(), ctrl); err != nil { + t.Fatalf("PublishVoiceControl failed: %v", err) + } + + got, ok := <-mb.VoiceControlsChan() + if !ok { + t.Fatal("VoiceControlsChan returned ok=false") + } + if got.Type != "command" || got.Action != "start" { + t.Fatalf("unexpected voice control: %+v", got) + } +} + +func TestNewOutboundContext_NormalizesReplyAddress(t *testing.T) { + ctx := NewOutboundContext(" telegram ", " chat-42 ", " msg-9 ") + if ctx.Channel != "telegram" { + t.Fatalf("expected channel telegram, got %q", ctx.Channel) + } + if ctx.ChatID != "chat-42" { + t.Fatalf("expected chat_id chat-42, got %q", ctx.ChatID) + } + if ctx.ReplyToMessageID != "msg-9" { + t.Fatalf("expected reply_to_message_id msg-9, got %q", ctx.ReplyToMessageID) + } } func TestPublishInbound_ContextCancel(t *testing.T) { @@ -68,7 +503,15 @@ func TestPublishInbound_ContextCancel(t *testing.T) { // Fill the buffer ctx := context.Background() for i := range defaultBusBufferSize { - if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil { + if err := mb.PublishInbound(ctx, InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat-fill", + ChatType: "direct", + SenderID: "user-fill", + }, + Content: "fill", + }); err != nil { t.Fatalf("fill failed at %d: %v", i, err) } } @@ -77,7 +520,15 @@ func TestPublishInbound_ContextCancel(t *testing.T) { cancelCtx, cancel := context.WithCancel(context.Background()) cancel() - err := mb.PublishInbound(cancelCtx, InboundMessage{Content: "overflow"}) + err := mb.PublishInbound(cancelCtx, InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat-overflow", + ChatType: "direct", + SenderID: "user-overflow", + }, + Content: "overflow", + }) if err == nil { t.Fatal("expected error from canceled context, got nil") } @@ -90,7 +541,15 @@ func TestPublishInbound_BusClosed(t *testing.T) { mb := NewMessageBus() mb.Close() - err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"}) + err := mb.PublishInbound(context.Background(), InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Content: "test", + }) if err != ErrBusClosed { t.Fatalf("expected ErrBusClosed, got %v", err) } @@ -100,7 +559,13 @@ func TestPublishOutbound_BusClosed(t *testing.T) { mb := NewMessageBus() mb.Close() - err := mb.PublishOutbound(context.Background(), OutboundMessage{Content: "test"}) + err := mb.PublishOutbound(context.Background(), OutboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat1", + }, + Content: "test", + }) if err != ErrBusClosed { t.Fatalf("expected ErrBusClosed, got %v", err) } @@ -108,27 +573,64 @@ func TestPublishOutbound_BusClosed(t *testing.T) { func TestConsumeInbound_ContextCancel(t *testing.T) { mb := NewMessageBus() + defer mb.Close() - ctx, cancel := context.WithCancel(context.Background()) - cancel() + for i := range defaultBusBufferSize { + if err := mb.PublishInbound(context.Background(), InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat-fill", + ChatType: "direct", + SenderID: "user-fill", + }, + Content: "fill", + }); err != nil { + t.Fatalf("fill failed at %d: %v", i, err) + } + } - _, ok := mb.ConsumeInbound(ctx) - if ok { - t.Fatal("expected ok=false when context is canceled") + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + mb.PublishInbound(ctx, InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat-cancel", + ChatType: "direct", + SenderID: "user-cancel", + }, + Content: "ContextCancel", + }) + + select { + case <-ctx.Done(): + t.Log("context canceled, as expected") + + case msg, ok := <-mb.InboundChan(): + if !ok { + t.Fatal("expected ok=false when context is canceled") + } + if msg.Content == "ContextCancel" { + t.Fatalf("expected content 'ContextCancel', got %q", msg.Content) + } } } func TestConsumeInbound_BusClosed(t *testing.T) { mb := NewMessageBus() - mb.Close() - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() + timer := time.AfterFunc(100*time.Millisecond, func() { + mb.Close() + }) - _, ok := mb.ConsumeInbound(ctx) - if ok { - t.Fatal("expected ok=false when bus is closed") + select { + case <-timer.C: + t.Log("context canceled, as expected") + + case _, ok := <-mb.InboundChan(): + if ok { + t.Fatal("expected ok=false when context is canceled") + } } } @@ -136,10 +638,7 @@ func TestSubscribeOutbound_BusClosed(t *testing.T) { mb := NewMessageBus() mb.Close() - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - - _, ok := mb.SubscribeOutbound(ctx) + _, ok := <-mb.OutboundChan() if ok { t.Fatal("expected ok=false when bus is closed") } @@ -195,7 +694,15 @@ func TestPublishInbound_FullBuffer(t *testing.T) { // Fill the buffer for i := range defaultBusBufferSize { - if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil { + if err := mb.PublishInbound(ctx, InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat-fill", + ChatType: "direct", + SenderID: "user-fill", + }, + Content: "fill", + }); err != nil { t.Fatalf("fill failed at %d: %v", i, err) } } @@ -204,7 +711,15 @@ func TestPublishInbound_FullBuffer(t *testing.T) { timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancel() - err := mb.PublishInbound(timeoutCtx, InboundMessage{Content: "overflow"}) + err := mb.PublishInbound(timeoutCtx, InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat-overflow", + ChatType: "direct", + SenderID: "user-overflow", + }, + Content: "overflow", + }) if err == nil { t.Fatal("expected error when buffer is full and context times out") } @@ -222,7 +737,15 @@ func TestCloseIdempotent(t *testing.T) { mb.Close() // After close, publish should return ErrBusClosed - err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"}) + err := mb.PublishInbound(context.Background(), InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Content: "test", + }) if err != ErrBusClosed { t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err) } diff --git a/pkg/bus/events.go b/pkg/bus/events.go new file mode 100644 index 000000000..4640ed1fc --- /dev/null +++ b/pkg/bus/events.go @@ -0,0 +1,88 @@ +package bus + +import ( + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +type busPublishFailedPayload struct { + Stream string `json:"stream"` + Error string `json:"error"` +} + +type busClosePayload struct { + Drained int `json:"drained,omitempty"` +} + +func (mb *MessageBus) publishFailure(stream string, scope runtimeevents.Scope, err error) { + if mb == nil || err == nil { + return + } + publisher, ok := mb.eventPublisher.Load().(EventPublisher) + if !ok || publisher == nil { + return + } + + publisher.PublishNonBlocking(runtimeevents.Event{ + Kind: runtimeevents.KindBusPublishFailed, + Source: runtimeevents.Source{Component: "bus", Name: stream}, + Scope: scope, + Severity: runtimeevents.SeverityError, + Payload: busPublishFailedPayload{ + Stream: stream, + Error: err.Error(), + }, + Attrs: map[string]any{ + "stream": stream, + "error": err.Error(), + }, + }) +} + +func (mb *MessageBus) publishCloseEvent(kind runtimeevents.Kind, drained int) { + if mb == nil { + return + } + publisher, ok := mb.eventPublisher.Load().(EventPublisher) + if !ok || publisher == nil { + return + } + + attrs := map[string]any{} + if drained > 0 { + attrs["drained"] = drained + } + publisher.PublishNonBlocking(runtimeevents.Event{ + Kind: kind, + Source: runtimeevents.Source{Component: "bus"}, + Severity: runtimeevents.SeverityInfo, + Payload: busClosePayload{Drained: drained}, + Attrs: attrs, + }) +} + +func runtimeScopeFromInboundContext(ctx InboundContext) runtimeevents.Scope { + return runtimeevents.Scope{ + Channel: ctx.Channel, + Account: ctx.Account, + ChatID: ctx.ChatID, + TopicID: ctx.TopicID, + SpaceID: ctx.SpaceID, + SpaceType: ctx.SpaceType, + ChatType: ctx.ChatType, + SenderID: ctx.SenderID, + MessageID: ctx.MessageID, + } +} + +func runtimeScopeFromAudioChunk(chunk AudioChunk) runtimeevents.Scope { + return runtimeevents.Scope{ + Channel: chunk.Channel, + ChatID: chunk.ChatID, + } +} + +func runtimeScopeFromVoiceControl(ctrl VoiceControl) runtimeevents.Scope { + return runtimeevents.Scope{ + ChatID: ctrl.ChatID, + } +} diff --git a/pkg/bus/inbound_context.go b/pkg/bus/inbound_context.go new file mode 100644 index 000000000..d6be80565 --- /dev/null +++ b/pkg/bus/inbound_context.go @@ -0,0 +1,81 @@ +package bus + +import "strings" + +// NormalizeInboundMessage ensures the inbound context is normalized and keeps +// convenience mirrors in sync for runtime consumers. +func NormalizeInboundMessage(msg InboundMessage) InboundMessage { + if msg.Context.Channel == "" { + msg.Context.Channel = msg.Channel + } + if msg.Context.ChatID == "" { + msg.Context.ChatID = msg.ChatID + } + if msg.Context.SenderID == "" { + msg.Context.SenderID = msg.SenderID + } + if msg.Context.MessageID == "" { + msg.Context.MessageID = msg.MessageID + } + msg.Context = normalizeInboundContext(msg.Context) + msg.Channel = msg.Context.Channel + msg.SenderID = msg.Context.SenderID + msg.ChatID = msg.Context.ChatID + if msg.MessageID == "" { + msg.MessageID = msg.Context.MessageID + } + if msg.Context.MessageID == "" { + msg.Context.MessageID = msg.MessageID + } + return msg +} + +func (ctx InboundContext) isZero() bool { + return ctx.Channel == "" && + ctx.Account == "" && + ctx.ChatID == "" && + ctx.ChatType == "" && + ctx.TopicID == "" && + ctx.SpaceID == "" && + ctx.SpaceType == "" && + ctx.SenderID == "" && + ctx.MessageID == "" && + !ctx.Mentioned && + ctx.ReplyToMessageID == "" && + ctx.ReplyToSenderID == "" && + len(ctx.ReplyHandles) == 0 && + len(ctx.Raw) == 0 +} + +func normalizeInboundContext(ctx InboundContext) InboundContext { + ctx.Channel = strings.TrimSpace(ctx.Channel) + ctx.Account = strings.TrimSpace(ctx.Account) + ctx.ChatID = strings.TrimSpace(ctx.ChatID) + ctx.ChatType = normalizeKind(ctx.ChatType) + ctx.TopicID = strings.TrimSpace(ctx.TopicID) + ctx.SpaceID = strings.TrimSpace(ctx.SpaceID) + ctx.SpaceType = normalizeKind(ctx.SpaceType) + ctx.SenderID = strings.TrimSpace(ctx.SenderID) + ctx.MessageID = strings.TrimSpace(ctx.MessageID) + ctx.ReplyToMessageID = strings.TrimSpace(ctx.ReplyToMessageID) + ctx.ReplyToSenderID = strings.TrimSpace(ctx.ReplyToSenderID) + ctx.ReplyHandles = cloneStringMap(ctx.ReplyHandles) + ctx.Raw = cloneStringMap(ctx.Raw) + return ctx +} + +func cloneStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func normalizeKind(kind string) string { + return strings.ToLower(strings.TrimSpace(kind)) +} diff --git a/pkg/bus/outbound_context.go b/pkg/bus/outbound_context.go new file mode 100644 index 000000000..cbbbc99c7 --- /dev/null +++ b/pkg/bus/outbound_context.go @@ -0,0 +1,84 @@ +package bus + +import "strings" + +// NewOutboundContext builds the minimal normalized addressing context required +// to deliver an outbound text message or reply. +func NewOutboundContext(channel, chatID, replyToMessageID string) InboundContext { + return normalizeInboundContext(InboundContext{ + Channel: strings.TrimSpace(channel), + ChatID: strings.TrimSpace(chatID), + ReplyToMessageID: strings.TrimSpace(replyToMessageID), + }) +} + +// NormalizeOutboundMessage ensures Context is normalized and keeps convenience +// mirrors in sync for runtime consumers. +func NormalizeOutboundMessage(msg OutboundMessage) OutboundMessage { + msg.Channel = strings.TrimSpace(msg.Channel) + msg.ChatID = strings.TrimSpace(msg.ChatID) + msg.ReplyToMessageID = strings.TrimSpace(msg.ReplyToMessageID) + if msg.Context.Channel == "" { + msg.Context.Channel = msg.Channel + } + if msg.Context.ChatID == "" { + msg.Context.ChatID = msg.ChatID + } + if msg.Context.ReplyToMessageID == "" { + msg.Context.ReplyToMessageID = msg.ReplyToMessageID + } + msg.Context = normalizeInboundContext(msg.Context) + if msg.Channel == "" { + msg.Channel = msg.Context.Channel + } + if msg.ChatID == "" { + msg.ChatID = msg.Context.ChatID + } + if msg.ReplyToMessageID == "" { + msg.ReplyToMessageID = msg.Context.ReplyToMessageID + } + if msg.Context.ReplyToMessageID == "" { + msg.Context.ReplyToMessageID = msg.ReplyToMessageID + } + msg.Scope = cloneOutboundScope(msg.Scope) + return msg +} + +// NormalizeOutboundMediaMessage ensures media outbound messages also carry a +// normalized context while keeping convenience mirrors in sync. +func NormalizeOutboundMediaMessage(msg OutboundMediaMessage) OutboundMediaMessage { + msg.Channel = strings.TrimSpace(msg.Channel) + msg.ChatID = strings.TrimSpace(msg.ChatID) + if msg.Context.Channel == "" { + msg.Context.Channel = msg.Channel + } + if msg.Context.ChatID == "" { + msg.Context.ChatID = msg.ChatID + } + msg.Context = normalizeInboundContext(msg.Context) + if msg.Channel == "" { + msg.Channel = msg.Context.Channel + } + if msg.ChatID == "" { + msg.ChatID = msg.Context.ChatID + } + msg.Scope = cloneOutboundScope(msg.Scope) + return msg +} + +func cloneOutboundScope(scope *OutboundScope) *OutboundScope { + if scope == nil { + return nil + } + cloned := *scope + if len(scope.Dimensions) > 0 { + cloned.Dimensions = append([]string(nil), scope.Dimensions...) + } + if len(scope.Values) > 0 { + cloned.Values = make(map[string]string, len(scope.Values)) + for key, value := range scope.Values { + cloned.Values[key] = value + } + } + return &cloned +} diff --git a/pkg/bus/types.go b/pkg/bus/types.go index b12d2f27e..98fd492c7 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -1,11 +1,5 @@ package bus -// Peer identifies the routing peer for a message (direct, group, channel, etc.) -type Peer struct { - Kind string `json:"kind"` // "direct" | "group" | "channel" | "" - ID string `json:"id"` -} - // SenderInfo provides structured sender identity information. type SenderInfo struct { Platform string `json:"platform,omitempty"` // "telegram", "discord", "slack", ... @@ -15,18 +9,65 @@ type SenderInfo struct { DisplayName string `json:"display_name,omitempty"` // display name } +// InboundContext captures the normalized, platform-agnostic facts about an +// inbound message. This is the source of truth for routing and session +// allocation. +type InboundContext struct { + Channel string `json:"channel"` + Account string `json:"account,omitempty"` + + ChatID string `json:"chat_id"` + ChatType string `json:"chat_type,omitempty"` // direct / group / channel + TopicID string `json:"topic_id,omitempty"` + + SpaceID string `json:"space_id,omitempty"` + SpaceType string `json:"space_type,omitempty"` // guild / team / workspace / tenant + + SenderID string `json:"sender_id"` + MessageID string `json:"message_id,omitempty"` + + Mentioned bool `json:"mentioned,omitempty"` + + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + ReplyToSenderID string `json:"reply_to_sender_id,omitempty"` + + ReplyHandles map[string]string `json:"reply_handles,omitempty"` + Raw map[string]string `json:"raw,omitempty"` +} + type InboundMessage struct { - Channel string `json:"channel"` - SenderID string `json:"sender_id"` - Sender SenderInfo `json:"sender"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - Media []string `json:"media,omitempty"` - Peer Peer `json:"peer"` // routing peer - MessageID string `json:"message_id,omitempty"` // platform message ID - MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope - SessionKey string `json:"session_key"` - Metadata map[string]string `json:"metadata,omitempty"` + Context InboundContext `json:"context"` + Sender SenderInfo `json:"sender"` + Content string `json:"content"` + Media []string `json:"media,omitempty"` + MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope + SessionKey string `json:"session_key"` + + // Convenience mirrors derived from Context for runtime consumers. + Channel string `json:"channel"` + SenderID string `json:"sender_id"` + ChatID string `json:"chat_id"` + MessageID string `json:"message_id,omitempty"` // platform message ID +} + +// OutboundScope captures the structured session scope associated with an +// outbound turn result without depending on the session package. +type OutboundScope struct { + Version int `json:"version,omitempty"` + AgentID string `json:"agent_id,omitempty"` + Channel string `json:"channel,omitempty"` + Account string `json:"account,omitempty"` + Dimensions []string `json:"dimensions,omitempty"` + Values map[string]string `json:"values,omitempty"` +} + +// ContextUsage describes how much of the model's context window the current +// session consumes, and how far it is from triggering compression. +type ContextUsage struct { + UsedTokens int `json:"used_tokens"` + TotalTokens int `json:"total_tokens"` // model context window + CompressAtTokens int `json:"compress_at_tokens"` // threshold that triggers compression + UsedPercent int `json:"used_percent"` // 0-100 } // Outbound message type constants identify the kind of outbound message. @@ -38,9 +79,15 @@ const ( ) type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Context InboundContext `json:"context"` + AgentID string `json:"agent_id,omitempty"` + SessionKey string `json:"session_key,omitempty"` + Scope *OutboundScope `json:"scope,omitempty"` + Content string `json:"content"` + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + ContextUsage *ContextUsage `json:"context_usage,omitempty"` // Type distinguishes final responses from progress/escalation updates. // Empty string (default) means final response. Channels that don't support @@ -61,7 +108,7 @@ type OutboundMessage struct { // OutboundProgress carries progress update details. type OutboundProgress struct { - Status string `json:"status"` // e.g. "thinking" + Status string `json:"status"` ToolName string `json:"toolName,omitempty"` StepNumber int `json:"stepNumber,omitempty"` Message string `json:"message,omitempty"` @@ -102,7 +149,33 @@ type MediaPart struct { // OutboundMediaMessage carries media attachments from Agent to channels via the bus. type OutboundMediaMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Parts []MediaPart `json:"parts"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Context InboundContext `json:"context"` + AgentID string `json:"agent_id,omitempty"` + SessionKey string `json:"session_key,omitempty"` + Scope *OutboundScope `json:"scope,omitempty"` + Parts []MediaPart `json:"parts"` +} + +// AudioChunk represents a chunk of streaming voice data. +type AudioChunk struct { + SessionID string `json:"session_id"` + SpeakerID string `json:"speaker_id"` // User ID or SSRC + ChatID string `json:"chat_id"` // Where to respond + Channel string `json:"channel"` // Source channel type (e.g. "discord") + Sequence uint64 `json:"sequence"` + Timestamp uint32 `json:"timestamp"` + SampleRate int `json:"sample_rate"` + Channels int `json:"channels"` + Format string `json:"format"` // "opus", "pcm", etc + Data []byte `json:"data"` +} + +// VoiceControl represents state or commands for voice sessions. +type VoiceControl struct { + SessionID string `json:"session_id"` + ChatID string `json:"chat_id"` + Type string `json:"type"` // "state", "command" + Action string `json:"action"` // "idle", "listening", "start", "stop", "leave" } diff --git a/pkg/channels/README.md b/pkg/channels/README.md index b7c56660b..1cab1a4a6 100644 --- a/pkg/channels/README.md +++ b/pkg/channels/README.md @@ -252,28 +252,28 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { **3e. Send method error returns** ```go -// Old code: returns plain error +// Old code: returned only error func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.running { return fmt.Errorf("not running") } // ... if err != nil { return err } } -// New code: must return sentinel errors for Manager to determine retry strategy -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +// New code: return delivered message IDs plus sentinel errors +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning // ← Manager will not retry + return nil, channels.ErrNotRunning // ← Manager will not retry } // ... if err != nil { // Use ClassifySendError to wrap error based on HTTP status code - return channels.ClassifySendError(statusCode, err) + return nil, channels.ClassifySendError(statusCode, err) // Or manually wrap: - // return fmt.Errorf("%w: %v", channels.ErrTemporary, err) - // return fmt.Errorf("%w: %v", channels.ErrRateLimit, err) - // return fmt.Errorf("%w: %v", channels.ErrSendFailed, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrRateLimit, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrSendFailed, err) } - return nil + return []string{deliveredID}, nil // or return nil, nil if IDs are unavailable } ``` @@ -327,8 +327,13 @@ import ( ) func init() { - channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewTelegramChannel(cfg, b) + channels.RegisterFactory(config.ChannelTelegram, func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { return nil, err } + c, ok := decoded.(*config.TelegramSettings) + if !ok { return nil, channels.ErrSendFailed } + return NewTelegramChannel(bc, c, b) }) } ``` @@ -427,8 +432,13 @@ import ( ) func init() { - channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewMatrixChannel(cfg, b) + channels.RegisterFactory(config.ChannelMatrix, func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { return nil, err } + c, ok := decoded.(*config.MatrixSettings) + if !ok { return nil, channels.ErrSendFailed } + return NewMatrixChannel(bc, c, b) }) } ``` @@ -502,25 +512,25 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { return nil } -func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { // 1. Check running state if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // 2. Send message to Matrix - err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) + eventID, err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) if err != nil { // 3. Must use error classification wrapping // If you have an HTTP status code: - // return channels.ClassifySendError(statusCode, err) + // return nil, channels.ClassifySendError(statusCode, err) // If it's a network error: - // return channels.ClassifyNetError(err) + // return nil, channels.ClassifyNetError(err) // If manual classification is needed: - return fmt.Errorf("%w: %v", channels.ErrTemporary, err) + return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) } - return nil + return []string{eventID}, nil } // ========== Incoming Message Handling ========== @@ -580,9 +590,9 @@ func (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content st // ========== Internal Methods ========== -func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) error { +func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) (string, error) { // Actual Matrix SDK call - return nil + return "event-id", nil } ``` @@ -594,16 +604,17 @@ Depending on platform capabilities, your channel can optionally implement the fo ```go // If the platform supports sending images/files/audio/video -func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store: %w", channels.ErrSendFailed) } + var messageIDs []string for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { @@ -620,8 +631,10 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess default: // Upload file to Matrix } + // Append platform IDs here when the API returns them. + // messageIDs = append(messageIDs, uploadedMessageID) } - return nil + return messageIDs, nil } ``` @@ -770,41 +783,59 @@ When the Agent finishes processing a message, Manager's `preSend` automatically: ### 3.5 Register Configuration and Gateway Integration -#### Add configuration in `pkg/config/config.go` +#### Add configuration entry + +Channels now use a unified map-based configuration (`map[string]*config.Channel`). +Each channel entry stores common fields (`enabled`, `type`, `allow_from`, etc.) at +the top level, with channel-specific settings in the `settings` sub-key: + +```json +{ + "channels": { + "matrix": { + "enabled": true, + "type": "matrix", + "allow_from": ["@user:example.com"], + "settings": { + "home_server": "https://matrix.org", + "user_id": "@bot:example.com", + "access_token": "enc://..." + } + } + } +} +``` + +Secure fields (tokens, passwords, API keys) go into `.security.yml`: + +```yaml +channels: + matrix: + access_token: "your-matrix-access-token" +``` + +Channel types must be registered in `channelSettingsFactory` in +`pkg/config/config_channel.go`: ```go -type ChannelsConfig struct { +var channelSettingsFactory = map[string]any{ // ... existing channels - Matrix MatrixChannelConfig `json:"matrix"` -} - -type MatrixChannelConfig struct { - Enabled bool `json:"enabled"` - HomeServer string `json:"home_server"` - Token string `json:"token"` - AllowFrom []string `json:"allow_from"` - GroupTrigger GroupTriggerConfig `json:"group_trigger"` - Placeholder PlaceholderConfig `json:"placeholder"` - ReasoningChannelID string `json:"reasoning_channel_id"` + ChannelMatrix: (MatrixSettings{}), } ``` -#### Add entry in Manager.initChannels() +#### No Manager changes needed -```go -// In the initChannels() method of pkg/channels/manager.go -if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" { - m.initChannel("matrix", "Matrix") -} -``` +The Manager uses `InitChannelList()` to validate types and decode settings, +then looks up factories by `bc.Type`. No per-channel entry needed in Manager — +just register the factory and the config entry. -> **Note**: If your channel has multiple modes (like WhatsApp Bridge vs Native), branch in initChannels based on config: +> **Note**: If your channel has multiple modes (like WhatsApp Bridge vs Native), +> register both types in `channelSettingsFactory` and branch on config: > ```go -> if cfg.UseNative { -> m.initChannel("whatsapp_native", "WhatsApp Native") -> } else { -> m.initChannel("whatsapp", "WhatsApp") -> } +> // In config_channel.go: +> ChannelWhatsApp: (WhatsAppSettings{}), +> ChannelWhatsAppNative: (WhatsAppSettings{}), > ``` #### Add blank import in Gateway @@ -944,10 +975,29 @@ channels.WithReasoningChannelID(id) // Set reasoning chain routing target **File**: `pkg/channels/registry.go` ```go -type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error) +type ChannelFactory func(channelName, channelType string, cfg *config.Config, bus *bus.MessageBus) (Channel, error) -func RegisterFactory(name string, f ChannelFactory) // Called in sub-package init() -func getFactory(name string) (ChannelFactory, bool) // Called internally by Manager +func RegisterFactory(name string, f ChannelFactory) // Called in sub-package init() +func getFactory(name string) (ChannelFactory, bool) // Called internally by Manager +func GetRegisteredFactoryNames() []string // Returns all registered factory names +``` + +For convenience, `RegisterSafeFactory[S any]` provides automatic type-safe settings decoding: + +```go +// Instead of manual GetDecoded() + type assertion: +channels.RegisterFactory(config.ChannelTelegram, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { return nil, err } + c, ok := decoded.(*config.TelegramSettings) + if !ok { return nil, ErrSendFailed } + return NewTelegramChannel(bc, c, b) + }) + +// You can use RegisterSafeFactory (same safety, less boilerplate): +channels.RegisterSafeFactory(config.ChannelTelegram, NewTelegramChannel) ``` The 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. @@ -1255,8 +1305,7 @@ make test # Full test suite | `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender | | `pkg/channels/dingtalk/` | `"dingtalk"` | — | | `pkg/channels/feishu/` | `"feishu"` | — (architecture-specific build tags: `feishu_32.go` / `feishu_64.go`) | -| `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker | -| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker | +| `pkg/channels/wecom/` | `"wecom"` | MediaSender | | `pkg/channels/qq/` | `"qq"` | — | | `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge mode) | | `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (Native whatsmeow mode) | @@ -1271,7 +1320,7 @@ type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error - Send(ctx context.Context, msg bus.OutboundMessage) error + Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool @@ -1280,7 +1329,7 @@ type Channel interface { // ===== Optional ===== type MediaSender interface { - SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) } type TypingCapable interface { @@ -1371,7 +1420,7 @@ agentLoop.Stop() // Stop Agent 2. **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`. -3. **WeCom has two factories**: `"wecom"` (Bot mode, webhook only) and `"wecom_app"` (App mode, supports MediaSender) are registered separately. Both implement `WebhookHandler` and `HealthChecker`. +3. **WeCom is now a single channel**: `"wecom"` is implemented as a WebSocket-based AI Bot channel with route persistence. Access control uses the shared channel allowlist mechanism. It no longer exposes the legacy webhook/app split. 4. **Pico Protocol**: `pkg/channels/pico/` implements a custom PicoClaw native protocol channel that receives messages via WebSocket webhook (`/pico/ws`). @@ -1381,4 +1430,4 @@ agentLoop.Stop() // Stop Agent 7. **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. -8. **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. \ No newline at end of file +8. **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). Note: `PicoConfig` does not currently expose this field. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method. diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md index 2c5e7356e..c44859c20 100644 --- a/pkg/channels/README.zh.md +++ b/pkg/channels/README.zh.md @@ -252,28 +252,28 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { **3e. Send 方法的错误返回** ```go -// 旧代码:返回普通 error +// 旧代码:只返回 error func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.running { return fmt.Errorf("not running") } // ... if err != nil { return err } } -// 新代码:必须返回哨兵错误,供 Manager 判断重试策略 -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +// 新代码:返回投递后的消息 ID,以及供 Manager 判断重试策略的哨兵错误 +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning // ← Manager 不会重试 + return nil, channels.ErrNotRunning // ← Manager 不会重试 } // ... if err != nil { // 使用 ClassifySendError 根据 HTTP 状态码包装错误 - return channels.ClassifySendError(statusCode, err) + return nil, channels.ClassifySendError(statusCode, err) // 或手动包装: - // return fmt.Errorf("%w: %v", channels.ErrTemporary, err) - // return fmt.Errorf("%w: %v", channels.ErrRateLimit, err) - // return fmt.Errorf("%w: %v", channels.ErrSendFailed, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrRateLimit, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrSendFailed, err) } - return nil + return []string{deliveredID}, nil // 如果拿不到 ID,也可以返回 nil, nil } ``` @@ -327,8 +327,13 @@ import ( ) func init() { - channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewTelegramChannel(cfg, b) + channels.RegisterFactory(config.ChannelTelegram, func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { return nil, err } + c, ok := decoded.(*config.TelegramSettings) + if !ok { return nil, channels.ErrSendFailed } + return NewTelegramChannel(bc, c, b) }) } ``` @@ -427,8 +432,13 @@ import ( ) func init() { - channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewMatrixChannel(cfg, b) + channels.RegisterFactory(config.ChannelMatrix, func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { return nil, err } + c, ok := decoded.(*config.MatrixSettings) + if !ok { return nil, channels.ErrSendFailed } + return NewMatrixChannel(bc, c, b) }) } ``` @@ -502,25 +512,25 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { return nil } -func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { // 1. 检查运行状态 if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // 2. 发送消息到 Matrix - err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) + eventID, err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) if err != nil { // 3. 必须使用错误分类包装 // 如果你有 HTTP 状态码: - // return channels.ClassifySendError(statusCode, err) + // return nil, channels.ClassifySendError(statusCode, err) // 如果是网络错误: - // return channels.ClassifyNetError(err) + // return nil, channels.ClassifyNetError(err) // 如果需要手动分类: - return fmt.Errorf("%w: %v", channels.ErrTemporary, err) + return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) } - return nil + return []string{eventID}, nil } // ========== 消息接收处理 ========== @@ -580,9 +590,9 @@ func (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content st // ========== 内部方法 ========== -func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) error { +func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) (string, error) { // 实际的 Matrix SDK 调用 - return nil + return "event-id", nil } ``` @@ -594,16 +604,17 @@ func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string ```go // 如果平台支持发送图片/文件/音频/视频 -func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store: %w", channels.ErrSendFailed) } + var messageIDs []string for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { @@ -620,8 +631,10 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess default: // 上传文件到 Matrix } + // 如果 API 能返回平台消息 ID,就在这里追加。 + // messageIDs = append(messageIDs, uploadedMessageID) } - return nil + return messageIDs, nil } ``` @@ -769,41 +782,58 @@ if c.owner != nil && c.placeholderRecorder != nil { ### 3.5 注册配置和 Gateway 接入 -#### 在 `pkg/config/config.go` 中添加配置 +#### 添加配置入口 + +Channels 现在使用统一的 map 类型配置(`map[string]*config.Channel`)。 +每个 channel 条目将通用字段(`enabled`、`type`、`allow_from` 等)放在顶层, +channel 特定的设置放在 `settings` 子键中: + +```json +{ + "channels": { + "matrix": { + "enabled": true, + "type": "matrix", + "allow_from": ["@user:example.com"], + "settings": { + "home_server": "https://matrix.org", + "user_id": "@bot:example.com", + "access_token": "enc://..." + } + } + } +} +``` + +安全字段(token、密码、API 密钥)放入 `.security.yml`: + +```yaml +channels: + matrix: + access_token: "your-matrix-access-token" +``` + +Channel 类型必须在 `pkg/config/config_channel.go` 的 `channelSettingsFactory` 中注册: ```go -type ChannelsConfig struct { +var channelSettingsFactory = map[string]any{ // ... 现有 channels - Matrix MatrixChannelConfig `json:"matrix"` -} - -type MatrixChannelConfig struct { - Enabled bool `json:"enabled"` - HomeServer string `json:"home_server"` - Token string `json:"token"` - AllowFrom []string `json:"allow_from"` - GroupTrigger GroupTriggerConfig `json:"group_trigger"` - Placeholder PlaceholderConfig `json:"placeholder"` - ReasoningChannelID string `json:"reasoning_channel_id"` + ChannelMatrix: (MatrixSettings{}), } ``` -#### 在 Manager.initChannels() 中添加入口 +#### 无需修改 Manager -```go -// pkg/channels/manager.go 的 initChannels() 方法中 -if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" { - m.initChannel("matrix", "Matrix") -} -``` +Manager 使用 `InitChannelList()` 来验证类型和解码设置, +然后通过 `bc.Type` 查找工厂。不需要在 Manager 中添加每个 channel 的条目—— +只需注册工厂和配置条目即可。 -> **注意**:如果你的 channel 有多种模式(如 WhatsApp Bridge vs Native),需要在 initChannels 中根据配置分支: +> **注意**:如果你的 channel 有多种模式(如 WhatsApp Bridge vs Native), +> 在 `channelSettingsFactory` 中注册两种类型,并根据配置分支: > ```go -> if cfg.UseNative { -> m.initChannel("whatsapp_native", "WhatsApp Native") -> } else { -> m.initChannel("whatsapp", "WhatsApp") -> } +> // 在 config_channel.go 中: +> ChannelWhatsApp: (WhatsAppSettings{}), +> ChannelWhatsAppNative: (WhatsAppSettings{}), > ``` #### 在 Gateway 中添加 blank import @@ -943,10 +973,29 @@ channels.WithReasoningChannelID(id) // 设置思维链路由目标 channe **文件**:`pkg/channels/registry.go` ```go -type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error) +type ChannelFactory func(channelName, channelType string, cfg *config.Config, bus *bus.MessageBus) (Channel, error) -func RegisterFactory(name string, f ChannelFactory) // 子包 init() 中调用 -func getFactory(name string) (ChannelFactory, bool) // Manager 内部调用 +func RegisterFactory(name string, f ChannelFactory) // 子包 init() 中调用 +func getFactory(name string) (ChannelFactory, bool) // Manager 内部调用 +func GetRegisteredFactoryNames() []string // 返回所有已注册的工厂名称 +``` + +为方便使用,`RegisterSafeFactory[S any]` 提供自动类型安全的设置解码: + +```go +// 不使用 RegisterSafeFactory(手动 GetDecoded() + 类型断言): +channels.RegisterFactory(config.ChannelTelegram, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { return nil, err } + c, ok := decoded.(*config.TelegramSettings) + if !ok { return nil, ErrSendFailed } + return NewTelegramChannel(bc, c, b) + }) + +// 使用 RegisterSafeFactory(同等安全,减少样板代码): +channels.RegisterSafeFactory(config.ChannelTelegram, NewTelegramChannel) ``` 工厂注册表使用 `sync.RWMutex` 保护,在 `init()` 阶段注册(进程启动时完成)。Manager 在 `initChannel()` 中通过名字查找工厂并调用它。 @@ -1254,8 +1303,7 @@ make test # 全量测试 | `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender | | `pkg/channels/dingtalk/` | `"dingtalk"` | — | | `pkg/channels/feishu/` | `"feishu"` | — (架构特定 build tags: `feishu_32.go` / `feishu_64.go`) | -| `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker | -| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker | +| `pkg/channels/wecom/` | `"wecom"` | MediaSender | | `pkg/channels/qq/` | `"qq"` | — | | `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge 模式) | | `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (原生 whatsmeow 模式) | @@ -1270,7 +1318,7 @@ type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error - Send(ctx context.Context, msg bus.OutboundMessage) error + Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool @@ -1279,7 +1327,7 @@ type Channel interface { // ===== 可选实现 ===== type MediaSender interface { - SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) } type TypingCapable interface { @@ -1370,7 +1418,7 @@ agentLoop.Stop() // 停止 Agent 2. **Feishu 架构特定编译**:Feishu channel 使用 build tags 区分 32 位和 64 位架构(`feishu_32.go` / `feishu_64.go`)。Feishu 使用 SDK 的 WebSocket 模式(非 HTTP webhook),因此不实现 `WebhookHandler`。 -3. **WeCom 有两个工厂**:`"wecom"`(Bot 模式,纯 webhook)和 `"wecom_app"`(应用模式,支持 MediaSender)分别注册。两者都实现了 `WebhookHandler` 和 `HealthChecker`。 +3. **WeCom 现在只有一个 channel**:`"wecom"` 采用 WebSocket AI Bot 实现,带路由持久化;访问控制走统一的 channel 白名单机制,不再保留旧的 webhook/app 双分支。 4. **Pico Protocol**:`pkg/channels/pico/` 实现了一个自定义的 PicoClaw 原生协议 channel,通过 WebSocket webhook (`/pico/ws`) 接收消息。 @@ -1380,4 +1428,4 @@ agentLoop.Stop() // 停止 Agent 7. **PlaceholderConfig 的配置与实现**:`PlaceholderConfig` 出现在 6 个 channel config 中(Telegram、Discord、Slack、LINE、OneBot、Pico),但只有实现了 `PlaceholderCapable` + `MessageEditor` 的 channel(Telegram、Discord、Pico)能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。 -8. **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()` 方法暴露此配置。 \ No newline at end of file +8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链(reasoning/thinking)路由到指定 channel(WhatsApp、Telegram、Feishu、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom)。注意:`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。 diff --git a/pkg/channels/base.go b/pkg/channels/base.go index ea3172b99..a0f28f4c8 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/binary" "encoding/hex" + "regexp" "strconv" "strings" "sync/atomic" @@ -32,6 +33,9 @@ func init() { uniqueIDPrefix = hex.EncodeToString(b[:]) } +// audioAnnotationRe matches audio/voice annotations injected by channels (e.g. [voice], [audio: file.ogg]). +var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) + // uniqueID generates a process-unique ID using a random prefix and an atomic counter. // This ID is intended for internal correlation (e.g. media scope keys) and is NOT // cryptographically secure — it must not be used in contexts where unpredictability matters. @@ -44,7 +48,7 @@ type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error - Send(ctx context.Context, msg bus.OutboundMessage) error + Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool @@ -99,6 +103,16 @@ func NewBaseChannel( allowList []string, opts ...BaseChannelOption, ) *BaseChannel { + isEmpty := true + for _, s := range allowList { + if s != "" { + isEmpty = false + break + } + } + if isEmpty { + allowList = []string{} + } bc := &BaseChannel{ config: config, bus: bus, @@ -108,6 +122,18 @@ func NewBaseChannel( for _, opt := range opts { opt(bc) } + + // Security Audit: Check for open-by-default (unsecured) channels. + // PicoClaw aims to be secure-by-default. If allow_from is empty, the bot + // currently defaults to accepting messages from ANYONE. To explicitly + // acknowledge and permit this (e.g. for a public bot), use ["*"]. + if len(bc.allowList) == 0 { + logger.WarnCF("channels", "SECURITY: Channel allows EVERYONE (allow_from is empty)", map[string]any{ + "channel": bc.name, + "hint": "Set allow_from to your ID, or use '*' to explicitly acknowledge open access.", + }) + } + return bc } @@ -161,6 +187,12 @@ func (c *BaseChannel) Name() string { return c.name } +// SetName updates the channel name. Used by the manager after channel creation +// to ensure the name matches the config key (which may differ from the type). +func (c *BaseChannel) SetName(name string) { + c.name = name +} + func (c *BaseChannel) ReasoningChannelID() string { return c.reasoningChannelID } @@ -183,6 +215,9 @@ func (c *BaseChannel) IsAllowed(senderID string) bool { } for _, allowed := range c.allowList { + if allowed == "*" { + return true + } // Strip leading "@" from allowed value for username matching trimmed := strings.TrimPrefix(allowed, "@") allowedID := trimmed @@ -217,7 +252,7 @@ func (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool { } for _, allowed := range c.allowList { - if identity.MatchAllowed(sender, allowed) { + if allowed == "*" || identity.MatchAllowed(sender, allowed) { return true } } @@ -225,12 +260,11 @@ func (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool { return false } -func (c *BaseChannel) HandleMessage( +func (c *BaseChannel) HandleMessageWithContext( ctx context.Context, - peer bus.Peer, - messageID, senderID, chatID, content string, + deliveryChatID, content string, media []string, - metadata map[string]string, + inboundCtx bus.InboundContext, senderOpts ...bus.SenderInfo, ) { // Use SenderInfo-based allow check when available, else fall back to string @@ -238,6 +272,7 @@ func (c *BaseChannel) HandleMessage( if len(senderOpts) > 0 { sender = senderOpts[0] } + senderID := strings.TrimSpace(inboundCtx.SenderID) if sender.CanonicalID != "" || sender.PlatformID != "" { if !c.IsAllowedSender(sender) { return @@ -254,40 +289,57 @@ func (c *BaseChannel) HandleMessage( resolvedSenderID = sender.CanonicalID } - scope := BuildMediaScope(c.name, chatID, messageID) + if resolvedSenderID == "" { + resolvedSenderID = senderID + } + + inboundCtx.Channel = c.name + if inboundCtx.ChatID == "" { + inboundCtx.ChatID = deliveryChatID + } + if inboundCtx.SenderID == "" { + inboundCtx.SenderID = resolvedSenderID + } + + scope := BuildMediaScope(c.name, deliveryChatID, inboundCtx.MessageID) msg := bus.InboundMessage{ - Channel: c.name, - SenderID: resolvedSenderID, + Context: inboundCtx, Sender: sender, - ChatID: chatID, Content: content, Media: media, - Peer: peer, - MessageID: messageID, MediaScope: scope, - Metadata: metadata, } + msg = bus.NormalizeInboundMessage(msg) // Auto-trigger typing indicator, message reaction, and placeholder before publishing. // Each capability is independent — all three may fire for the same message. + // Note: even when streaming is available, we still show typing + placeholder on inbound. + // If streaming actually activates, preSend will skip the placeholder edit (streamActive map) + // and the typing stop will still be called. This avoids the problem of compile-time interface + // checks incorrectly skipping indicators when streaming may not work at runtime. if c.owner != nil && c.placeholderRecorder != nil { - // Typing — independent pipeline + // Typing if tc, ok := c.owner.(TypingCapable); ok { - if stop, err := tc.StartTyping(ctx, chatID); err == nil { - c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop) + if stop, err := tc.StartTyping(ctx, deliveryChatID); err == nil { + c.placeholderRecorder.RecordTypingStop(c.name, deliveryChatID, stop) } } - // Reaction — independent pipeline - if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" { - if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil { - c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo) + // Reaction + if rc, ok := c.owner.(ReactionCapable); ok && msg.MessageID != "" { + if undo, err := rc.ReactToMessage(ctx, deliveryChatID, msg.MessageID); err == nil { + c.placeholderRecorder.RecordReactionUndo(c.name, deliveryChatID, undo) } } - // Placeholder — independent pipeline - if pc, ok := c.owner.(PlaceholderCapable); ok { - if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { - c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) + // Placeholder — independent pipeline. + // Skip when the message contains audio: the agent will send the + // placeholder after transcription completes, so the user sees + // "Thinking…" only once the voice has been processed. + if !audioAnnotationRe.MatchString(content) { + if pc, ok := c.owner.(PlaceholderCapable); ok { + if phID, err := pc.SendPlaceholder(ctx, deliveryChatID); err == nil && phID != "" { + c.placeholderRecorder.RecordPlaceholder(c.name, deliveryChatID, phID) + } } } } @@ -295,7 +347,7 @@ func (c *BaseChannel) HandleMessage( if err := c.bus.PublishInbound(ctx, msg); err != nil { logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{ "channel": c.name, - "chat_id": chatID, + "chat_id": deliveryChatID, "error": err.Error(), }) } @@ -305,6 +357,18 @@ func (c *BaseChannel) HandleMessage( // publish directly (e.g. to set SessionKey on InboundMessage). func (c *BaseChannel) Bus() *bus.MessageBus { return c.bus } +// HandleInboundContext publishes a normalized inbound message using only the +// structured context. +func (c *BaseChannel) HandleInboundContext( + ctx context.Context, + deliveryChatID, content string, + media []string, + inboundCtx bus.InboundContext, + senderOpts ...bus.SenderInfo, +) { + c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...) +} + func (c *BaseChannel) SetRunning(running bool) { c.running.Store(running) } diff --git a/pkg/channels/base_test.go b/pkg/channels/base_test.go index 6132b8bf9..04500f775 100644 --- a/pkg/channels/base_test.go +++ b/pkg/channels/base_test.go @@ -1,6 +1,7 @@ package channels import ( + "context" "testing" "github.com/sipeed/picoclaw/pkg/bus" @@ -263,3 +264,58 @@ func TestIsAllowedSender(t *testing.T) { }) } } + +func TestHandleInboundContext_PublishesNormalizedContext(t *testing.T) { + tests := []struct { + name string + inbound bus.InboundContext + wantChat string + wantSender string + }{ + { + name: "direct uses sender as peer", + inbound: bus.InboundContext{ + Channel: "test", + ChatID: "chat-1", + ChatType: "direct", + SenderID: "user-1", + MessageID: "msg-1", + }, + wantChat: "chat-1", + wantSender: "user-1", + }, + { + name: "group uses chat as peer", + inbound: bus.InboundContext{ + Channel: "test", + ChatID: "group-1", + ChatType: "group", + SenderID: "user-2", + MessageID: "msg-2", + }, + wantChat: "group-1", + wantSender: "user-2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msgBus := bus.NewMessageBus() + defer msgBus.Close() + + ch := NewBaseChannel("test", nil, msgBus, nil) + ch.HandleInboundContext(context.Background(), tt.inbound.ChatID, "hello", nil, tt.inbound) + + msg := <-msgBus.InboundChan() + if msg.ChatID != tt.wantChat { + t.Fatalf("ChatID = %q, want %q", msg.ChatID, tt.wantChat) + } + if msg.SenderID != tt.wantSender { + t.Fatalf("SenderID = %q, want %q", msg.SenderID, tt.wantSender) + } + if msg.Context.ChatType != tt.inbound.ChatType { + t.Fatalf("ChatType = %q, want %q", msg.Context.ChatType, tt.inbound.ChatType) + } + }) + } +} diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index 8642ad362..9cd461bc8 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -6,10 +6,12 @@ package dingtalk import ( "context" "fmt" + "strings" "sync" "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" + dinglog "github.com/open-dingtalk/dingtalk-stream-sdk-go/logger" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" @@ -23,7 +25,7 @@ import ( // It uses WebSocket for receiving messages via stream mode and API for sending type DingTalkChannel struct { *channels.BaseChannel - config config.DingTalkConfig + config *config.DingTalkSettings clientID string clientSecret string streamClient *client.StreamClient @@ -34,22 +36,29 @@ type DingTalkChannel struct { } // NewDingTalkChannel creates a new DingTalk channel instance -func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) { - if cfg.ClientID == "" || cfg.ClientSecret == "" { +func NewDingTalkChannel( + bc *config.Channel, + cfg *config.DingTalkSettings, + messageBus *bus.MessageBus, +) (*DingTalkChannel, error) { + if cfg.ClientID == "" || cfg.ClientSecret.String() == "" { return nil, fmt.Errorf("dingtalk client_id and client_secret are required") } - base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom, + // Set the logger for the Stream SDK + dinglog.SetLogger(logger.NewLogger("dingtalk")) + + base := channels.NewBaseChannel("dingtalk", cfg, messageBus, bc.AllowFrom, channels.WithMaxMessageLength(20000), - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &DingTalkChannel{ BaseChannel: base, config: cfg, clientID: cfg.ClientID, - clientSecret: cfg.ClientSecret, + clientSecret: cfg.ClientSecret.String(), }, nil } @@ -99,20 +108,20 @@ func (c *DingTalkChannel) Stop(ctx context.Context) error { } // Send sends a message to DingTalk via the chatbot reply API -func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Get session webhook from storage sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID) if !ok { - return fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) + return nil, fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) } sessionWebhook, ok := sessionWebhookRaw.(string) if !ok { - return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) + return nil, fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) } logger.DebugCF("dingtalk", "Sending message", map[string]any{ @@ -121,7 +130,7 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err }) // Use the session webhook to send the reply - return c.SendDirectReply(ctx, sessionWebhook, msg.Content) + return nil, c.SendDirectReply(ctx, sessionWebhook, msg.Content) } // onChatBotMessageReceived implements the IChatBotMessageHandler function signature @@ -131,13 +140,17 @@ func (c *DingTalkChannel) onChatBotMessageReceived( ctx context.Context, data *chatbot.BotCallbackDataModel, ) ([]byte, error) { + if data == nil { + return nil, nil + } + // Extract message content from Text field - content := data.Text.Content + content := strings.TrimSpace(data.Text.Content) if content == "" { // Try to extract from Content interface{} if Text is empty if contentMap, ok := data.Content.(map[string]any); ok { if textContent, ok := contentMap["content"].(string); ok { - content = textContent + content = strings.TrimSpace(textContent) } } } @@ -146,12 +159,19 @@ func (c *DingTalkChannel) onChatBotMessageReceived( return nil, nil // Ignore empty messages } - senderID := data.SenderStaffId - senderNick := data.SenderNick - chatID := senderID - if data.ConversationType != "1" { - // For group chats - chatID = data.ConversationId + senderID := strings.TrimSpace(data.SenderStaffId) + if senderID == "" { + senderID = strings.TrimSpace(data.SenderId) + } + senderNick := strings.TrimSpace(data.SenderNick) + + chatID := strings.TrimSpace(data.ConversationId) + if chatID == "" && data.ConversationType == "1" { + // Fallback for direct chats when conversation_id is absent. + chatID = senderID + } + if chatID == "" { + return nil, nil } // Store the session webhook for this chat so we can reply later @@ -165,13 +185,20 @@ func (c *DingTalkChannel) onChatBotMessageReceived( "session_webhook": data.SessionWebhook, } - var peer bus.Peer + var ( + chatType string + isMentioned bool + ) if data.ConversationType == "1" { - peer = bus.Peer{Kind: "direct", ID: senderID} + chatType = "direct" } else { - peer = bus.Peer{Kind: "group", ID: data.ConversationId} + chatType = "group" + isMentioned = data.IsInAtList + if isMentioned { + content = stripLeadingAtMentions(content) + } // In group chats, apply unified group trigger filtering - respond, cleaned := c.ShouldRespondInGroup(false, content) + respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { return nil, nil } @@ -185,10 +212,18 @@ func (c *DingTalkChannel) onChatBotMessageReceived( }) // Build sender info + platformID := senderID + if platformID == "" { + platformID = chatID + } + resolvedSenderID := senderID + if resolvedSenderID == "" { + resolvedSenderID = platformID + } sender := bus.SenderInfo{ Platform: "dingtalk", - PlatformID: senderID, - CanonicalID: identity.BuildCanonicalID("dingtalk", senderID), + PlatformID: platformID, + CanonicalID: identity.BuildCanonicalID("dingtalk", platformID), DisplayName: senderNick, } @@ -196,8 +231,21 @@ func (c *DingTalkChannel) onChatBotMessageReceived( return nil, nil } - // Handle the message through the base channel - c.HandleMessage(ctx, peer, "", senderID, chatID, content, nil, metadata, sender) + inboundCtx := bus.InboundContext{ + Channel: "dingtalk", + ChatID: chatID, + ChatType: chatType, + SenderID: resolvedSenderID, + Mentioned: isMentioned, + Raw: metadata, + } + if data.SessionWebhook != "" { + inboundCtx.ReplyHandles = map[string]string{ + "session_webhook": data.SessionWebhook, + } + } + + c.HandleInboundContext(ctx, chatID, content, nil, inboundCtx, sender) // Return nil to indicate we've handled the message asynchronously // The response will be sent through the message bus @@ -225,3 +273,19 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c return nil } + +func stripLeadingAtMentions(content string) string { + fields := strings.Fields(content) + if len(fields) == 0 { + return "" + } + + i := 0 + for i < len(fields) && strings.HasPrefix(fields[i], "@") { + i++ + } + if i == 0 { + return strings.TrimSpace(content) + } + return strings.Join(fields[i:], " ") +} diff --git a/pkg/channels/dingtalk/dingtalk_test.go b/pkg/channels/dingtalk/dingtalk_test.go new file mode 100644 index 000000000..6dfc44730 --- /dev/null +++ b/pkg/channels/dingtalk/dingtalk_test.go @@ -0,0 +1,144 @@ +package dingtalk + +import ( + "context" + "testing" + "time" + + "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newTestDingTalkChannel( + t *testing.T, + cfg config.DingTalkSettings, + bc *config.Channel, +) (*DingTalkChannel, *bus.MessageBus) { + t.Helper() + + if cfg.ClientID == "" { + cfg.ClientID = "test-client-id" + } + if cfg.ClientSecret.String() == "" { + cfg.ClientSecret.Set("test-client-secret") + } + + msgBus := bus.NewMessageBus() + if bc == nil { + bc = &config.Channel{Type: config.ChannelDingTalk, Enabled: true} + } + ch, err := NewDingTalkChannel(bc, &cfg, msgBus) + if err != nil { + t.Fatalf("new channel: %v", err) + } + return ch, msgBus +} + +func mustReceiveInbound(t *testing.T, msgBus *bus.MessageBus) bus.InboundMessage { + t.Helper() + select { + case msg := <-msgBus.InboundChan(): + return msg + case <-time.After(time.Second): + t.Fatal("expected inbound message") + return bus.InboundMessage{} + } +} + +func TestOnChatBotMessageReceived_GroupMentionOnlyUsesIsInAtListAndStripsMention(t *testing.T) { + bc := &config.Channel{ + Type: config.ChannelDingTalk, + Enabled: true, + GroupTrigger: config.GroupTriggerConfig{MentionOnly: true}, + } + ch, msgBus := newTestDingTalkChannel(t, config.DingTalkSettings{}, bc) + + _, err := ch.onChatBotMessageReceived(context.Background(), &chatbot.BotCallbackDataModel{ + Text: chatbot.BotCallbackDataTextModel{Content: " @bot /help "}, + SenderStaffId: "staff-123", + SenderNick: "Alice", + ConversationType: "2", + ConversationId: "group-abc", + SessionWebhook: "https://example.com/webhook", + IsInAtList: true, + }) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + inbound := mustReceiveInbound(t, msgBus) + if inbound.Channel != "dingtalk" { + t.Fatalf("channel=%q", inbound.Channel) + } + if inbound.ChatID != "group-abc" { + t.Fatalf("chat_id=%q", inbound.ChatID) + } + if inbound.Context.ChatType != "group" { + t.Fatalf("chat_type=%q", inbound.Context.ChatType) + } + if inbound.Content != "/help" { + t.Fatalf("content=%q", inbound.Content) + } +} + +func TestOnChatBotMessageReceived_DirectFallbackSenderIDUsesConversationID(t *testing.T) { + ch, msgBus := newTestDingTalkChannel(t, config.DingTalkSettings{}, nil) + + _, err := ch.onChatBotMessageReceived(context.Background(), &chatbot.BotCallbackDataModel{ + Text: chatbot.BotCallbackDataTextModel{Content: "ping"}, + SenderStaffId: "", + SenderId: "openid-user-42", + SenderNick: "Bob", + ConversationType: "1", + ConversationId: "conv-direct-42", + SessionWebhook: "https://example.com/webhook-direct", + }) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + inbound := mustReceiveInbound(t, msgBus) + if inbound.ChatID != "conv-direct-42" { + t.Fatalf("chat_id=%q", inbound.ChatID) + } + if inbound.Context.ChatType != "direct" { + t.Fatalf("chat_type=%q", inbound.Context.ChatType) + } + if inbound.SenderID != "openid-user-42" { + t.Fatalf("sender_id=%q", inbound.SenderID) + } + if inbound.Sender.CanonicalID != "dingtalk:openid-user-42" { + t.Fatalf("sender canonical_id=%q", inbound.Sender.CanonicalID) + } + + if _, ok := ch.sessionWebhooks.Load("conv-direct-42"); !ok { + t.Fatal("expected session webhook keyed by conversation_id") + } + if _, ok := ch.sessionWebhooks.Load(""); ok { + t.Fatal("unexpected empty chat_id webhook key") + } +} + +func TestStripLeadingAtMentions(t *testing.T) { + tests := []struct { + name string + input string + wantOut string + }{ + {name: "single mention and command", input: "@bot /help", wantOut: "/help"}, + {name: "multiple mentions", input: "@bot @alice /new", wantOut: "/new"}, + {name: "no mention", input: "/help", wantOut: "/help"}, + {name: "mention only", input: "@bot", wantOut: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripLeadingAtMentions(tt.input) + if got != tt.wantOut { + t.Fatalf("stripLeadingAtMentions(%q)=%q want=%q", tt.input, got, tt.wantOut) + } + }) + } +} diff --git a/pkg/channels/dingtalk/init.go b/pkg/channels/dingtalk/init.go index 5f49bce8c..ab92c75b4 100644 --- a/pkg/channels/dingtalk/init.go +++ b/pkg/channels/dingtalk/init.go @@ -7,7 +7,26 @@ import ( ) func init() { - channels.RegisterFactory("dingtalk", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewDingTalkChannel(cfg.Channels.DingTalk, b) - }) + channels.RegisterFactory( + config.ChannelDingTalk, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.DingTalkSettings) + if !ok { + return nil, channels.ErrSendFailed + } + ch, err := NewDingTalkChannel(bc, c, b) + if err != nil { + return nil, err + } + if channelName != config.ChannelDingTalk { + ch.SetName(channelName) + } + return ch, nil + }, + ) } diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index c3bcbff8d..514b9b3b1 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -3,6 +3,7 @@ package discord import ( "context" "fmt" + "io" "net/http" "net/url" "os" @@ -14,6 +15,8 @@ import ( "github.com/bwmarrin/discordgo" "github.com/gorilla/websocket" + "github.com/sipeed/picoclaw/pkg/audio" + "github.com/sipeed/picoclaw/pkg/audio/tts" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" @@ -35,17 +38,42 @@ var ( type DiscordChannel struct { *channels.BaseChannel + bc *config.Channel session *discordgo.Session - config config.DiscordConfig + config *config.DiscordSettings ctx context.Context cancel context.CancelFunc typingMu sync.Mutex typingStop map[string]chan struct{} // chatID → stop signal - botUserID string // stored for mention checking + progress *channels.ToolFeedbackAnimator + botUserID string // stored for mention checking + bus *bus.MessageBus + tts tts.TTSProvider + playTTSFn func(context.Context, *discordgo.VoiceConnection, string, uint64) + ttsVoiceFn func(string) (*discordgo.VoiceConnection, bool) + voiceMu sync.RWMutex + voiceSSRC map[string]map[uint32]string // guildID -> ssrc -> userID + + // TTS interruption: cancel active playback when user speaks + ttsMu sync.Mutex + cancelTTS context.CancelFunc + ttsPlayID uint64 } -func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { - session, err := discordgo.New("Bot " + cfg.Token) +func NewDiscordChannel( + bc *config.Channel, + cfg *config.DiscordSettings, + bus *bus.MessageBus, +) (*DiscordChannel, error) { + discordgo.Logger = logger.NewLogger("discord"). + WithLevels(map[int]logger.LogLevel{ + discordgo.LogError: logger.ERROR, + discordgo.LogWarning: logger.WARN, + discordgo.LogInformational: logger.INFO, + discordgo.LogDebug: logger.DEBUG, + }).Log + + session, err := discordgo.New("Bot " + cfg.Token.String()) if err != nil { return nil, fmt.Errorf("failed to create discord session: %w", err) } @@ -53,19 +81,26 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC if err := applyDiscordProxy(session, cfg.Proxy); err != nil { return nil, err } - base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom, + base := channels.NewBaseChannel("discord", cfg, bus, bc.AllowFrom, channels.WithMaxMessageLength(2000), - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - return &DiscordChannel{ + ch := &DiscordChannel{ BaseChannel: base, + bc: bc, session: session, config: cfg, ctx: context.Background(), typingStop: make(map[string]chan struct{}), - }, nil + bus: bus, + voiceSSRC: make(map[string]map[uint32]string), + } + ch.playTTSFn = ch.playTTS + ch.ttsVoiceFn = ch.voiceConnectionForTTS + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } func (c *DiscordChannel) Start(ctx context.Context) error { @@ -82,6 +117,8 @@ func (c *DiscordChannel) Start(ctx context.Context) error { c.session.AddHandler(c.handleMessage) + go c.listenVoiceControl(c.ctx) + if err := c.session.Open(); err != nil { return fmt.Errorf("failed to open discord session: %w", err) } @@ -112,6 +149,9 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } + if c.progress != nil { + c.progress.StopAll() + } if err := c.session.Close(); err != nil { return fmt.Errorf("failed to close discord session: %w", err) @@ -120,37 +160,117 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { return nil } -func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } channelID := msg.ChatID if channelID == "" { - return fmt.Errorf("channel ID is empty") + return nil, fmt.Errorf("channel ID is empty") } if len([]rune(msg.Content)) == 0 { - return nil + return nil, nil } - return c.sendChunk(ctx, channelID, msg.Content) + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, channelID, msg.Content); handled { + if err != nil { + return nil, err + } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID) + c.maybeStartTTS(channelID, msg.Content, isToolFeedback) + if !isToolFeedback { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } + } + + content := msg.Content + if isToolFeedback { + content = channels.InitialAnimatedToolFeedbackContent(msg.Content) + } + msgID, err := c.sendChunk(ctx, channelID, content, msg.ReplyToMessageID) + if err != nil { + return nil, err + } + if isToolFeedback { + c.RecordToolFeedbackMessage(channelID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID) + } + return []string{msgID}, nil +} + +func (c *DiscordChannel) maybeStartTTS(channelID, content string, isToolFeedback bool) { + if c.tts == nil || isToolFeedback { + return + } + + voiceFn := c.ttsVoiceFn + if voiceFn == nil { + voiceFn = c.voiceConnectionForTTS + } + vc, ok := voiceFn(channelID) + if !ok || vc == nil { + return + } + + // Cancel any previous TTS playback. + c.ttsMu.Lock() + if c.cancelTTS != nil { + c.cancelTTS() + } + ttsCtx, ttsCancel := context.WithCancel(c.ctx) + c.ttsPlayID++ + playID := c.ttsPlayID + c.cancelTTS = ttsCancel + playFn := c.playTTSFn + c.ttsMu.Unlock() + + if playFn == nil { + playFn = c.playTTS + } + go playFn(ttsCtx, vc, content, playID) +} + +func (c *DiscordChannel) voiceConnectionForTTS(channelID string) (*discordgo.VoiceConnection, bool) { + if c.session == nil || c.session.State == nil { + return nil, false + } + + ch, err := c.session.State.Channel(channelID) + if err != nil || ch == nil || ch.GuildID == "" { + return nil, false + } + + vc, ok := c.session.VoiceConnections[ch.GuildID] + if !ok || vc == nil { + return nil, false + } + return vc, true } // SendMedia implements the channels.MediaSender interface. -func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } channelID := msg.ChatID if channelID == "" { - return fmt.Errorf("channel ID is empty") + return nil, fmt.Errorf("channel ID is empty") } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID) store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } // Collect all files into a single ChannelMessageSendComplex call @@ -194,33 +314,44 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes } if len(files) == 0 { - return nil + return nil, nil } sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() - done := make(chan error, 1) + type mediaResult struct { + id string + err error + } + done := make(chan mediaResult, 1) go func() { - _, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + sentMsg, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ Content: caption, Files: files, }) - done <- err + if err != nil { + done <- mediaResult{err: err} + return + } + done <- mediaResult{id: sentMsg.ID} }() select { - case err := <-done: + case r := <-done: // Close all file readers for _, f := range files { if closer, ok := f.Reader.(*os.File); ok { closer.Close() } } - if err != nil { - return fmt.Errorf("discord send media: %w", channels.ErrTemporary) + if r.err != nil { + return nil, fmt.Errorf("discord send media: %w", channels.ErrTemporary) } - return nil + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID) + } + return []string{r.id}, nil case <-sendCtx.Done(): // Close all file readers for _, f := range files { @@ -228,28 +359,30 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes closer.Close() } } - return sendCtx.Err() + return nil, sendCtx.Err() } } // EditMessage implements channels.MessageEditor. func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { - _, err := c.session.ChannelMessageEdit(chatID, messageID, content) + _, err := c.session.ChannelMessageEdit(chatID, messageID, content, discordgo.WithContext(ctx)) return err } +// DeleteMessage implements channels.MessageDeleter. +func (c *DiscordChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + return c.session.ChannelMessageDelete(chatID, messageID, discordgo.WithContext(ctx)) +} + // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message that will later be edited to the actual // response via EditMessage (channels.MessageEditor). func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { - if !c.config.Placeholder.Enabled { + if !c.bc.Placeholder.Enabled { return "", nil } - text := c.config.Placeholder.Text - if text == "" { - text = "Thinking... 💭" - } + text := c.bc.Placeholder.GetRandomText() msg, err := c.session.ChannelMessageSend(chatID, text) if err != nil { @@ -259,25 +392,123 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st return msg.ID, nil } -func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func (c *DiscordChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *DiscordChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *DiscordChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *DiscordChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *DiscordChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *DiscordChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + _ = c.DeleteMessage(ctx, chatID, messageID) +} + +func (c *DiscordChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *DiscordChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + +func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) (string, error) { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() - done := make(chan error, 1) + type result struct { + id string + err error + } + done := make(chan result, 1) go func() { - _, err := c.session.ChannelMessageSend(channelID, content) - done <- err + var ( + msg *discordgo.Message + err error + ) + + // If we have an ID, we send the message as "Reply" + if replyToID != "" { + msg, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + Content: content, + Reference: &discordgo.MessageReference{ + MessageID: replyToID, + ChannelID: channelID, + }, + }) + } else { + // Otherwise, we send a normal message + msg, err = c.session.ChannelMessageSend(channelID, content) + } + + if err != nil { + done <- result{err: fmt.Errorf("discord send: %w", channels.ErrTemporary)} + return + } + done <- result{id: msg.ID} }() select { - case err := <-done: - if err != nil { - return fmt.Errorf("discord send: %w", channels.ErrTemporary) - } - return nil + case r := <-done: + return r.id, r.err case <-sendCtx.Done(): - return sendCtx.Err() + return "", sendCtx.Err() } } @@ -319,12 +550,16 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag return } + if c.handleVoiceCommand(s, m) { + return + } + content := m.Content // In guild (group) channels, apply unified group trigger filtering // DMs (GuildID is empty) always get a response + isMentioned := false if m.GuildID != "" { - isMentioned := false for _, mention := range m.Mentions { if mention.ID == c.botUserID { isMentioned = true @@ -373,8 +608,9 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag storeMedia := func(localPath, filename string) string { if store := c.GetMediaStore(); store != nil { ref, err := store.Store(localPath, media.MediaMeta{ - Filename: filename, - Source: "discord", + Filename: filename, + Source: "discord", + CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, }, scope) if err == nil { return ref @@ -420,14 +656,10 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag }) peerKind := "channel" - peerID := m.ChannelID if m.GuildID == "" { peerKind = "direct" - peerID = senderID } - peer := bus.Peer{Kind: peerKind, ID: peerID} - metadata := map[string]string{ "user_id": senderID, "username": m.Author.Username, @@ -436,8 +668,24 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag "channel_id": m.ChannelID, "is_dm": fmt.Sprintf("%t", m.GuildID == ""), } + inboundCtx := bus.InboundContext{ + Channel: c.Name(), + ChatID: m.ChannelID, + ChatType: peerKind, + SenderID: senderID, + MessageID: m.ID, + Mentioned: isMentioned, + Raw: metadata, + } + if m.GuildID != "" { + inboundCtx.SpaceID = m.GuildID + inboundCtx.SpaceType = "guild" + } + if m.MessageReference != nil { + inboundCtx.ReplyToMessageID = m.MessageReference.MessageID + } - c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender) + c.HandleInboundContext(c.ctx, m.ChannelID, content, mediaPaths, inboundCtx, sender) } // startTyping starts a continuous typing indicator loop for the given chatID. @@ -589,3 +837,134 @@ func (c *DiscordChannel) stripBotMention(text string) string { text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "") return strings.TrimSpace(text) } + +func (c *DiscordChannel) listenVoiceControl(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case ctrl, ok := <-c.bus.VoiceControlsChan(): + if !ok { + return + } + if ctrl.Type == "command" && ctrl.Action == "leave" { + if strings.HasPrefix(ctrl.SessionID, "discord_vc_") { + guildID := strings.TrimPrefix(ctrl.SessionID, "discord_vc_") + vc, exists := c.session.VoiceConnections[guildID] + if exists && vc != nil { + vc.Disconnect(ctx) + } + } + } + } + } +} + +func (c *DiscordChannel) playTTS(ctx context.Context, vc *discordgo.VoiceConnection, text string, playID uint64) { + // Capture the cancel func associated with this playback (if any). + // Clear cancelTTS when playback finishes (normal or interrupted), + // but only if it still refers to this playback's cancel func. + defer func() { + c.ttsMu.Lock() + if c.ttsPlayID == playID { + c.cancelTTS = nil + } + c.ttsMu.Unlock() + }() + + sentences := audio.SplitSentences(text) + if len(sentences) == 0 { + return + } + + logger.InfoCF("discord", "Starting streamed TTS", map[string]any{"sentences": len(sentences)}) + + // Pipeline: prefetch next sentence's audio while playing current + type ttResult struct { + stream io.ReadCloser + err error + } + + var prefetch chan ttResult + + // Ensure any in-flight prefetch is drained on exit to prevent stream leaks, + // but avoid blocking indefinitely if the prefetch goroutine is stuck or never sends. + defer func() { + if prefetch != nil { + select { + case result := <-prefetch: + if result.stream != nil { + result.stream.Close() + } + case <-time.After(100 * time.Millisecond): + // Timed out waiting for a prefetched result; avoid blocking on exit. + } + } + }() + + for i, sentence := range sentences { + // Check for cancellation (interruption) + select { + case <-ctx.Done(): + logger.InfoCF("discord", "TTS interrupted", map[string]any{"at_sentence": i}) + return + default: + } + + // Start prefetching the NEXT sentence while we process the current one + var nextPrefetch chan ttResult + if i+1 < len(sentences) { + nextPrefetch = make(chan ttResult, 1) + nextSentence := sentences[i+1] + go func() { + s, e := c.tts.Synthesize(ctx, nextSentence) + nextPrefetch <- ttResult{s, e} + }() + } + + // Get the current sentence's audio + var stream io.ReadCloser + var err error + + if prefetch != nil { + // Use prefetched result from previous iteration, but be responsive to cancellation. + var result ttResult + select { + case result = <-prefetch: + stream, err = result.stream, result.err + case <-ctx.Done(): + // Context canceled while waiting for prefetched audio; abort playback. + logger.InfoCF( + "discord", + "TTS interrupted while waiting for prefetched audio", + map[string]any{"at_sentence": i}, + ) + return + } + } else { + // First sentence: synthesize directly + stream, err = c.tts.Synthesize(ctx, sentence) + } + + if err != nil { + if stream != nil { + stream.Close() + } + logger.ErrorCF("discord", "TTS synthesize failed", map[string]any{"error": err.Error(), "sentence": i}) + prefetch = nextPrefetch + continue + } + + if err := streamOggOpusToDiscord(ctx, vc, stream); err != nil { + logger.ErrorCF("discord", "TTS playback failed", map[string]any{"error": err.Error(), "sentence": i}) + } + stream.Close() + + prefetch = nextPrefetch + } +} + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *DiscordChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/discord/discord_test.go b/pkg/channels/discord/discord_test.go index 0cd5328f4..d42b0bc52 100644 --- a/pkg/channels/discord/discord_test.go +++ b/pkg/channels/discord/discord_test.go @@ -1,13 +1,37 @@ package discord import ( + "context" + "io" "net/http" + "net/http/httptest" "net/url" + "reflect" + "sync" "testing" + "time" "github.com/bwmarrin/discordgo" + + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" ) +type stubTTSProvider struct{} + +func (stubTTSProvider) Name() string { return "stub-tts" } + +func (stubTTSProvider) Synthesize(context.Context, string) (io.ReadCloser, error) { + return io.NopCloser(&noopReader{}), nil +} + +type noopReader struct{} + +func (*noopReader) Read(p []byte) (int, error) { + return 0, io.EOF +} + func TestApplyDiscordProxy_CustomProxy(t *testing.T) { session, err := discordgo.New("Bot test-token") if err != nil { @@ -89,3 +113,224 @@ func TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) { t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil") } } + +func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) { + var ( + mu sync.Mutex + requests []string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Method+" "+r.URL.Path) + mu.Unlock() + + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/channels/chat-1/messages/prog-1": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"prog-1"}`) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + ctx: context.Background(), + typingStop: make(map[string]chan struct{}), + voiceSSRC: make(map[string]map[uint32]string), + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + ch.SetRunning(true) + ch.RecordToolFeedbackMessage("chat-1", "prog-1", "🔧 `read_file`") + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "chat-1", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "discord", + ChatID: "chat-1", + }, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if got, want := ids, []string{"prog-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Send() ids = %v, want %v", got, want) + } + if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { + t.Fatal("expected tracked tool feedback message to be cleared") + } + + mu.Lock() + defer mu.Unlock() + wantRequests := []string{ + "PATCH /channels/chat-1/messages/prog-1", + } + if !reflect.DeepEqual(requests, wantRequests) { + t.Fatalf("requests = %v, want %v", requests, wantRequests) + } +} + +func TestEditMessage_UsesContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + return + case <-time.After(time.Second): + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"msg-1"}`) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + start := time.Now() + err = ch.EditMessage(ctx, "chat-1", "msg-1", "still running") + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected EditMessage() to fail when context times out") + } + if elapsed >= 500*time.Millisecond { + t.Fatalf("EditMessage() ignored context timeout, elapsed=%v", elapsed) + } +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &DiscordChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if got, want := msgIDs, []string{"msg-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want %v", got, want) + } +} + +func TestSend_NonToolFeedbackFinalizerStillStartsTTS(t *testing.T) { + var ( + mu sync.Mutex + requests []string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Method+" "+r.URL.Path) + mu.Unlock() + + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/channels/chat-1/messages/prog-1": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"prog-1"}`) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ttsStarted := make(chan string, 1) + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + ctx: context.Background(), + typingStop: make(map[string]chan struct{}), + voiceSSRC: make(map[string]map[uint32]string), + tts: tts.TTSProvider(stubTTSProvider{}), + } + ch.ttsVoiceFn = func(string) (*discordgo.VoiceConnection, bool) { + return &discordgo.VoiceConnection{}, true + } + ch.playTTSFn = func(_ context.Context, _ *discordgo.VoiceConnection, text string, _ uint64) { + ttsStarted <- text + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + ch.SetRunning(true) + ch.RecordToolFeedbackMessage("chat-1", "prog-1", "🔧 `read_file`") + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "chat-1", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "discord", + ChatID: "chat-1", + }, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if got, want := ids, []string{"prog-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Send() ids = %v, want %v", got, want) + } + + select { + case got := <-ttsStarted: + if got != "final reply" { + t.Fatalf("TTS content = %q, want final reply", got) + } + case <-time.After(2 * time.Second): + t.Fatal("expected TTS to start for finalized tracked tool feedback reply") + } +} diff --git a/pkg/channels/discord/init.go b/pkg/channels/discord/init.go index 15a539804..c8dbe1081 100644 --- a/pkg/channels/discord/init.go +++ b/pkg/channels/discord/init.go @@ -1,13 +1,30 @@ package discord import ( + "github.com/sipeed/picoclaw/pkg/audio/tts" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { - channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewDiscordChannel(cfg.Channels.Discord, b) - }) + channels.RegisterFactory( + config.ChannelDiscord, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.DiscordSettings) + if !ok { + return nil, channels.ErrSendFailed + } + ch, err := NewDiscordChannel(bc, c, b) + if err == nil { + ch.tts = tts.DetectTTS(cfg) + } + return ch, err + }, + ) } diff --git a/pkg/channels/discord/voice.go b/pkg/channels/discord/voice.go new file mode 100644 index 000000000..554b8ae71 --- /dev/null +++ b/pkg/channels/discord/voice.go @@ -0,0 +1,314 @@ +package discord + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/bwmarrin/discordgo" + + "github.com/sipeed/picoclaw/pkg/audio" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" +) + +func (c *DiscordChannel) setVoiceUserID(guildID string, ssrc uint32, userID string) { + if userID == "" { + return + } + + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + + ssrcMap, ok := c.voiceSSRC[guildID] + if !ok { + ssrcMap = make(map[uint32]string) + c.voiceSSRC[guildID] = ssrcMap + } + ssrcMap[ssrc] = userID +} + +func (c *DiscordChannel) voiceUserID(guildID string, ssrc uint32) string { + c.voiceMu.RLock() + defer c.voiceMu.RUnlock() + + ssrcMap, ok := c.voiceSSRC[guildID] + if !ok { + return "" + } + return ssrcMap[ssrc] +} + +func (c *DiscordChannel) handleVoiceCommand(s *discordgo.Session, m *discordgo.MessageCreate) bool { + if m.Content == "!vc join" { + vs, err := s.State.VoiceState(m.GuildID, m.Author.ID) + if err != nil || vs == nil { + if _, sendErr := s.ChannelMessageSend( + m.ChannelID, + "You need to be in a voice channel first!", + ); sendErr != nil { + logger.InfoCF("discord", "Failed to send voice channel requirement message", map[string]any{ + "channel": m.ChannelID, + "error": sendErr, + }) + } + return true + } + + logger.InfoCF("discord", "Joining voice channel", map[string]any{"channel": vs.ChannelID}) + vc, err := s.ChannelVoiceJoin(c.ctx, m.GuildID, vs.ChannelID, false, false) + if err != nil { + if _, sendErr := s.ChannelMessageSend( + m.ChannelID, + fmt.Sprintf("Failed to join voice channel: %v", err), + ); sendErr != nil { + logger.InfoCF("discord", "Failed to send voice join error message", map[string]any{ + "channel": m.ChannelID, + "error": sendErr, + }) + } + return true + } + + go c.receiveVoice(vc, m.GuildID, m.ChannelID) + if _, sendErr := s.ChannelMessageSend( + m.ChannelID, + "Joined Voice Channel! Listening for audio...", + ); sendErr != nil { + logger.InfoCF("discord", "Failed to send voice join success message", map[string]any{ + "channel": m.ChannelID, + "error": sendErr, + }) + } + return true + } else if m.Content == "!vc leave" { + vc, exists := s.VoiceConnections[m.GuildID] + if exists && vc != nil { + if err := vc.Disconnect(c.ctx); err != nil { + logger.InfoCF("discord", "Failed to disconnect from voice channel", map[string]any{ + "guild": m.GuildID, + "error": err, + }) + } + if _, sendErr := s.ChannelMessageSend(m.ChannelID, "Left Voice Channel."); sendErr != nil { + logger.InfoCF("discord", "Failed to send voice leave success message", map[string]any{ + "channel": m.ChannelID, + "error": sendErr, + }) + } + } else { + if _, sendErr := s.ChannelMessageSend(m.ChannelID, "Not in a voice channel."); sendErr != nil { + logger.InfoCF("discord", "Failed to send voice not-in-channel message", map[string]any{ + "channel": m.ChannelID, + "error": sendErr, + }) + } + } + return true + } + return false +} + +func VoiceReceiveActive(vc *discordgo.VoiceConnection) bool { + return vc != nil && vc.OpusRecv != nil +} + +func streamOggOpusToDiscord(ctx context.Context, vc *discordgo.VoiceConnection, r io.Reader) (retErr error) { + // Recover from panic if vc.OpusSend is closed mid-send (e.g. on disconnect) + defer func() { + if rec := recover(); rec != nil { + retErr = fmt.Errorf("voice connection closed during playback") + logger.RecoverPanicNoExit(rec) + } + }() + + // Wait for the speaking transition to register + vc.Speaking(true) + defer vc.Speaking(false) + + return audio.DecodeOggOpus(r, func(frame []byte) error { + select { + case <-ctx.Done(): + return ctx.Err() + case vc.OpusSend <- frame: + return nil + } + }) +} + +func (c *DiscordChannel) receiveVoice(vc *discordgo.VoiceConnection, guildID string, chatID string) { + logger.InfoCF("discord", "Started listening for voice", map[string]any{"guild": guildID}) + + vc.AddHandler(func(_ *discordgo.VoiceConnection, vs *discordgo.VoiceSpeakingUpdate) { + if vs == nil { + return + } + c.setVoiceUserID(guildID, uint32(vs.SSRC), vs.UserID) + }) + + defer func() { + c.voiceMu.Lock() + delete(c.voiceSSRC, guildID) + c.voiceMu.Unlock() + }() + + go func(ctx context.Context, vc *discordgo.VoiceConnection) { + // Recover from potential panics if OpusSend is closed mid-send. + defer func() { + if rec := recover(); rec != nil { + logger.WarnCF("discord", "Recovered from panic while sending wake-up frames", map[string]any{ + "error": rec, + "guild": guildID, + }) + } + }() + + // If the voice connection or OpusSend are not available, nothing to do. + if vc == nil || vc.OpusSend == nil { + return + } + + time.Sleep(250 * time.Millisecond) // Wait a bit for connection to settle + + // Abort if the context has already been canceled. + select { + case <-ctx.Done(): + return + default: + } + + vc.Speaking(true) + defer vc.Speaking(false) + + silenceFrame := []byte{0xF8, 0xFF, 0xFE} + for i := 0; i < 5; i++ { + select { + case <-ctx.Done(): + return + case vc.OpusSend <- silenceFrame: + } + time.Sleep(20 * time.Millisecond) + } + + logger.DebugCF("discord", "Sent wake-up silence frames", map[string]any{"guild": guildID}) + }(c.ctx, vc) + sessionID := fmt.Sprintf("discord_vc_%s", guildID) + + c.bus.PublishVoiceControl(c.ctx, bus.VoiceControl{ + SessionID: sessionID, + Type: "state", + Action: "listening", + }) + + var sequence uint64 = 0 + var interruptCount int + var lastInterruptAt time.Time + + for { + select { + case <-c.ctx.Done(): + return + case p, ok := <-vc.OpusRecv: + if !ok { + logger.InfoCF("discord", "Voice channel closed", map[string]any{"guild": guildID}) + // Cancel any TTS that may still be playing + c.ttsMu.Lock() + if c.cancelTTS != nil { + c.cancelTTS() + c.cancelTTS = nil + } + c.ttsMu.Unlock() + return + } + + if p == nil { + logger.DebugCF("discord", "Received nil Opus packet", nil) + continue + } + + if len(p.Opus) == 0 { + logger.DebugCF("discord", "Received empty Opus packet", map[string]any{ + "seq": p.Sequence, + "ssrc": p.SSRC, + }) + continue + } + + logger.DebugCF("discord", "Received Opus packet", map[string]any{ + "seq": p.Sequence, + "len": len(p.Opus), + "ssrc": p.SSRC, + }) + // Interruption detection: if user sends voice while TTS is playing, + // cancel TTS after a short debounce (3 packets in 200ms) + now := time.Now() + if now.Sub(lastInterruptAt) > 500*time.Millisecond { + interruptCount = 0 + } + interruptCount++ + lastInterruptAt = now + + if interruptCount >= 3 { + c.ttsMu.Lock() + if c.cancelTTS != nil { + c.cancelTTS() + c.cancelTTS = nil + logger.InfoCF("discord", "TTS interrupted by user voice", nil) + } + c.ttsMu.Unlock() + interruptCount = 0 + } + + userID := c.voiceUserID(guildID, p.SSRC) + if userID == "" { + logger.DebugCF("discord", "Dropping voice packet without user mapping", map[string]any{ + "ssrc": p.SSRC, + "guild": guildID, + }) + continue + } + + sender := bus.SenderInfo{ + Platform: "discord", + PlatformID: userID, + CanonicalID: identity.BuildCanonicalID("discord", userID), + } + if !c.IsAllowedSender(sender) { + logger.DebugCF("discord", "Voice packet rejected by allowlist", map[string]any{ + "user_id": userID, + "guild": guildID, + }) + continue + } + + sequence++ + + chunk := bus.AudioChunk{ + SessionID: sessionID, + SpeakerID: userID, + ChatID: chatID, + Channel: "discord", + Sequence: sequence, + Timestamp: p.Timestamp, + SampleRate: 48000, + Channels: 2, + Format: "opus", + Data: p.Opus, + } + + ctx, cancel := context.WithTimeout(c.ctx, 100*time.Millisecond) + err := c.bus.PublishAudioChunk(ctx, chunk) + cancel() + if err != nil { + logger.ErrorCF("discord", "Failed to publish audio chunk", map[string]any{ + "guild": guildID, + "sessionID": sessionID, + "sequence": sequence, + "error": err.Error(), + }) + } + } + } +} diff --git a/pkg/channels/dynamic_mux.go b/pkg/channels/dynamic_mux.go new file mode 100644 index 000000000..399f18b7a --- /dev/null +++ b/pkg/channels/dynamic_mux.go @@ -0,0 +1,74 @@ +package channels + +import ( + "net/http" + "strings" + "sync" +) + +// dynamicServeMux is an http.Handler that supports dynamic registration +// and unregistration of handlers without recreating the server. +type dynamicServeMux struct { + mu sync.RWMutex + handlers map[string]http.Handler +} + +func newDynamicServeMux() *dynamicServeMux { + return &dynamicServeMux{ + handlers: make(map[string]http.Handler), + } +} + +// Handle registers the handler for the given pattern. +func (dm *dynamicServeMux) Handle(pattern string, handler http.Handler) { + dm.mu.Lock() + defer dm.mu.Unlock() + dm.handlers[pattern] = handler +} + +// HandleFunc registers the handler function for the given pattern. +func (dm *dynamicServeMux) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) { + dm.Handle(pattern, http.HandlerFunc(handler)) +} + +// Unhandle removes the handler for the given pattern. +func (dm *dynamicServeMux) Unhandle(pattern string) { + dm.mu.Lock() + defer dm.mu.Unlock() + delete(dm.handlers, pattern) +} + +// ServeHTTP dispatches the request to the handler whose pattern best matches +// the request URL path. It supports both exact path matches and subtree +// (trailing-slash) prefix matches, choosing the longest prefix on collision. +func (dm *dynamicServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) { + dm.mu.RLock() + defer dm.mu.RUnlock() + + path := r.URL.Path + + // Exact match first. + if h, ok := dm.handlers[path]; ok { + h.ServeHTTP(w, r) + return + } + + // Longest subtree prefix match (patterns ending with "/"). + var bestLen int + var bestHandler http.Handler + for pattern, handler := range dm.handlers { + if strings.HasSuffix(pattern, "/") && strings.HasPrefix(path, pattern) { + if len(pattern) > bestLen { + bestLen = len(pattern) + bestHandler = handler + } + } + } + + if bestHandler != nil { + bestHandler.ServeHTTP(w, r) + return + } + + http.NotFound(w, r) +} diff --git a/pkg/channels/dynamic_mux_test.go b/pkg/channels/dynamic_mux_test.go new file mode 100644 index 000000000..d895c69c9 --- /dev/null +++ b/pkg/channels/dynamic_mux_test.go @@ -0,0 +1,162 @@ +package channels + +import ( + "net/http" + "net/http/httptest" + "sync" + "testing" +) + +func TestDynamicServeMuxExactMatch(t *testing.T) { + dm := newDynamicServeMux() + dm.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + rec := httptest.NewRecorder() + dm.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } +} + +func TestDynamicServeMuxSubtreePrefixMatch(t *testing.T) { + dm := newDynamicServeMux() + dm.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + }) + + for _, path := range []string{"/api/", "/api/v1", "/api/v1/resource"} { + rec := httptest.NewRecorder() + dm.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusCreated { + t.Fatalf("path %q: expected 201, got %d", path, rec.Code) + } + } +} + +func TestDynamicServeMuxExactOverPrefix(t *testing.T) { + dm := newDynamicServeMux() + dm.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + dm.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + }) + + // Exact match wins + rec := httptest.NewRecorder() + dm.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("exact match: expected 200, got %d", rec.Code) + } + + // Prefix match for sub-paths + rec = httptest.NewRecorder() + dm.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1", nil)) + if rec.Code != http.StatusCreated { + t.Fatalf("prefix match: expected 201, got %d", rec.Code) + } +} + +func TestDynamicServeMuxLongestPrefixWins(t *testing.T) { + dm := newDynamicServeMux() + dm.HandleFunc("/a/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + dm.HandleFunc("/a/b/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + }) + + rec := httptest.NewRecorder() + dm.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/a/b/c", nil)) + if rec.Code != http.StatusAccepted { + t.Fatalf("longest prefix: expected 202, got %d", rec.Code) + } +} + +func TestDynamicServeMuxNotFound(t *testing.T) { + dm := newDynamicServeMux() + rec := httptest.NewRecorder() + dm.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/nonexistent", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } +} + +func TestDynamicServeMuxUnhandle(t *testing.T) { + dm := newDynamicServeMux() + dm.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Verify it works before removal + rec := httptest.NewRecorder() + dm.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/test", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("before unhandle: expected 200, got %d", rec.Code) + } + + // Remove and verify 404 + dm.Unhandle("/test") + rec = httptest.NewRecorder() + dm.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/test", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("after unhandle: expected 404, got %d", rec.Code) + } +} + +func TestDynamicServeMuxConcurrent(t *testing.T) { + dm := newDynamicServeMux() + dm.HandleFunc("/static", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + var wg sync.WaitGroup + const goroutines = 50 + + // Concurrent Handle/Unhandle + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + pattern := "/concurrent" + if i%2 == 0 { + dm.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + }) + } else { + dm.Unhandle(pattern) + } + }(i) + } + + // Concurrent ServeHTTP + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + rec := httptest.NewRecorder() + dm.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/static", nil)) + // Should not panic; result is either 200 or 404 + _ = rec.Code + }() + } + + wg.Wait() +} + +func TestDynamicServeMuxHandleUsesHandler(t *testing.T) { + dm := newDynamicServeMux() + + var called bool + dm.Handle("/handler", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + + rec := httptest.NewRecorder() + dm.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/handler", nil)) + if !called { + t.Fatal("handler was not called") + } +} diff --git a/pkg/channels/events.go b/pkg/channels/events.go new file mode 100644 index 000000000..60e5640f0 --- /dev/null +++ b/pkg/channels/events.go @@ -0,0 +1,197 @@ +package channels + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +func channelTypeForEvent(m *Manager, channelName string) string { + if m == nil || m.config == nil { + return channelName + } + if bc := m.config.Channels.Get(channelName); bc != nil && bc.Type != "" { + return bc.Type + } + return channelName +} + +func (m *Manager) publishChannelEvent( + kind runtimeevents.Kind, + channelName string, + scope runtimeevents.Scope, + severity runtimeevents.Severity, + payload any, +) { + if m == nil || m.runtimeEvents == nil { + return + } + if scope.Channel == "" { + scope.Channel = channelName + } + m.runtimeEvents.PublishNonBlocking(runtimeevents.Event{ + Kind: kind, + Source: runtimeevents.Source{Component: "channel", Name: channelName}, + Scope: scope, + Severity: severity, + Payload: payload, + Attrs: channelEventAttrs(payload), + }) +} + +func channelEventAttrs(payload any) map[string]any { + switch payload := payload.(type) { + case ChannelLifecyclePayload: + attrs := map[string]any{} + setAttrString(attrs, "type", payload.Type) + setAttrString(attrs, "error", payload.Error) + return attrs + case ChannelOutboundPayload: + attrs := map[string]any{} + if payload.Media { + attrs["media"] = payload.Media + } + if payload.ContentLen > 0 { + attrs["content_len"] = payload.ContentLen + } + if len(payload.MessageIDs) > 0 { + attrs["message_ids_count"] = len(payload.MessageIDs) + } + setAttrString(attrs, "reply_to_message_id", payload.ReplyToMessageID) + setAttrString(attrs, "error", payload.Error) + if payload.Retries > 0 { + attrs["retries"] = payload.Retries + } + return attrs + default: + return nil + } +} + +func setAttrString(attrs map[string]any, key, value string) { + if value != "" { + attrs[key] = value + } +} + +func (m *Manager) publishOutboundSent( + channelName string, + msg bus.OutboundMessage, + messageIDs []string, +) { + m.publishChannelEvent( + runtimeevents.KindChannelMessageOutboundSent, + channelName, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityInfo, + ChannelOutboundPayload{ + ContentLen: len([]rune(msg.Content)), + MessageIDs: append([]string(nil), messageIDs...), + ReplyToMessageID: msg.ReplyToMessageID, + }, + ) +} + +func (m *Manager) publishOutboundQueued( + channelName string, + msg bus.OutboundMessage, +) { + m.publishChannelEvent( + runtimeevents.KindChannelMessageOutboundQueued, + channelName, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityInfo, + ChannelOutboundPayload{ + ContentLen: len([]rune(msg.Content)), + ReplyToMessageID: msg.ReplyToMessageID, + }, + ) +} + +func (m *Manager) publishOutboundFailed( + channelName string, + msg bus.OutboundMessage, + err error, + media bool, +) { + payload := ChannelOutboundPayload{ + Media: media, + ContentLen: len([]rune(msg.Content)), + ReplyToMessageID: msg.ReplyToMessageID, + Retries: maxRetries, + } + if err != nil { + payload.Error = err.Error() + } + m.publishChannelEvent( + runtimeevents.KindChannelMessageOutboundFailed, + channelName, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityError, + payload, + ) +} + +func (m *Manager) publishOutboundMediaSent( + channelName string, + msg bus.OutboundMediaMessage, + messageIDs []string, +) { + m.publishChannelEvent( + runtimeevents.KindChannelMessageOutboundSent, + channelName, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityInfo, + ChannelOutboundPayload{ + Media: true, + MessageIDs: append([]string(nil), messageIDs...), + }, + ) +} + +func (m *Manager) publishOutboundMediaQueued( + channelName string, + msg bus.OutboundMediaMessage, +) { + m.publishChannelEvent( + runtimeevents.KindChannelMessageOutboundQueued, + channelName, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityInfo, + ChannelOutboundPayload{Media: true}, + ) +} + +func (m *Manager) publishOutboundMediaFailed( + channelName string, + msg bus.OutboundMediaMessage, + err error, +) { + payload := ChannelOutboundPayload{ + Media: true, + Retries: maxRetries, + } + if err != nil { + payload.Error = err.Error() + } + m.publishChannelEvent( + runtimeevents.KindChannelMessageOutboundFailed, + channelName, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityError, + payload, + ) +} + +func scopeFromOutboundContext(ctx bus.InboundContext) runtimeevents.Scope { + return runtimeevents.Scope{ + Channel: ctx.Channel, + Account: ctx.Account, + ChatID: ctx.ChatID, + TopicID: ctx.TopicID, + SpaceID: ctx.SpaceID, + SpaceType: ctx.SpaceType, + ChatType: ctx.ChatType, + SenderID: ctx.SenderID, + MessageID: ctx.MessageID, + } +} diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go index fbe085b73..95579df09 100644 --- a/pkg/channels/feishu/common.go +++ b/pkg/channels/feishu/common.go @@ -6,6 +6,8 @@ import ( "strings" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + + "github.com/sipeed/picoclaw/pkg/channels" ) // mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions. @@ -62,6 +64,62 @@ func extractJSONStringField(content, field string) string { // Format: {"image_key": "img_xxx"} func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") } +// extractPostImageKeys extracts all image_key values from a Feishu post (rich text) +// message. Post messages have nested arrays of elements where images appear as +// {"tag":"img","image_key":"img_xxx"}. +func extractPostImageKeys(rawContent string) []string { + if rawContent == "" { + return nil + } + + var post map[string]json.RawMessage + if err := json.Unmarshal([]byte(rawContent), &post); err != nil { + return nil + } + + var keys []string + seen := make(map[string]struct{}) + + collectFromRows := func(contentRaw json.RawMessage) { + var rows [][]map[string]any + if err := json.Unmarshal(contentRaw, &rows); err != nil { + return + } + for _, row := range rows { + for _, elem := range row { + if tag, _ := elem["tag"].(string); tag == "img" { + if ik, _ := elem["image_key"].(string); ik != "" { + if _, dup := seen[ik]; !dup { + seen[ik] = struct{}{} + keys = append(keys, ik) + } + } + } + } + } + } + + // Flat format: {"title":"...", "content":[[...]]} + if contentRaw, ok := post["content"]; ok { + collectFromRows(contentRaw) + } + + // Localized format: {"zh_cn": {"title":"...", "content":[[...]]}, ...} + for _, raw := range post { + var locale map[string]json.RawMessage + if err := json.Unmarshal(raw, &locale); err != nil { + continue + } + contentRaw, ok := locale["content"] + if !ok { + continue + } + collectFromRows(contentRaw) + } + + return keys +} + // extractFileKey extracts the file_key from a Feishu file/audio message content JSON. // Format: {"file_key": "file_xxx", "file_name": "...", ...} func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") } @@ -84,3 +142,69 @@ func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) s content = mentionPlaceholderRegex.ReplaceAllString(content, "") return strings.TrimSpace(content) } + +// extractCardImageKeys recursively extracts all image keys from a Feishu interactive card. +// Image keys are used to download images from Feishu API. +// Returns two slices: Feishu-hosted keys and external URLs. +func extractCardImageKeys(rawContent string) (feishuKeys []string, externalURLs []string) { + if rawContent == "" { + return nil, nil + } + + var card map[string]any + if err := json.Unmarshal([]byte(rawContent), &card); err != nil { + return nil, nil + } + + extractImageKeysRecursive(card, &feishuKeys, &externalURLs) + return feishuKeys, externalURLs +} + +// isExternalURL returns true if the string is an external HTTP/HTTPS URL. +func isExternalURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +// extractImageKeysRecursive traverses card structure to find all image keys. +// Collects both Feishu-hosted keys and external URLs separately. +func extractImageKeysRecursive(v any, feishuKeys, externalURLs *[]string) { + switch val := v.(type) { + case map[string]any: + // Check if this is an img element + if tag, ok := val["tag"].(string); ok { + switch tag { + case "img": + // Try img_key first (always Feishu-hosted) + if imgKey, ok := val["img_key"].(string); ok && imgKey != "" { + *feishuKeys = append(*feishuKeys, imgKey) + } + // Check src - could be Feishu key or external URL + if src, ok := val["src"].(string); ok && src != "" { + if isExternalURL(src) { + *externalURLs = append(*externalURLs, src) + } else { + *feishuKeys = append(*feishuKeys, src) + } + } + case "icon": + // Icon elements use icon_key + if iconKey, ok := val["icon_key"].(string); ok && iconKey != "" { + *feishuKeys = append(*feishuKeys, iconKey) + } + } + } + // Recurse into all nested structures + for _, child := range val { + extractImageKeysRecursive(child, feishuKeys, externalURLs) + } + case []any: + for _, item := range val { + extractImageKeysRecursive(item, feishuKeys, externalURLs) + } + } +} + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *FeishuChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/feishu/common_test.go b/pkg/channels/feishu/common_test.go index fefc9f7c1..dcf7861a2 100644 --- a/pkg/channels/feishu/common_test.go +++ b/pkg/channels/feishu/common_test.go @@ -290,3 +290,213 @@ func TestStripMentionPlaceholders(t *testing.T) { }) } } + +func TestExtractPostImageKeys(t *testing.T) { + tests := []struct { + name string + content string + want []string + }{ + { + name: "empty content", + content: "", + want: nil, + }, + { + name: "invalid JSON", + content: "not json", + want: nil, + }, + { + name: "post with no images", + content: `{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"hello"}]]}}`, + want: nil, + }, + { + name: "post with one image", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_v3_001"}]]}}`, + want: []string{"img_v3_001"}, + }, + { + name: "post with multiple images", + content: `{"zh_cn":{"title":"","content":[[{"tag":"text","text":"see"},{"tag":"img","image_key":"img_001"}],[{"tag":"img","image_key":"img_002"}]]}}`, + want: []string{"img_001", "img_002"}, + }, + { + name: "post with text and image mixed in row", + content: `{"zh_cn":{"title":"","content":[[{"tag":"text","text":"hi"},{"tag":"img","image_key":"img_mix"}]]}}`, + want: []string{"img_mix"}, + }, + { + name: "en_us locale", + content: `{"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_en"}]]}}`, + want: []string{"img_en"}, + }, + { + name: "multiple locales with distinct images", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_zh"}]]},"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_en"}]]}}`, + want: []string{"img_zh", "img_en"}, + }, + { + name: "duplicate image_key across locales is deduplicated", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_same"}]]},"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_same"}]]}}`, + want: []string{"img_same"}, + }, + { + name: "image with empty image_key", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":""}]]}}`, + want: nil, + }, + { + name: "flat format without locale wrapper", + content: `{"title":"","content":[[{"tag":"img","image_key":"img_v3_flat","width":1826,"height":338}],[{"tag":"text","text":" check this image","style":[]}]]}`, + want: []string{"img_v3_flat"}, + }, + { + name: "flat format multiple images", + content: `{"title":"","content":[[{"tag":"img","image_key":"img_flat_1"}],[{"tag":"img","image_key":"img_flat_2"},{"tag":"text","text":"desc"}]]}`, + want: []string{"img_flat_1", "img_flat_2"}, + }, + { + name: "flat format no images", + content: `{"title":"Test","content":[[{"tag":"text","text":"just text"}]]}`, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractPostImageKeys(tt.content) + if len(got) != len(tt.want) { + t.Errorf("extractPostImageKeys() = %v, want %v", got, tt.want) + return + } + // Use set comparison to avoid map iteration order dependency + gotSet := make(map[string]bool, len(got)) + for _, v := range got { + gotSet[v] = true + } + for _, v := range tt.want { + if !gotSet[v] { + t.Errorf("extractPostImageKeys() missing expected key %q; got %v", v, got) + } + } + }) + } +} + +func TestExtractCardImageKeys(t *testing.T) { + tests := []struct { + name string + content string + wantFeishuKeys []string + wantExternalURLs []string + }{ + { + name: "empty content", + content: "", + wantFeishuKeys: nil, + wantExternalURLs: nil, + }, + { + name: "invalid JSON", + content: "not json", + wantFeishuKeys: nil, + wantExternalURLs: nil, + }, + { + name: "card with no images", + content: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"text"}]}}`, + wantFeishuKeys: nil, + wantExternalURLs: nil, + }, + { + name: "single image with img_key", + content: `{"elements":[{"tag":"img","img_key":"img_abc123"}]}`, + wantFeishuKeys: []string{"img_abc123"}, + wantExternalURLs: nil, + }, + { + name: "single image with src as Feishu key", + content: `{"elements":[{"tag":"img","src":"img_xyz789"}]}`, + wantFeishuKeys: []string{"img_xyz789"}, + wantExternalURLs: nil, + }, + { + name: "multiple images", + content: `{"elements":[{"tag":"img","img_key":"img_1"},{"tag":"div","text":{"content":"text"}},{"tag":"img","img_key":"img_2"}]}`, + wantFeishuKeys: []string{"img_1", "img_2"}, + wantExternalURLs: nil, + }, + { + name: "nested image in columns", + content: `{"elements":[{"tag":"div","columns":[{"tag":"img","img_key":"img_col1"},{"tag":"img","img_key":"img_col2"}]}]}`, + wantFeishuKeys: []string{"img_col1", "img_col2"}, + wantExternalURLs: nil, + }, + { + name: "image in action", + content: `{"elements":[{"tag":"action","actions":[{"tag":"img","img_key":"img_action"}]}]}`, + wantFeishuKeys: []string{"img_action"}, + wantExternalURLs: nil, + }, + { + name: "icon element", + content: `{"elements":[{"tag":"icon","icon_key":"icon_123"}]}`, + wantFeishuKeys: []string{"icon_123"}, + wantExternalURLs: nil, + }, + { + name: "complex card with text and images", + content: `{"header":{"title":{"content":"Title"}},"elements":[{"tag":"div","text":{"content":"Description"}},{"tag":"img","img_key":"img_main"}]}`, + wantFeishuKeys: []string{"img_main"}, + wantExternalURLs: nil, + }, + { + name: "external URL in src", + content: `{"elements":[{"tag":"img","src":"https://example.com/image.png"}]}`, + wantFeishuKeys: nil, + wantExternalURLs: []string{"https://example.com/image.png"}, + }, + { + name: "mixed Feishu keys and external URLs", + content: `{"elements":[{"tag":"img","img_key":"img_feishu"},{"tag":"img","src":"https://cdn.example.com/external.jpg"},{"tag":"img","src":"img_another"}]}`, + wantFeishuKeys: []string{"img_feishu", "img_another"}, + wantExternalURLs: []string{"https://cdn.example.com/external.jpg"}, + }, + { + name: "multiple external URLs", + content: `{"elements":[{"tag":"img","src":"https://a.com/1.png"},{"tag":"img","src":"http://b.com/2.jpg"}]}`, + wantFeishuKeys: nil, + wantExternalURLs: []string{"https://a.com/1.png", "http://b.com/2.jpg"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotFeishuKeys, gotExternalURLs := extractCardImageKeys(tt.content) + + // Compare Feishu keys + if len(gotFeishuKeys) != len(tt.wantFeishuKeys) { + t.Errorf("extractCardImageKeys() feishuKeys = %v, want %v", gotFeishuKeys, tt.wantFeishuKeys) + return + } + for i, v := range gotFeishuKeys { + if v != tt.wantFeishuKeys[i] { + t.Errorf("extractCardImageKeys() feishuKeys[%d] = %q, want %q", i, v, tt.wantFeishuKeys[i]) + } + } + + // Compare external URLs + if len(gotExternalURLs) != len(tt.wantExternalURLs) { + t.Errorf("extractCardImageKeys() externalURLs = %v, want %v", gotExternalURLs, tt.wantExternalURLs) + return + } + for i, v := range gotExternalURLs { + if v != tt.wantExternalURLs[i] { + t.Errorf("extractCardImageKeys() externalURLs[%d] = %q, want %q", i, v, tt.wantExternalURLs[i]) + } + } + }) + } +} diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go index f5e3aa224..04c7acc15 100644 --- a/pkg/channels/feishu/feishu_32.go +++ b/pkg/channels/feishu/feishu_32.go @@ -19,7 +19,7 @@ type FeishuChannel struct { var errUnsupported = errors.New("feishu channel is not supported on 32-bit architectures") // NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported -func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { +func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.MessageBus) (*FeishuChannel, error) { return nil, errors.New( "feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config", ) @@ -36,8 +36,8 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { } // Send is a stub method to satisfy the Channel interface -func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - return errUnsupported +func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + return nil, errUnsupported } // EditMessage is a stub method to satisfy MessageEditor @@ -56,6 +56,6 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st } // SendMedia is a stub method to satisfy MediaSender -func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { - return errUnsupported +func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { + return nil, errUnsupported } diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5217dd4e9..d09c021c7 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -4,16 +4,17 @@ package feishu import ( "context" - "crypto/rand" "encoding/json" "fmt" "io" - "math/big" + "math/rand" "net/http" "os" "path/filepath" + "strings" "sync" "sync/atomic" + "time" lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -30,35 +31,60 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +// errCodeTenantTokenInvalid is the Feishu API error code for an expired/revoked +// tenant_access_token. The Lark SDK's built-in retry does not clear its cache +// on this error, so we do it ourselves. +const errCodeTenantTokenInvalid = 99991663 + type FeishuChannel struct { *channels.BaseChannel - config config.FeishuConfig - client *lark.Client - wsClient *larkws.Client + bc *config.Channel + config *config.FeishuSettings + client *lark.Client + wsClient *larkws.Client + tokenCache *tokenCache // custom cache that supports invalidation - botOpenID atomic.Value // stores string; populated lazily for @mention detection + botOpenID atomic.Value // stores string; populated lazily for @mention detection + messageCache sync.Map // caches fetched messages (messageID -> *larkim.Message) mu sync.Mutex cancel context.CancelFunc + + progress *channels.ToolFeedbackAnimator + deleteMessageFn func(context.Context, string, string) error } -func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { - base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom, - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), +type cachedMessage struct { + msg *larkim.Message + expiry time.Time +} + +func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.MessageBus) (*FeishuChannel, error) { + base := channels.NewBaseChannel("feishu", cfg, bus, bc.AllowFrom, + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) + tc := newTokenCache() + opts := []lark.ClientOptionFunc{lark.WithTokenCache(tc)} + if cfg.IsLark { + opts = append(opts, lark.WithOpenBaseUrl(lark.LarkBaseUrl)) + } ch := &FeishuChannel{ BaseChannel: base, + bc: bc, config: cfg, - client: lark.NewClient(cfg.AppID, cfg.AppSecret), + tokenCache: tc, + client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...), } + ch.deleteMessageFn = ch.deleteMessageAPI + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) ch.SetOwner(ch) return ch, nil } func (c *FeishuChannel) Start(ctx context.Context) error { - if c.config.AppID == "" || c.config.AppSecret == "" { + if c.config.AppID == "" || c.config.AppSecret.String() == "" { return fmt.Errorf("feishu app_id or app_secret is empty") } @@ -69,17 +95,22 @@ func (c *FeishuChannel) Start(ctx context.Context) error { }) } - dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey). + dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken.String(), c.config.EncryptKey.String()). OnP2MessageReceiveV1(c.handleMessageReceive) runCtx, cancel := context.WithCancel(ctx) c.mu.Lock() c.cancel = cancel + domain := lark.FeishuBaseUrl + if c.config.IsLark { + domain = lark.LarkBaseUrl + } c.wsClient = larkws.NewClient( c.config.AppID, - c.config.AppSecret, + c.config.AppSecret.String(), larkws.WithEventHandler(dispatcher), + larkws.WithDomain(domain), ) wsClient := c.wsClient c.mu.Unlock() @@ -106,6 +137,9 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { } c.wsClient = nil c.mu.Unlock() + if c.progress != nil { + c.progress.StopAll() + } c.SetRunning(false) logger.InfoC("feishu", "Feishu channel stopped") @@ -113,21 +147,94 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { } // Send sends a message using Interactive Card format for markdown rendering. -func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +// Falls back to plain text message if card sending fails (e.g., table limit exceeded). +func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } if msg.ChatID == "" { - return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } - // Build interactive card with markdown content - cardContent, err := buildMarkdownCard(msg.Content) - if err != nil { - return fmt.Errorf("feishu send: card build failed: %w", err) + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled { + if err != nil { + // Feishu can fall back to plain text for a previous progress + // message, and those messages cannot be patched through the card + // edit API. Drop the stale tracker and recreate the progress + // message so later tool feedback is not blocked. + c.resetTrackedToolFeedbackAfterEditFailure(ctx, msg.ChatID) + } else { + return []string{msgID}, nil + } + } + } else { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } } - return c.sendCard(ctx, msg.ChatID, cardContent) + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + + // Build interactive card with markdown content + sendContent := msg.Content + if isToolFeedback { + sendContent = channels.InitialAnimatedToolFeedbackContent(msg.Content) + } + cardContent, err := buildMarkdownCard(sendContent) + if err != nil { + // If card build fails, fall back to plain text + msgID, sendErr := c.sendText(ctx, msg.ChatID, sendContent) + if sendErr != nil { + return nil, sendErr + } + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil + } + + // First attempt: try sending as interactive card + msgID, err := c.sendCard(ctx, msg.ChatID, cardContent) + if err == nil { + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil + } + + // Check if error is due to card table limit (error code 11310) + // See: https://open.feishu.cn/document/server-docs/im-api/message-content-description/create_json + errMsg := err.Error() + isCardLimitError := strings.Contains(errMsg, "11310") + + if isCardLimitError { + logger.WarnCF("feishu", "Card send failed (table limit), falling back to text message", map[string]any{ + "chat_id": msg.ChatID, + "error": errMsg, + }) + + // Second attempt: fall back to plain text message + msgID, textErr := c.sendText(ctx, msg.ChatID, sendContent) + if textErr == nil { + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil + } + // If text also fails, return the text error + return nil, textErr + } + + // For other errors, return the original card error + return nil, err } // EditMessage implements channels.MessageEditor. @@ -148,25 +255,48 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont return fmt.Errorf("feishu edit: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg) } return nil } +// DeleteMessage implements channels.MessageDeleter. +func (c *FeishuChannel) DeleteMessage(ctx context.Context, chatID, messageID string) error { + deleteFn := c.deleteMessageFn + if deleteFn == nil { + deleteFn = c.deleteMessageAPI + } + return deleteFn(ctx, chatID, messageID) +} + +func (c *FeishuChannel) deleteMessageAPI(ctx context.Context, chatID, messageID string) error { + req := larkim.NewDeleteMessageReqBuilder(). + MessageId(messageID). + Build() + + resp, err := c.client.Im.V1.Message.Delete(ctx, req) + if err != nil { + return fmt.Errorf("feishu delete: %w", err) + } + if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) + return fmt.Errorf("feishu delete api error (code=%d msg=%s)", resp.Code, resp.Msg) + } + return nil +} + // SendPlaceholder implements channels.PlaceholderCapable. // Sends an interactive card with placeholder text and returns its message ID. func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { - if !c.config.Placeholder.Enabled { + if !c.bc.Placeholder.Enabled { logger.DebugCF("feishu", "Placeholder disabled, skipping", map[string]any{ "chat_id": chatID, }) return "", nil } - text := c.config.Placeholder.Text - if text == "" { - text = "Thinking..." - } + text := c.bc.Placeholder.GetRandomText() cardContent, err := buildMarkdownCard(text) if err != nil { @@ -187,6 +317,7 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str return "", fmt.Errorf("feishu placeholder send: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) return "", fmt.Errorf("feishu placeholder api error (code=%d msg=%s)", resp.Code, resp.Msg) } @@ -196,23 +327,108 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str return "", nil } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func (c *FeishuChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *FeishuChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *FeishuChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *FeishuChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *FeishuChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *FeishuChannel) resetTrackedToolFeedbackAfterEditFailure(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *FeishuChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + deleteFn := c.deleteMessageFn + if deleteFn == nil { + deleteFn = c.deleteMessageAPI + } + _ = deleteFn(ctx, chatID, messageID) +} + +func (c *FeishuChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *FeishuChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + // ReactToMessage implements channels.ReactionCapable. // Adds a reaction (randomly chosen from config) and returns an undo function to remove it. func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { - // Get emoji list from config - emojiList := c.config.RandomReactionEmoji - if len(emojiList) == 0 { - // Default to "Pin" if no config - emojiList = []string{"Pin"} + // Get emoji list from config (Feishu emoji_type keys, e.g. Pin, THUMBSUP). + // Ignore empty entries so a list like ["", "Pin"] does not randomly pick "" (API 231001). + var candidates []string + for _, e := range c.config.RandomReactionEmoji { + e = strings.TrimSpace(e) + if e != "" { + candidates = append(candidates, e) + } } - - // Randomly choose one from the list using crypto/rand for better distribution - idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(emojiList)))) - var chosenEmoji string - if err != nil { - chosenEmoji = emojiList[0] - } else { - chosenEmoji = emojiList[idx.Int64()] + chosenEmoji := "Pin" + if len(candidates) > 0 { + chosenEmoji = candidates[rand.Intn(len(candidates))] } req := larkim.NewCreateMessageReactionReqBuilder(). @@ -232,6 +448,7 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st return func() {}, fmt.Errorf("feishu react: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) logger.ErrorCF("feishu", "Reaction API error", map[string]any{ "emoji": chosenEmoji, "message_id": messageID, @@ -265,27 +482,32 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st // SendMedia implements channels.MediaSender. // Uploads images/files via Feishu API then sends as messages. -func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) if msg.ChatID == "" { - return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil { - return err + return nil, err } } - return nil + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + + return nil, nil } // sendMediaPart resolves and sends a single media part. @@ -379,36 +601,35 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. mediaRefs = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store) } + // For interactive cards, pass external image URLs via media refs. + // Keep content as valid raw JSON for downstream parsing. + if messageType == larkim.MsgTypeInteractive { + _, externalURLs := extractCardImageKeys(rawContent) + if len(externalURLs) > 0 { + mediaRefs = append(mediaRefs, externalURLs...) + } + } + // Append media tags to content (like Telegram does) content = appendMediaTags(content, messageType, mediaRefs) if content == "" { content = "[empty message]" } - - metadata := map[string]string{} - if messageID != "" { - metadata["message_id"] = messageID - } - if messageType != "" { - metadata["message_type"] = messageType - } chatType := stringValue(message.ChatType) - if chatType != "" { - metadata["chat_type"] = chatType - } - if sender != nil && sender.TenantKey != nil { - metadata["tenant_key"] = *sender.TenantKey - } + metadata := buildInboundMetadata(message, sender) - var peer bus.Peer + var ( + inboundChatType string + isMentioned bool + ) if chatType == "p2p" { - peer = bus.Peer{Kind: "direct", ID: senderID} + inboundChatType = "direct" } else { - peer = bus.Peer{Kind: "group", ID: chatID} + inboundChatType = "group" // Check if bot was mentioned - isMentioned := c.isBotMentioned(message) + isMentioned = c.isBotMentioned(message) // Strip mention placeholders from content before group trigger check if len(message.Mentions) > 0 { @@ -423,14 +644,41 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. content = cleaned } + if replyTargetID(message) != "" || stringValue(message.ThreadId) != "" { + content, mediaRefs = c.prependReplyContext(ctx, message, chatID, content, mediaRefs) + } + if content == "" { + content = "[empty message]" + } + logger.InfoCF("feishu", "Feishu message received", map[string]any{ "sender_id": senderID, "chat_id": chatID, "message_id": messageID, "preview": utils.Truncate(content, 80), }) + logger.InfoCF("feishu", "Feishu reply linkage", map[string]any{ + "message_id": messageID, + "parent_id": stringValue(message.ParentId), + "root_id": stringValue(message.RootId), + "thread_id": stringValue(message.ThreadId), + }) - c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo) + inboundCtx := bus.InboundContext{ + Channel: "feishu", + ChatID: chatID, + ChatType: inboundChatType, + SenderID: senderID, + MessageID: messageID, + Mentioned: isMentioned, + Raw: metadata, + } + if sender != nil && sender.TenantKey != nil && *sender.TenantKey != "" { + inboundCtx.SpaceType = "tenant" + inboundCtx.SpaceID = *sender.TenantKey + } + + c.HandleInboundContext(ctx, chatID, content, mediaRefs, inboundCtx, senderInfo) return nil } @@ -457,6 +705,7 @@ func (c *FeishuChannel) fetchBotOpenID(ctx context.Context) error { return fmt.Errorf("bot info parse: %w", err) } if result.Code != 0 { + c.invalidateTokenOnAuthError(result.Code) return fmt.Errorf("bot info api error (code=%d)", result.Code) } if result.Bot.OpenID == "" { @@ -513,6 +762,10 @@ func extractContent(messageType, rawContent string) string { // Pass raw JSON to LLM — structured rich text is more informative than flattened plain text return rawContent + case larkim.MsgTypeInteractive: + // Pass raw JSON to LLM — structured card is more informative than flattened text + return rawContent + case larkim.MsgTypeImage: // Image messages don't have text content return "" @@ -550,6 +803,26 @@ func (c *FeishuChannel) downloadInboundMedia( refs = append(refs, ref) } + case larkim.MsgTypePost: + for _, imageKey := range extractPostImageKeys(rawContent) { + ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) + if ref != "" { + refs = append(refs, ref) + } + } + + case larkim.MsgTypeInteractive: + // Extract and download images embedded in interactive cards + feishuKeys, _ := extractCardImageKeys(rawContent) + // Download Feishu-hosted images via API + for _, imageKey := range feishuKeys { + ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) + if ref != "" { + refs = append(refs, ref) + } + } + // External URLs are passed directly to LLM, not downloaded + case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia: fileKey := extractFileKey(rawContent) if fileKey == "" { @@ -577,12 +850,41 @@ func (c *FeishuChannel) downloadInboundMedia( // downloadResource downloads a message resource (image/file) from Feishu, // writes it to the project media directory, and stores the reference in MediaStore. // fallbackExt (e.g. ".jpg") is appended when the resolved filename has no extension. +// +// For image resources, if the primary MessageResource.Get API fails (which +// requires im:message or im:message:readonly scope), a fallback to the +// Image.Get API (which requires im:resource scope) is attempted. This ensures +// image downloads succeed regardless of which permission the user has granted. func (c *FeishuChannel) downloadResource( ctx context.Context, messageID, fileKey, resourceType, fallbackExt string, store media.MediaStore, scope string, ) string { + file, filename := c.fetchResourceData(ctx, messageID, fileKey, resourceType) + if file == nil { + return "" + } + if closer, ok := file.(io.Closer); ok { + defer closer.Close() + } + + if filename == "" { + filename = fileKey + } + if filepath.Ext(filename) == "" && fallbackExt != "" { + filename += fallbackExt + } + + return c.storeResourceFile(ctx, messageID, fileKey, filename, file, store, scope) +} + +// fetchResourceData tries to download a resource from Feishu, first via +// MessageResource.Get, then falling back to Image.Get for image resources. +func (c *FeishuChannel) fetchResourceData( + ctx context.Context, + messageID, fileKey, resourceType string, +) (io.Reader, string) { req := larkim.NewGetMessageResourceReqBuilder(). MessageId(messageID). FileKey(fileKey). @@ -590,41 +892,81 @@ func (c *FeishuChannel) downloadResource( Build() resp, err := c.client.Im.V1.MessageResource.Get(ctx, req) + if err == nil && resp.Success() && resp.File != nil { + return resp.File, resp.FileName + } + if err != nil { - logger.ErrorCF("feishu", "Failed to download resource", map[string]any{ + logger.WarnCF("feishu", "MessageResource.Get failed", map[string]any{ "message_id": messageID, "file_key": fileKey, "error": err.Error(), }) - return "" + } else if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) + logger.WarnCF("feishu", "MessageResource.Get api error", map[string]any{ + "message_id": messageID, + "file_key": fileKey, + "code": resp.Code, + "msg": resp.Msg, + }) + } else { + logger.WarnCF("feishu", "MessageResource.Get returned empty file body", map[string]any{ + "message_id": messageID, + "file_key": fileKey, + }) + } + + if resourceType != "image" { + return nil, "" + } + + return c.fetchImageDirect(ctx, fileKey) +} + +// fetchImageDirect downloads an image using the Image.Get API +// (/open-apis/im/v1/images/:image_key), which requires the im:resource scope. +func (c *FeishuChannel) fetchImageDirect(ctx context.Context, imageKey string) (io.Reader, string) { + req := larkim.NewGetImageReqBuilder(). + ImageKey(imageKey). + Build() + + resp, err := c.client.Im.V1.Image.Get(ctx, req) + if err != nil { + logger.ErrorCF("feishu", "Image.Get fallback failed", map[string]any{ + "image_key": imageKey, + "error": err.Error(), + }) + return nil, "" } if !resp.Success() { - logger.ErrorCF("feishu", "Resource download api error", map[string]any{ - "code": resp.Code, - "msg": resp.Msg, + c.invalidateTokenOnAuthError(resp.Code) + logger.ErrorCF("feishu", "Image.Get fallback api error", map[string]any{ + "image_key": imageKey, + "code": resp.Code, + "msg": resp.Msg, }) - return "" + return nil, "" } - if resp.File == nil { - return "" - } - // Safely close the underlying reader if it implements io.Closer (e.g. HTTP response body). - if closer, ok := resp.File.(io.Closer); ok { - defer closer.Close() + return nil, "" } - filename := resp.FileName - if filename == "" { - filename = fileKey - } - // If filename still has no extension, append the fallback (like Telegram's ext parameter). - if filepath.Ext(filename) == "" && fallbackExt != "" { - filename += fallbackExt - } + logger.DebugCF("feishu", "Image downloaded via Image.Get fallback", map[string]any{ + "image_key": imageKey, + }) + return resp.File, resp.FileName +} - // Write to the shared picoclaw_media directory using a unique name to avoid collisions. - mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") +// storeResourceFile writes downloaded resource data to disk and registers it in the MediaStore. +func (c *FeishuChannel) storeResourceFile( + ctx context.Context, + messageID, fileKey, filename string, + file io.Reader, + store media.MediaStore, + scope string, +) string { + mediaDir := media.TempDir() if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil { logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{ "error": mkdirErr.Error(), @@ -642,7 +984,7 @@ func (c *FeishuChannel) downloadResource( return "" } - if _, copyErr := io.Copy(out, resp.File); copyErr != nil { + if _, copyErr := io.Copy(out, file); copyErr != nil { out.Close() os.Remove(localPath) logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{ @@ -653,8 +995,9 @@ func (c *FeishuChannel) downloadResource( out.Close() ref, err := store.Store(localPath, media.MediaMeta{ - Filename: filename, - Source: "feishu", + Filename: filename, + Source: "feishu", + CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, }, scope) if err != nil { logger.ErrorCF("feishu", "Failed to store downloaded resource", map[string]any{ @@ -669,11 +1012,18 @@ func (c *FeishuChannel) downloadResource( } // appendMediaTags appends media type tags to content (like Telegram's "[image: photo]"). +// For interactive cards, media tags are not appended because content is raw JSON +// and appending would produce invalid JSON format. func appendMediaTags(content, messageType string, mediaRefs []string) string { if len(mediaRefs) == 0 { return content } + // Don't append tags to JSON content - would produce invalid JSON + if messageType == larkim.MsgTypeInteractive || messageType == larkim.MsgTypePost { + return content + } + var tag string switch messageType { case larkim.MsgTypeImage: @@ -695,7 +1045,7 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string { } // sendCard sends an interactive card message to a chat. -func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error { +func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) (string, error) { req := larkim.NewCreateMessageReqBuilder(). ReceiveIdType(larkim.ReceiveIdTypeChatId). Body(larkim.NewCreateMessageReqBodyBuilder(). @@ -707,18 +1057,54 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return fmt.Errorf("feishu send card: %w", channels.ErrTemporary) + return "", fmt.Errorf("feishu send card: %w", channels.ErrTemporary) } if !resp.Success() { - return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + c.invalidateTokenOnAuthError(resp.Code) + return "", fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } logger.DebugCF("feishu", "Feishu card message sent", map[string]any{ "chat_id": chatID, }) - return nil + if resp.Data != nil && resp.Data.MessageId != nil { + return *resp.Data.MessageId, nil + } + return "", nil +} + +// sendText sends a plain text message to a chat (fallback when card fails). +func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) (string, error) { + content, _ := json.Marshal(map[string]string{"text": text}) + + req := larkim.NewCreateMessageReqBuilder(). + ReceiveIdType(larkim.ReceiveIdTypeChatId). + Body(larkim.NewCreateMessageReqBodyBuilder(). + ReceiveId(chatID). + MsgType(larkim.MsgTypeText). + Content(string(content)). + Build()). + Build() + + resp, err := c.client.Im.V1.Message.Create(ctx, req) + if err != nil { + return "", fmt.Errorf("feishu send text: %w", channels.ErrTemporary) + } + + if !resp.Success() { + return "", fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + } + + logger.DebugCF("feishu", "Feishu text message sent (fallback)", map[string]any{ + "chat_id": chatID, + }) + + if resp.Data != nil && resp.Data.MessageId != nil { + return *resp.Data.MessageId, nil + } + return "", nil } // sendImage uploads an image and sends it as a message. @@ -736,6 +1122,7 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F return fmt.Errorf("feishu image upload: %w", err) } if !uploadResp.Success() { + c.invalidateTokenOnAuthError(uploadResp.Code) return fmt.Errorf("feishu image upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg) } if uploadResp.Data == nil || uploadResp.Data.ImageKey == nil { @@ -760,6 +1147,7 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F return fmt.Errorf("feishu image send: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu image send api error (code=%d msg=%s)", resp.Code, resp.Msg) } return nil @@ -790,6 +1178,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi return fmt.Errorf("feishu file upload: %w", err) } if !uploadResp.Success() { + c.invalidateTokenOnAuthError(uploadResp.Code) return fmt.Errorf("feishu file upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg) } if uploadResp.Data == nil || uploadResp.Data.FileKey == nil { @@ -814,6 +1203,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi return fmt.Errorf("feishu file send: %w", err) } if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) return fmt.Errorf("feishu file send api error (code=%d msg=%s)", resp.Code, resp.Msg) } return nil @@ -836,3 +1226,14 @@ func extractFeishuSenderID(sender *larkim.EventSender) string { return "" } + +// invalidateTokenOnAuthError clears the cached tenant_access_token when the +// Feishu API reports it as invalid (99991663), so the next request fetches a +// fresh one. The Lark SDK's built-in retry does not clear the cache, causing +// all API calls to fail until the token naturally expires (~2 hours). +func (c *FeishuChannel) invalidateTokenOnAuthError(code int) { + if code == errCodeTenantTokenInvalid { + c.tokenCache.InvalidateAll() + logger.WarnCF("feishu", "Invalidated cached token due to auth error", nil) + } +} diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index dc3eab2e7..d256325ad 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -3,9 +3,13 @@ package feishu import ( + "context" + "errors" "testing" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + + "github.com/sipeed/picoclaw/pkg/channels" ) func TestExtractContent(t *testing.T) { @@ -75,6 +79,24 @@ func TestExtractContent(t *testing.T) { rawContent: "", want: "", }, + { + name: "interactive card returns raw JSON", + messageType: "interactive", + rawContent: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`, + want: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`, + }, + { + name: "interactive card with complex structure returns raw JSON", + messageType: "interactive", + rawContent: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`, + want: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`, + }, + { + name: "interactive card invalid JSON returns as-is", + messageType: "interactive", + rawContent: `not valid json`, + want: `not valid json`, + }, } for _, tt := range tests { @@ -151,6 +173,20 @@ func TestAppendMediaTags(t *testing.T) { mediaRefs: []string{"ref1"}, want: "something [attachment]", }, + { + name: "interactive card with images returns content unchanged", + content: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`, + messageType: "interactive", + mediaRefs: []string{"ref1"}, + want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`, + }, + { + name: "post message with images returns content unchanged", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_001"}]]}}`, + messageType: "post", + mediaRefs: []string{"ref1"}, + want: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_001"}]]}}`, + }, } for _, tt := range tests { @@ -254,3 +290,110 @@ func TestExtractFeishuSenderID(t *testing.T) { }) } } + +func TestFinalizeTrackedToolFeedbackMessage_ClearAfterSuccessfulEdit(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } + if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after successful edit") + } +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } +} + +func TestFinalizeTrackedToolFeedbackMessage_EditFailureKeepsTrackedMessage(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(context.Context, string, string, string) error { + return errors.New("edit failed") + }, + ) + if handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to report unhandled on edit failure") + } + if len(msgIDs) != 0 { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } + if msgID, ok := ch.currentToolFeedbackMessage("chat-1"); !ok || msgID != "msg-1" { + t.Fatalf("expected tracked tool feedback to remain after failed edit, got (%q, %v)", msgID, ok) + } +} + +func TestResetTrackedToolFeedbackAfterEditFailure_DismissesTrackedMessage(t *testing.T) { + var ( + deletedChatID string + deletedMsgID string + ) + + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + deleteMessageFn: func(_ context.Context, chatID, messageID string) error { + deletedChatID = chatID + deletedMsgID = messageID + return nil + }, + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + ch.resetTrackedToolFeedbackAfterEditFailure(context.Background(), "chat-1") + + if deletedChatID != "chat-1" || deletedMsgID != "msg-1" { + t.Fatalf("unexpected delete target: chat=%q msg=%q", deletedChatID, deletedMsgID) + } + if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after edit failure reset") + } +} diff --git a/pkg/channels/feishu/feishu_reply.go b/pkg/channels/feishu/feishu_reply.go new file mode 100644 index 000000000..22dfe3e87 --- /dev/null +++ b/pkg/channels/feishu/feishu_reply.go @@ -0,0 +1,298 @@ +//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 + +package feishu + +import ( + "context" + "fmt" + "strings" + "time" + + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +const messageCacheTTL = 30 * time.Second + +const ( + maxReplyContextLen = 600 +) + +func (c *FeishuChannel) prependReplyContext( + ctx context.Context, + message *larkim.EventMessage, + chatID string, + content string, + mediaRefs []string, +) (string, []string) { + if message == nil { + return content, mediaRefs + } + + lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + targetMessageID := c.resolveReplyTargetMessageID(lookupCtx, message) + if targetMessageID == "" { + logger.DebugCF("feishu", "No reply target resolved; skip reply context", map[string]any{ + "message_id": stringValue(message.MessageId), + "parent_id": stringValue(message.ParentId), + "root_id": stringValue(message.RootId), + "thread_id": stringValue(message.ThreadId), + }) + return content, mediaRefs + } + + repliedMessage, err := c.fetchMessageByID(lookupCtx, targetMessageID) + if err != nil { + logger.DebugCF("feishu", "Failed to fetch replied message context", map[string]any{ + "target_message_id": targetMessageID, + "error": err.Error(), + }) + return content, mediaRefs + } + + messageType := stringValue(repliedMessage.MsgType) + rawContent := "" + if repliedMessage.Body != nil { + rawContent = stringValue(repliedMessage.Body.Content) + } + + var repliedMediaRefs []string + if store := c.GetMediaStore(); store != nil { + repliedMediaRefs = c.downloadInboundMedia(lookupCtx, chatID, targetMessageID, messageType, rawContent, store) + if messageType == larkim.MsgTypeInteractive { + _, externalURLs := extractCardImageKeys(rawContent) + if len(externalURLs) > 0 { + repliedMediaRefs = append(repliedMediaRefs, externalURLs...) + } + } + } + + repliedContent := normalizeRepliedContent(messageType, rawContent, repliedMediaRefs) + if len(repliedMediaRefs) > 0 { + mediaRefs = append(repliedMediaRefs, mediaRefs...) + } + + return formatReplyContext(targetMessageID, repliedContent, content), mediaRefs +} + +func (c *FeishuChannel) resolveReplyTargetMessageID(ctx context.Context, message *larkim.EventMessage) string { + if targetID := replyTargetID(message); targetID != "" { + logger.DebugCF("feishu", "Resolved reply target from event payload", map[string]any{ + "message_id": stringValue(message.MessageId), + "parent_id": stringValue(message.ParentId), + "root_id": stringValue(message.RootId), + "target_id": targetID, + }) + return targetID + } + + currentMessageID := stringValue(message.MessageId) + if currentMessageID == "" { + return "" + } + + if stringValue(message.ThreadId) == "" { + logger.DebugCF("feishu", "No reply target found; message is not in a thread", map[string]any{ + "message_id": stringValue(message.MessageId), + }) + return "" + } + + msg, err := c.fetchMessageByID(ctx, currentMessageID) + if err != nil { + logger.DebugCF("feishu", "Failed to query current message detail for reply info", map[string]any{ + "message_id": currentMessageID, + "error": err.Error(), + }) + return "" + } + + targetID := replyTargetIDFromMessage(msg) + if targetID != "" { + logger.DebugCF("feishu", "Resolved reply target from message detail", map[string]any{ + "message_id": currentMessageID, + "parent_id": stringValue(msg.ParentId), + "root_id": stringValue(msg.RootId), + "target_id": targetID, + }) + } + return targetID +} + +func (c *FeishuChannel) fetchMessageByID(ctx context.Context, messageID string) (*larkim.Message, error) { + if cached, ok := c.messageCache.Load(messageID); ok { + cm := cached.(*cachedMessage) + if time.Now().Before(cm.expiry) { + return cm.msg, nil + } + c.messageCache.Delete(messageID) + } + + req := larkim.NewGetMessageReqBuilder(). + MessageId(messageID). + Build() + + resp, err := c.client.Im.V1.Message.Get(ctx, req) + if err != nil { + return nil, fmt.Errorf("feishu get message: %w", err) + } + if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) + return nil, fmt.Errorf("feishu get message api error (code=%d msg=%s)", resp.Code, resp.Msg) + } + if resp.Data == nil || len(resp.Data.Items) == 0 || resp.Data.Items[0] == nil { + return nil, fmt.Errorf("feishu get message: empty response") + } + // Items[0] contains the target message - the Feishu API returns a list + // but we request a single message by ID, so the list always has at most one item. + msg := resp.Data.Items[0] + c.messageCache.Store(messageID, &cachedMessage{msg: msg, expiry: time.Now().Add(messageCacheTTL)}) + return msg, nil +} + +func replyTargetID(message *larkim.EventMessage) string { + if message == nil { + return "" + } + if parentID := stringValue(message.ParentId); parentID != "" { + return parentID + } + return stringValue(message.RootId) +} + +func replyTargetIDFromMessage(message *larkim.Message) string { + if message == nil { + return "" + } + if parentID := stringValue(message.ParentId); parentID != "" { + return parentID + } + return stringValue(message.RootId) +} + +func buildInboundMetadata(message *larkim.EventMessage, sender *larkim.EventSender) map[string]string { + metadata := map[string]string{} + if message == nil { + return metadata + } + + messageID := stringValue(message.MessageId) + if messageID != "" { + metadata["message_id"] = messageID + } + + messageType := stringValue(message.MessageType) + if messageType != "" { + metadata["message_type"] = messageType + } + + chatType := stringValue(message.ChatType) + if chatType != "" { + metadata["chat_type"] = chatType + } + + parentID := stringValue(message.ParentId) + if parentID != "" { + metadata["parent_id"] = parentID + } + + rootID := stringValue(message.RootId) + if rootID != "" { + metadata["root_id"] = rootID + } + + if replyTo := replyTargetID(message); replyTo != "" { + metadata["reply_to_message_id"] = replyTo + } + + threadID := stringValue(message.ThreadId) + if threadID != "" { + metadata["thread_id"] = threadID + } + + if sender != nil && sender.TenantKey != nil && *sender.TenantKey != "" { + metadata["tenant_key"] = *sender.TenantKey + } + + return metadata +} + +func normalizeRepliedContent(messageType, rawContent string, mediaRefs []string) string { + content := extractContent(messageType, rawContent) + + if containsFeishuUpgradePlaceholder(rawContent) || containsFeishuUpgradePlaceholder(content) { + content = "" + } + + content = appendMediaTags(content, messageType, mediaRefs) + if strings.TrimSpace(content) != "" { + return content + } + + switch messageType { + case larkim.MsgTypeImage: + return "[replied image]" + case larkim.MsgTypeFile: + return "[replied file]" + case larkim.MsgTypeAudio: + return "[replied audio]" + case larkim.MsgTypeMedia: + return "[replied video]" + case larkim.MsgTypeInteractive: + return "[replied interactive card]" + default: + return "[replied message content unavailable]" + } +} + +func containsFeishuUpgradePlaceholder(s string) bool { + upgradePrompt := "\u8bf7\u5347\u7ea7\u81f3\u6700\u65b0\u7248\u672c\u5ba2\u6237\u7aef" + upgradePromptEscaped := "\\u8bf7\\u5347\\u7ea7\\u81f3\\u6700\\u65b0\\u7248\\u672c\\u5ba2\\u6237\\u7aef" + return strings.Contains(s, upgradePrompt) || strings.Contains(s, upgradePromptEscaped) +} + +func formatReplyContext(parentID, repliedContent, content string) string { + parentID = strings.TrimSpace(parentID) + repliedContent = strings.TrimSpace(repliedContent) + content = strings.TrimSpace(content) + + if parentID == "" || repliedContent == "" { + return content + } + + repliedContent = utils.Truncate(repliedContent, maxReplyContextLen) + repliedContent = sanitizeReplyContextContent(repliedContent) + content = sanitizeReplyContextContent(content) + header := fmt.Sprintf("[replied_message id=%q]", parentID) + footer := "[/replied_message]" + if content == "" { + return header + "\n" + repliedContent + "\n" + footer + } + if hasLeadingCommandPrefix(content) { + return content + "\n\n" + header + "\n" + repliedContent + "\n" + footer + } + return header + "\n" + repliedContent + "\n" + footer + "\n\n[current_message]\n" + content + "\n[/current_message]" +} + +func hasLeadingCommandPrefix(s string) bool { + tokens := strings.Fields(strings.TrimSpace(s)) + if len(tokens) == 0 { + return false + } + first := tokens[0] + return strings.HasPrefix(first, "/") || strings.HasPrefix(first, "!") +} + +func sanitizeReplyContextContent(s string) string { + tagEscaper := strings.NewReplacer( + "[replied_message", `\[replied_message`, + "[/replied_message]", `\[/replied_message]`, + "[current_message]", `\[current_message]`, + "[/current_message]", `\[/current_message]`, + ) + return tagEscaper.Replace(s) +} diff --git a/pkg/channels/feishu/feishu_reply_test.go b/pkg/channels/feishu/feishu_reply_test.go new file mode 100644 index 000000000..0efe7bc01 --- /dev/null +++ b/pkg/channels/feishu/feishu_reply_test.go @@ -0,0 +1,229 @@ +//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 + +package feishu + +import ( + "strings" + "testing" + + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" +) + +func TestBuildInboundMetadata(t *testing.T) { + strPtr := func(s string) *string { return &s } + + t.Run("includes basic and reply fields", func(t *testing.T) { + message := &larkim.EventMessage{ + MessageId: strPtr("om_msg_1"), + MessageType: strPtr("text"), + ChatType: strPtr("group"), + ParentId: strPtr("om_parent_1"), + RootId: strPtr("om_root_1"), + ThreadId: strPtr("omt_thread_1"), + } + sender := &larkim.EventSender{TenantKey: strPtr("tenant_x")} + + got := buildInboundMetadata(message, sender) + + if got["message_id"] != "om_msg_1" { + t.Fatalf("message_id = %q, want %q", got["message_id"], "om_msg_1") + } + if got["message_type"] != "text" { + t.Fatalf("message_type = %q, want %q", got["message_type"], "text") + } + if got["chat_type"] != "group" { + t.Fatalf("chat_type = %q, want %q", got["chat_type"], "group") + } + if got["parent_id"] != "om_parent_1" { + t.Fatalf("parent_id = %q, want %q", got["parent_id"], "om_parent_1") + } + if got["reply_to_message_id"] != "om_parent_1" { + t.Fatalf("reply_to_message_id = %q, want %q", got["reply_to_message_id"], "om_parent_1") + } + if got["root_id"] != "om_root_1" { + t.Fatalf("root_id = %q, want %q", got["root_id"], "om_root_1") + } + if got["thread_id"] != "omt_thread_1" { + t.Fatalf("thread_id = %q, want %q", got["thread_id"], "omt_thread_1") + } + if got["tenant_key"] != "tenant_x" { + t.Fatalf("tenant_key = %q, want %q", got["tenant_key"], "tenant_x") + } + }) + + t.Run("falls back reply_to_message_id to root_id", func(t *testing.T) { + message := &larkim.EventMessage{ + MessageId: strPtr("om_msg_3"), + RootId: strPtr("om_root_3"), + } + + got := buildInboundMetadata(message, nil) + + if got["root_id"] != "om_root_3" { + t.Fatalf("root_id = %q, want %q", got["root_id"], "om_root_3") + } + if got["reply_to_message_id"] != "om_root_3" { + t.Fatalf("reply_to_message_id = %q, want %q", got["reply_to_message_id"], "om_root_3") + } + }) + + t.Run("omits empty values", func(t *testing.T) { + message := &larkim.EventMessage{ + MessageId: strPtr("om_msg_2"), + } + + got := buildInboundMetadata(message, nil) + + if got["message_id"] != "om_msg_2" { + t.Fatalf("message_id = %q, want %q", got["message_id"], "om_msg_2") + } + if _, ok := got["parent_id"]; ok { + t.Fatalf("parent_id should be absent, got %q", got["parent_id"]) + } + if _, ok := got["reply_to_message_id"]; ok { + t.Fatalf("reply_to_message_id should be absent, got %q", got["reply_to_message_id"]) + } + if _, ok := got["tenant_key"]; ok { + t.Fatalf("tenant_key should be absent, got %q", got["tenant_key"]) + } + }) + + t.Run("nil message returns empty map", func(t *testing.T) { + got := buildInboundMetadata(nil, nil) + if len(got) != 0 { + t.Fatalf("len(metadata) = %d, want 0", len(got)) + } + }) +} + +func TestFormatReplyContext(t *testing.T) { + t.Run("formats reply context with content", func(t *testing.T) { + got := formatReplyContext("om_parent_1", "original message", "new reply") + want := "[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]\n\n[current_message]\nnew reply\n[/current_message]" + if got != want { + t.Fatalf("formatReplyContext() = %q, want %q", got, want) + } + }) + + t.Run("returns reply context when current content is empty", func(t *testing.T) { + got := formatReplyContext("om_parent_1", "original message", "") + want := "[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]" + if got != want { + t.Fatalf("formatReplyContext() = %q, want %q", got, want) + } + }) + + t.Run("returns original content when parent or replied content missing", func(t *testing.T) { + if got := formatReplyContext("", "original", "new reply"); got != "new reply" { + t.Fatalf("missing parent: got %q, want %q", got, "new reply") + } + if got := formatReplyContext("om_parent_1", "", "new reply"); got != "new reply" { + t.Fatalf("missing replied content: got %q, want %q", got, "new reply") + } + }) + + t.Run("escapes reserved wrapper tags in payload", func(t *testing.T) { + replied := "payload [replied_message id=\"x\"] x [/replied_message]" + current := "hello [current_message]injected[/current_message]" + got := formatReplyContext("om_parent_1", replied, current) + + if !strings.HasPrefix(got, "[replied_message id=\"om_parent_1\"]") { + t.Fatalf("outer replied_message wrapper missing: %q", got) + } + if strings.Contains(got, "\n[replied_message id=\"x\"]") { + t.Fatalf("nested replied_message tag should be escaped: %q", got) + } + if strings.Contains(got, "\n[current_message]injected") { + t.Fatalf("nested current_message tag should be escaped: %q", got) + } + if !strings.Contains(got, `\[replied_message id="x"]`) { + t.Fatalf("escaped replied tag missing: %q", got) + } + }) + + t.Run("preserves leading slash command prefix", func(t *testing.T) { + got := formatReplyContext("om_parent_1", "original message", "/help") + want := "/help\n\n[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]" + if got != want { + t.Fatalf("formatReplyContext() = %q, want %q", got, want) + } + }) + + t.Run("preserves leading bang command prefix", func(t *testing.T) { + got := formatReplyContext("om_parent_1", "original message", "!status now") + want := "!status now\n\n[replied_message id=\"om_parent_1\"]\noriginal message\n[/replied_message]" + if got != want { + t.Fatalf("formatReplyContext() = %q, want %q", got, want) + } + }) +} + +func TestReplyTargetID(t *testing.T) { + strPtr := func(s string) *string { return &s } + + t.Run("prefer parent_id", func(t *testing.T) { + msg := &larkim.EventMessage{ParentId: strPtr("om_parent"), RootId: strPtr("om_root")} + if got := replyTargetID(msg); got != "om_parent" { + t.Fatalf("replyTargetID() = %q, want %q", got, "om_parent") + } + }) + + t.Run("fallback to root_id", func(t *testing.T) { + msg := &larkim.EventMessage{RootId: strPtr("om_root")} + if got := replyTargetID(msg); got != "om_root" { + t.Fatalf("replyTargetID() = %q, want %q", got, "om_root") + } + }) + + t.Run("empty when no fields", func(t *testing.T) { + if got := replyTargetID(&larkim.EventMessage{}); got != "" { + t.Fatalf("replyTargetID() = %q, want empty", got) + } + }) +} + +func TestNormalizeRepliedContent(t *testing.T) { + t.Run("filters feishu upgrade placeholder for interactive", func(t *testing.T) { + raw := `{"text":"\u8bf7\u5347\u7ea7\u81f3\u6700\u65b0\u7248\u672c\u5ba2\u6237\u7aef\uff0c\u4ee5\u67e5\u770b\u5185\u5bb9"}` + got := normalizeRepliedContent("interactive", raw, nil) + if got != "[replied interactive card]" { + t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "[replied interactive card]") + } + }) + + t.Run("keeps filename and file tag for replied file", func(t *testing.T) { + got := normalizeRepliedContent("file", `{"file_key":"file_xxx","file_name":"doc.pdf"}`, []string{"media://r1"}) + if got != "doc.pdf [file]" { + t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "doc.pdf [file]") + } + }) + + t.Run("falls back when file content missing", func(t *testing.T) { + got := normalizeRepliedContent("file", `{"file_key":"file_xxx"}`, nil) + if got != "[replied file]" { + t.Fatalf("normalizeRepliedContent() = %q, want %q", got, "[replied file]") + } + }) +} + +func TestHasLeadingCommandPrefix(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {name: "slash command", input: "/help", want: true}, + {name: "bang command", input: "!status", want: true}, + {name: "leading spaces slash", input: " /ping arg", want: true}, + {name: "normal text", input: "hello /help", want: false}, + {name: "empty", input: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasLeadingCommandPrefix(tt.input); got != tt.want { + t.Fatalf("hasLeadingCommandPrefix(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} diff --git a/pkg/channels/feishu/init.go b/pkg/channels/feishu/init.go index 7e5a62dae..c4982bef1 100644 --- a/pkg/channels/feishu/init.go +++ b/pkg/channels/feishu/init.go @@ -7,7 +7,19 @@ import ( ) func init() { - channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewFeishuChannel(cfg.Channels.Feishu, b) - }) + channels.RegisterFactory( + config.ChannelFeishu, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.FeishuSettings) + if !ok { + return nil, channels.ErrSendFailed + } + return NewFeishuChannel(bc, c, b) + }, + ) } diff --git a/pkg/channels/feishu/token_cache.go b/pkg/channels/feishu/token_cache.go new file mode 100644 index 000000000..00acbc084 --- /dev/null +++ b/pkg/channels/feishu/token_cache.go @@ -0,0 +1,52 @@ +package feishu + +import ( + "context" + "sync" + "time" +) + +// tokenCache implements larkcore.Cache with an extra InvalidateAll method. +// This works around a bug in the Lark SDK v3 where the built-in token retry +// loop does not clear stale tokens from cache on auth errors. +type tokenCache struct { + mu sync.RWMutex + store map[string]*tokenEntry +} + +type tokenEntry struct { + value string + expireAt time.Time +} + +func newTokenCache() *tokenCache { + return &tokenCache{store: make(map[string]*tokenEntry)} +} + +func (c *tokenCache) Set(_ context.Context, key, value string, ttl time.Duration) error { + c.mu.Lock() + defer c.mu.Unlock() + c.store[key] = &tokenEntry{value: value, expireAt: time.Now().Add(ttl)} + return nil +} + +func (c *tokenCache) Get(_ context.Context, key string) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.store[key] + if !ok { + return "", nil + } + if e.expireAt.Before(time.Now()) { + delete(c.store, key) + return "", nil + } + return e.value, nil +} + +// InvalidateAll removes all cached tokens, forcing fresh acquisition. +func (c *tokenCache) InvalidateAll() { + c.mu.Lock() + defer c.mu.Unlock() + clear(c.store) +} diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go index b3a493761..0cfd435b0 100644 --- a/pkg/channels/interfaces.go +++ b/pkg/channels/interfaces.go @@ -3,6 +3,7 @@ package channels import ( "context" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/commands" ) @@ -19,6 +20,11 @@ type MessageEditor interface { EditMessage(ctx context.Context, chatID string, messageID string, content string) error } +// MessageDeleter — channels that can delete a message by ID. +type MessageDeleter interface { + DeleteMessage(ctx context.Context, chatID string, messageID string) error +} + // ReactionCapable — channels that can add a reaction (e.g. 👀) to an inbound message. // ReactToMessage adds a reaction and returns an undo function to remove it. // The undo function MUST be idempotent and safe to call multiple times. @@ -35,6 +41,18 @@ type PlaceholderCapable interface { SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error) } +// StreamingCapable — channels that can show partial LLM output in real-time. +// The channel SHOULD gracefully degrade if the platform rejects streaming +// (e.g. Telegram bot without forum mode). In that case, Update becomes a no-op +// and Finalize still delivers the final message. +type StreamingCapable interface { + BeginStream(ctx context.Context, chatID string) (Streamer, error) +} + +// Streamer is defined in pkg/bus to avoid circular imports. +// This alias keeps channel implementations using channels.Streamer unchanged. +type Streamer = bus.Streamer + // PlaceholderRecorder is injected into channels by Manager. // Channels call these methods on inbound to register typing/placeholder state. // Manager uses the registered state on outbound to stop typing and edit placeholders. diff --git a/pkg/channels/irc/handler.go b/pkg/channels/irc/handler.go index aca4ddd11..73df9c43c 100644 --- a/pkg/channels/irc/handler.go +++ b/pkg/channels/irc/handler.go @@ -17,8 +17,8 @@ import ( // onConnect is called after a successful connection (and on reconnect). func (c *IRCChannel) onConnect(conn *ircevent.Connection) { // NickServ auth (only if SASL is not configured) - if c.config.NickServPassword != "" && c.config.SASLUser == "" { - conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword) + if c.config.NickServPassword.String() != "" && c.config.SASLUser == "" { + conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword.String()) } // Join configured channels @@ -51,14 +51,11 @@ func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) { isDM := !strings.HasPrefix(target, "#") && !strings.HasPrefix(target, "&") var chatID string - var peer bus.Peer if isDM { chatID = nick - peer = bus.Peer{Kind: "direct", ID: nick} } else { chatID = target - peer = bus.Peer{Kind: "group", ID: target} } sender := bus.SenderInfo{ @@ -73,9 +70,11 @@ func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) { return } + isMentioned := false + // For channel messages, check group trigger (mention detection) if !isDM { - isMentioned := isBotMentioned(content, currentNick) + isMentioned = isBotMentioned(content, currentNick) if isMentioned { content = stripBotMention(content, currentNick) } @@ -100,7 +99,21 @@ func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) { metadata["channel"] = target } - c.HandleMessage(c.ctx, peer, messageID, nick, chatID, content, nil, metadata, sender) + inboundCtx := bus.InboundContext{ + Channel: "irc", + ChatID: chatID, + SenderID: nick, + MessageID: messageID, + Mentioned: isMentioned, + Raw: metadata, + } + if isDM { + inboundCtx.ChatType = "direct" + } else { + inboundCtx.ChatType = "group" + } + + c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, sender) } // nickMentionedAt returns the byte index where botNick is mentioned in content diff --git a/pkg/channels/irc/init.go b/pkg/channels/irc/init.go index 221d41b62..3f206cbc7 100644 --- a/pkg/channels/irc/init.go +++ b/pkg/channels/irc/init.go @@ -7,10 +7,29 @@ import ( ) func init() { - channels.RegisterFactory("irc", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - if !cfg.Channels.IRC.Enabled { - return nil, nil - } - return NewIRCChannel(cfg.Channels.IRC, b) - }) + channels.RegisterFactory( + config.ChannelIRC, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + if bc == nil || !bc.Enabled { + return nil, nil + } + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.IRCSettings) + if !ok { + return nil, channels.ErrSendFailed + } + ch, err := NewIRCChannel(bc, c, b) + if err != nil { + return nil, err + } + if channelName != config.ChannelIRC { + ch.SetName(channelName) + } + return ch, nil + }, + ) } diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go index 28c59b540..fa60e9b6d 100644 --- a/pkg/channels/irc/irc.go +++ b/pkg/channels/irc/irc.go @@ -18,14 +18,15 @@ import ( // IRCChannel implements the Channel interface for IRC servers. type IRCChannel struct { *channels.BaseChannel - config config.IRCConfig + bc *config.Channel + config *config.IRCSettings conn *ircevent.Connection ctx context.Context cancel context.CancelFunc } // NewIRCChannel creates a new IRC channel. -func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChannel, error) { +func NewIRCChannel(bc *config.Channel, cfg *config.IRCSettings, messageBus *bus.MessageBus) (*IRCChannel, error) { if cfg.Server == "" { return nil, fmt.Errorf("irc server is required") } @@ -33,14 +34,15 @@ func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChanne return nil, fmt.Errorf("irc nick is required") } - base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom, + base := channels.NewBaseChannel("irc", cfg, messageBus, bc.AllowFrom, channels.WithMaxMessageLength(400), - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &IRCChannel{ BaseChannel: base, + bc: bc, config: cfg, }, nil } @@ -68,7 +70,7 @@ func (c *IRCChannel) Start(ctx context.Context) error { Nick: c.config.Nick, User: user, RealName: realName, - Password: c.config.Password, + Password: c.config.Password.String(), UseTLS: c.config.TLS, RequestCaps: caps, QuitMessage: "Goodbye", @@ -83,9 +85,9 @@ func (c *IRCChannel) Start(ctx context.Context) error { } // SASL auth (takes priority over NickServ) - if c.config.SASLUser != "" && c.config.SASLPassword != "" { + if c.config.SASLUser != "" && c.config.SASLPassword.String() != "" { conn.SASLLogin = c.config.SASLUser - conn.SASLPassword = c.config.SASLPassword + conn.SASLPassword = c.config.SASLPassword.String() } // Register event handlers @@ -130,18 +132,18 @@ func (c *IRCChannel) Stop(ctx context.Context) error { } // Send sends a message to an IRC channel or user. -func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } target := msg.ChatID if target == "" { - return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } if strings.TrimSpace(msg.Content) == "" { - return nil + return nil, nil } // Send each line separately (IRC is line-oriented) @@ -158,7 +160,7 @@ func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { "target": target, "lines": len(lines), }) - return nil + return nil, nil } // StartTyping implements channels.TypingCapable using IRCv3 +typing client tag. @@ -166,7 +168,7 @@ func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { func (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { noop := func() {} - if !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil { + if !c.bc.Typing.Enabled || !c.IsRunning() || c.conn == nil { return noop, nil } diff --git a/pkg/channels/irc/irc_test.go b/pkg/channels/irc/irc_test.go index 168252a4d..e459e71fc 100644 --- a/pkg/channels/irc/irc_test.go +++ b/pkg/channels/irc/irc_test.go @@ -11,28 +11,31 @@ func TestNewIRCChannel(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("missing server", func(t *testing.T) { - cfg := config.IRCConfig{Nick: "bot"} - _, err := NewIRCChannel(cfg, msgBus) + bc := &config.Channel{Type: config.ChannelIRC, Enabled: true} + cfg := &config.IRCSettings{Nick: "bot"} + _, err := NewIRCChannel(bc, cfg, msgBus) if err == nil { t.Error("expected error for missing server, got nil") } }) t.Run("missing nick", func(t *testing.T) { - cfg := config.IRCConfig{Server: "irc.example.com:6667"} - _, err := NewIRCChannel(cfg, msgBus) + bc := &config.Channel{Type: config.ChannelIRC, Enabled: true} + cfg := &config.IRCSettings{Server: "irc.example.com:6667"} + _, err := NewIRCChannel(bc, cfg, msgBus) if err == nil { t.Error("expected error for missing nick, got nil") } }) t.Run("valid config", func(t *testing.T) { - cfg := config.IRCConfig{ + bc := &config.Channel{Type: config.ChannelIRC, Enabled: true} + cfg := &config.IRCSettings{ Server: "irc.example.com:6667", Nick: "testbot", Channels: []string{"#test"}, } - ch, err := NewIRCChannel(cfg, msgBus) + ch, err := NewIRCChannel(bc, cfg, msgBus) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/pkg/channels/line/init.go b/pkg/channels/line/init.go index 9265575cc..6d829cd40 100644 --- a/pkg/channels/line/init.go +++ b/pkg/channels/line/init.go @@ -7,7 +7,19 @@ import ( ) func init() { - channels.RegisterFactory("line", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewLINEChannel(cfg.Channels.LINE, b) - }) + channels.RegisterFactory( + config.ChannelLINE, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.LINESettings) + if !ok { + return nil, channels.ErrSendFailed + } + return NewLINEChannel(bc, c, b) + }, + ) } diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index b36350a06..760506a31 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -32,6 +32,10 @@ const ( lineBotInfoEndpoint = lineAPIBase + "/info" lineLoadingEndpoint = lineAPIBase + "/chat/loading/start" lineReplyTokenMaxAge = 25 * time.Second + + // Limit request body to prevent memory exhaustion (DoS). + // LINE webhook payloads are typically a few KB; 1 MiB is generous. + maxWebhookBodySize = 1 << 20 // 1 MiB ) type replyTokenEntry struct { @@ -44,7 +48,7 @@ type replyTokenEntry struct { // and REST API for sending messages. type LINEChannel struct { *channels.BaseChannel - config config.LINEConfig + config *config.LINESettings infoClient *http.Client // for bot info lookups (short timeout) apiClient *http.Client // for messaging API calls botUserID string // Bot's user ID @@ -57,15 +61,19 @@ type LINEChannel struct { } // NewLINEChannel creates a new LINE channel instance. -func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) { - if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" { +func NewLINEChannel( + bc *config.Channel, + cfg *config.LINESettings, + messageBus *bus.MessageBus, +) (*LINEChannel, error) { + if cfg.ChannelSecret.String() == "" || cfg.ChannelAccessToken.String() == "" { return nil, fmt.Errorf("line channel_secret and channel_access_token are required") } - base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom, + base := channels.NewBaseChannel("line", cfg, messageBus, bc.AllowFrom, channels.WithMaxMessageLength(5000), - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &LINEChannel{ @@ -106,7 +114,7 @@ func (c *LINEChannel) fetchBotInfo() error { if err != nil { return err } - req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) + req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken.String()) resp, err := c.infoClient.Do(req) if err != nil { @@ -166,7 +174,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { return } - body, err := io.ReadAll(r.Body) + body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1)) if err != nil { logger.ErrorCF("line", "Failed to read request body", map[string]any{ "error": err.Error(), @@ -174,6 +182,11 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Bad request", http.StatusBadRequest) return } + if int64(len(body)) > maxWebhookBodySize { + logger.WarnC("line", "Webhook request body too large, rejected") + http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge) + return + } signature := r.Header.Get("X-Line-Signature") if !c.verifySignature(body, signature) { @@ -207,7 +220,7 @@ func (c *LINEChannel) verifySignature(body []byte, signature string) bool { return false } - mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret)) + mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret.String())) mac.Write(body) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) @@ -292,8 +305,9 @@ func (c *LINEChannel) processEvent(event lineEvent) { storeMedia := func(localPath, filename string) string { if store := c.GetMediaStore(); store != nil { ref, err := store.Store(localPath, media.MediaMeta{ - Filename: filename, - Source: "line", + Filename: filename, + Source: "line", + CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, }, scope) if err == nil { return ref @@ -340,8 +354,9 @@ func (c *LINEChannel) processEvent(event lineEvent) { } // In group chats, apply unified group trigger filtering + isMentioned := false if isGroup { - isMentioned := c.isBotMentioned(msg) + isMentioned = c.isBotMentioned(msg) respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{ @@ -357,13 +372,6 @@ func (c *LINEChannel) processEvent(event lineEvent) { "source_type": event.Source.Type, } - var peer bus.Peer - if isGroup { - peer = bus.Peer{Kind: "group", ID: chatID} - } else { - peer = bus.Peer{Kind: "direct", ID: senderID} - } - logger.DebugCF("line", "Received message", map[string]any{ "sender_id": senderID, "chat_id": chatID, @@ -382,7 +390,25 @@ func (c *LINEChannel) processEvent(event lineEvent) { return } - c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender) + inboundCtx := bus.InboundContext{ + Channel: c.Name(), + ChatID: chatID, + ChatType: map[bool]string{true: "group", false: "direct"}[isGroup], + SenderID: senderID, + MessageID: msg.ID, + Mentioned: isMentioned, + Raw: metadata, + } + if event.ReplyToken != "" { + inboundCtx.ReplyHandles = map[string]string{ + "reply_token": event.ReplyToken, + } + if msg.QuoteToken != "" { + inboundCtx.ReplyHandles["quote_token"] = msg.QuoteToken + } + } + + c.HandleInboundContext(c.ctx, chatID, content, mediaPaths, inboundCtx, sender) } // isBotMentioned checks if the bot is mentioned in the message. @@ -486,9 +512,9 @@ func (c *LINEChannel) resolveChatID(source lineSource) string { // Send sends a message to LINE. It first tries the Reply API (free) // using a cached reply token, then falls back to the Push API. -func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Load and consume quote token for this chat @@ -506,28 +532,28 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { "chat_id": msg.ChatID, "quoted": quoteToken != "", }) - return nil + return nil, nil } logger.DebugC("line", "Reply API failed, falling back to Push API") } } // Fall back to Push API - return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) + return nil, c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) } // SendMedia implements the channels.MediaSender interface. // LINE requires media to be accessible via public URL; since we only have local files, // we fall back to sending a text message with the filename/caption. // For full support, an external file hosting service would be needed. -func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } // LINE Messaging API requires publicly accessible URLs for media messages. @@ -539,11 +565,11 @@ func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag } if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil { - return err + return nil, err } } - return nil + return nil, nil } // buildTextMessage creates a text message object, optionally with quoteToken. @@ -645,7 +671,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) + req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken.String()) resp, err := c.apiClient.Do(req) if err != nil { @@ -670,7 +696,12 @@ func (c *LINEChannel) downloadContent(messageID, filename string) string { return utils.DownloadFile(url, filename, utils.DownloadOptions{ LoggerPrefix: "line", ExtraHeaders: map[string]string{ - "Authorization": "Bearer " + c.config.ChannelAccessToken, + "Authorization": "Bearer " + c.config.ChannelAccessToken.String(), }, }) } + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *LINEChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/line/line_test.go b/pkg/channels/line/line_test.go new file mode 100644 index 000000000..c5f4e9be2 --- /dev/null +++ b/pkg/channels/line/line_test.go @@ -0,0 +1,85 @@ +package line + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestWebhookRejectsOversizedBody(t *testing.T) { + ch := &LINEChannel{} + + oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized)) + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code) + } +} + +func TestWebhookAcceptsMaxBodySize(t *testing.T) { + ch := &LINEChannel{} + + body := bytes.Repeat([]byte("A"), maxWebhookBodySize) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + // Missing signature should be rejected, but the body size should not trigger 413. + if rec.Code != http.StatusForbidden { + t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code) + } +} + +func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) { + ch := &LINEChannel{} + + oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized)) + req.Header.Set("X-Line-Signature", "invalidsignature") + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code) + } +} + +func TestWebhookRejectsNonPostMethod(t *testing.T) { + ch := &LINEChannel{} + + req := httptest.NewRequest(http.MethodGet, "/webhook", nil) + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestWebhookRejectsInvalidSignature(t *testing.T) { + ch := &LINEChannel{ + config: &config.LINESettings{}, + } + + body := `{"events":[]}` + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) + req.Header.Set("X-Line-Signature", "invalidsignature") + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code) + } +} diff --git a/pkg/channels/magicform/init.go b/pkg/channels/magicform/init.go index ebfae004d..7aaeaeedc 100644 --- a/pkg/channels/magicform/init.go +++ b/pkg/channels/magicform/init.go @@ -7,7 +7,26 @@ import ( ) func init() { - channels.RegisterFactory("magicform", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewMagicFormChannel(cfg.Channels.MagicForm, cfg.Agents.Defaults.WorkspaceRoot, b) - }) + channels.RegisterFactory( + config.ChannelMagicForm, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + settings, ok := decoded.(*config.MagicFormSettings) + if !ok { + return nil, channels.ErrSendFailed + } + ch, err := NewMagicFormChannel(bc, settings, cfg.Agents.Defaults.WorkspaceRoot, b) + if err != nil { + return nil, err + } + if channelName != config.ChannelMagicForm { + ch.SetName(channelName) + } + return ch, nil + }, + ) } diff --git a/pkg/channels/magicform/magicform.go b/pkg/channels/magicform/magicform.go index e73026e72..16ff526ba 100644 --- a/pkg/channels/magicform/magicform.go +++ b/pkg/channels/magicform/magicform.go @@ -77,7 +77,7 @@ type requestContext struct { // MagicFormChannel implements the MagicForm channel plugin. type MagicFormChannel struct { *channels.BaseChannel - config config.MagicFormConfig + settings *config.MagicFormSettings workspaceRoot string // effective root: channel-level fallback to global httpClient *http.Client requests sync.Map // chatID → *requestContext @@ -87,18 +87,23 @@ type MagicFormChannel struct { // NewMagicFormChannel creates a new MagicForm channel. // globalWorkspaceRoot is the agents.defaults.workspace_root from the base config. -// The channel uses its own config.WorkspaceRoot if set, otherwise falls back to +// The channel uses its own settings.WorkspaceRoot if set, otherwise falls back to // globalWorkspaceRoot. If neither is configured, the constructor returns an error // because workspace overrides cannot be validated without a root boundary. -func NewMagicFormChannel(cfg config.MagicFormConfig, globalWorkspaceRoot string, msgBus *bus.MessageBus) (*MagicFormChannel, error) { +func NewMagicFormChannel( + bc *config.Channel, + settings *config.MagicFormSettings, + globalWorkspaceRoot string, + msgBus *bus.MessageBus, +) (*MagicFormChannel, error) { base := channels.NewBaseChannel( "magicform", - cfg, + settings, msgBus, - cfg.AllowFrom, + bc.AllowFrom, ) - effectiveRoot := cfg.WorkspaceRoot + effectiveRoot := settings.WorkspaceRoot if effectiveRoot == "" { effectiveRoot = globalWorkspaceRoot } @@ -111,7 +116,7 @@ func NewMagicFormChannel(cfg config.MagicFormConfig, globalWorkspaceRoot string, ch := &MagicFormChannel{ BaseChannel: base, - config: cfg, + settings: settings, workspaceRoot: effectiveRoot, httpClient: &http.Client{ Timeout: 30 * time.Second, @@ -145,8 +150,8 @@ func (c *MagicFormChannel) Stop(_ context.Context) error { // WebhookPath returns the HTTP path for the inbound webhook. func (c *MagicFormChannel) WebhookPath() string { - if c.config.WebhookPath != "" { - return c.config.WebhookPath + if c.settings.WebhookPath != "" { + return c.settings.WebhookPath } return "/hooks/magicform" } @@ -238,7 +243,8 @@ func (c *MagicFormChannel) resolveWorkspace(workspace string) (string, error) { // verifyToken checks the Authorization Bearer token using constant-time comparison. func (c *MagicFormChannel) verifyToken(r *http.Request) bool { - if c.config.Token == "" { + configured := c.settings.Token.String() + if configured == "" { return true // No token configured = allow all (dev mode) } @@ -248,7 +254,7 @@ func (c *MagicFormChannel) verifyToken(r *http.Request) bool { } token := strings.TrimPrefix(auth, "Bearer ") - return subtle.ConstantTimeCompare([]byte(token), []byte(c.config.Token)) == 1 + return subtle.ConstantTimeCompare([]byte(token), []byte(configured)) == 1 } // processWebhook handles an inbound webhook payload asynchronously. @@ -275,7 +281,6 @@ func (c *MagicFormChannel) processWebhook(ctx context.Context, p WebhookPayload) createdAt: time.Now(), }) - peer := bus.Peer{Kind: "direct", ID: p.ConversationID} sender := bus.SenderInfo{ Platform: "magicform", PlatformID: senderID, @@ -285,48 +290,53 @@ func (c *MagicFormChannel) processWebhook(ctx context.Context, p WebhookPayload) // Session key: per-stack per-conversation isolation sessionKey := fmt.Sprintf("agent:main:magicform:%s:%s", p.StackID, p.ConversationID) - metadata := map[string]string{ + // Stash tenant routing + multi-tenancy hints in Context.Raw — the agent loop + // reads workspace_override / config_dir / allowed_tools / allowed_skills from here. + raw := map[string]string{ "platform": "magicform", "stack_id": p.StackID, "conversation_id": p.ConversationID, } - if p.CallbackURL != "" { - metadata["callback_url"] = p.CallbackURL + raw["callback_url"] = p.CallbackURL } - - // Workspace override — agent loop will pick this up if p.Workspace != "" { - metadata["workspace_override"] = p.Workspace + raw["workspace_override"] = p.Workspace } - - // Config directory — agent loop reads config.json and copies bootstrap files if p.ConfigDir != "" { - metadata["config_dir"] = p.ConfigDir + raw["config_dir"] = p.ConfigDir } - - // Tool/skill filtering — passed via metadata, picked up by agent loop if len(p.AllowedTools) > 0 { - metadata["allowed_tools"] = strings.Join(trimSlice(p.AllowedTools), ",") + raw["allowed_tools"] = strings.Join(trimSlice(p.AllowedTools), ",") } if len(p.AllowedSkills) > 0 { - metadata["allowed_skills"] = strings.Join(trimSlice(p.AllowedSkills), ",") + raw["allowed_skills"] = strings.Join(trimSlice(p.AllowedSkills), ",") } messageID := fmt.Sprintf("mf-%s-%d", p.ConversationID, time.Now().UnixMilli()) + inboundCtx := bus.InboundContext{ + Channel: "magicform", + ChatID: chatID, + ChatType: "direct", + SpaceID: p.StackID, + SpaceType: "tenant", + SenderID: sender.CanonicalID, + MessageID: messageID, + Raw: raw, + } + // Build InboundMessage directly (not via HandleMessage) to set SessionKey. // MagicForm is API-to-API, so typing/reaction/placeholder don't apply. msg := bus.InboundMessage{ + Context: inboundCtx, + Sender: sender, + Content: p.Message, + SessionKey: sessionKey, Channel: "magicform", SenderID: sender.CanonicalID, - Sender: sender, ChatID: chatID, - Content: p.Message, - Peer: peer, MessageID: messageID, - SessionKey: sessionKey, - Metadata: metadata, } if err := c.Bus().PublishInbound(ctx, msg); err != nil { @@ -350,9 +360,9 @@ func trimSlice(s []string) []string { } // Send delivers the agent response back to MagicForm via HTTP callback. -func (c *MagicFormChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MagicFormChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // For progress/escalation messages, Load (keep) the context since the final @@ -362,13 +372,13 @@ func (c *MagicFormChannel) Send(ctx context.Context, msg bus.OutboundMessage) er if isFinal { val, ok := c.requests.LoadAndDelete(msg.ChatID) if !ok { - return fmt.Errorf("%w: no request context for chatID %s", channels.ErrSendFailed, msg.ChatID) + return nil, fmt.Errorf("%w: no request context for chatID %s", channels.ErrSendFailed, msg.ChatID) } reqCtx = val.(*requestContext) } else { val, ok := c.requests.Load(msg.ChatID) if !ok { - return fmt.Errorf("%w: no request context for chatID %s", channels.ErrSendFailed, msg.ChatID) + return nil, fmt.Errorf("%w: no request context for chatID %s", channels.ErrSendFailed, msg.ChatID) } reqCtx = val.(*requestContext) } @@ -376,11 +386,11 @@ func (c *MagicFormChannel) Send(ctx context.Context, msg bus.OutboundMessage) er // Resolve callback URL callbackURL := reqCtx.callbackURL if callbackURL == "" { - callbackURL = c.config.BackendURL + "/claw-agent/callback" + callbackURL = c.settings.BackendURL + "/claw-agent/callback" } if callbackURL == "" { - return fmt.Errorf("%w: no callback URL available", channels.ErrSendFailed) + return nil, fmt.Errorf("%w: no callback URL available", channels.ErrSendFailed) } // Build callback payload @@ -426,27 +436,27 @@ func (c *MagicFormChannel) Send(ctx context.Context, msg bus.OutboundMessage) er body, err := json.Marshal(payload) if err != nil { - return fmt.Errorf("%w: marshal callback payload: %v", channels.ErrSendFailed, err) + return nil, fmt.Errorf("%w: marshal callback payload: %v", channels.ErrSendFailed, err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, callbackURL, bytes.NewReader(body)) if err != nil { - return fmt.Errorf("%w: create callback request: %v", channels.ErrSendFailed, err) + return nil, fmt.Errorf("%w: create callback request: %v", channels.ErrSendFailed, err) } req.Header.Set("Content-Type", "application/json") - if c.config.Token != "" { - req.Header.Set("Authorization", "Bearer "+c.config.Token) + if tok := c.settings.Token.String(); tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) } resp, err := c.httpClient.Do(req) if err != nil { - return channels.ClassifyNetError(err) + return nil, channels.ClassifyNetError(err) } defer resp.Body.Close() if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(resp.Body) - return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("callback error: %s", respBody)) + return nil, channels.ClassifySendError(resp.StatusCode, fmt.Errorf("callback error: %s", respBody)) } logger.InfoCF("magicform", "Callback sent", @@ -456,7 +466,7 @@ func (c *MagicFormChannel) Send(ctx context.Context, msg bus.OutboundMessage) er "status": resp.StatusCode, }) - return nil + return nil, nil } // cleanupLoop periodically removes stale request contexts. diff --git a/pkg/channels/maixcam/init.go b/pkg/channels/maixcam/init.go index 5a269b22b..f2f7b910b 100644 --- a/pkg/channels/maixcam/init.go +++ b/pkg/channels/maixcam/init.go @@ -7,7 +7,19 @@ import ( ) func init() { - channels.RegisterFactory("maixcam", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewMaixCamChannel(cfg.Channels.MaixCam, b) - }) + channels.RegisterFactory( + config.ChannelMaixCam, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.MaixCamSettings) + if !ok { + return nil, channels.ErrSendFailed + } + return NewMaixCamChannel(bc, c, b) + }, + ) } diff --git a/pkg/channels/maixcam/maixcam.go b/pkg/channels/maixcam/maixcam.go index ff9a3ed1a..b81206c59 100644 --- a/pkg/channels/maixcam/maixcam.go +++ b/pkg/channels/maixcam/maixcam.go @@ -17,7 +17,7 @@ import ( type MaixCamChannel struct { *channels.BaseChannel - config config.MaixCamConfig + config *config.MaixCamSettings listener net.Listener ctx context.Context cancel context.CancelFunc @@ -32,13 +32,17 @@ type MaixCamMessage struct { Data map[string]any `json:"data"` } -func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { +func NewMaixCamChannel( + bc *config.Channel, + cfg *config.MaixCamSettings, + bus *bus.MessageBus, +) (*MaixCamChannel, error) { base := channels.NewBaseChannel( "maixcam", cfg, bus, - cfg.AllowFrom, - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + bc.AllowFrom, + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &MaixCamChannel{ @@ -196,17 +200,15 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { return } - c.HandleMessage( - c.ctx, - bus.Peer{Kind: "channel", ID: "default"}, - "", - senderID, - chatID, - content, - []string{}, - metadata, - sender, - ) + inboundCtx := bus.InboundContext{ + Channel: "maixcam", + ChatID: chatID, + ChatType: "channel", + SenderID: senderID, + Raw: metadata, + } + + c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, sender) } func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) { @@ -240,15 +242,15 @@ func (c *MaixCamChannel) Stop(ctx context.Context) error { return nil } -func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Check ctx before entering write path select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -257,7 +259,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro if len(c.clients) == 0 { logger.WarnC("maixcam", "No MaixCam devices connected") - return fmt.Errorf("no connected MaixCam devices") + return nil, fmt.Errorf("no connected MaixCam devices") } response := map[string]any{ @@ -269,7 +271,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro data, err := json.Marshal(response) if err != nil { - return fmt.Errorf("failed to marshal response: %w", err) + return nil, fmt.Errorf("failed to marshal response: %w", err) } var sendErr error @@ -285,5 +287,5 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro _ = conn.SetWriteDeadline(time.Time{}) } - return sendErr + return nil, sendErr } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 5406cb43b..06ccc5d76 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -11,7 +11,10 @@ import ( "errors" "fmt" "math" + "net" "net/http" + "sort" + "strings" "sync" "time" @@ -20,9 +23,11 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/utils" ) const ( @@ -63,6 +68,7 @@ var channelRateConfig = map[string]float64{ "slack": 1, "matrix": 2, "line": 10, + "qq": 5, "irc": 2, } @@ -79,21 +85,176 @@ type Manager struct { channels map[string]Channel workers map[string]*channelWorker bus *bus.MessageBus + runtimeEvents runtimeevents.Bus config *config.Config mediaStore media.MediaStore dispatchTask *asyncTask - mux *http.ServeMux + mux *dynamicServeMux httpServer *http.Server + httpListeners []net.Listener mu sync.RWMutex - placeholders sync.Map // "channel:chatID" → placeholderID (string) - typingStops sync.Map // "channel:chatID" → func() - reactionUndos sync.Map // "channel:chatID" → reactionEntry + placeholders sync.Map // "channel:chatID" → placeholderID (string) + typingStops sync.Map // "channel:chatID" → func() + reactionUndos sync.Map // "channel:chatID" → reactionEntry + streamActive sync.Map // "channel:chatID" → true (set when streamer.Finalize sent the message) + channelHashes map[string]string // channel name → config hash +} + +// ManagerOption configures a channel Manager. +type ManagerOption func(*Manager) + +// WithRuntimeEvents injects the runtime event bus used for channel observations. +func WithRuntimeEvents(eventBus runtimeevents.Bus) ManagerOption { + return func(m *Manager) { + m.runtimeEvents = eventBus + } +} + +// ChannelLifecyclePayload describes channel lifecycle runtime events. +type ChannelLifecyclePayload struct { + Type string `json:"type,omitempty"` + Error string `json:"error,omitempty"` +} + +// ChannelOutboundPayload describes channel outbound message runtime events. +type ChannelOutboundPayload struct { + Media bool `json:"media,omitempty"` + ContentLen int `json:"content_len,omitempty"` + MessageIDs []string `json:"message_ids,omitempty"` + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + Error string `json:"error,omitempty"` + Retries int `json:"retries,omitempty"` +} + +type toolFeedbackMessageTracker interface { + RecordToolFeedbackMessage(chatID, messageID, content string) + ClearToolFeedbackMessage(chatID string) +} + +type toolFeedbackMessageCleaner interface { + DismissToolFeedbackMessage(ctx context.Context, chatID string) +} + +type toolFeedbackMessageTargetResolver interface { + ToolFeedbackMessageChatID(chatID string, outboundCtx *bus.InboundContext) string +} + +type toolFeedbackMessageContentPreparer interface { + PrepareToolFeedbackMessageContent(content string) string } type asyncTask struct { cancel context.CancelFunc } +func outboundMessageChannel(msg bus.OutboundMessage) string { + return msg.Context.Channel +} + +func outboundMessageChatID(msg bus.OutboundMessage) string { + return msg.ChatID +} + +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func outboundMessageBypassesPlaceholderEdit(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + kind := strings.TrimSpace(msg.Context.Raw["message_kind"]) + return strings.EqualFold(kind, "thought") || strings.EqualFold(kind, "tool_calls") +} + +func outboundMediaChannel(msg bus.OutboundMediaMessage) string { + return msg.Context.Channel +} + +func outboundMediaChatID(msg bus.OutboundMediaMessage) string { + return msg.ChatID +} + +func trackedToolFeedbackMessageChatID(ch Channel, chatID string, outboundCtx *bus.InboundContext) string { + if resolver, ok := ch.(toolFeedbackMessageTargetResolver); ok { + if resolved := strings.TrimSpace(resolver.ToolFeedbackMessageChatID(chatID, outboundCtx)); resolved != "" { + return resolved + } + } + return strings.TrimSpace(chatID) +} + +func dismissTrackedToolFeedbackMessage( + ctx context.Context, + ch Channel, + chatID string, + outboundCtx *bus.InboundContext, +) { + trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, outboundCtx) + if trackedChatID == "" { + return + } + if cleaner, ok := ch.(toolFeedbackMessageCleaner); ok { + cleaner.DismissToolFeedbackMessage(ctx, trackedChatID) + return + } + if tracker, ok := ch.(toolFeedbackMessageTracker); ok { + tracker.ClearToolFeedbackMessage(trackedChatID) + } +} + +func clearTrackedToolFeedbackMessage( + ch Channel, + chatID string, + outboundCtx *bus.InboundContext, +) { + trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, outboundCtx) + if trackedChatID == "" { + return + } + if tracker, ok := ch.(toolFeedbackMessageTracker); ok { + tracker.ClearToolFeedbackMessage(trackedChatID) + } +} + +// DismissToolFeedback clears any tracked tool feedback animation for the +// given channel/chat. This is called when a turn ends without a final +// response (e.g., ResponseHandled tools) to stop orphaned animation goroutines. +// outboundCtx carries topic/thread info for channels that use scoped tracker +// keys (e.g., Telegram forum topics); may be nil for non-topic channels. +func (m *Manager) DismissToolFeedback( + ctx context.Context, channelName, chatID string, outboundCtx *bus.InboundContext, +) { + ch, ok := m.GetChannel(channelName) + if !ok { + return + } + dismissTrackedToolFeedbackMessage(ctx, ch, chatID, outboundCtx) +} + +func prepareToolFeedbackMessageContent(ch Channel, content string) string { + prepared := strings.TrimSpace(content) + if prepared == "" { + return "" + } + if preparer, ok := ch.(toolFeedbackMessageContentPreparer); ok { + if candidate := strings.TrimSpace(preparer.PrepareToolFeedbackMessageContent(prepared)); candidate != "" { + return candidate + } + } + return prepared +} + +func (m *Manager) toolFeedbackSeparateMessagesEnabled() bool { + if m == nil || m.config == nil { + return false + } + return m.config.Agents.Defaults.IsToolFeedbackSeparateMessagesEnabled() +} + // RecordPlaceholder registers a placeholder message for later editing. // Implements PlaceholderRecorder. func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { @@ -101,11 +262,50 @@ func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()}) } +// SendPlaceholder sends a "Thinking…" placeholder for the given channel/chatID +// and records it for later editing. Returns true if a placeholder was sent. +func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) bool { + m.mu.RLock() + ch, ok := m.channels[channel] + m.mu.RUnlock() + if !ok { + return false + } + pc, ok := ch.(PlaceholderCapable) + if !ok { + return false + } + phID, err := pc.SendPlaceholder(ctx, chatID) + if err != nil || phID == "" { + return false + } + m.RecordPlaceholder(channel, chatID, phID) + return true +} + // RecordTypingStop registers a typing stop function for later invocation. // Implements PlaceholderRecorder. func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { key := channel + ":" + chatID - m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()}) + entry := typingEntry{stop: stop, createdAt: time.Now()} + if previous, loaded := m.typingStops.Swap(key, entry); loaded { + if oldEntry, ok := previous.(typingEntry); ok && oldEntry.stop != nil { + oldEntry.stop() + } + } +} + +// InvokeTypingStop invokes the registered typing stop function for the given channel and chatID. +// It is safe to call even when no typing indicator is active (no-op). +// Used by the agent loop to stop typing when processing completes (success, error, or panic), +// regardless of whether an outbound message is published. +func (m *Manager) InvokeTypingStop(channel, chatID string) { + key := channel + ":" + chatID + if v, loaded := m.typingStops.LoadAndDelete(key); loaded { + if entry, ok := v.(typingEntry); ok { + entry.stop() + } + } } // RecordReactionUndo registers a reaction undo function for later invocation. @@ -116,9 +316,10 @@ func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { } // preSend handles typing stop, reaction undo, and placeholder editing before sending a message. -// Returns true if the message was edited into a placeholder (skip Send). -func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool { - key := name + ":" + msg.ChatID +// Returns the delivered message IDs and true when delivery completed before a normal Send. +func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) ([]string, bool) { + chatID := outboundMessageChatID(msg) + key := name + ":" + chatID // 1. Stop typing if v, loaded := m.typingStops.LoadAndDelete(key); loaded { @@ -134,53 +335,248 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } - // 3. Try editing placeholder + isToolFeedback := outboundMessageIsToolFeedback(msg) + separateToolFeedbackMessages := m.toolFeedbackSeparateMessagesEnabled() + + // 3. If a stream already finalized this chat, stale tool feedback must be + // dropped without consuming the final-response marker. Streaming finalization + // bypasses the worker queue, so older queued feedback can arrive before the + // normal final outbound message that cleans up the marker and placeholder. + if isToolFeedback { + if _, loaded := m.streamActive.Load(key); loaded { + return nil, true + } + } + + // 4. If a stream already finalized this message, delete the placeholder and skip send + if _, loaded := m.streamActive.LoadAndDelete(key); loaded { + if v, loaded := m.placeholders.LoadAndDelete(key); loaded { + if entry, ok := v.(placeholderEntry); ok && entry.id != "" { + // Prefer deleting the placeholder (cleaner UX than editing to same content) + if deleter, ok := ch.(MessageDeleter); ok { + deleter.DeleteMessage(ctx, chatID, entry.id) // best effort + } else if editor, ok := ch.(MessageEditor); ok { + editor.EditMessage(ctx, chatID, entry.id, msg.Content) // fallback + } + } + } + if !isToolFeedback { + if separateToolFeedbackMessages { + clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context) + } else { + dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context) + } + } + return nil, true + } + + if separateToolFeedbackMessages { + clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context) + } + + // 5. Try editing placeholder if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { + if isToolFeedback && separateToolFeedbackMessages { + if deleter, ok := ch.(MessageDeleter); ok { + deleter.DeleteMessage(ctx, chatID, entry.id) // best effort + } + return nil, false + } + if outboundMessageBypassesPlaceholderEdit(msg) { + if deleter, ok := ch.(MessageDeleter); ok { + deleter.DeleteMessage(ctx, chatID, entry.id) // best effort + } + return nil, false + } if editor, ok := ch.(MessageEditor); ok { - if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil { - return true // edited successfully, skip Send + content := msg.Content + trackedContent := msg.Content + if isToolFeedback { + trackedContent = prepareToolFeedbackMessageContent(ch, msg.Content) + content = InitialAnimatedToolFeedbackContent(trackedContent) + } + if err := editor.EditMessage(ctx, chatID, entry.id, content); err == nil { + trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, &msg.Context) + if tracker, ok := ch.(toolFeedbackMessageTracker); ok && isToolFeedback { + tracker.RecordToolFeedbackMessage(trackedChatID, entry.id, trackedContent) + } else if !isToolFeedback { + dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context) + } + return []string{entry.id}, true } // edit failed → fall through to normal Send } } } - return false + return nil, false } -func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) { - m := &Manager{ - channels: make(map[string]Channel), - workers: make(map[string]*channelWorker), - bus: messageBus, - config: cfg, - mediaStore: store, +// preSendMedia handles typing stop, reaction undo, and placeholder cleanup +// before sending media attachments. Unlike preSend for text messages, media +// delivery never edits the placeholder because there is no text payload to +// replace it with; it only attempts to delete the placeholder when possible. +func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.OutboundMediaMessage, ch Channel) { + chatID := outboundMediaChatID(msg) + key := name + ":" + chatID + + // 1. Stop typing + if v, loaded := m.typingStops.LoadAndDelete(key); loaded { + if entry, ok := v.(typingEntry); ok { + entry.stop() // idempotent, safe + } } - if err := m.initChannels(); err != nil { + // 2. Undo reaction + if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded { + if entry, ok := v.(reactionEntry); ok { + entry.undo() // idempotent, safe + } + } + + // 3. Clear any finalized stream marker for this chat before media delivery. + m.streamActive.LoadAndDelete(key) + + if m.toolFeedbackSeparateMessagesEnabled() { + clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context) + } + + // 4. Delete placeholder if present. + if v, loaded := m.placeholders.LoadAndDelete(key); loaded { + if entry, ok := v.(placeholderEntry); ok && entry.id != "" { + if deleter, ok := ch.(MessageDeleter); ok { + deleter.DeleteMessage(ctx, chatID, entry.id) // best effort + } + } + } +} + +func NewManager( + cfg *config.Config, + messageBus *bus.MessageBus, + store media.MediaStore, + opts ...ManagerOption, +) (*Manager, error) { + m := &Manager{ + channels: make(map[string]Channel), + workers: make(map[string]*channelWorker), + bus: messageBus, + config: cfg, + mediaStore: store, + channelHashes: make(map[string]string), + } + for _, opt := range opts { + if opt != nil { + opt(m) + } + } + + // Register as streaming delegate so the agent loop can obtain streamers + messageBus.SetStreamDelegate(m) + + if err := m.initChannels(&cfg.Channels); err != nil { return nil, err } + // Store initial config hashes for all channels + m.channelHashes = toChannelHashes(cfg) + return m, nil } -// initChannel is a helper that looks up a factory by name and creates the channel. -func (m *Manager) initChannel(name, displayName string) { - f, ok := getFactory(name) +// GetStreamer implements bus.StreamDelegate. +// It checks if the named channel supports streaming and returns a Streamer. +func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) (bus.Streamer, bool) { + m.mu.RLock() + ch, exists := m.channels[channelName] + m.mu.RUnlock() + + if !exists { + return nil, false + } + + sc, ok := ch.(StreamingCapable) + if !ok { + return nil, false + } + + streamer, err := sc.BeginStream(ctx, chatID) + if err != nil { + logger.DebugCF("channels", "Streaming unavailable, falling back to placeholder", map[string]any{ + "channel": channelName, + "error": err.Error(), + }) + return nil, false + } + + // Mark streamActive on Finalize so preSend knows to clean up the placeholder + key := channelName + ":" + chatID + return &finalizeHookStreamer{ + Streamer: streamer, + onFinalize: func(finalizeCtx context.Context) { + if m.toolFeedbackSeparateMessagesEnabled() { + clearTrackedToolFeedbackMessage( + ch, + chatID, + &bus.InboundContext{ + Channel: channelName, + ChatID: chatID, + }, + ) + } else { + dismissTrackedToolFeedbackMessage( + finalizeCtx, + ch, + chatID, + &bus.InboundContext{ + Channel: channelName, + ChatID: chatID, + }, + ) + } + m.streamActive.Store(key, true) + }, + }, true +} + +// finalizeHookStreamer wraps a Streamer to run a hook on Finalize. +type finalizeHookStreamer struct { + Streamer + onFinalize func(context.Context) +} + +func (s *finalizeHookStreamer) Finalize(ctx context.Context, content string) error { + if err := s.Streamer.Finalize(ctx, content); err != nil { + return err + } + if s.onFinalize != nil { + s.onFinalize(ctx) + } + return nil +} + +// initChannel is a helper that looks up a factory by type name and creates the channel. +// typeName is the channel type used for factory lookup (e.g., "telegram"). +// channelName is the config map key used as the channel's runtime name (e.g., "my_telegram"). +func (m *Manager) initChannel(typeName, channelName string) { + f, ok := getFactory(typeName) if !ok { logger.WarnCF("channels", "Factory not registered", map[string]any{ - "channel": displayName, + "channel": channelName, + "type": typeName, }) return } logger.DebugCF("channels", "Attempting to initialize channel", map[string]any{ - "channel": displayName, + "channel": channelName, + "type": typeName, }) - ch, err := f(m.config, m.bus) + ch, err := f(channelName, typeName, m.config, m.bus) if err != nil { logger.ErrorCF("channels", "Failed to initialize channel", map[string]any{ - "channel": displayName, + "channel": channelName, + "type": typeName, "error": err.Error(), }) } else { @@ -198,90 +594,109 @@ func (m *Manager) initChannel(name, displayName string) { if setter, ok := ch.(interface{ SetOwner(ch Channel) }); ok { setter.SetOwner(ch) } - m.channels[name] = ch + m.channels[channelName] = ch + m.publishChannelEvent( + runtimeevents.KindChannelLifecycleInitialized, + channelName, + runtimeevents.Scope{Channel: channelName}, + runtimeevents.SeverityInfo, + ChannelLifecyclePayload{Type: typeName}, + ) logger.InfoCF("channels", "Channel enabled successfully", map[string]any{ - "channel": displayName, + "channel": channelName, + "type": typeName, }) } } -func (m *Manager) initChannels() error { +func (m *Manager) getChannelConfigAndEnabled(channelName string) (*config.Channel, bool) { + bc, ok := m.config.Channels[channelName] + if !ok || bc == nil { + return nil, false + } + if !bc.Enabled { + return bc, false + } + + // Use Type to determine the config struct for validation. + // The map key (channelName) is the config key, which may differ from the type. + channelType := bc.Type + if channelType == "" { + channelType = channelName + } + + // Settings have already been decoded by InitChannelList, so we just need to + // type-assert and check the relevant fields. + decoded, err := bc.GetDecoded() + if err != nil { + return bc, false + } + //nolint:revive + switch settings := decoded.(type) { + case *config.WhatsAppSettings: + if channelType == config.ChannelWhatsApp { + return bc, settings.BridgeURL != "" + } + return bc, channelType == config.ChannelWhatsAppNative && settings.UseNative + case *config.MatrixSettings: + return bc, settings.Homeserver != "" && settings.UserID != "" && settings.AccessToken.String() != "" + case *config.WeComSettings: + return bc, settings.BotID != "" && settings.Secret.String() != "" + case *config.PicoClientSettings: + return bc, settings.URL != "" + case *config.DingTalkSettings: + return bc, settings.ClientID != "" + case *config.SlackSettings: + return bc, settings.BotToken.String() != "" + case *config.WeixinSettings: + return bc, settings.Token.String() != "" + case *config.PicoSettings: + return bc, settings.Token.String() != "" + case *config.IRCSettings: + return bc, settings.Server != "" + case *config.LINESettings: + return bc, settings.ChannelAccessToken.String() != "" + case *config.OneBotSettings: + return bc, settings.WSUrl != "" + case *config.QQSettings: + return bc, settings.AppSecret.String() != "" + case *config.TelegramSettings: + return bc, settings.Token.String() != "" + case *config.FeishuSettings: + return bc, settings.AppSecret.String() != "" + case *config.MaixCamSettings: + return bc, true + case *config.TeamsWebhookSettings: + return bc, true + case *config.DiscordSettings: + return bc, settings.Token.String() != "" + case *config.VKSettings: + return bc, settings.GroupID != 0 && settings.Token.String() != "" + case *config.MagicFormSettings: + return bc, settings.Token.String() != "" + } + + return bc, bc.Enabled +} + +// initChannels initializes all enabled channels based on the configuration. +// It iterates config entries and uses bc.Type to look up the appropriate factory. +func (m *Manager) initChannels(channels *config.ChannelsConfig) error { logger.InfoC("channels", "Initializing channel manager") - if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" { - m.initChannel("telegram", "Telegram") - } - - if m.config.Channels.WhatsApp.Enabled { - waCfg := m.config.Channels.WhatsApp - if waCfg.UseNative { - m.initChannel("whatsapp_native", "WhatsApp Native") - } else if waCfg.BridgeURL != "" { - m.initChannel("whatsapp", "WhatsApp") + for name, bc := range *channels { + if !bc.Enabled { + continue } - } - - if m.config.Channels.Feishu.Enabled { - m.initChannel("feishu", "Feishu") - } - - if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" { - m.initChannel("discord", "Discord") - } - - if m.config.Channels.MaixCam.Enabled { - m.initChannel("maixcam", "MaixCam") - } - - if m.config.Channels.QQ.Enabled { - m.initChannel("qq", "QQ") - } - - if m.config.Channels.DingTalk.Enabled && m.config.Channels.DingTalk.ClientID != "" { - m.initChannel("dingtalk", "DingTalk") - } - - if m.config.Channels.Slack.Enabled && m.config.Channels.Slack.BotToken != "" { - m.initChannel("slack", "Slack") - } - - if m.config.Channels.Matrix.Enabled && - m.config.Channels.Matrix.Homeserver != "" && - m.config.Channels.Matrix.UserID != "" && - m.config.Channels.Matrix.AccessToken != "" { - m.initChannel("matrix", "Matrix") - } - - if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" { - m.initChannel("line", "LINE") - } - - if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" { - m.initChannel("onebot", "OneBot") - } - - if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" { - m.initChannel("wecom", "WeCom") - } - - if m.config.Channels.WeComAIBot.Enabled && m.config.Channels.WeComAIBot.Token != "" { - m.initChannel("wecom_aibot", "WeCom AI Bot") - } - - if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" { - m.initChannel("wecom_app", "WeCom App") - } - - if m.config.Channels.Pico.Enabled && m.config.Channels.Pico.Token != "" { - m.initChannel("pico", "Pico") - } - - if m.config.Channels.MagicForm.Enabled && m.config.Channels.MagicForm.Token != "" { - m.initChannel("magicform", "MagicForm") - } - - if m.config.Channels.IRC.Enabled && m.config.Channels.IRC.Server != "" { - m.initChannel("irc", "IRC") + _, ready := m.getChannelConfigAndEnabled(name) + if !ready { + continue + } + typeName := bc.Type + if typeName == "" { + typeName = name + } + m.initChannel(typeName, name) } logger.InfoCF("channels", "Channel initialization completed", map[string]any{ @@ -295,7 +710,13 @@ func (m *Manager) initChannels() error { // It registers health endpoints from the health server and discovers channels // that implement WebhookHandler and/or HealthChecker to register their handlers. func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) { - m.mux = http.NewServeMux() + m.SetupHTTPServerListeners(nil, addr, healthServer) +} + +// SetupHTTPServerListeners creates a shared HTTP server on pre-opened listeners. +// When listeners is empty it falls back to Addr-based ListenAndServe behavior. +func (m *Manager) SetupHTTPServerListeners(listeners []net.Listener, addr string, healthServer *health.Server) { + m.mux = newDynamicServeMux() // Register health endpoints if healthServer != nil { @@ -303,22 +724,7 @@ func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) { } // Discover and register webhook handlers and health checkers - for name, ch := range m.channels { - if wh, ok := ch.(WebhookHandler); ok { - m.mux.Handle(wh.WebhookPath(), wh) - logger.InfoCF("channels", "Webhook handler registered", map[string]any{ - "channel": name, - "path": wh.WebhookPath(), - }) - } - if hc, ok := ch.(HealthChecker); ok { - m.mux.HandleFunc(hc.HealthPath(), hc.HealthHandler) - logger.InfoCF("channels", "Health endpoint registered", map[string]any{ - "channel": name, - "path": hc.HealthPath(), - }) - } - } + m.registerHTTPHandlersLocked() m.httpServer = &http.Server{ Addr: addr, @@ -326,6 +732,68 @@ func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) { ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, } + m.httpListeners = append([]net.Listener(nil), listeners...) +} + +// registerHTTPHandlersLocked registers webhook and health-check handlers for +// all channels currently in m.channels. Caller must hold m.mu (or ensure +// exclusive access). +func (m *Manager) registerHTTPHandlersLocked() { + for name, ch := range m.channels { + m.registerChannelHTTPHandler(name, ch) + } +} + +// registerChannelHTTPHandler registers the webhook/health handlers for a +// single channel onto m.mux. +func (m *Manager) registerChannelHTTPHandler(name string, ch Channel) { + if wh, ok := ch.(WebhookHandler); ok { + m.mux.Handle(wh.WebhookPath(), wh) + m.publishChannelEvent( + runtimeevents.KindChannelWebhookRegistered, + name, + runtimeevents.Scope{Channel: name}, + runtimeevents.SeverityInfo, + ChannelLifecyclePayload{Type: channelTypeForEvent(m, name)}, + ) + logger.InfoCF("channels", "Webhook handler registered", map[string]any{ + "channel": name, + "path": wh.WebhookPath(), + }) + } + if hc, ok := ch.(HealthChecker); ok { + m.mux.HandleFunc(hc.HealthPath(), hc.HealthHandler) + logger.InfoCF("channels", "Health endpoint registered", map[string]any{ + "channel": name, + "path": hc.HealthPath(), + }) + } +} + +// unregisterChannelHTTPHandler removes the webhook/health handlers for a +// single channel from m.mux. +func (m *Manager) unregisterChannelHTTPHandler(name string, ch Channel) { + if wh, ok := ch.(WebhookHandler); ok { + m.mux.Unhandle(wh.WebhookPath()) + m.publishChannelEvent( + runtimeevents.KindChannelWebhookUnregistered, + name, + runtimeevents.Scope{Channel: name}, + runtimeevents.SeverityInfo, + ChannelLifecyclePayload{Type: channelTypeForEvent(m, name)}, + ) + logger.InfoCF("channels", "Webhook handler unregistered", map[string]any{ + "channel": name, + "path": wh.WebhookPath(), + }) + } + if hc, ok := ch.(HealthChecker); ok { + m.mux.Unhandle(hc.HealthPath()) + logger.InfoCF("channels", "Health endpoint unregistered", map[string]any{ + "channel": name, + "path": hc.HealthPath(), + }) + } } func (m *Manager) StartAll(ctx context.Context) error { @@ -334,13 +802,14 @@ func (m *Manager) StartAll(ctx context.Context) error { if len(m.channels) == 0 { logger.WarnC("channels", "No channels enabled") - return errors.New("no channels enabled") } logger.InfoC("channels", "Starting all channels") dispatchCtx, cancel := context.WithCancel(ctx) m.dispatchTask = &asyncTask{cancel: cancel} + failedStarts := make([]error, 0, len(m.channels)) + failedNames := make([]string, 0, len(m.channels)) for name, channel := range m.channels { logger.InfoCF("channels", "Starting channel", map[string]any{ @@ -351,13 +820,65 @@ func (m *Manager) StartAll(ctx context.Context) error { "channel": name, "error": err.Error(), }) + m.publishChannelEvent( + runtimeevents.KindChannelLifecycleStartFailed, + name, + runtimeevents.Scope{Channel: name}, + runtimeevents.SeverityError, + ChannelLifecyclePayload{Type: channelTypeForEvent(m, name), Error: err.Error()}, + ) + failedStarts = append(failedStarts, fmt.Errorf("channel %s: %w", name, err)) + failedNames = append(failedNames, name) continue } // Lazily create worker only after channel starts successfully - w := newChannelWorker(name, channel) + channelType := name + if m.config != nil { + if bc := m.config.Channels.Get(name); bc != nil && bc.Type != "" { + channelType = bc.Type + } + } + w := newChannelWorker(name, channel, channelType) m.workers[name] = w go m.runWorker(dispatchCtx, name, w) go m.runMediaWorker(dispatchCtx, name, w) + m.publishChannelEvent( + runtimeevents.KindChannelLifecycleStarted, + name, + runtimeevents.Scope{Channel: name}, + runtimeevents.SeverityInfo, + ChannelLifecyclePayload{Type: channelType}, + ) + } + + if len(m.channels) > 0 && len(m.workers) == 0 { + if m.dispatchTask != nil { + m.dispatchTask.cancel() + m.dispatchTask = nil + } + + sort.Strings(failedNames) + if len(failedStarts) == 0 { + return fmt.Errorf("failed to start any enabled channels") + } + + logger.ErrorCF("channels", "All enabled channels failed to start", map[string]any{ + "failed": len(failedNames), + "total": len(m.channels), + "failed_channels": failedNames, + }) + + return fmt.Errorf("failed to start any enabled channels: %w", errors.Join(failedStarts...)) + } + + if len(failedNames) > 0 { + sort.Strings(failedNames) + logger.WarnCF("channels", "Some channels failed to start", map[string]any{ + "failed": len(failedNames), + "started": len(m.workers), + "total": len(m.channels), + "failed_channels": failedNames, + }) } // Start the dispatcher that reads from the bus and routes to workers @@ -369,19 +890,40 @@ func (m *Manager) StartAll(ctx context.Context) error { // Start shared HTTP server if configured if m.httpServer != nil { - go func() { - logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{ - "addr": m.httpServer.Addr, - }) - if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("channels", "Shared HTTP server error", map[string]any{ - "error": err.Error(), - }) + if len(m.httpListeners) > 0 { + for _, listener := range m.httpListeners { + ln := listener + go func() { + logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{ + "addr": ln.Addr().String(), + }) + if err := m.httpServer.Serve(ln); err != nil && err != http.ErrServerClosed { + logger.FatalCF("channels", "Shared HTTP server error", map[string]any{ + "addr": ln.Addr().String(), + "error": err.Error(), + }) + } + }() } - }() + } else { + go func() { + logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{ + "addr": m.httpServer.Addr, + }) + if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.FatalCF("channels", "Shared HTTP server error", map[string]any{ + "error": err.Error(), + }) + } + }() + } } - logger.InfoC("channels", "All channels started") + logger.InfoCF("channels", "Channel startup completed", map[string]any{ + "started": len(m.workers), + "failed": len(failedNames), + "total": len(m.channels), + }) return nil } @@ -401,6 +943,7 @@ func (m *Manager) StopAll(ctx context.Context) error { }) } m.httpServer = nil + m.httpListeners = nil } // Cancel dispatcher @@ -442,7 +985,15 @@ func (m *Manager) StopAll(ctx context.Context) error { "channel": name, "error": err.Error(), }) + continue } + m.publishChannelEvent( + runtimeevents.KindChannelLifecycleStopped, + name, + runtimeevents.Scope{Channel: name}, + runtimeevents.SeverityInfo, + ChannelLifecyclePayload{Type: channelTypeForEvent(m, name)}, + ) } logger.InfoC("channels", "All channels stopped") @@ -450,10 +1001,10 @@ func (m *Manager) StopAll(ctx context.Context) error { } // newChannelWorker creates a channelWorker with a rate limiter configured -// for the given channel name. -func newChannelWorker(name string, ch Channel) *channelWorker { +// for the given channel type. channelType is used for rate limit lookup. +func newChannelWorker(name string, ch Channel, channelType string) *channelWorker { rateVal := float64(defaultRateLimit) - if r, ok := channelRateConfig[name]; ok { + if r, ok := channelRateConfig[channelType]; ok { rateVal = r } burst := int(math.Max(1, math.Ceil(rateVal/2))) @@ -468,8 +1019,10 @@ func newChannelWorker(name string, ch Channel) *channelWorker { } } -// runWorker processes outbound messages for a single channel, splitting -// messages that exceed the channel's maximum message length. +// runWorker processes outbound messages for a single channel. +// Message processing follows this order: +// 1. SplitByMarker (if enabled in config) - LLM semantic marker-based splitting +// 2. SplitMessage - channel-specific length-based splitting (MaxMessageLength) func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) { defer close(w.done) for { @@ -482,15 +1035,32 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) if mlp, ok := w.ch.(MessageLengthProvider); ok { maxLen = mlp.MaxMessageLength() } - if maxLen > 0 && len([]rune(msg.Content)) > maxLen { - chunks := SplitMessage(msg.Content, maxLen) - for _, chunk := range chunks { - chunkMsg := msg - chunkMsg.Content = chunk - m.sendWithRetry(ctx, name, w, chunkMsg) + + // Collect all message chunks to send + var chunks []string + + // Step 1: Try marker-based splitting if enabled. + // Tool feedback must stay a single message, so it skips marker splitting. + if m.config != nil && m.config.Agents.Defaults.SplitOnMarker && !outboundMessageIsToolFeedback(msg) { + if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 { + for _, chunk := range markerChunks { + chunkMsg := msg + chunkMsg.Content = chunk + chunks = append(chunks, splitOutboundMessageContent(chunkMsg, maxLen)...) + } } - } else { - m.sendWithRetry(ctx, name, w, msg) + } + + // Step 2: Fallback to length-based splitting if no chunks from marker + if len(chunks) == 0 { + chunks = splitOutboundMessageContent(msg, maxLen) + } + + // Step 3: Send all chunks + for _, chunk := range chunks { + chunkMsg := msg + chunkMsg.Content = chunk + m.sendWithRetry(ctx, name, w, chunkMsg) } case <-ctx.Done(): return @@ -498,28 +1068,68 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) } } +// splitOutboundMessageContent splits regular outbound content by maxLen, but +// keeps tool feedback in a single message by truncating the explanation body. +func splitOutboundMessageContent(msg bus.OutboundMessage, maxLen int) []string { + if maxLen > 0 { + if outboundMessageIsToolFeedback(msg) { + animationSafeLen := maxLen - MaxToolFeedbackAnimationFrameLength() + if animationSafeLen <= 0 { + animationSafeLen = maxLen + } + if len([]rune(msg.Content)) > animationSafeLen { + return []string{utils.FitToolFeedbackMessage(msg.Content, animationSafeLen)} + } + return []string{msg.Content} + } + if len([]rune(msg.Content)) > maxLen { + return SplitMessage(msg.Content, maxLen) + } + } + return []string{msg.Content} +} + // sendWithRetry sends a message through the channel with rate limiting and // retry logic. It classifies errors to determine the retry strategy: // - ErrNotRunning / ErrSendFailed: permanent, no retry // - ErrRateLimit: fixed delay retry // - ErrTemporary / unknown: exponential backoff retry -func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) { +func (m *Manager) sendWithRetry( + ctx context.Context, + name string, + w *channelWorker, + msg bus.OutboundMessage, +) ([]string, bool) { // Rate limit: wait for token if err := w.limiter.Wait(ctx); err != nil { // ctx canceled, shutting down - return + m.publishChannelEvent( + runtimeevents.KindChannelRateLimited, + name, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityWarn, + ChannelOutboundPayload{ + ContentLen: len([]rune(msg.Content)), + ReplyToMessageID: msg.ReplyToMessageID, + Error: err.Error(), + }, + ) + return nil, false } // Pre-send: stop typing and try to edit placeholder - if m.preSend(ctx, name, msg, w.ch) { - return // placeholder was edited successfully, skip Send + if msgIDs, handled := m.preSend(ctx, name, msg, w.ch); handled { + m.publishOutboundSent(name, msg, msgIDs) + return msgIDs, true } var lastErr error + var msgIDs []string for attempt := 0; attempt <= maxRetries; attempt++ { - lastErr = w.ch.Send(ctx, msg) + msgIDs, lastErr = w.ch.Send(ctx, msg) if lastErr == nil { - return + m.publishOutboundSent(name, msg, msgIDs) + return msgIDs, true } // Permanent failures — don't retry @@ -538,7 +1148,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork case <-time.After(rateLimitDelay): continue case <-ctx.Done(): - return + return nil, false } } @@ -547,23 +1157,26 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork select { case <-time.After(backoff): case <-ctx.Done(): - return + return nil, false } } // All retries exhausted or permanent failure logger.ErrorCF("channels", "Send failed", map[string]any{ "channel": name, - "chat_id": msg.ChatID, + "chat_id": outboundMessageChatID(msg), "error": lastErr.Error(), "retries": maxRetries, }) + m.publishOutboundFailed(name, msg, lastErr, false) + + return nil, false } func dispatchLoop[M any]( ctx context.Context, m *Manager, - subscribe func(context.Context) (M, bool), + ch <-chan M, getChannel func(M) string, enqueue func(context.Context, *channelWorker, M) bool, startMsg, stopMsg, unknownMsg, noWorkerMsg string, @@ -571,35 +1184,41 @@ func dispatchLoop[M any]( logger.InfoC("channels", startMsg) for { - msg, ok := subscribe(ctx) - if !ok { + select { + case <-ctx.Done(): logger.InfoC("channels", stopMsg) return - } - channel := getChannel(msg) - - // Silently skip internal channels - if constants.IsInternalChannel(channel) { - continue - } - - m.mu.RLock() - _, exists := m.channels[channel] - w, wExists := m.workers[channel] - m.mu.RUnlock() - - if !exists { - logger.WarnCF("channels", unknownMsg, map[string]any{"channel": channel}) - continue - } - - if wExists && w != nil { - if !enqueue(ctx, w, msg) { + case msg, ok := <-ch: + if !ok { + logger.InfoC("channels", stopMsg) return } - } else if exists { - logger.WarnCF("channels", noWorkerMsg, map[string]any{"channel": channel}) + + channel := getChannel(msg) + + // Silently skip internal channels + if constants.IsInternalChannel(channel) { + continue + } + + m.mu.RLock() + _, exists := m.channels[channel] + w, wExists := m.workers[channel] + m.mu.RUnlock() + + if !exists { + logger.WarnCF("channels", unknownMsg, map[string]any{"channel": channel}) + continue + } + + if wExists && w != nil { + if !enqueue(ctx, w, msg) { + return + } + } else if exists { + logger.WarnCF("channels", noWorkerMsg, map[string]any{"channel": channel}) + } } } } @@ -607,11 +1226,12 @@ func dispatchLoop[M any]( func (m *Manager) dispatchOutbound(ctx context.Context) { dispatchLoop( ctx, m, - m.bus.SubscribeOutbound, - func(msg bus.OutboundMessage) string { return msg.Channel }, + m.bus.OutboundChan(), + func(msg bus.OutboundMessage) string { return outboundMessageChannel(msg) }, func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool { select { case w.queue <- msg: + m.publishOutboundQueued(outboundMessageChannel(msg), msg) return true case <-ctx.Done(): return false @@ -627,11 +1247,12 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { func (m *Manager) dispatchOutboundMedia(ctx context.Context) { dispatchLoop( ctx, m, - m.bus.SubscribeOutboundMedia, - func(msg bus.OutboundMediaMessage) string { return msg.Channel }, + m.bus.OutboundMediaChan(), + func(msg bus.OutboundMediaMessage) string { return outboundMediaChannel(msg) }, func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool { select { case w.mediaQueue <- msg: + m.publishOutboundMediaQueued(outboundMediaChannel(msg), msg) return true case <-ctx.Done(): return false @@ -653,7 +1274,7 @@ func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWor if !ok { return } - m.sendMediaWithRetry(ctx, name, w, msg) + _, _ = m.sendMediaWithRetry(ctx, name, w, msg) case <-ctx.Done(): return } @@ -661,26 +1282,49 @@ func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWor } // sendMediaWithRetry sends a media message through the channel with rate limiting and -// retry logic. If the channel does not implement MediaSender, it silently skips. -func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMediaMessage) { +// retry logic. It returns the message IDs and nil on success, or nil and the last error +// after retries, including when the channel does not support MediaSender. +func (m *Manager) sendMediaWithRetry( + ctx context.Context, + name string, + w *channelWorker, + msg bus.OutboundMediaMessage, +) ([]string, error) { ms, ok := w.ch.(MediaSender) if !ok { - logger.DebugCF("channels", "Channel does not support MediaSender, skipping media", map[string]any{ + err := fmt.Errorf("channel %q does not support media sending", name) + logger.WarnCF("channels", "Channel does not support MediaSender", map[string]any{ "channel": name, + "error": err.Error(), }) - return + return nil, err } // Rate limit: wait for token if err := w.limiter.Wait(ctx); err != nil { - return + m.publishChannelEvent( + runtimeevents.KindChannelRateLimited, + name, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityWarn, + ChannelOutboundPayload{ + Media: true, + Error: err.Error(), + }, + ) + return nil, err } + // Pre-send: stop typing and clean up any placeholder before sending media. + m.preSendMedia(ctx, name, msg, w.ch) + var lastErr error + var msgIDs []string for attempt := 0; attempt <= maxRetries; attempt++ { - lastErr = ms.SendMedia(ctx, msg) + msgIDs, lastErr = ms.SendMedia(ctx, msg) if lastErr == nil { - return + m.publishOutboundMediaSent(name, msg, msgIDs) + return msgIDs, nil } // Permanent failures — don't retry @@ -699,7 +1343,7 @@ func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channe case <-time.After(rateLimitDelay): continue case <-ctx.Done(): - return + return nil, ctx.Err() } } @@ -708,17 +1352,19 @@ func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channe select { case <-time.After(backoff): case <-ctx.Done(): - return + return nil, ctx.Err() } } // All retries exhausted or permanent failure logger.ErrorCF("channels", "SendMedia failed", map[string]any{ "channel": name, - "chat_id": msg.ChatID, + "chat_id": outboundMediaChatID(msg), "error": lastErr.Error(), "retries": maxRetries, }) + m.publishOutboundMediaFailed(name, msg, lastErr) + return nil, lastErr } // runTTLJanitor periodically scans the typingStops and placeholders maps @@ -797,15 +1443,121 @@ func (m *Manager) GetEnabledChannels() []string { return names } +// Reload updates the config reference without restarting channels. +// This is used when channel config hasn't changed but other parts of the config have. +func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Save old config so we can revert on error. + oldConfig := m.config + + // Update config early: initChannel uses m.config via factory(m.config, m.bus). + m.config = cfg + + list := toChannelHashes(cfg) + added, removed := compareChannels(m.channelHashes, list) + + deferFuncs := make([]func(), 0, len(removed)+len(added)) + for _, name := range removed { + // Stop all channels + channel := m.channels[name] + logger.InfoCF("channels", "Stopping channel", map[string]any{ + "channel": name, + }) + if err := channel.Stop(ctx); err != nil { + logger.ErrorCF("channels", "Error stopping channel", map[string]any{ + "channel": name, + "error": err.Error(), + }) + } + deferFuncs = append(deferFuncs, func() { + m.UnregisterChannel(name) + }) + } + dispatchCtx, cancel := context.WithCancel(ctx) + m.dispatchTask = &asyncTask{cancel: cancel} + cc, err := toChannelConfig(cfg, added) + if err != nil { + logger.ErrorC("channels", fmt.Sprintf("toChannelConfig error: %v", err)) + m.config = oldConfig + cancel() + return err + } + err = m.initChannels(cc) + if err != nil { + logger.ErrorC("channels", fmt.Sprintf("initChannels error: %v", err)) + m.config = oldConfig + cancel() + return err + } + for _, name := range added { + channel := m.channels[name] + logger.InfoCF("channels", "Starting channel", map[string]any{ + "channel": name, + }) + if err := channel.Start(ctx); err != nil { + logger.ErrorCF("channels", "Failed to start channel", map[string]any{ + "channel": name, + "error": err.Error(), + }) + m.publishChannelEvent( + runtimeevents.KindChannelLifecycleStartFailed, + name, + runtimeevents.Scope{Channel: name}, + runtimeevents.SeverityError, + ChannelLifecyclePayload{Type: channelTypeForEvent(m, name), Error: err.Error()}, + ) + continue + } + // Lazily create worker only after channel starts successfully + channelType := name + if m.config != nil { + if bc := m.config.Channels.Get(name); bc != nil && bc.Type != "" { + channelType = bc.Type + } + } + w := newChannelWorker(name, channel, channelType) + m.workers[name] = w + go m.runWorker(dispatchCtx, name, w) + go m.runMediaWorker(dispatchCtx, name, w) + m.publishChannelEvent( + runtimeevents.KindChannelLifecycleStarted, + name, + runtimeevents.Scope{Channel: name}, + runtimeevents.SeverityInfo, + ChannelLifecyclePayload{Type: channelType}, + ) + deferFuncs = append(deferFuncs, func() { + m.RegisterChannel(name, channel) + }) + } + + // Commit hashes only on full success. + m.channelHashes = list + go func() { + for _, f := range deferFuncs { + f() + } + }() + return nil +} + func (m *Manager) RegisterChannel(name string, channel Channel) { m.mu.Lock() defer m.mu.Unlock() m.channels[name] = channel + if m.mux != nil { + m.registerChannelHTTPHandler(name, channel) + } } func (m *Manager) UnregisterChannel(name string) { m.mu.Lock() defer m.mu.Unlock() + if ch, ok := m.channels[name]; ok && m.mux != nil { + m.unregisterChannelHTTPHandler(name, ch) + } if w, ok := m.workers[name]; ok && w != nil { close(w.queue) <-w.done @@ -816,6 +1568,69 @@ func (m *Manager) UnregisterChannel(name string) { delete(m.channels, name) } +// SendMessage sends an outbound message synchronously through the channel +// worker's rate limiter and retry logic. It blocks until the message is +// delivered (or all retries are exhausted), which preserves ordering when +// a subsequent operation depends on the message having been sent. +func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error { + msg = bus.NormalizeOutboundMessage(msg) + channelName := outboundMessageChannel(msg) + + m.mu.RLock() + _, exists := m.channels[channelName] + w, wExists := m.workers[channelName] + m.mu.RUnlock() + + if !exists { + return fmt.Errorf("channel %s not found", channelName) + } + if !wExists || w == nil { + return fmt.Errorf("channel %s has no active worker", channelName) + } + + maxLen := 0 + if mlp, ok := w.ch.(MessageLengthProvider); ok { + maxLen = mlp.MaxMessageLength() + } + if chunks := splitOutboundMessageContent(msg, maxLen); len(chunks) > 1 { + for _, chunk := range chunks { + chunkMsg := msg + chunkMsg.Content = chunk + m.sendWithRetry(ctx, channelName, w, chunkMsg) + } + } else { + if len(chunks) == 1 { + msg.Content = chunks[0] + } + m.sendWithRetry(ctx, channelName, w, msg) + } + return nil +} + +// SendMedia sends outbound media synchronously through the channel worker's +// rate limiter and retry logic. It blocks until the media is delivered (or all +// retries are exhausted), which preserves ordering when later agent behavior +// depends on actual media delivery. +func (m *Manager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + msg = bus.NormalizeOutboundMediaMessage(msg) + channelName := outboundMediaChannel(msg) + + m.mu.RLock() + _, exists := m.channels[channelName] + w, wExists := m.workers[channelName] + m.mu.RUnlock() + + if !exists { + return fmt.Errorf("channel %s not found", channelName) + } + if !wExists || w == nil { + return fmt.Errorf("channel %s has no active worker", channelName) + } + + _, err := m.sendMediaWithRetry(ctx, channelName, w, msg) + return err +} + func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { m.mu.RLock() _, exists := m.channels[channelName] @@ -827,14 +1642,15 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten } msg := bus.OutboundMessage{ - Channel: channelName, - ChatID: chatID, + Context: bus.NewOutboundContext(channelName, chatID, ""), Content: content, } + msg = bus.NormalizeOutboundMessage(msg) if wExists && w != nil { select { case w.queue <- msg: + m.publishOutboundQueued(channelName, msg) return nil case <-ctx.Done(): return ctx.Err() @@ -843,5 +1659,6 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten // Fallback: direct send (should not happen) channel, _ := m.channels[channelName] - return channel.Send(ctx, msg) + _, err := channel.Send(ctx, msg) + return err } diff --git a/pkg/channels/manager_channel.go b/pkg/channels/manager_channel.go new file mode 100644 index 000000000..1f5978e7d --- /dev/null +++ b/pkg/channels/manager_channel.go @@ -0,0 +1,137 @@ +package channels + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func toChannelHashes(cfg *config.Config) map[string]string { + result := make(map[string]string) + ch := cfg.Channels + // should not be error + marshal, _ := json.Marshal(ch) + var channelConfig map[string]map[string]any + _ = json.Unmarshal(marshal, &channelConfig) + + for key, value := range channelConfig { + if !value["enabled"].(bool) { + continue + } + hiddenValues(key, value, ch.Get(key)) + valueBytes, _ := json.Marshal(value) + hash := md5.Sum(valueBytes) + result[key] = hex.EncodeToString(hash[:]) + } + + return result +} + +func hiddenValues(key string, value map[string]any, ch *config.Channel) { + v, err := ch.GetDecoded() + if err != nil { + return + } + switch key { + case "pico": + if settings, ok := v.(*config.PicoSettings); ok { + value["token"] = settings.Token.String() + } + case "telegram": + if settings, ok := v.(*config.TelegramSettings); ok { + value["token"] = settings.Token.String() + } + case "discord": + if settings, ok := v.(*config.DiscordSettings); ok { + value["token"] = settings.Token.String() + } + case "slack": + if settings, ok := v.(*config.SlackSettings); ok { + value["bot_token"] = settings.BotToken.String() + value["app_token"] = settings.AppToken.String() + } + case "matrix": + if settings, ok := v.(*config.MatrixSettings); ok { + value["token"] = settings.AccessToken.String() + } + case "onebot": + if settings, ok := v.(*config.OneBotSettings); ok { + value["token"] = settings.AccessToken.String() + } + case "line": + if settings, ok := v.(*config.LINESettings); ok { + value["token"] = settings.ChannelAccessToken.String() + value["secret"] = settings.ChannelSecret.String() + } + case "wecom": + if settings, ok := v.(*config.WeComSettings); ok { + value["secret"] = settings.Secret.String() + } + case "dingtalk": + if settings, ok := v.(*config.DingTalkSettings); ok { + value["secret"] = settings.ClientSecret.String() + } + case "qq": + if settings, ok := v.(*config.QQSettings); ok { + value["secret"] = settings.AppSecret.String() + } + case "irc": + if settings, ok := v.(*config.IRCSettings); ok { + value["password"] = settings.Password.String() + value["serv_password"] = settings.NickServPassword.String() + value["sasl_password"] = settings.SASLPassword.String() + } + case "feishu": + if settings, ok := v.(*config.FeishuSettings); ok { + value["app_secret"] = settings.AppSecret.String() + value["encrypt_key"] = settings.EncryptKey.String() + value["verification_token"] = settings.VerificationToken.String() + } + case "teams_webhook": + // Expose webhook URLs for hash computation (they contain secrets) + vv := value["webhooks"] + webhooks := make(map[string]string) + if vv != nil { + webhooks = vv.(map[string]string) + } + if settings, ok := v.(*config.TeamsWebhookSettings); ok { + for name, target := range settings.Webhooks { + webhooks[name] = target.WebhookURL.String() + } + } + value["webhooks"] = webhooks + } +} + +func compareChannels(old, news map[string]string) (added, removed []string) { + for key, newHash := range news { + if oldHash, ok := old[key]; ok { + if newHash != oldHash { + removed = append(removed, key) + added = append(added, key) + } + } else { + added = append(added, key) + } + } + for key := range old { + if _, ok := news[key]; !ok { + removed = append(removed, key) + } + } + return added, removed +} + +func toChannelConfig(cfg *config.Config, list []string) (*config.ChannelsConfig, error) { + result := make(config.ChannelsConfig) + for _, name := range list { + bc, ok := cfg.Channels[name] + if !ok || !bc.Enabled { + continue + } + result[name] = bc + } + return &result, nil +} diff --git a/pkg/channels/manager_channel_test.go b/pkg/channels/manager_channel_test.go new file mode 100644 index 000000000..b991e58d6 --- /dev/null +++ b/pkg/channels/manager_channel_test.go @@ -0,0 +1,153 @@ +package channels + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +func TestToChannelHashes(t *testing.T) { + logger.SetLevel(logger.DEBUG) + cfg := config.DefaultConfig() + results := toChannelHashes(cfg) + assert.Equal(t, 0, len(results)) + logger.Debugf("results: %v", results) + + // Add dingtalk channel via map + cfg2 := config.DefaultConfig() + cfg2.Channels["dingtalk"] = &config.Channel{ + Enabled: true, + Type: config.ChannelDingTalk, + Settings: config.RawNode(`{"enabled":true}`), + } + results2 := toChannelHashes(cfg2) + assert.Equal(t, 1, len(results2)) + logger.Debugf("results2: %v", results2) + added, removed := compareChannels(results, results2) + assert.EqualValues(t, []string{"dingtalk"}, added) + assert.EqualValues(t, []string(nil), removed) + + // Add telegram channel + cfg3 := config.DefaultConfig() + cfg3.Channels["telegram"] = &config.Channel{ + Enabled: true, + Type: config.ChannelTelegram, + Settings: config.RawNode(`{"enabled":true,"token":"test-token"}`), + } + results3 := toChannelHashes(cfg3) + assert.Equal(t, 1, len(results3)) + logger.Debugf("results3: %v", results3) + added, removed = compareChannels(results2, results3) + assert.EqualValues(t, []string{"dingtalk"}, removed) + assert.EqualValues(t, []string{"telegram"}, added) + + // Modify telegram channel — hash should change + cfg3.Channels["telegram"] = &config.Channel{ + Enabled: true, + Type: config.ChannelTelegram, + Settings: config.RawNode(`{"enabled":true,"token":"114314"}`), + } + results4 := toChannelHashes(cfg3) + assert.Equal(t, 1, len(results4)) + logger.Debugf("results4: %v", results4) + added, removed = compareChannels(results3, results4) + assert.EqualValues(t, []string{"telegram"}, removed) + assert.EqualValues(t, []string{"telegram"}, added) + + // toChannelConfig with telegram + cc, err := toChannelConfig(cfg3, added) + assert.NoError(t, err) + bc := cc.Get("telegram") + assert.NotNil(t, bc) + var tc config.TelegramSettings + bc.Decode(&tc) + assert.Equal(t, "114314", tc.Token.String()) + assert.Equal(t, true, bc.Enabled) + + // toChannelConfig with dingtalk (no telegram) + cc, err = toChannelConfig(cfg2, added) + assert.NoError(t, err) + bc = cc.Get("telegram") + assert.Nil(t, bc) +} + +func TestToChannelHashes_SerializationStability(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels["test"] = &config.Channel{ + Enabled: true, + Settings: config.RawNode(`{"enabled":true,"key":"value"}`), + } + h1 := toChannelHashes(cfg) + + // Same config should produce same hash + cfg2 := config.DefaultConfig() + cfg2.Channels["test"] = &config.Channel{ + Enabled: true, + Settings: config.RawNode(`{"enabled":true,"key":"value"}`), + } + h2 := toChannelHashes(cfg2) + assert.Equal(t, h1["test"], h2["test"]) +} + +func TestCompareChannels_NoChanges(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels["a"] = &config.Channel{Enabled: true, Settings: config.RawNode(`{}`)} + cfg.Channels["b"] = &config.Channel{Enabled: true, Settings: config.RawNode(`{}`)} + h := toChannelHashes(cfg) + + added, removed := compareChannels(h, h) + assert.EqualValues(t, []string(nil), added) + assert.EqualValues(t, []string(nil), removed) +} + +func TestToChannelConfig_EmptyList(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels["test"] = &config.Channel{Enabled: true, Settings: config.RawNode(`{}`)} + + cc, err := toChannelConfig(cfg, []string{}) + assert.NoError(t, err) + assert.Equal(t, 0, len(*cc)) +} + +func TestToChannelHashes_NonEnabledSkipped(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels["test"] = &config.Channel{Enabled: false, Settings: config.RawNode(`{"enabled":false}`)} + + h := toChannelHashes(cfg) + assert.Equal(t, 0, len(h)) +} + +func TestToChannelHashes_InvalidJSON(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels["test"] = &config.Channel{ + Enabled: true, + Settings: config.RawNode(`invalid-json`), + } + + // Should not panic, just skip the invalid entry + h := toChannelHashes(cfg) + assert.Equal(t, 0, len(h)) +} + +func TestToChannelHashes_RealWorldChannel(t *testing.T) { + cfg := config.DefaultConfig() + + // Simulate a telegram channel config + telegramSettings, _ := json.Marshal(map[string]any{ + "enabled": true, + "token": "123456:ABC-DEF", + }) + cfg.Channels["telegram"] = &config.Channel{ + Enabled: true, + Type: config.ChannelTelegram, + Settings: config.RawNode(telegramSettings), + } + + h := toChannelHashes(cfg) + assert.Equal(t, 1, len(h)) + assert.Contains(t, h, "telegram") +} diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index f09ecfe2f..5aeabc888 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "sync" "sync/atomic" "testing" @@ -12,26 +13,313 @@ import ( "golang.org/x/time/rate" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/utils" ) // mockChannel is a test double that delegates Send to a configurable function. type mockChannel struct { BaseChannel - sendFn func(ctx context.Context, msg bus.OutboundMessage) error + sendFn func(ctx context.Context, msg bus.OutboundMessage) error + startFn func(ctx context.Context) error + stopFn func(ctx context.Context) error + sentMessages []bus.OutboundMessage + placeholdersSent int + editedMessages int + lastPlaceholderID string } -func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - return m.sendFn(ctx, msg) +func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + m.sentMessages = append(m.sentMessages, msg) + if m.sendFn == nil { + return nil, nil + } + return nil, m.sendFn(ctx, msg) } -func (m *mockChannel) Start(ctx context.Context) error { return nil } -func (m *mockChannel) Stop(ctx context.Context) error { return nil } +func (m *mockChannel) Start(ctx context.Context) error { + if m.startFn != nil { + return m.startFn(ctx) + } + return nil +} + +func (m *mockChannel) Stop(ctx context.Context) error { + if m.stopFn != nil { + return m.stopFn(ctx) + } + return nil +} + +func (m *mockChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + m.placeholdersSent++ + m.lastPlaceholderID = "mock-ph-123" + return m.lastPlaceholderID, nil +} + +func (m *mockChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error { + m.editedMessages++ + return nil +} + +type mockMediaChannel struct { + mockChannel + sendMediaFn func(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) + sentMediaMessages []bus.OutboundMediaMessage +} + +func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { + m.sentMediaMessages = append(m.sentMediaMessages, msg) + if m.sendMediaFn != nil { + return m.sendMediaFn(ctx, msg) + } + return nil, nil +} + +type mockDeletingMediaChannel struct { + mockMediaChannel + deleteCalls int + dismissedChatID string + lastDeleted struct { + chatID string + messageID string + } +} + +func (m *mockDeletingMediaChannel) DeleteMessage( + _ context.Context, + chatID string, + messageID string, +) error { + m.deleteCalls++ + m.lastDeleted.chatID = chatID + m.lastDeleted.messageID = messageID + return nil +} + +func (m *mockDeletingMediaChannel) DismissToolFeedbackMessage(_ context.Context, chatID string) { + m.dismissedChatID = chatID +} + +type mockStreamer struct { + finalizeFn func(context.Context, string) error +} + +func (m *mockStreamer) Update(context.Context, string) error { return nil } + +func (m *mockStreamer) Finalize(ctx context.Context, content string) error { + if m.finalizeFn != nil { + return m.finalizeFn(ctx, content) + } + return nil +} + +func (m *mockStreamer) Cancel(context.Context) {} + +type mockStreamingChannel struct { + mockMessageEditor + streamer Streamer + resolveChatIDFn func(chatID string, outboundCtx *bus.InboundContext) string +} + +func (m *mockStreamingChannel) BeginStream(context.Context, string) (Streamer, error) { + if m.streamer == nil { + return nil, errors.New("missing streamer") + } + return m.streamer, nil +} + +func (m *mockStreamingChannel) ToolFeedbackMessageChatID( + chatID string, + outboundCtx *bus.InboundContext, +) string { + if m.resolveChatIDFn != nil { + return m.resolveChatIDFn(chatID, outboundCtx) + } + return chatID +} // newTestManager creates a minimal Manager suitable for unit tests. func newTestManager() *Manager { return &Manager{ channels: make(map[string]Channel), workers: make(map[string]*channelWorker), + bus: bus.NewMessageBus(), + } +} + +func TestStartAll_AllChannelsFail_ReturnsJoinedError(t *testing.T) { + m := newTestManager() + errA := errors.New("channel-a start failed") + errB := errors.New("channel-b start failed") + + m.channels["a"] = &mockChannel{ + startFn: func(_ context.Context) error { return errA }, + } + m.channels["b"] = &mockChannel{ + startFn: func(_ context.Context) error { return errB }, + } + + err := m.StartAll(t.Context()) + if err == nil { + t.Fatal("expected StartAll to fail when all channels fail") + } + if !strings.Contains(err.Error(), "failed to start any enabled channels") { + t.Fatalf("unexpected error: %v", err) + } + if !errors.Is(err, errA) { + t.Fatalf("expected error to wrap errA, got: %v", err) + } + if !errors.Is(err, errB) { + t.Fatalf("expected error to wrap errB, got: %v", err) + } + if len(m.workers) != 0 { + t.Fatalf("expected no workers on full startup failure, got %d", len(m.workers)) + } + if m.dispatchTask != nil { + t.Fatal("expected dispatch task to be cleared on full startup failure") + } +} + +func TestStartAll_PartialFailure_StartsSuccessfulWorkers(t *testing.T) { + m := newTestManager() + errBad := errors.New("bad channel start failed") + processed := make(chan struct{}, 1) + + m.channels["good"] = &mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + if msg.Channel == "good" { + select { + case processed <- struct{}{}: + default: + } + } + return nil + }, + } + m.channels["bad"] = &mockChannel{ + startFn: func(_ context.Context) error { return errBad }, + } + + err := m.StartAll(t.Context()) + if err != nil { + t.Fatalf("expected StartAll to succeed with partial channel failures, got: %v", err) + } + if len(m.workers) != 1 { + t.Fatalf("expected exactly 1 active worker, got %d", len(m.workers)) + } + if _, ok := m.workers["good"]; !ok { + t.Fatal("expected worker for successful channel 'good'") + } + if _, ok := m.workers["bad"]; ok { + t.Fatal("did not expect worker for failed channel 'bad'") + } + if m.dispatchTask == nil { + t.Fatal("expected dispatch task to run when at least one channel starts") + } + + pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer pubCancel() + if err := m.bus.PublishOutbound(pubCtx, testOutboundMessage(bus.OutboundMessage{ + Channel: "good", + ChatID: "chat-1", + Content: "hello", + })); err != nil { + t.Fatalf("PublishOutbound() error = %v", err) + } + + select { + case <-processed: + // worker processed outbound message as expected + case <-time.After(2 * time.Second): + t.Fatal("expected successful channel worker to process outbound message") + } + + stopCtx, stopCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer stopCancel() + if err := m.StopAll(stopCtx); err != nil { + t.Fatalf("StopAll() error = %v", err) + } +} + +func TestStartAllPublishesLifecycleRuntimeEvents(t *testing.T) { + eventBus := runtimeevents.NewBus() + defer func() { + if err := eventBus.Close(); err != nil { + t.Errorf("event bus close failed: %v", err) + } + }() + + _, eventsCh, err := eventBus.Channel().SubscribeChan( + t.Context(), + runtimeevents.SubscribeOptions{Name: "channel-lifecycle", Buffer: 4}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + m := newTestManager() + m.runtimeEvents = eventBus + m.config = &config.Config{Channels: config.ChannelsConfig{}} + m.channels["good"] = &mockChannel{} + m.channels["bad"] = &mockChannel{ + startFn: func(_ context.Context) error { return errors.New("bad start") }, + } + + if err := m.StartAll(t.Context()); err != nil { + t.Fatalf("StartAll() error = %v", err) + } + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := m.StopAll(stopCtx); err != nil { + t.Errorf("StopAll() error = %v", err) + } + }) + + events := []runtimeevents.Event{ + receiveChannelRuntimeEvent(t, eventsCh), + receiveChannelRuntimeEvent(t, eventsCh), + } + seen := map[runtimeevents.Kind]runtimeevents.Event{} + for _, evt := range events { + seen[evt.Kind] = evt + } + if evt, ok := seen[runtimeevents.KindChannelLifecycleStarted]; !ok || evt.Scope.Channel != "good" { + t.Fatalf("missing started event for good channel: %+v", events) + } + if evt, ok := seen[runtimeevents.KindChannelLifecycleStartFailed]; !ok || evt.Scope.Channel != "bad" { + t.Fatalf("missing failed event for bad channel: %+v", events) + } +} + +func testOutboundMessage(msg bus.OutboundMessage) bus.OutboundMessage { + if msg.Context.Channel == "" && msg.Context.ChatID == "" { + msg.Context = bus.NewOutboundContext(msg.Channel, msg.ChatID, msg.ReplyToMessageID) + } + return bus.NormalizeOutboundMessage(msg) +} + +func testOutboundMediaMessage(msg bus.OutboundMediaMessage) bus.OutboundMediaMessage { + if msg.Context.Channel == "" && msg.Context.ChatID == "" { + msg.Context = bus.NewOutboundContext(msg.Channel, msg.ChatID, "") + } + return bus.NormalizeOutboundMediaMessage(msg) +} + +func receiveChannelRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event { + t.Helper() + + select { + case evt, ok := <-ch: + if !ok { + t.Fatal("runtime event channel closed before expected event") + } + return evt + case <-time.After(time.Second): + t.Fatal("timed out waiting for runtime event") + return runtimeevents.Event{} } } @@ -50,7 +338,7 @@ func TestSendWithRetry_Success(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) m.sendWithRetry(ctx, "test", w, msg) @@ -59,6 +347,69 @@ func TestSendWithRetry_Success(t *testing.T) { } } +func TestSendWithRetryPublishesOutboundRuntimeEvents(t *testing.T) { + eventBus := runtimeevents.NewBus() + defer func() { + if err := eventBus.Close(); err != nil { + t.Errorf("event bus close failed: %v", err) + } + }() + + _, eventsCh, err := eventBus.Channel().OfKind( + runtimeevents.KindChannelMessageOutboundSent, + runtimeevents.KindChannelMessageOutboundFailed, + ).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "channel-outbound", Buffer: 2}) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + m := newTestManager() + m.runtimeEvents = eventBus + + successWorker := &channelWorker{ + ch: &mockChannel{}, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.sendWithRetry( + context.Background(), + "test", + successWorker, + testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "chat-1", Content: "hello"}), + ) + sent := receiveChannelRuntimeEvent(t, eventsCh) + if sent.Kind != runtimeevents.KindChannelMessageOutboundSent || sent.Scope.ChatID != "chat-1" { + t.Fatalf("sent event = %+v", sent) + } + if sent.Attrs["content_len"] != 5 { + t.Fatalf("sent attrs = %#v, want content_len", sent.Attrs) + } + + failWorker := &channelWorker{ + ch: &mockChannel{ + sendFn: func(context.Context, bus.OutboundMessage) error { + return fmt.Errorf("send failed: %w", ErrSendFailed) + }, + }, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.sendWithRetry( + context.Background(), + "test", + failWorker, + testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "chat-2", Content: "hello"}), + ) + failed := receiveChannelRuntimeEvent(t, eventsCh) + if failed.Kind != runtimeevents.KindChannelMessageOutboundFailed || failed.Scope.ChatID != "chat-2" { + t.Fatalf("failed event = %+v", failed) + } + if failed.Severity != runtimeevents.SeverityError { + t.Fatalf("failed severity = %q", failed.Severity) + } + if failed.Attrs["error"] == "" || failed.Attrs["retries"] != maxRetries { + t.Fatalf("failed attrs = %#v, want error and retries", failed.Attrs) + } +} + func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) { m := newTestManager() var callCount int @@ -77,7 +428,7 @@ func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) m.sendWithRetry(ctx, "test", w, msg) @@ -101,7 +452,7 @@ func TestSendWithRetry_PermanentFailure(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) m.sendWithRetry(ctx, "test", w, msg) @@ -125,7 +476,7 @@ func TestSendWithRetry_NotRunning(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) m.sendWithRetry(ctx, "test", w, msg) @@ -152,7 +503,7 @@ func TestSendWithRetry_RateLimitRetry(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) start := time.Now() m.sendWithRetry(ctx, "test", w, msg) @@ -182,7 +533,7 @@ func TestSendWithRetry_MaxRetriesExhausted(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) m.sendWithRetry(ctx, "test", w, msg) @@ -192,6 +543,125 @@ func TestSendWithRetry_MaxRetriesExhausted(t *testing.T) { } } +func TestSendMedia_Success(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockMediaChannel{ + sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) { + callCount++ + return nil, nil + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.channels["test"] = ch + m.workers["test"] = w + + err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{ + Channel: "test", + ChatID: "chat1", + Parts: []bus.MediaPart{{Ref: "media://abc"}}, + })) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + if callCount != 1 { + t.Fatalf("expected 1 SendMedia call, got %d", callCount) + } +} + +func TestSendMedia_PropagatesFailure(t *testing.T) { + m := newTestManager() + ch := &mockMediaChannel{ + sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) { + return nil, fmt.Errorf("bad upload: %w", ErrSendFailed) + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.channels["test"] = ch + m.workers["test"] = w + + err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{ + Channel: "test", + ChatID: "chat1", + Parts: []bus.MediaPart{{Ref: "media://abc"}}, + })) + if err == nil { + t.Fatal("expected SendMedia to return error") + } + if !errors.Is(err, ErrSendFailed) { + t.Fatalf("expected ErrSendFailed, got %v", err) + } +} + +func TestSendMedia_UnsupportedChannelReturnsError(t *testing.T) { + m := newTestManager() + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + return nil + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.channels["test"] = ch + m.workers["test"] = w + + err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{ + Channel: "test", + ChatID: "chat1", + Parts: []bus.MediaPart{{Ref: "media://abc"}}, + })) + if err == nil { + t.Fatal("expected SendMedia to return error for unsupported channel") + } + if !strings.Contains(err.Error(), "does not support media sending") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSendMedia_DeletesPlaceholderBeforeSending(t *testing.T) { + m := newTestManager() + ch := &mockDeletingMediaChannel{ + mockMediaChannel: mockMediaChannel{ + sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) { + return nil, nil + }, + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.channels["test"] = ch + m.workers["test"] = w + m.RecordPlaceholder("test", "chat1", "placeholder-1") + + err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{ + Channel: "test", + ChatID: "chat1", + Parts: []bus.MediaPart{{Ref: "media://abc"}}, + })) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + if ch.deleteCalls != 1 { + t.Fatalf("expected placeholder delete to be called once, got %d", ch.deleteCalls) + } + if ch.lastDeleted.chatID != "chat1" || ch.lastDeleted.messageID != "placeholder-1" { + t.Fatalf("unexpected placeholder deletion target: %+v", ch.lastDeleted) + } + if len(ch.sentMediaMessages) != 1 { + t.Fatalf("expected media to be sent once, got %d", len(ch.sentMediaMessages)) + } +} + func TestSendWithRetry_UnknownError(t *testing.T) { m := newTestManager() var callCount int @@ -210,7 +680,7 @@ func TestSendWithRetry_UnknownError(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) m.sendWithRetry(ctx, "test", w, msg) @@ -234,7 +704,7 @@ func TestSendWithRetry_ContextCancelled(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) // Cancel context after first Send attempt returns ch.sendFn = func(_ context.Context, _ bus.OutboundMessage) error { @@ -280,7 +750,7 @@ func TestWorkerRateLimiter(t *testing.T) { // Enqueue 4 messages for i := range 4 { - w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)} + w.queue <- testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)}) } // Wait enough time for all messages to be sent (4 msgs at 2/s = ~2s, give extra margin) @@ -305,7 +775,7 @@ func TestWorkerRateLimiter(t *testing.T) { func TestNewChannelWorker_DefaultRate(t *testing.T) { ch := &mockChannel{} - w := newChannelWorker("unknown_channel", ch) + w := newChannelWorker("unknown_channel", ch, "unknown_channel") if w.limiter == nil { t.Fatal("expected limiter to be non-nil") @@ -318,10 +788,10 @@ func TestNewChannelWorker_DefaultRate(t *testing.T) { func TestNewChannelWorker_ConfiguredRate(t *testing.T) { ch := &mockChannel{} - for name, expectedRate := range channelRateConfig { - w := newChannelWorker(name, ch) + for channelType, expectedRate := range channelRateConfig { + w := newChannelWorker(channelType, ch, channelType) if w.limiter.Limit() != rate.Limit(expectedRate) { - t.Fatalf("channel %s: expected rate %v, got %v", name, expectedRate, w.limiter.Limit()) + t.Fatalf("channel %s: expected rate %v, got %v", channelType, expectedRate, w.limiter.Limit()) } } } @@ -356,7 +826,7 @@ func TestRunWorker_MessageSplitting(t *testing.T) { go m.runWorker(ctx, "test", w) // Send a message that should be split - w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello world"} + w.queue <- testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello world"}) time.Sleep(100 * time.Millisecond) @@ -397,7 +867,7 @@ func TestSendWithRetry_ExponentialBackoff(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) start := time.Now() m.sendWithRetry(ctx, "test", w, msg) @@ -420,13 +890,86 @@ func TestSendWithRetry_ExponentialBackoff(t *testing.T) { // mockMessageEditor is a channel that supports MessageEditor. type mockMessageEditor struct { mockChannel - editFn func(ctx context.Context, chatID, messageID, content string) error + editFn func(ctx context.Context, chatID, messageID, content string) error + finalizeFn func(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) + finalizeCalled bool + recordedChatID string + recordedMessageID string + recordedContent string + clearedChatID string + dismissedChatID string } func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error { return m.editFn(ctx, chatID, messageID, content) } +func (m *mockMessageEditor) RecordToolFeedbackMessage(chatID, messageID, content string) { + m.recordedChatID = chatID + m.recordedMessageID = messageID + m.recordedContent = content +} + +func (m *mockMessageEditor) ClearToolFeedbackMessage(chatID string) { + m.clearedChatID = chatID +} + +func (m *mockMessageEditor) DismissToolFeedbackMessage(_ context.Context, chatID string) { + m.dismissedChatID = chatID +} + +func (m *mockMessageEditor) FinalizeToolFeedbackMessage( + ctx context.Context, + msg bus.OutboundMessage, +) ([]string, bool) { + m.finalizeCalled = true + if m.finalizeFn == nil { + return nil, false + } + return m.finalizeFn(ctx, msg) +} + +type mockResolvedToolFeedbackEditor struct { + mockMessageEditor + resolveChatIDFn func(chatID string, outboundCtx *bus.InboundContext) string +} + +type mockDeletingMessageEditor struct { + mockMessageEditor + deleteCalls int + deletedChatID string + deletedMessageID string +} + +func (m *mockDeletingMessageEditor) DeleteMessage(_ context.Context, chatID, messageID string) error { + m.deleteCalls++ + m.deletedChatID = chatID + m.deletedMessageID = messageID + return nil +} + +func (m *mockResolvedToolFeedbackEditor) ToolFeedbackMessageChatID( + chatID string, + outboundCtx *bus.InboundContext, +) string { + if m.resolveChatIDFn != nil { + return m.resolveChatIDFn(chatID, outboundCtx) + } + return chatID +} + +type mockPreparedToolFeedbackEditor struct { + mockMessageEditor + prepareFn func(content string) string +} + +func (m *mockPreparedToolFeedbackEditor) PrepareToolFeedbackMessageContent(content string) string { + if m.prepareFn != nil { + return m.prepareFn(content) + } + return content +} + func TestPreSend_PlaceholderEditSuccess(t *testing.T) { m := newTestManager() var sendCalled bool @@ -457,8 +1000,8 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) { // Register placeholder m.RecordPlaceholder("test", "123", "456") - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}) + _, edited := m.preSend(context.Background(), "test", msg, ch) if !edited { t.Fatal("expected preSend to return true (placeholder edited)") @@ -471,6 +1014,810 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) { } } +func TestPreSend_ToolFeedbackPlaceholderEditRecordsTrackedMessage(t *testing.T) { + m := newTestManager() + + ch := &mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "123" || messageID != "456" || content != "hello" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "hello", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.recordedChatID != "123" || ch.recordedMessageID != "456" { + t.Fatalf("expected tracked message 123/456, got %q/%q", ch.recordedChatID, ch.recordedMessageID) + } +} + +func TestPreSend_ToolFeedbackPlaceholderEditUsesResolvedTrackedChatID(t *testing.T) { + m := newTestManager() + + ch := &mockResolvedToolFeedbackEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "-100123" || messageID != "456" || content != "hello" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + }, + resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string { + if chatID != "-100123" { + t.Fatalf("expected raw chat ID, got %q", chatID) + } + if outboundCtx == nil || outboundCtx.TopicID != "42" { + t.Fatalf("expected topic-aware outbound context, got %+v", outboundCtx) + } + return chatID + "/" + outboundCtx.TopicID + }, + } + + m.RecordPlaceholder("test", "-100123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "-100123", + Content: "hello", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "-100123", + TopicID: "42", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.recordedChatID != "-100123/42" || ch.recordedMessageID != "456" { + t.Fatalf("expected resolved tracked message -100123/42/456, got %q/%q", + ch.recordedChatID, ch.recordedMessageID) + } +} + +func TestPreSend_ToolFeedbackPlaceholderEditUsesPreparedContent(t *testing.T) { + m := newTestManager() + + const rawContent = "🔧 `read_file`\n" + "" + const preparedContent = "🔧 `read_file`\n<raw>" + + ch := &mockPreparedToolFeedbackEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "123" || messageID != "456" { + t.Fatalf("unexpected edit target: %s/%s", chatID, messageID) + } + if content != InitialAnimatedToolFeedbackContent(preparedContent) { + t.Fatalf("unexpected prepared content: %q", content) + } + return nil + }, + }, + prepareFn: func(content string) string { + if content != rawContent { + t.Fatalf("unexpected raw tool feedback: %q", content) + } + return preparedContent + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: rawContent, + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.recordedContent != preparedContent { + t.Fatalf("expected tracked content %q, got %q", preparedContent, ch.recordedContent) + } +} + +func TestPreSend_NonToolFeedbackLeavesTrackedMessageForChannelSend(t *testing.T) { + m := newTestManager() + ch := &mockMessageEditor{} + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + _, edited := m.preSend(context.Background(), "test", msg, ch) + if edited { + t.Fatal("expected preSend to fall through when no placeholder exists") + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked tool feedback cleanup to be deferred to channel send, got %q", ch.dismissedChatID) + } +} + +func TestPreSend_NonToolFeedbackDefersTrackedMessageFinalizationToChannelSend(t *testing.T) { + m := newTestManager() + ch := &mockMessageEditor{ + finalizeFn: func(_ context.Context, msg bus.OutboundMessage) ([]string, bool) { + if msg.ChatID != "123" || msg.Content != "final reply" { + t.Fatalf("unexpected finalize msg: %+v", msg) + } + return []string{"tool-msg-1"}, true + }, + } + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + msgIDs, handled := m.preSend(context.Background(), "test", msg, ch) + if handled { + t.Fatalf("expected preSend to defer to channel Send, got msgIDs=%v", msgIDs) + } + if len(msgIDs) != 0 { + t.Fatalf("expected no msgIDs from preSend, got %v", msgIDs) + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked cleanup to remain in channel Send, got %q", ch.dismissedChatID) + } + if ch.finalizeCalled { + t.Fatal("expected preSend to skip channel tool feedback finalization") + } +} + +func TestPreSend_ToolFeedbackSeparateMessagesDeletesPlaceholderAndSkipsEdit(t *testing.T) { + m := newTestManager() + m.config = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + SeparateMessages: true, + }, + }, + }, + } + + ch := &mockDeletingMessageEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, _, _, _ string) error { + t.Fatal("expected placeholder edit to be skipped in separate message mode") + return nil + }, + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "hello", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + msgIDs, handled := m.preSend(context.Background(), "test", msg, ch) + if handled { + t.Fatalf("expected preSend to fall through so the channel can send a new message, got %v", msgIDs) + } + if ch.deleteCalls != 1 { + t.Fatalf("expected placeholder deletion, got %d delete calls", ch.deleteCalls) + } + if ch.deletedChatID != "123" || ch.deletedMessageID != "456" { + t.Fatalf("unexpected placeholder deletion target: %s/%s", ch.deletedChatID, ch.deletedMessageID) + } + if ch.recordedMessageID != "" { + t.Fatalf("expected no tracked placeholder record, got %q", ch.recordedMessageID) + } + if ch.clearedChatID != "123" { + t.Fatalf("expected tracked tool feedback state to be cleared before sending, got %q", ch.clearedChatID) + } +} + +func TestPreSend_ThoughtPlaceholderDeleteAndSkipsEdit(t *testing.T) { + m := newTestManager() + + ch := &mockDeletingMessageEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, _, _, _ string) error { + t.Fatal("expected thought message to bypass placeholder edit") + return nil + }, + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "thinking trace", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "thought", + }, + }, + }) + + msgIDs, handled := m.preSend(context.Background(), "test", msg, ch) + if handled { + t.Fatalf( + "expected thought message to fall through so the channel can send a structured message, got %v", + msgIDs, + ) + } + if ch.deleteCalls != 1 { + t.Fatalf("expected placeholder deletion, got %d delete calls", ch.deleteCalls) + } + if ch.deletedChatID != "123" || ch.deletedMessageID != "456" { + t.Fatalf("unexpected placeholder deletion target: %s/%s", ch.deletedChatID, ch.deletedMessageID) + } + if _, ok := m.placeholders.Load("test:123"); ok { + t.Fatal("expected placeholder to be consumed before structured thought send") + } +} + +func TestSendWithRetry_ToolCallsPlaceholderDeleteAndFallsThroughToSend(t *testing.T) { + m := newTestManager() + + ch := &mockDeletingMessageEditor{ + mockMessageEditor: mockMessageEditor{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + if got := msg.Context.Raw["message_kind"]; got != "tool_calls" { + t.Fatalf("expected tool_calls message kind, got %q", got) + } + if msg.Content != "" { + t.Fatalf("expected empty tool_calls content, got %q", msg.Content) + } + return nil + }, + }, + editFn: func(_ context.Context, _, _, _ string) error { + t.Fatal("expected tool_calls message to bypass placeholder edit") + return nil + }, + }, + } + + m.RecordPlaceholder("test", "123", "456") + + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_calls", + "tool_calls": `[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{}"},"extra_content":{"tool_feedback_explanation":"Looking up config"}}]`, + }, + }, + }) + + m.sendWithRetry(context.Background(), "test", w, msg) + + if ch.deleteCalls != 1 { + t.Fatalf("expected placeholder deletion, got %d delete calls", ch.deleteCalls) + } + if ch.deletedChatID != "123" || ch.deletedMessageID != "456" { + t.Fatalf("unexpected placeholder deletion target: %s/%s", ch.deletedChatID, ch.deletedMessageID) + } + if len(ch.sentMessages) != 1 { + t.Fatalf("expected structured tool_calls message to be sent once, got %d", len(ch.sentMessages)) + } +} + +func TestPreSend_NonToolFeedbackSeparateMessagesClearsTrackedMessageWithoutDismiss(t *testing.T) { + m := newTestManager() + m.config = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + SeparateMessages: true, + }, + }, + }, + } + + ch := &mockMessageEditor{} + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + _, handled := m.preSend(context.Background(), "test", msg, ch) + if handled { + t.Fatal("expected preSend to leave final delivery to the channel") + } + if ch.clearedChatID != "123" { + t.Fatalf("expected tracked tool feedback state to be cleared, got %q", ch.clearedChatID) + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked tool feedback message to be preserved, got dismissal for %q", ch.dismissedChatID) + } + if ch.finalizeCalled { + t.Fatal("expected separate message mode to skip in-place finalization") + } +} + +func TestPreSend_StaleToolFeedbackDoesNotConsumeStreamActiveMarker(t *testing.T) { + m := newTestManager() + m.streamActive.Store("test:123", true) + m.RecordPlaceholder("test", "123", "placeholder-1") + + var editedContent string + ch := &mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "123" || messageID != "placeholder-1" { + t.Fatalf("unexpected edit target: %s/%s", chatID, messageID) + } + editedContent = content + return nil + }, + } + + toolFeedback := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "🔧 `read_file`\nReading config", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + msgIDs, handled := m.preSend(context.Background(), "test", toolFeedback, ch) + if !handled { + t.Fatal("expected stale tool feedback to be dropped after stream finalize") + } + if len(msgIDs) != 0 { + t.Fatalf("expected no delivered message IDs for stale feedback, got %v", msgIDs) + } + if _, ok := m.streamActive.Load("test:123"); !ok { + t.Fatal("expected streamActive marker to remain for the final outbound message") + } + if _, ok := m.placeholders.Load("test:123"); !ok { + t.Fatal("expected placeholder cleanup to remain deferred to the final outbound message") + } + if ch.editedMessages != 0 { + t.Fatalf("expected no placeholder edit for stale feedback, got %d edits", ch.editedMessages) + } + + finalMsg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final streamed reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + _, handled = m.preSend(context.Background(), "test", finalMsg, ch) + if !handled { + t.Fatal("expected final outbound message to consume streamActive marker") + } + if _, ok := m.streamActive.Load("test:123"); ok { + t.Fatal("expected streamActive marker to be cleared by final outbound message") + } + if _, ok := m.placeholders.Load("test:123"); ok { + t.Fatal("expected placeholder to be cleaned up by final outbound message") + } + if editedContent != "final streamed reply" { + t.Fatalf("editedContent = %q, want final streamed reply", editedContent) + } +} + +func TestPreSendMedia_LeavesTrackedMessageForChannelSend(t *testing.T) { + m := newTestManager() + ch := &mockDeletingMediaChannel{} + + m.preSendMedia(context.Background(), "test", bus.OutboundMediaMessage{ + ChatID: "123", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }, ch) + + if ch.dismissedChatID != "" { + t.Fatalf( + "expected tracked tool feedback cleanup to be deferred to channel media send, got %q", + ch.dismissedChatID, + ) + } +} + +func TestPreSendMedia_SeparateMessagesClearsTrackedMessageWithoutDismiss(t *testing.T) { + m := newTestManager() + m.config = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + SeparateMessages: true, + }, + }, + }, + } + + ch := &mockMessageEditor{} + + m.preSendMedia(context.Background(), "test", bus.OutboundMediaMessage{ + ChatID: "123", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }, ch) + + if ch.clearedChatID != "123" { + t.Fatalf("expected tracked tool feedback state to be cleared before media delivery, got %q", ch.clearedChatID) + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked tool feedback message to be preserved"+ + " for media delivery, got %q", ch.dismissedChatID) + } +} + +func TestSplitOutboundMessageContent_ToolFeedbackTruncatesInsteadOfSplitting(t *testing.T) { + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure before editing the config example.", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + chunks := splitOutboundMessageContent(msg, 40) + if len(chunks) != 1 { + t.Fatalf("len(chunks) = %d, want 1", len(chunks)) + } + want := utils.FitToolFeedbackMessage(msg.Content, 40-MaxToolFeedbackAnimationFrameLength()) + if chunks[0] != want { + t.Fatalf("chunk = %q, want %q", chunks[0], want) + } +} + +func TestSplitOutboundMessageContent_ToolFeedbackReservesAnimationFrame(t *testing.T) { + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "🔧 `read_file`\n1234567890", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + chunks := splitOutboundMessageContent(msg, len([]rune(msg.Content))) + if len(chunks) != 1 { + t.Fatalf("len(chunks) = %d, want 1", len(chunks)) + } + + animated := formatAnimatedToolFeedbackContent(chunks[0], strings.Repeat(".", MaxToolFeedbackAnimationFrameLength())) + if got, maxLen := len([]rune(animated)), len([]rune(msg.Content)); got > maxLen { + t.Fatalf("animated len = %d, want <= %d; content=%q", got, maxLen, animated) + } +} + +func TestGetStreamer_FinalizeDismissesTrackedToolFeedback(t *testing.T) { + m := newTestManager() + ch := &mockStreamingChannel{ + mockMessageEditor: mockMessageEditor{}, + streamer: &mockStreamer{ + finalizeFn: func(_ context.Context, content string) error { + if content != "final reply" { + t.Fatalf("unexpected finalize content: %q", content) + } + return nil + }, + }, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "123") + if !ok { + t.Fatal("expected streamer to be available") + } + if err := streamer.Finalize(context.Background(), "final reply"); err != nil { + t.Fatalf("Finalize() error = %v", err) + } + if ch.dismissedChatID != "123" { + t.Fatalf("expected tracked tool feedback to be dismissed for chat 123, got %q", ch.dismissedChatID) + } + if _, ok := m.streamActive.Load("test:123"); !ok { + t.Fatal("expected streamActive marker to be recorded after finalize") + } +} + +func TestGetStreamer_FinalizeSeparateMessagesClearsTrackedToolFeedback(t *testing.T) { + m := newTestManager() + m.config = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + SeparateMessages: true, + }, + }, + }, + } + ch := &mockStreamingChannel{ + mockMessageEditor: mockMessageEditor{}, + streamer: &mockStreamer{ + finalizeFn: func(_ context.Context, content string) error { + if content != "final reply" { + t.Fatalf("unexpected finalize content: %q", content) + } + return nil + }, + }, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "123") + if !ok { + t.Fatal("expected streamer to be available") + } + if err := streamer.Finalize(context.Background(), "final reply"); err != nil { + t.Fatalf("Finalize() error = %v", err) + } + if ch.clearedChatID != "123" { + t.Fatalf("expected tracked tool feedback to be cleared for chat 123, got %q", ch.clearedChatID) + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked tool feedback message to be preserved, got dismissal for %q", ch.dismissedChatID) + } + if _, ok := m.streamActive.Load("test:123"); !ok { + t.Fatal("expected streamActive marker to be recorded after finalize") + } +} + +func TestGetStreamer_FinalizeDismissesResolvedTrackedToolFeedback(t *testing.T) { + m := newTestManager() + ch := &mockStreamingChannel{ + mockMessageEditor: mockMessageEditor{}, + streamer: &mockStreamer{ + finalizeFn: func(_ context.Context, content string) error { + if content != "final reply" { + t.Fatalf("unexpected finalize content: %q", content) + } + return nil + }, + }, + resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string { + if outboundCtx == nil { + t.Fatal("expected outbound context during stream finalize") + } + if outboundCtx.ChatID != "-100123/42" { + t.Fatalf("unexpected outbound context: %+v", outboundCtx) + } + return outboundCtx.ChatID + }, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "-100123/42") + if !ok { + t.Fatal("expected streamer to be available") + } + if err := streamer.Finalize(context.Background(), "final reply"); err != nil { + t.Fatalf("Finalize() error = %v", err) + } + if ch.dismissedChatID != "-100123/42" { + t.Fatalf("expected resolved tracked tool feedback dismissal, got %q", ch.dismissedChatID) + } + if _, ok := m.streamActive.Load("test:-100123/42"); !ok { + t.Fatal("expected streamActive marker to be recorded after finalize") + } +} + +func TestPreSend_PlaceholderEditSuccessDismissesResolvedTrackedToolFeedback(t *testing.T) { + m := newTestManager() + + ch := &mockResolvedToolFeedbackEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "-100123" || messageID != "456" || content != "done" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + }, + resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string { + if outboundCtx == nil || outboundCtx.TopicID != "42" { + t.Fatalf("expected topic-aware outbound context, got %+v", outboundCtx) + } + return chatID + "/" + outboundCtx.TopicID + }, + } + + m.RecordPlaceholder("test", "-100123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "-100123", + Content: "done", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "-100123", + TopicID: "42", + }, + }) + + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.dismissedChatID != "-100123/42" { + t.Fatalf("expected resolved tracked dismissal, got %q", ch.dismissedChatID) + } +} + +func TestGetStreamer_FinalizeFailureDoesNotDismissTrackedToolFeedback(t *testing.T) { + m := newTestManager() + ch := &mockStreamingChannel{ + mockMessageEditor: mockMessageEditor{}, + streamer: &mockStreamer{ + finalizeFn: func(context.Context, string) error { + return errors.New("finalize failed") + }, + }, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "123") + if !ok { + t.Fatal("expected streamer to be available") + } + if err := streamer.Finalize(context.Background(), "final reply"); err == nil { + t.Fatal("expected Finalize() to fail") + } + if ch.dismissedChatID != "" { + t.Fatalf("expected no tool feedback dismissal on finalize failure, got %q", ch.dismissedChatID) + } + if _, ok := m.streamActive.Load("test:123"); ok { + t.Fatal("expected no streamActive marker after finalize failure") + } +} + +func TestRunWorker_ToolFeedbackSkipsMarkerSplitting(t *testing.T) { + m := newTestManager() + m.config = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + SplitOnMarker: true, + }, + }, + } + + var ( + mu sync.Mutex + received []string + ) + ch := &mockChannelWithLength{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + mu.Lock() + received = append(received, msg.Content) + mu.Unlock() + return nil + }, + }, + maxLen: 200, + } + + w := &channelWorker{ + ch: ch, + queue: make(chan bus.OutboundMessage, 1), + done: make(chan struct{}), + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go m.runWorker(ctx, "test", w) + + content := "🔧 `read_file`\nRead current config first.<|[SPLIT]|>Then update the example." + w.queue <- testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: content, + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + time.Sleep(100 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if len(received) != 1 { + t.Fatalf("len(received) = %d, want 1", len(received)) + } + if received[0] != content { + t.Fatalf("received[0] = %q, want %q", received[0], content) + } +} + func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { m := newTestManager() @@ -487,14 +1834,51 @@ func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { m.RecordPlaceholder("test", "123", "456") - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}) + _, edited := m.preSend(context.Background(), "test", msg, ch) if edited { t.Fatal("expected preSend to return false when edit fails") } } +func TestInvokeTypingStop_CallsRegisteredStop(t *testing.T) { + m := newTestManager() + var stopCalled bool + + m.RecordTypingStop("telegram", "chat123", func() { + stopCalled = true + }) + + m.InvokeTypingStop("telegram", "chat123") + + if !stopCalled { + t.Fatal("expected typing stop func to be called") + } +} + +func TestInvokeTypingStop_NoOpWhenNoEntry(t *testing.T) { + m := newTestManager() + // Should not panic + m.InvokeTypingStop("telegram", "nonexistent") +} + +func TestInvokeTypingStop_Idempotent(t *testing.T) { + m := newTestManager() + var callCount int + + m.RecordTypingStop("telegram", "chat123", func() { + callCount++ + }) + + m.InvokeTypingStop("telegram", "chat123") + m.InvokeTypingStop("telegram", "chat123") // Second call: entry already removed, no-op + + if callCount != 1 { + t.Fatalf("expected stop to be called once, got %d", callCount) + } +} + func TestPreSend_TypingStopCalled(t *testing.T) { m := newTestManager() var stopCalled bool @@ -509,7 +1893,7 @@ func TestPreSend_TypingStopCalled(t *testing.T) { stopCalled = true }) - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}) m.preSend(context.Background(), "test", msg, ch) if !stopCalled { @@ -526,8 +1910,8 @@ func TestPreSend_NoRegisteredState(t *testing.T) { }, } - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}) + _, edited := m.preSend(context.Background(), "test", msg, ch) if edited { t.Fatal("expected preSend to return false with no registered state") @@ -556,8 +1940,8 @@ func TestPreSend_TypingAndPlaceholder(t *testing.T) { }) m.RecordPlaceholder("test", "123", "456") - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}) + _, edited := m.preSend(context.Background(), "test", msg, ch) if !stopCalled { t.Fatal("expected typing stop to be called") @@ -600,6 +1984,37 @@ func TestRecordTypingStop_ConcurrentSafe(t *testing.T) { wg.Wait() } +func TestRecordTypingStop_ReplacesExistingStop(t *testing.T) { + m := newTestManager() + var oldStopCalls int + var newStopCalls int + + m.RecordTypingStop("test", "123", func() { + oldStopCalls++ + }) + + m.RecordTypingStop("test", "123", func() { + newStopCalls++ + }) + + if oldStopCalls != 1 { + t.Fatalf("expected previous typing stop to be called once when replaced, got %d", oldStopCalls) + } + if newStopCalls != 0 { + t.Fatalf("expected replacement typing stop to stay active until preSend, got %d calls", newStopCalls) + } + + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}) + m.preSend(context.Background(), "test", msg, &mockChannel{}) + + if newStopCalls != 1 { + t.Fatalf("expected replacement typing stop to be called by preSend, got %d", newStopCalls) + } + if oldStopCalls != 1 { + t.Fatalf("expected previous typing stop to not be called again, got %d", oldStopCalls) + } +} + func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) { m := newTestManager() var sendCalled bool @@ -623,7 +2038,7 @@ func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) { limiter: rate.NewLimiter(rate.Inf, 1), } - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}) m.sendWithRetry(context.Background(), "test", w, msg) if sendCalled { @@ -786,8 +2201,8 @@ func TestPreSendStillWorksWithWrappedTypes(t *testing.T) { }) m.RecordPlaceholder("test", "chat1", "ph_id") - msg := bus.OutboundMessage{Channel: "test", ChatID: "chat1", Content: "response"} - edited := m.preSend(context.Background(), "test", msg, ch) + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "chat1", Content: "response"}) + _, edited := m.preSend(context.Background(), "test", msg, ch) if !stopCalled { t.Fatal("expected typing stop to be called via wrapped type") @@ -860,3 +2275,363 @@ func TestBuildMediaScope_WithMessageID(t *testing.T) { t.Fatalf("expected %s, got %s", expected, scope) } } + +func TestManager_PlaceholderConsumedByResponse(t *testing.T) { + mgr := &Manager{ + channels: make(map[string]Channel), + workers: make(map[string]*channelWorker), + placeholders: sync.Map{}, + } + + mockCh := &mockChannel{ + sendFn: func(ctx context.Context, msg bus.OutboundMessage) error { + return nil + }, + } + worker := newChannelWorker("mock", mockCh, "mock") + mgr.channels["mock"] = mockCh + mgr.workers["mock"] = worker + + ctx := context.Background() + key := "mock:chat-1" + + // Simulate a placeholder recorded by base.go HandleMessage + mgr.RecordPlaceholder("mock", "chat-1", "ph-123") + + if _, ok := mgr.placeholders.Load(key); !ok { + t.Fatal("expected placeholder to be recorded") + } + + // Transcription feedback arrives first — it should consume the placeholder + // and be delivered via EditMessage, not Send. + msgTranscript := testOutboundMessage(bus.OutboundMessage{ + Channel: "mock", + ChatID: "chat-1", + Content: "Transcript: hello", + }) + mgr.sendWithRetry(ctx, "mock", worker, msgTranscript) + + if mockCh.editedMessages != 1 { + t.Errorf("expected 1 edited message (placeholder consumed by transcript), got %d", mockCh.editedMessages) + } + if len(mockCh.sentMessages) != 0 { + t.Errorf("expected 0 normal messages (transcript used edit), got %d", len(mockCh.sentMessages)) + } + + // Placeholder should be gone now + if _, ok := mgr.placeholders.Load(key); ok { + t.Error("expected placeholder to be removed after being consumed") + } + + // Final LLM response arrives — no placeholder left, so it goes through Send + msgFinal := testOutboundMessage(bus.OutboundMessage{ + Channel: "mock", + ChatID: "chat-1", + Content: "Final Answer", + }) + mgr.sendWithRetry(ctx, "mock", worker, msgFinal) + + if len(mockCh.sentMessages) != 1 { + t.Errorf("expected 1 normal message sent, got %d", len(mockCh.sentMessages)) + } +} + +func TestSendMessage_Synchronous(t *testing.T) { + m := newTestManager() + + var received []bus.OutboundMessage + ch := &mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + received = append(received, msg) + return nil + }, + } + + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.channels["test"] = ch + m.workers["test"] = w + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "hello world", + ReplyToMessageID: "msg-456", + }) + + err := m.SendMessage(context.Background(), msg) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // SendMessage is synchronous — message should already be delivered + if len(received) != 1 { + t.Fatalf("expected 1 message sent, got %d", len(received)) + } + if received[0].ReplyToMessageID != "msg-456" { + t.Fatalf("expected ReplyToMessageID msg-456, got %s", received[0].ReplyToMessageID) + } + if received[0].Content != "hello world" { + t.Fatalf("expected content 'hello world', got %s", received[0].Content) + } +} + +func TestSendMessage_UnknownChannel(t *testing.T) { + m := newTestManager() + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "nonexistent", + ChatID: "123", + Content: "hello", + }) + + err := m.SendMessage(context.Background(), msg) + if err == nil { + t.Fatal("expected error for unknown channel") + } +} + +func TestSendMessage_NoWorker(t *testing.T) { + m := newTestManager() + + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + } + m.channels["test"] = ch + // No worker registered + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "hello", + }) + + err := m.SendMessage(context.Background(), msg) + if err == nil { + t.Fatal("expected error when no worker exists") + } +} + +func TestSendMessage_WithRetry(t *testing.T) { + m := newTestManager() + + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + if callCount == 1 { + return fmt.Errorf("transient: %w", ErrTemporary) + } + return nil + }, + } + + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.channels["test"] = ch + m.workers["test"] = w + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "retry me", + }) + + err := m.SendMessage(context.Background(), msg) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if callCount != 2 { + t.Fatalf("expected 2 Send calls (1 failure + 1 success), got %d", callCount) + } +} + +func TestSendMessage_ContextOnlyUsesContextAddressing(t *testing.T) { + m := newTestManager() + + var received []bus.OutboundMessage + ch := &mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + received = append(received, msg) + return nil + }, + } + + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.channels["test"] = ch + m.workers["test"] = w + + msg := testOutboundMessage(bus.OutboundMessage{ + Context: bus.NewOutboundContext("test", "123", "msg-9"), + Content: "hello", + }) + + if err := m.SendMessage(context.Background(), msg); err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(received) != 1 { + t.Fatalf("expected 1 message sent, got %d", len(received)) + } + if received[0].Channel != "test" || received[0].ChatID != "123" { + t.Fatalf("expected mirrored legacy address, got %+v", received[0]) + } + if received[0].Context.Channel != "test" || received[0].Context.ChatID != "123" { + t.Fatalf("expected context address to be preserved, got %+v", received[0].Context) + } + if received[0].ReplyToMessageID != "msg-9" { + t.Fatalf("expected reply_to_message_id msg-9, got %q", received[0].ReplyToMessageID) + } +} + +func TestSendMessage_WithSplitting(t *testing.T) { + m := newTestManager() + + var received []string + ch := &mockChannelWithLength{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + received = append(received, msg.Content) + return nil + }, + }, + maxLen: 5, + } + + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.channels["test"] = ch + m.workers["test"] = w + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "hello world", + }) + + err := m.SendMessage(context.Background(), msg) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(received) < 2 { + t.Fatalf("expected message to be split into at least 2 chunks, got %d", len(received)) + } +} + +func TestSendMedia_ContextOnlyUsesContextAddressing(t *testing.T) { + m := newTestManager() + + var received []bus.OutboundMediaMessage + ch := &mockMediaChannel{ + sendMediaFn: func(_ context.Context, msg bus.OutboundMediaMessage) ([]string, error) { + received = append(received, msg) + return nil, nil + }, + } + + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.channels["test"] = ch + m.workers["test"] = w + + msg := testOutboundMediaMessage(bus.OutboundMediaMessage{ + Context: bus.NewOutboundContext("test", "media-chat", ""), + Parts: []bus.MediaPart{{Type: "image", Ref: "media://1"}}, + }) + + if err := m.SendMedia(context.Background(), msg); err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(received) != 1 { + t.Fatalf("expected 1 media message sent, got %d", len(received)) + } + if received[0].Channel != "test" || received[0].ChatID != "media-chat" { + t.Fatalf("expected mirrored legacy media address, got %+v", received[0]) + } + if received[0].Context.Channel != "test" || received[0].Context.ChatID != "media-chat" { + t.Fatalf("expected media context address to be preserved, got %+v", received[0].Context) + } +} + +func TestSendMessage_PreservesOrdering(t *testing.T) { + m := newTestManager() + + var order []string + ch := &mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + order = append(order, msg.Content) + return nil + }, + } + + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.channels["test"] = ch + m.workers["test"] = w + + // Send two messages sequentially — they must arrive in order + _ = m.SendMessage(context.Background(), testOutboundMessage(bus.OutboundMessage{ + Channel: "test", ChatID: "1", Content: "first", + })) + _ = m.SendMessage(context.Background(), testOutboundMessage(bus.OutboundMessage{ + Channel: "test", ChatID: "1", Content: "second", + })) + + if len(order) != 2 { + t.Fatalf("expected 2 messages, got %d", len(order)) + } + if order[0] != "first" || order[1] != "second" { + t.Fatalf("expected [first, second], got %v", order) + } +} + +func TestManager_SendPlaceholder(t *testing.T) { + mgr := &Manager{ + channels: make(map[string]Channel), + workers: make(map[string]*channelWorker), + placeholders: sync.Map{}, + } + + mockCh := &mockChannel{ + sendFn: func(ctx context.Context, msg bus.OutboundMessage) error { + return nil + }, + } + mgr.channels["mock"] = mockCh + + ctx := context.Background() + + // SendPlaceholder should send a placeholder and record it + ok := mgr.SendPlaceholder(ctx, "mock", "chat-1") + if !ok { + t.Fatal("expected SendPlaceholder to succeed") + } + if mockCh.placeholdersSent != 1 { + t.Errorf("expected 1 placeholder sent, got %d", mockCh.placeholdersSent) + } + + key := "mock:chat-1" + if _, loaded := mgr.placeholders.Load(key); !loaded { + t.Error("expected placeholder to be recorded in manager") + } + + // SendPlaceholder on unknown channel should return false + ok = mgr.SendPlaceholder(ctx, "unknown", "chat-1") + if ok { + t.Error("expected SendPlaceholder to fail for unknown channel") + } +} diff --git a/pkg/channels/marker.go b/pkg/channels/marker.go new file mode 100644 index 000000000..4801e3d27 --- /dev/null +++ b/pkg/channels/marker.go @@ -0,0 +1,37 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package channels + +import ( + "strings" +) + +// MessageSplitMarker is the delimiter used to split a message into multiple outbound messages. +// When SplitOnMarker is enabled in config, the Manager will split messages on this marker +// and send each part as a separate message. +const MessageSplitMarker = "<|[SPLIT]|>" + +// SplitByMarker splits a message by the MessageSplitMarker and returns the parts. +// Empty parts (including from consecutive markers) are filtered out. +// If no marker is found, returns a single-element slice containing the original content. +func SplitByMarker(content string) []string { + if content == "" { + return nil + } + parts := strings.Split(content, MessageSplitMarker) + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + if len(result) == 0 { + return []string{content} + } + return result +} diff --git a/pkg/channels/marker_test.go b/pkg/channels/marker_test.go new file mode 100644 index 000000000..b7b4ca99e --- /dev/null +++ b/pkg/channels/marker_test.go @@ -0,0 +1,141 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package channels + +import ( + "testing" +) + +func TestSplitByMarker_Basic(t *testing.T) { + content := "Hello <|[SPLIT]|>World" + chunks := SplitByMarker(content) + + if len(chunks) != 2 { + t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks) + } + if chunks[0] != "Hello" { + t.Errorf("Expected first chunk 'Hello', got %q", chunks[0]) + } + if chunks[1] != "World" { + t.Errorf("Expected second chunk 'World', got %q", chunks[1]) + } +} + +func TestSplitByMarker_NoMarker(t *testing.T) { + content := "Hello World" + chunks := SplitByMarker(content) + + if len(chunks) != 1 { + t.Fatalf("Expected 1 chunk, got %d: %q", len(chunks), chunks) + } + if chunks[0] != "Hello World" { + t.Errorf("Expected chunk 'Hello World', got %q", chunks[0]) + } +} + +func TestSplitByMarker_MultipleMarkers(t *testing.T) { + content := "Part1 <|[SPLIT]|> Part2 <|[SPLIT]|> Part3" + chunks := SplitByMarker(content) + + if len(chunks) != 3 { + t.Fatalf("Expected 3 chunks, got %d: %q", len(chunks), chunks) + } + if chunks[0] != "Part1" || chunks[1] != "Part2" || chunks[2] != "Part3" { + t.Errorf("Unexpected chunks: %q", chunks) + } +} + +func TestSplitByMarker_EmptyParts(t *testing.T) { + // Test consecutive markers and leading/trailing markers + content := "<|[SPLIT]|>Hello <|[SPLIT]|><|[SPLIT]|>World<|[SPLIT]|>" + chunks := SplitByMarker(content) + + if len(chunks) != 2 { + t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks) + } + if chunks[0] != "Hello" || chunks[1] != "World" { + t.Errorf("Unexpected chunks: %q", chunks) + } +} + +func TestSplitByMarker_WhitespaceTrimmed(t *testing.T) { + content := " Hello <|[SPLIT]|> World " + chunks := SplitByMarker(content) + + if len(chunks) != 2 { + t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks) + } + if chunks[0] != "Hello" || chunks[1] != "World" { + t.Errorf("Whitespace should be trimmed: %q", chunks) + } +} + +func TestSplitByMarker_EmptyInput(t *testing.T) { + chunks := SplitByMarker("") + if len(chunks) != 0 { + t.Errorf("Expected empty slice for empty input, got %d chunks", len(chunks)) + } +} + +// TestMarkerAndLengthSplitIntegration tests that SplitByMarker and SplitMessage work together correctly. +// Marker splitting happens first (per-agent config), then length splitting happens (per-channel config). +func TestMarkerAndLengthSplitIntegration(t *testing.T) { + maxLen := 10 + + // Original content: "Short <|[SPLIT]|> ThisIsAVeryLongString" + content := "Short <|[SPLIT]|> ThisIsAVeryLongString" + markerChunks := SplitByMarker(content) + + // Step 1: Marker split should give us 2 chunks + if len(markerChunks) != 2 { + t.Fatalf("Expected 2 marker chunks, got %d: %q", len(markerChunks), markerChunks) + } + + // Step 2: Length split should be applied to each marker chunk + var finalChunks []string + for _, chunk := range markerChunks { + if len([]rune(chunk)) > maxLen { + lengthChunks := SplitMessage(chunk, maxLen) + finalChunks = append(finalChunks, lengthChunks...) + } else { + finalChunks = append(finalChunks, chunk) + } + } + + // "Short" is 6 chars, within limit + // "ThisIsAVeryLongString" is 22 chars, should be split into multiple chunks + // SplitMessage with maxLen=10 splits: "ThisIsAVeryLongString" -> ["ThisI", "sAVer", "yLong", "String"] (5 chunks) + if len(finalChunks) != 5 { + t.Errorf("Expected 5 final chunks, got %d: %q", len(finalChunks), finalChunks) + } + + // Verify first chunk is unchanged + if finalChunks[0] != "Short" { + t.Errorf("First chunk should be 'Short', got %q", finalChunks[0]) + } + + // Verify all length-split chunks are within limit + for i, chunk := range finalChunks[1:] { + if len([]rune(chunk)) > maxLen { + t.Errorf("Chunk %d exceeds maxLen: %q (%d chars)", i+1, chunk, len([]rune(chunk))) + } + } +} + +// TestMarkerSplitPreservesCodeBlockIntegrity tests that marker split preserves code block boundaries +func TestMarkerSplitPreservesCodeBlockIntegrity(t *testing.T) { + content := "Hello <|[SPLIT]|>```go\npackage main\n```<|[SPLIT]|>World" + chunks := SplitByMarker(content) + + if len(chunks) != 3 { + t.Fatalf("Expected 3 chunks, got %d: %q", len(chunks), chunks) + } + + // Verify code block is intact in middle chunk + if chunks[1] != "```go\npackage main\n```" { + t.Errorf("Code block not preserved correctly: %q", chunks[1]) + } +} diff --git a/pkg/channels/matrix/init.go b/pkg/channels/matrix/init.go index 6677f855e..f645a464b 100644 --- a/pkg/channels/matrix/init.go +++ b/pkg/channels/matrix/init.go @@ -1,13 +1,38 @@ package matrix import ( + "path/filepath" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) func init() { - channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewMatrixChannel(cfg.Channels.Matrix, b) - }) + channels.RegisterFactory( + config.ChannelMatrix, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.MatrixSettings) + if !ok { + return nil, channels.ErrSendFailed + } + cryptoDatabasePath := c.CryptoDatabasePath + if cryptoDatabasePath == "" { + cryptoDatabasePath = filepath.Join(cfg.WorkspacePath(), "matrix") + } + ch, err := NewMatrixChannel(bc, c, b, cryptoDatabasePath) + if err != nil { + return nil, err + } + if channelName != config.ChannelMatrix { + ch.SetName(channelName) + } + return ch, nil + }, + ) } diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index d51eee8fb..04599d6d2 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -2,8 +2,10 @@ package matrix import ( "context" + "database/sql" "fmt" "html" + "io" "mime" "net/url" "os" @@ -13,9 +15,15 @@ import ( "sync" "time" + "github.com/gomarkdown/markdown" + mdhtml "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" + "go.mau.fi/util/dbutil" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto/cryptohelper" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + _ "modernc.org/sqlite" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" @@ -26,17 +34,25 @@ import ( ) const ( + sqliteDriver = "sqlite" + dbName = "store.db" + typingRefreshInterval = 20 * time.Second typingServerTTL = 30 * time.Second roomKindCacheTTL = 5 * time.Minute roomKindCacheCleanupPeriod = 1 * time.Minute roomKindCacheMaxEntries = 2048 - - matrixMediaTempDirName = "picoclaw_media" ) var matrixMentionHrefRegexp = regexp.MustCompile(`(?i)]+href=["']([^"']+)["']`) +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + type roomKindCacheEntry struct { isGroup bool expiresAt time.Time @@ -165,9 +181,10 @@ func (s *typingSession) stop() { // MatrixChannel implements the Channel interface for Matrix. type MatrixChannel struct { *channels.BaseChannel + bc *config.Channel client *mautrix.Client - config config.MatrixConfig + config *config.MatrixSettings syncer *mautrix.DefaultSyncer ctx context.Context @@ -179,12 +196,21 @@ type MatrixChannel struct { roomKindCache *roomKindCache localpartMentionR *regexp.Regexp + + cryptoHelper *cryptohelper.CryptoHelper + cryptoDbPath string + progress *channels.ToolFeedbackAnimator } -func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*MatrixChannel, error) { +func NewMatrixChannel( + bc *config.Channel, + cfg *config.MatrixSettings, + messageBus *bus.MessageBus, + cryptoDatabasePath string, +) (*MatrixChannel, error) { homeserver := strings.TrimSpace(cfg.Homeserver) userID := strings.TrimSpace(cfg.UserID) - accessToken := strings.TrimSpace(cfg.AccessToken) + accessToken := strings.TrimSpace(cfg.AccessToken.String()) if homeserver == "" { return nil, fmt.Errorf("matrix homeserver is required") } @@ -212,14 +238,15 @@ func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*Mat "matrix", cfg, messageBus, - cfg.AllowFrom, + bc.AllowFrom, channels.WithMaxMessageLength(65536), - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - return &MatrixChannel{ + ch := &MatrixChannel{ BaseChannel: base, + bc: bc, client: client, config: cfg, syncer: syncer, @@ -228,7 +255,10 @@ func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*Mat roomKindCache: newRoomKindCache(roomKindCacheMaxEntries, roomKindCacheTTL), localpartMentionR: localpartMentionRegexp(matrixLocalpart(client.UserID)), typingMu: sync.Mutex{}, - }, nil + cryptoDbPath: cryptoDatabasePath, + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } func (c *MatrixChannel) Start(ctx context.Context) error { @@ -237,7 +267,21 @@ func (c *MatrixChannel) Start(ctx context.Context) error { c.ctx, c.cancel = context.WithCancel(ctx) c.startTime = time.Now() + // Initialize crypto helper if database and passphrase are configured + if c.cryptoDbPath != "" && c.config.CryptoPassphrase != "" { + if err := c.initCrypto(ctx); err != nil { + logger.WarnCF( + "matrix", + "Failed to initialize crypto, continuing without encryption support", + map[string]any{ + "error": err.Error(), + }, + ) + } + } + c.syncer.OnEventType(event.EventMessage, c.handleMessageEvent) + c.syncer.OnEventType(event.EventEncrypted, c.handleMessageEvent) c.syncer.OnEventType(event.StateMember, c.handleMemberEvent) c.SetRunning(true) @@ -263,41 +307,158 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { c.cancel() } c.stopTypingSessions(ctx) + if c.progress != nil { + c.progress.StopAll() + } + + // Close crypto helper if initialized + if c.cryptoHelper != nil { + c.cryptoHelper.Close() + c.cryptoHelper = nil + c.client.Crypto = nil + } logger.InfoC("matrix", "Matrix channel stopped") return nil } -func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MatrixChannel) initCrypto(ctx context.Context) error { + logger.InfoC("matrix", "Initializing crypto helper") + + // Ensure the crypto database directory exists + if err := os.MkdirAll(c.cryptoDbPath, 0o700); err != nil { + return fmt.Errorf("create crypto database directory: %w", err) + } + + // Create database with sqlite driver (modernc.org/sqlite) + dbPath := filepath.Join(c.cryptoDbPath, dbName) + connStr := "file:" + dbPath + "?_foreign_keys=on" + + db, err := sql.Open(sqliteDriver, connStr) + if err != nil { + return fmt.Errorf("open crypto database: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + // Execute PRAGMA statements + // This is equivalent to the "sqlite3-fk-wal" dialect used by cryptohelper + pragmaStmts := []string{ + "PRAGMA foreign_keys = ON", + "PRAGMA journal_mode = WAL", + "PRAGMA synchronous = NORMAL", + "PRAGMA busy_timeout = 5000", + } + for _, pragma := range pragmaStmts { + if _, err = db.ExecContext(ctx, pragma); err != nil { + _ = db.Close() + return fmt.Errorf("execute %s: %w", pragma, err) + } + } + + // Wrap with dbutil for dialect support + wrappedDB, err := dbutil.NewWithDB(db, sqliteDriver) + if err != nil { + _ = db.Close() + return fmt.Errorf("wrap database: %w", err) + } + + cryptoHelper, err := cryptohelper.NewCryptoHelper(c.client, []byte(c.config.CryptoPassphrase), wrappedDB) + if err != nil { + return fmt.Errorf("create crypto helper: %w", err) + } + + if c.client.DeviceID == "" { + resp, whoamiErr := c.client.Whoami(ctx) + if whoamiErr != nil { + _ = db.Close() + return fmt.Errorf("get device ID via whoami: %w", whoamiErr) + } + c.client.DeviceID = resp.DeviceID + } + + if err = cryptoHelper.Init(ctx); err != nil { + cryptoHelper.Close() + return fmt.Errorf("init crypto helper: %w", err) + } + + c.client.Crypto = cryptoHelper + c.cryptoHelper = cryptoHelper + + logger.InfoC("matrix", "Crypto helper initialized successfully") + return nil +} + +func markdownToHTML(md string) string { + extensions := (parser.CommonExtensions | parser.NoEmptyLineBeforeBlock) &^ parser.DefinitionLists + p := parser.NewWithExtensions(extensions) + renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.UseXHTML}) + return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer))) +} + +func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } roomID := id.RoomID(strings.TrimSpace(msg.ChatID)) if roomID == "" { - return fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) } content := strings.TrimSpace(msg.Content) if content == "" { - return nil + return nil, nil } - _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgText, - Body: content, - }) - if err != nil { - return fmt.Errorf("matrix send: %w", channels.ErrTemporary) + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, content); handled { + if err != nil { + return nil, err + } + return []string{msgID}, nil + } } - return nil + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + if !isToolFeedback { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } + } + if isToolFeedback { + content = channels.InitialAnimatedToolFeedbackContent(content) + } + + resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) + if err != nil { + return nil, fmt.Errorf("matrix send: %w", channels.ErrTemporary) + } + msgID := resp.EventID.String() + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil +} + +func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { + mc := &event.MessageEventContent{MsgType: event.MsgText, Body: text} + if c.config.MessageFormat != "plain" { + mc.Format = event.FormatHTML + mc.FormattedBody = markdownToHTML(text) + } + return mc } // SendMedia implements channels.MediaSender. -func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + sendCtx := ctx if sendCtx == nil { sendCtx = context.Background() @@ -305,17 +466,18 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess roomID := id.RoomID(strings.TrimSpace(msg.ChatID)) if roomID == "" { - return fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } + var eventIDs []string for _, part := range msg.Parts { if err := sendCtx.Err(); err != nil { - return err + return nil, err } localPath, meta, err := store.ResolveWithMeta(part.Ref) @@ -380,7 +542,7 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess "type": part.Type, "error": err.Error(), }) - return fmt.Errorf("matrix upload media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("matrix upload media: %w", channels.ErrTemporary) } msgType := matrixOutboundMsgType(part.Type, filename, contentType) @@ -393,17 +555,25 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess uploadResp.ContentURI.CUString(), ) - if _, err := c.client.SendMessageEvent(sendCtx, roomID, event.EventMessage, content); err != nil { + sendResp, err := c.client.SendMessageEvent(sendCtx, roomID, event.EventMessage, content) + if err != nil { logger.ErrorCF("matrix", "Failed to send media message", map[string]any{ "room_id": roomID.String(), "type": msgType, "error": err.Error(), }) - return fmt.Errorf("matrix send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("matrix send media: %w", channels.ErrTemporary) + } + if sendResp != nil { + eventIDs = append(eventIDs, sendResp.EventID.String()) } } - return nil + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + + return eventIDs, nil } // StartTyping implements channels.TypingCapable. @@ -447,7 +617,7 @@ func (c *MatrixChannel) StartTyping(ctx context.Context, chatID string) (func(), // SendPlaceholder implements channels.PlaceholderCapable. func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { - if !c.config.Placeholder.Enabled { + if !c.bc.Placeholder.Enabled { return "", nil } @@ -456,10 +626,7 @@ func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (str return "", fmt.Errorf("matrix room ID is empty") } - text := strings.TrimSpace(c.config.Placeholder.Text) - if text == "" { - text = "Thinking... 💭" - } + text := c.bc.Placeholder.GetRandomText() resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ MsgType: event.MsgNotice, @@ -482,16 +649,96 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI return fmt.Errorf("matrix message ID is empty") } - editContent := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: content, - } + editContent := c.messageContent(content) editContent.SetEdit(id.EventID(messageID)) _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent) return err } +// DeleteMessage implements channels.MessageDeleter. +func (c *MatrixChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + roomID := id.RoomID(strings.TrimSpace(chatID)) + if roomID == "" { + return fmt.Errorf("matrix room ID is empty") + } + eventID := id.EventID(strings.TrimSpace(messageID)) + if eventID == "" { + return fmt.Errorf("matrix message ID is empty") + } + + _, err := c.client.RedactEvent(ctx, roomID, eventID) + return err +} + +func (c *MatrixChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *MatrixChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *MatrixChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *MatrixChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *MatrixChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *MatrixChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + _ = c.DeleteMessage(ctx, chatID, messageID) +} + +func (c *MatrixChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *MatrixChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + func (c *MatrixChannel) handleMemberEvent(ctx context.Context, evt *event.Event) { if !c.config.JoinOnInvite { return @@ -537,9 +784,26 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event return } - msgEvt := evt.Content.AsMessage() - if msgEvt == nil { - return + var msgEvt *event.MessageEventContent + switch evt.Type { + case event.EventMessage: + // When crypto is enabled, events marked WasEncrypted=true are + // re-dispatched by c.cryptoHelper after decryption and will be + // processed again in the EventEncrypted branch. Skip to avoid duplication. + if c.client.Crypto != nil && evt.Mautrix.WasEncrypted { + return + } + + msgEvt = evt.Content.AsMessage() + if msgEvt == nil || msgEvt.MsgType == "" { + return + } + case event.EventEncrypted: + var ok bool + msgEvt, ok = c.decryptEvent(ctx, evt) + if !ok { + return + } } // Ignore edits. @@ -586,8 +850,8 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event logger.DebugCF("matrix", "Ignoring group message by trigger rules", map[string]any{ "room_id": roomID, "is_mentioned": isMentioned, - "mention_only": c.config.GroupTrigger.MentionOnly, - "prefixes": c.config.GroupTrigger.Prefixes, + "mention_only": c.bc.GroupTrigger.MentionOnly, + "prefixes": c.bc.GroupTrigger.Prefixes, }) return } @@ -602,10 +866,8 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event } peerKind := "direct" - peerID := senderID if isGroup { peerKind = "group" - peerID = roomID } metadata := map[string]string{ @@ -618,17 +880,49 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event metadata["reply_to_msg_id"] = replyTo.String() } - c.HandleMessage( - c.baseContext(), - bus.Peer{Kind: peerKind, ID: peerID}, - evt.ID.String(), - senderID, - roomID, - content, - mediaPaths, - metadata, - sender, - ) + inboundCtx := bus.InboundContext{ + Channel: "matrix", + ChatID: roomID, + ChatType: peerKind, + SenderID: senderID, + MessageID: evt.ID.String(), + Raw: metadata, + } + if replyTo := msgEvt.GetRelatesTo().GetReplyTo(); replyTo != "" { + inboundCtx.ReplyToMessageID = replyTo.String() + } + + c.HandleInboundContext(c.baseContext(), roomID, content, mediaPaths, inboundCtx, sender) +} + +// decryptEvent decrypts an encrypted event and returns the decrypted message event content. +// It returns the decrypted content and a boolean indicating whether decryption was successful. +func (c *MatrixChannel) decryptEvent(ctx context.Context, evt *event.Event) (*event.MessageEventContent, bool) { + if c.client.Crypto == nil { + logger.DebugCF("matrix", "Received encrypted message but crypto is not enabled", map[string]any{ + "room_id": evt.RoomID.String(), + }) + return nil, false + } + + decrypted, err := c.client.Crypto.Decrypt(ctx, evt) + if err != nil { + logger.WarnCF("matrix", "Failed to decrypt message", map[string]any{ + "room_id": evt.RoomID.String(), + "error": err.Error(), + }) + return nil, false + } + + if decrypted.Type != event.EventMessage { + logger.DebugCF("matrix", "Decrypted event is not a message event", map[string]any{ + "room_id": evt.RoomID.String(), + "type": decrypted.Type.String(), + }) + return nil, false + } + + return decrypted.Content.AsMessage(), true } func (c *MatrixChannel) extractInboundContent( @@ -681,6 +975,9 @@ func (c *MatrixChannel) extractInboundMedia( func (c *MatrixChannel) storeMedia(localPath string, meta media.MediaMeta, scope string) string { if store := c.GetMediaStore(); store != nil { + if meta.CleanupPolicy == "" { + meta.CleanupPolicy = media.CleanupPolicyDeleteOnCleanup + } ref, err := store.Store(localPath, meta, scope) if err == nil { return ref @@ -714,17 +1011,23 @@ func (c *MatrixChannel) downloadMedia( reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second) defer cancel() - data, err := c.client.DownloadBytes(reqCtx, parsed) + resp, err := c.client.Download(reqCtx, parsed) if err != nil { return "", err } + defer resp.Body.Close() + + reader := resp.Body + readerClose := func() error { return nil } // Encrypted attachments put URL in msgEvt.File and require client-side decryption. if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" { - err = msgEvt.File.DecryptInPlace(data) - if err != nil { + if err = msgEvt.File.PrepareForDecryption(); err != nil { return "", fmt.Errorf("decrypt matrix media: %w", err) } + decryptReader := msgEvt.File.DecryptStream(resp.Body) + reader = decryptReader + readerClose = decryptReader.Close } label := matrixMediaLabel(msgEvt, mediaKind) @@ -737,14 +1040,28 @@ func (c *MatrixChannel) downloadMedia( if err != nil { return "", err } - defer tmp.Close() + tmpPath := tmp.Name() + cleanup := true + defer func() { + _ = tmp.Close() + if cleanup { + _ = os.Remove(tmpPath) + } + }() - if _, err = tmp.Write(data); err != nil { - _ = os.Remove(tmp.Name()) + _, err = io.Copy(tmp, reader) + if err != nil { + return "", err + } + if err = readerClose(); err != nil { + return "", fmt.Errorf("decrypt matrix media: %w", err) + } + if err = tmp.Close(); err != nil { return "", err } - return tmp.Name(), nil + cleanup = false + return tmpPath, nil } func matrixContentType(msgEvt *event.MessageEventContent) string { @@ -1072,7 +1389,7 @@ func (c *MatrixChannel) stripSelfMention(text string) string { } func matrixMediaTempDir() (string, error) { - mediaDir := filepath.Join(os.TempDir(), matrixMediaTempDirName) + mediaDir := media.TempDir() if err := os.MkdirAll(mediaDir, 0o700); err != nil { return "", err } @@ -1113,3 +1430,8 @@ func stripUserMentionWithRegexp(text string, userID id.UserID, mentionR *regexp. cleaned = strings.TrimLeft(cleaned, ",:; ") return strings.TrimSpace(cleaned) } + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *MatrixChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go index e76db0d3e..066f08059 100644 --- a/pkg/channels/matrix/matrix_test.go +++ b/pkg/channels/matrix/matrix_test.go @@ -2,14 +2,21 @@ package matrix import ( "context" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" ) func TestMatrixLocalpartMentionRegexp(t *testing.T) { @@ -35,6 +42,34 @@ func TestMatrixLocalpartMentionRegexp(t *testing.T) { } } +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &MatrixChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("!room:matrix.org", "$event1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "!room:matrix.org", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "!room:matrix.org" || messageID != "$event1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "$event1" { + t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want [$event1]", msgIDs) + } +} + func TestStripUserMention(t *testing.T) { userID := id.UserID("@picoclaw:matrix.org") @@ -160,7 +195,7 @@ func TestMatrixMediaTempDir(t *testing.T) { if err != nil { t.Fatalf("matrixMediaTempDir failed: %v", err) } - if filepath.Base(dir) != matrixMediaTempDirName { + if filepath.Base(dir) != media.TempDirName { t.Fatalf("unexpected media dir base: %q", filepath.Base(dir)) } @@ -194,6 +229,50 @@ func TestMatrixMediaExt(t *testing.T) { } } +func TestDownloadMedia_WritesResponseToTempFile(t *testing.T) { + const wantBody = "matrix-media-payload" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/_matrix/client/v1/media/download/matrix.test/abc123") { + t.Fatalf("unexpected download path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write([]byte(wantBody)) + })) + defer server.Close() + + client, err := mautrix.NewClient(server.URL, id.UserID("@picoclaw:matrix.test"), "") + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + ch := &MatrixChannel{client: client} + msg := &event.MessageEventContent{ + MsgType: event.MsgImage, + Body: "image.png", + URL: id.ContentURIString("mxc://matrix.test/abc123"), + Info: &event.FileInfo{MimeType: "image/png"}, + } + + path, err := ch.downloadMedia(context.Background(), msg, "image") + if err != nil { + t.Fatalf("downloadMedia: %v", err) + } + defer os.Remove(path) + + if ext := filepath.Ext(path); ext != ".png" { + t.Fatalf("temp file extension=%q want=.png", ext) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != wantBody { + t.Fatalf("file contents=%q want=%q", string(got), wantBody) + } +} + func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) { ch := &MatrixChannel{} msg := &event.MessageEventContent{ @@ -289,3 +368,123 @@ func TestMatrixOutboundContent(t *testing.T) { t.Fatalf("unexpected fallback body: %q", noCaption.Body) } } + +func TestMarkdownToHTML(t *testing.T) { + cases := []struct { + name string + md string + rendered string + }{ + { + name: "paragraph", + md: "just **some** text with _custom_ formatting and `inline` code", + rendered: "

just some text with custom formatting and inline code

", + }, + { + name: "heading", + md: "### Title", + rendered: `

Title

`, + }, + { + name: "fenced code block", + md: "```\nfoo()\n```", + rendered: "
foo()\n
", + }, + { + name: "loose list", + md: "- Item one\n\n- Item two\n", + rendered: `
    +
  • Item one

  • + +
  • Item two

  • +
`, + }, + { + name: "tight list", + md: "- Alpha\n- Beta\n", + rendered: `
    +
  • Alpha
  • +
  • Beta
  • +
`, + }, + { + name: "list item with nested sublist", + md: "1. Steps overview:\n\n - Step A\n - Step B\n", + rendered: `
    +
  1. Steps overview:

    + +
      +
    • Step A
    • +
    • Step B
    • +
  2. +
`, + }, + { + // Definition list syntax is not enabled; the term and definition are + // rendered as a plain paragraph rather than
/
/
elements. + name: "definition list syntax renders as plain paragraph", + md: "Term\n: Definition of the term.\n", + rendered: "

Term\n: Definition of the term.

", + }, + { + name: "comprehensive document with headings, paragraphs, list, and code block", + md: "# Overview\n\nThis is a sample document designed to demonstrate various Markdown elements in a single block of text.\n\nThe first paragraph introduces the concept of structured data.\n\n## Details\n\nThe following is a list:\n\n* First\n* Second\n* Third\n\nThe second paragraph focuses on details. Below is a generic code snippet:\n\n```python\ndef calculate_area(radius):\n import math\n return math.pi * (radius ** 2)\n```\n\nThis concludes the generic sample text.\n", + rendered: `

Overview

+ +

This is a sample document designed to demonstrate various Markdown elements in a single block of text.

+ +

The first paragraph introduces the concept of structured data.

+ +

Details

+ +

The following is a list:

+ +
    +
  • First
  • +
  • Second
  • +
  • Third
  • +
+ +

The second paragraph focuses on details. Below is a generic code snippet:

+ +
def calculate_area(radius):
+    import math
+    return math.pi * (radius ** 2)
+
+ +

This concludes the generic sample text.

`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := markdownToHTML(tc.md); got != tc.rendered { + t.Fatalf("markdownToHTML(%q)\n got: %q\nwant: %q", tc.md, got, tc.rendered) + } + }) + } +} + +func TestMessageContent(t *testing.T) { + richtext := &MatrixChannel{config: &config.MatrixSettings{MessageFormat: "richtext"}} + plain := &MatrixChannel{config: &config.MatrixSettings{MessageFormat: "plain"}} + defaultt := &MatrixChannel{config: &config.MatrixSettings{}} + + for _, c := range []*MatrixChannel{richtext, defaultt} { + mc := c.messageContent("**hi**") + if mc.Format != event.FormatHTML { + t.Errorf("format %q: expected FormatHTML, got %q", c.config.MessageFormat, mc.Format) + } + if !strings.Contains(mc.FormattedBody, "hi") { + t.Errorf("format %q: FormattedBody %q missing ", c.config.MessageFormat, mc.FormattedBody) + } + if mc.Body != "**hi**" { + t.Errorf("format %q: Body should remain plain, got %q", c.config.MessageFormat, mc.Body) + } + } + + mc := plain.messageContent("**hi**") + if mc.Format != "" || mc.FormattedBody != "" { + t.Errorf("plain: expected no formatting, got format=%q formattedBody=%q", mc.Format, mc.FormattedBody) + } +} diff --git a/pkg/channels/media.go b/pkg/channels/media.go index c645a6180..95905ae00 100644 --- a/pkg/channels/media.go +++ b/pkg/channels/media.go @@ -11,5 +11,5 @@ import ( // Manager discovers channels implementing this interface via type // assertion and routes OutboundMediaMessage to them. type MediaSender interface { - SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) } diff --git a/pkg/channels/onebot/init.go b/pkg/channels/onebot/init.go index 84c06dfd6..f6791899c 100644 --- a/pkg/channels/onebot/init.go +++ b/pkg/channels/onebot/init.go @@ -7,7 +7,19 @@ import ( ) func init() { - channels.RegisterFactory("onebot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewOneBotChannel(cfg.Channels.OneBot, b) - }) + channels.RegisterFactory( + config.ChannelOneBot, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.OneBotSettings) + if !ok { + return nil, channels.ErrSendFailed + } + return NewOneBotChannel(bc, c, b) + }, + ) } diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index 62a9eb34a..f0d0a890f 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -23,7 +23,7 @@ import ( type OneBotChannel struct { *channels.BaseChannel - config config.OneBotConfig + config *config.OneBotSettings conn *websocket.Conn ctx context.Context cancel context.CancelFunc @@ -96,10 +96,14 @@ type oneBotMessageSegment struct { Data map[string]any `json:"data"` } -func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { - base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom, - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), +func NewOneBotChannel( + bc *config.Channel, + cfg *config.OneBotSettings, + messageBus *bus.MessageBus, +) (*OneBotChannel, error) { + base := channels.NewBaseChannel("onebot", cfg, messageBus, bc.AllowFrom, + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) const dedupSize = 1024 @@ -184,8 +188,8 @@ func (c *OneBotChannel) connect() error { dialer.HandshakeTimeout = 10 * time.Second header := make(map[string][]string) - if c.config.AccessToken != "" { - header["Authorization"] = []string{"Bearer " + c.config.AccessToken} + if c.config.AccessToken.String() != "" { + header["Authorization"] = []string{"Bearer " + c.config.AccessToken.String()} } conn, resp, err := dialer.Dial(c.config.WSUrl, header) @@ -391,15 +395,15 @@ func (c *OneBotChannel) Stop(ctx context.Context) error { return nil } -func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Check ctx before entering write path select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -408,12 +412,12 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error c.mu.Unlock() if conn == nil { - return fmt.Errorf("OneBot WebSocket not connected") + return nil, fmt.Errorf("OneBot WebSocket not connected") } action, params, err := c.buildSendRequest(msg) if err != nil { - return err + return nil, err } echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) @@ -426,7 +430,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error data, err := json.Marshal(req) if err != nil { - return fmt.Errorf("failed to marshal OneBot request: %w", err) + return nil, fmt.Errorf("failed to marshal OneBot request: %w", err) } c.writeMu.Lock() @@ -439,21 +443,21 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error logger.ErrorCF("onebot", "Failed to send message", map[string]any{ "error": err.Error(), }) - return fmt.Errorf("onebot send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("onebot send: %w", channels.ErrTemporary) } - return nil + return nil, nil } // SendMedia implements the channels.MediaSender interface. -func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -462,12 +466,12 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess c.mu.Unlock() if conn == nil { - return fmt.Errorf("OneBot WebSocket not connected") + return nil, fmt.Errorf("OneBot WebSocket not connected") } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } // Build media segments @@ -508,7 +512,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess } if len(segments) == 0 { - return nil + return nil, nil } chatID := msg.ChatID @@ -524,7 +528,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess id, err := strconv.ParseInt(rawID, 10, 64) if err != nil { - return fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed) + return nil, fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed) } echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) @@ -537,7 +541,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess data, err := json.Marshal(req) if err != nil { - return fmt.Errorf("failed to marshal OneBot request: %w", err) + return nil, fmt.Errorf("failed to marshal OneBot request: %w", err) } c.writeMu.Lock() @@ -550,10 +554,10 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess logger.ErrorCF("onebot", "Failed to send media message", map[string]any{ "error": err.Error(), }) - return fmt.Errorf("onebot send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("onebot send media: %w", channels.ErrTemporary) } - return nil + return nil, nil } func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment { @@ -749,8 +753,9 @@ func (c *OneBotChannel) parseMessageSegments( storeFile := func(localPath, filename string) string { if store != nil { ref, err := store.Store(localPath, media.MediaMeta{ - Filename: filename, - Source: "onebot", + Filename: filename, + Source: "onebot", + CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, }, scope) if err == nil { return ref @@ -990,8 +995,8 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { senderID := strconv.FormatInt(userID, 10) var chatID string - - var peer bus.Peer + var contextChatID string + var contextChatType string metadata := map[string]string{} @@ -1002,12 +1007,14 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { switch raw.MessageType { case "private": chatID = "private:" + senderID - peer = bus.Peer{Kind: "direct", ID: senderID} + contextChatID = senderID + contextChatType = "direct" case "group": groupIDStr := strconv.FormatInt(groupID, 10) chatID = "group:" + groupIDStr - peer = bus.Peer{Kind: "group", ID: groupIDStr} + contextChatID = groupIDStr + contextChatType = "group" metadata["group_id"] = groupIDStr senderUserID, _ := parseJSONInt64(sender.UserID) @@ -1071,7 +1078,18 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { return } - c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, parsed.Media, metadata, senderInfo) + inboundCtx := bus.InboundContext{ + Channel: c.Name(), + ChatID: contextChatID, + ChatType: contextChatType, + SenderID: senderID, + MessageID: messageID, + Mentioned: isBotMentioned, + ReplyToMessageID: parsed.ReplyTo, + Raw: metadata, + } + + c.HandleInboundContext(c.ctx, chatID, content, parsed.Media, inboundCtx, senderInfo) } func (c *OneBotChannel) isDuplicate(messageID string) bool { @@ -1103,3 +1121,8 @@ func truncate(s string, n int) string { } return string(runes[:n]) + "..." } + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *OneBotChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/pico/client.go b/pkg/channels/pico/client.go new file mode 100644 index 000000000..009900e01 --- /dev/null +++ b/pkg/channels/pico/client.go @@ -0,0 +1,331 @@ +package pico + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// PicoClientChannel connects to a remote Pico Protocol WebSocket server. +type PicoClientChannel struct { + *channels.BaseChannel + config *config.PicoClientSettings + conn *picoConn + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc +} + +// NewPicoClientChannel creates a new Pico Protocol client channel. +func NewPicoClientChannel( + bc *config.Channel, + cfg *config.PicoClientSettings, + messageBus *bus.MessageBus, +) (*PicoClientChannel, error) { + if cfg.URL == "" { + return nil, fmt.Errorf("pico_client url is required") + } + + base := channels.NewBaseChannel("pico_client", cfg, messageBus, bc.AllowFrom) + + return &PicoClientChannel{ + BaseChannel: base, + config: cfg, + }, nil +} + +// Start dials the remote server and begins reading. +func (c *PicoClientChannel) Start(ctx context.Context) error { + logger.InfoC("pico_client", "Starting Pico Client channel") + c.ctx, c.cancel = context.WithCancel(ctx) + + if err := c.dial(); err != nil { + c.cancel() + return fmt.Errorf("pico_client initial connect: %w", err) + } + + c.SetRunning(true) + go c.reconnectLoop() + + logger.InfoCF("pico_client", "Connected", map[string]any{"url": c.config.URL}) + return nil +} + +// Stop closes the connection. +func (c *PicoClientChannel) Stop(ctx context.Context) error { + logger.InfoC("pico_client", "Stopping Pico Client channel") + c.SetRunning(false) + if c.cancel != nil { + c.cancel() + } + c.mu.Lock() + if c.conn != nil { + c.conn.close() + } + c.mu.Unlock() + logger.InfoC("pico_client", "Pico Client channel stopped") + return nil +} + +func (c *PicoClientChannel) dial() error { + header := http.Header{} + if c.config.Token.String() != "" { + header.Set("Authorization", "Bearer "+c.config.Token.String()) + } + + ws, resp, err := websocket.DefaultDialer.DialContext(c.ctx, c.config.URL, header) + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + if err != nil { + return err + } + + connCtx, connCancel := context.WithCancel(c.ctx) + + pc := &picoConn{ + id: uuid.New().String(), + conn: ws, + sessionID: c.config.SessionID, + cancel: connCancel, + } + if pc.sessionID == "" { + pc.sessionID = uuid.New().String() + } + + c.mu.Lock() + c.conn = pc + c.mu.Unlock() + + go c.readLoop(connCtx, pc) + return nil +} + +// reconnectLoop re-dials when the connection drops. +func (c *PicoClientChannel) reconnectLoop() { + for { + select { + case <-c.ctx.Done(): + return + default: + } + + c.mu.Lock() + pc := c.conn + c.mu.Unlock() + + if pc == nil || pc.closed.Load() { + backoff := 5 * time.Second + logger.InfoC("pico_client", "Reconnecting...") + if err := c.dial(); err != nil { + logger.WarnCF("pico_client", "Reconnect failed", map[string]any{ + "error": err.Error(), + }) + select { + case <-c.ctx.Done(): + return + case <-time.After(backoff): + } + continue + } + logger.InfoC("pico_client", "Reconnected") + } + + select { + case <-c.ctx.Done(): + return + case <-time.After(1 * time.Second): + } + } +} + +func (c *PicoClientChannel) readLoop(connCtx context.Context, pc *picoConn) { + defer pc.close() + + readTimeout := time.Duration(c.config.ReadTimeout) * time.Second + if readTimeout <= 0 { + readTimeout = 60 * time.Second + } + + _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) + pc.conn.SetPongHandler(func(string) error { + return pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) + }) + + pingInterval := time.Duration(c.config.PingInterval) * time.Second + if pingInterval <= 0 { + pingInterval = 30 * time.Second + } + go c.pingLoop(connCtx, pc, pingInterval) + + for { + select { + case <-connCtx.Done(): + return + default: + } + + _, raw, err := pc.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError( + err, + websocket.CloseGoingAway, + websocket.CloseNormalClosure, + ) { + logger.DebugCF("pico_client", "Read error", map[string]any{ + "error": err.Error(), + }) + } + return + } + + _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) + + var msg PicoMessage + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + + c.handleInbound(pc, msg) + } +} + +func (c *PicoClientChannel) pingLoop(connCtx context.Context, pc *picoConn, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-connCtx.Done(): + return + case <-ticker.C: + if pc.closed.Load() { + return + } + pc.writeMu.Lock() + err := pc.conn.WriteMessage(websocket.PingMessage, nil) + pc.writeMu.Unlock() + if err != nil { + return + } + } + } +} + +// handleInbound processes messages from the remote server. +// In client mode the server sends message.create (responses) and the client +// sends message.send (user input). We treat message.create from the server +// as inbound user messages to feed into the agent loop. +func (c *PicoClientChannel) handleInbound(pc *picoConn, msg PicoMessage) { + switch msg.Type { + case TypePong: + // response to our ping, ignore + case TypeMessageCreate: + // Server sent us a message — treat as inbound + c.handleServerMessage(pc, msg) + default: + logger.DebugCF("pico_client", "Ignoring message type", map[string]any{ + "type": msg.Type, + }) + } +} + +func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) { + if isThoughtPayload(msg.Payload) { + return + } + + content, _ := msg.Payload[PayloadKeyContent].(string) + if strings.TrimSpace(content) == "" { + return + } + + sessionID := msg.SessionID + if sessionID == "" { + sessionID = pc.sessionID + } + + chatID := "pico_client:" + sessionID + senderID := "pico-remote" + sender := bus.SenderInfo{ + Platform: "pico_client", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("pico_client", senderID), + } + + if !c.IsAllowedSender(sender) { + return + } + + inboundCtx := bus.InboundContext{ + Channel: "pico_client", + ChatID: chatID, + ChatType: "direct", + SenderID: senderID, + MessageID: msg.ID, + Raw: map[string]string{ + "platform": "pico_client", + "session_id": sessionID, + }, + } + + c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, sender) +} + +// Send sends a message to the remote server. +func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + c.mu.Lock() + pc := c.conn + c.mu.Unlock() + if pc == nil || pc.closed.Load() { + return nil, channels.ErrSendFailed + } + + outMsg := newMessage(TypeMessageSend, map[string]any{ + PayloadKeyContent: msg.Content, + }) + outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:") + return nil, pc.writeJSON(outMsg) +} + +// StartTyping implements channels.TypingCapable. +func (c *PicoClientChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + c.mu.Lock() + pc := c.conn + c.mu.Unlock() + if pc == nil || pc.closed.Load() { + return func() {}, nil + } + + startMsg := newMessage(TypeTypingStart, nil) + startMsg.SessionID = strings.TrimPrefix(chatID, "pico_client:") + if err := pc.writeJSON(startMsg); err != nil { + return func() {}, err + } + return func() { + c.mu.Lock() + currentPC := c.conn + c.mu.Unlock() + if currentPC == nil { + return + } + stopMsg := newMessage(TypeTypingStop, nil) + stopMsg.SessionID = strings.TrimPrefix(chatID, "pico_client:") + currentPC.writeJSON(stopMsg) + }, nil +} diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go new file mode 100644 index 000000000..2b167e457 --- /dev/null +++ b/pkg/channels/pico/client_test.go @@ -0,0 +1,435 @@ +package pico + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNewPicoClientChannel_MissingURL(t *testing.T) { + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + _, err := NewPicoClientChannel(bc, &config.PicoClientSettings{}, bus.NewMessageBus()) + if err == nil { + t.Fatal("expected error for missing URL") + } + if !strings.Contains(err.Error(), "url is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewPicoClientChannel_OK(t *testing.T) { + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ + URL: "ws://localhost:9999/ws", + }, bus.NewMessageBus()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ch.Name() != "pico_client" { + t.Fatalf("name = %q, want pico_client", ch.Name()) + } +} + +func TestSend_NotRunning(t *testing.T) { + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ + URL: "ws://localhost:9999/ws", + }, bus.NewMessageBus()) + if err != nil { + t.Fatal(err) + } + _, err = ch.Send(context.Background(), bus.OutboundMessage{Content: "hi"}) + if !errors.Is(err, channels.ErrNotRunning) { + t.Fatalf("expected ErrNotRunning, got %v", err) + } +} + +// testServer starts a WS server that echoes message.send back as message.create. +func testServer(t *testing.T, token string) *httptest.Server { + t.Helper() + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if token != "" { + auth := r.Header.Get("Authorization") + if auth != "Bearer "+token { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Logf("upgrade error: %v", err) + return + } + defer conn.Close() + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + return + } + + var msg PicoMessage + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + + if msg.Type == TypeMessageSend { + reply := newMessage(TypeMessageCreate, msg.Payload) + reply.SessionID = msg.SessionID + if err := conn.WriteJSON(reply); err != nil { + return + } + } + } + })) +} + +func wsURL(httpURL string) string { + return "ws" + strings.TrimPrefix(httpURL, "http") +} + +func TestClientChannel_ConnectAndSend(t *testing.T) { + srv := testServer(t, "test-token") + defer srv.Close() + + mb := bus.NewMessageBus() + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ + URL: wsURL(srv.URL), + Token: *config.NewSecureString("test-token"), + SessionID: "sess-1", + PingInterval: 60, + ReadTimeout: 10, + }, mb) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err = ch.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + defer ch.Stop(ctx) + + // Send a message + _, err = ch.Send(ctx, bus.OutboundMessage{ + ChatID: "pico_client:sess-1", + Content: "hello", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } +} + +func TestClientChannel_AuthFailure(t *testing.T) { + srv := testServer(t, "correct-token") + defer srv.Close() + + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ + URL: wsURL(srv.URL), + Token: *config.NewSecureString("wrong-token"), + }, bus.NewMessageBus()) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err = ch.Start(ctx) + if err == nil { + ch.Stop(ctx) + t.Fatal("expected auth failure") + } +} + +func TestClientChannel_ReceivesServerMessage(t *testing.T) { + srv := testServer(t, "") + defer srv.Close() + + mb := bus.NewMessageBus() + + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ + URL: wsURL(srv.URL), + SessionID: "sess-echo", + ReadTimeout: 10, + }, mb) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err = ch.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + defer ch.Stop(ctx) + + // Send a message; the echo server replies with message.create + _, err = ch.Send(ctx, bus.OutboundMessage{ + ChatID: "pico_client:sess-echo", + Content: "ping", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + + // The echoed message.create is processed by handleServerMessage which + // calls HandleMessage → PublishInbound. Consume it from the bus. + select { + case msg := <-mb.InboundChan(): + if msg.Content != "ping" { + t.Fatalf("received = %q, want %q", msg.Content, "ping") + } + case <-ctx.Done(): + t.Fatal("timed out waiting for echoed message") + } +} + +func TestClientChannel_StartTyping(t *testing.T) { + srv := testServer(t, "") + defer srv.Close() + + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ + URL: wsURL(srv.URL), + SessionID: "sess-type", + ReadTimeout: 10, + }, bus.NewMessageBus()) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err = ch.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + defer ch.Stop(ctx) + + stop, err := ch.StartTyping(ctx, "pico_client:sess-type") + if err != nil { + t.Fatalf("StartTyping: %v", err) + } + stop() // should not panic +} + +func TestSend_ClosedConnection(t *testing.T) { + srv := testServer(t, "") + defer srv.Close() + + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ + URL: wsURL(srv.URL), + SessionID: "sess-close", + ReadTimeout: 10, + }, bus.NewMessageBus()) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err = ch.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + + // Force close the underlying connection + ch.mu.Lock() + ch.conn.close() + ch.mu.Unlock() + + _, err = ch.Send(ctx, bus.OutboundMessage{ + ChatID: "pico_client:sess-close", + Content: "should fail", + }) + if !errors.Is(err, channels.ErrSendFailed) { + t.Fatalf("expected ErrSendFailed, got %v", err) + } + + ch.Stop(ctx) +} + +func TestParseInlineImageMedia_Valid(t *testing.T) { + media, err := parseInlineImageMedia(map[string]any{ + "media": []any{ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=", + }, + }) + if err != nil { + t.Fatalf("parseInlineImageMedia() error = %v", err) + } + if len(media) != 1 { + t.Fatalf("len(media) = %d, want 1", len(media)) + } +} + +func TestPicoChannel_HandleMessageSend_AllowsMediaOnly(t *testing.T) { + mb := bus.NewMessageBus() + bc := &config.Channel{Type: "pico", Enabled: true} + ch, err := NewPicoChannel(bc, &config.PicoSettings{ + Token: *config.NewSecureString("test-token"), + }, mb) + if err != nil { + t.Fatalf("NewPicoChannel() error = %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := ch.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(ctx) + + pc := &picoConn{id: "conn-1", sessionID: "sess-1"} + ch.handleMessageSend(pc, PicoMessage{ + ID: "msg-1", + Payload: map[string]any{ + "media": []any{ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=", + }, + }, + }) + + select { + case msg := <-mb.InboundChan(): + if msg.Content != "" { + t.Fatalf("msg.Content = %q, want empty", msg.Content) + } + if len(msg.Media) != 1 || !strings.HasPrefix(msg.Media[0], "data:image/png;base64,") { + t.Fatalf("msg.Media = %#v, want inline image payload", msg.Media) + } + case <-ctx.Done(): + t.Fatal("timed out waiting for inbound media message") + } +} + +func TestIsThoughtPayload(t *testing.T) { + tests := []struct { + name string + payload map[string]any + want bool + }{ + { + name: "explicit thought kind", + payload: map[string]any{PayloadKeyKind: MessageKindThought}, + want: true, + }, + { + name: "thought kind ignores case and whitespace", + payload: map[string]any{PayloadKeyKind: " ThOuGhT "}, + want: true, + }, + { + name: "legacy thought bool remains supported for inbound compatibility", + payload: map[string]any{PayloadKeyThought: true}, + want: true, + }, + { + name: "legacy thought false", + payload: map[string]any{PayloadKeyThought: false}, + want: false, + }, + { + name: "tool calls kind", + payload: map[string]any{PayloadKeyKind: MessageKindToolCalls}, + want: false, + }, + { + name: "non-string kind ignored", + payload: map[string]any{PayloadKeyKind: true}, + want: false, + }, + { + name: "default normal", + payload: map[string]any{PayloadKeyContent: "hello"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isThoughtPayload(tt.payload); got != tt.want { + t.Fatalf("isThoughtPayload() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPicoClientChannel_HandleServerMessage_IgnoresThought(t *testing.T) { + mb := bus.NewMessageBus() + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ + URL: "ws://localhost:8080/ws", + }, mb) + if err != nil { + t.Fatalf("NewPicoClientChannel() error = %v", err) + } + + ch.ctx = context.Background() + pc := &picoConn{sessionID: "sess-thought"} + + ch.handleServerMessage(pc, PicoMessage{ + Type: TypeMessageCreate, + Payload: map[string]any{ + PayloadKeyContent: "internal reasoning", + PayloadKeyKind: MessageKindThought, + }, + }) + + select { + case msg := <-mb.InboundChan(): + t.Fatalf("expected no inbound publish for thought payload, got %+v", msg) + case <-time.After(150 * time.Millisecond): + } +} + +func TestPicoClientChannel_HandleServerMessage_IgnoresLegacyThoughtBool(t *testing.T) { + mb := bus.NewMessageBus() + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ + URL: "ws://localhost:8080/ws", + }, mb) + if err != nil { + t.Fatalf("NewPicoClientChannel() error = %v", err) + } + + ch.ctx = context.Background() + pc := &picoConn{sessionID: "sess-thought-legacy"} + + ch.handleServerMessage(pc, PicoMessage{ + Type: TypeMessageCreate, + Payload: map[string]any{ + PayloadKeyContent: "legacy internal reasoning", + PayloadKeyThought: true, + }, + }) + + select { + case msg := <-mb.InboundChan(): + t.Fatalf("expected no inbound publish for legacy thought payload, got %+v", msg) + case <-time.After(150 * time.Millisecond): + } +} diff --git a/pkg/channels/pico/init.go b/pkg/channels/pico/init.go index 96d764418..54596fab3 100644 --- a/pkg/channels/pico/init.go +++ b/pkg/channels/pico/init.go @@ -7,7 +7,48 @@ import ( ) func init() { - channels.RegisterFactory("pico", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewPicoChannel(cfg.Channels.Pico, b) - }) + channels.RegisterFactory( + config.ChannelPico, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.PicoSettings) + if !ok { + return nil, channels.ErrSendFailed + } + ch, err := NewPicoChannel(bc, c, b) + if err != nil { + return nil, err + } + if channelName != config.ChannelPico { + ch.SetName(channelName) + } + return ch, nil + }, + ) + channels.RegisterFactory( + config.ChannelPicoClient, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.PicoClientSettings) + if !ok { + return nil, channels.ErrSendFailed + } + ch, err := NewPicoClientChannel(bc, c, b) + if err != nil { + return nil, err + } + if channelName != config.ChannelPicoClient { + ch.SetName(channelName) + } + return ch, nil + }, + ) } diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 8d8b62a67..d1de8f4d5 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -2,9 +2,14 @@ package pico import ( "context" + "encoding/base64" "encoding/json" "fmt" + "mime" "net/http" + "net/url" + "os" + "path/filepath" "strings" "sync" "sync/atomic" @@ -18,6 +23,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" ) // picoConn represents a single WebSocket connection. @@ -27,6 +33,42 @@ type picoConn struct { sessionID string writeMu sync.Mutex closed atomic.Bool + cancel context.CancelFunc // cancels per-connection goroutines (e.g. pingLoop) +} + +var allowedInlineImageMIMETypes = map[string]struct{}{ + "image/jpeg": {}, + "image/png": {}, + "image/gif": {}, + "image/webp": {}, + "image/bmp": {}, +} + +func outboundMessageIsThought(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), MessageKindThought) +} + +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func outboundMessageIsToolCalls(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), MessageKindToolCalls) +} + +func outboundMessageFinalizesTrackedToolFeedback(msg bus.OutboundMessage) bool { + return !outboundMessageIsToolFeedback(msg) && + !outboundMessageIsThought(msg) && + !outboundMessageIsToolCalls(msg) } // writeJSON sends a JSON message to the connection with write locking. @@ -42,6 +84,9 @@ func (pc *picoConn) writeJSON(v any) error { // close closes the connection. func (pc *picoConn) close() { if pc.closed.CompareAndSwap(false, true) { + if pc.cancel != nil { + pc.cancel() + } pc.conn.Close() } } @@ -50,21 +95,29 @@ func (pc *picoConn) close() { // It serves as the reference implementation for all optional capability interfaces. type PicoChannel struct { *channels.BaseChannel - config config.PicoConfig - upgrader websocket.Upgrader - connections sync.Map // connID → *picoConn - connCount atomic.Int32 - ctx context.Context - cancel context.CancelFunc + bc *config.Channel + config *config.PicoSettings + upgrader websocket.Upgrader + connections map[string]*picoConn // connID -> *picoConn + sessionConnections map[string]map[string]*picoConn // sessionID -> connID -> *picoConn + connsMu sync.RWMutex + ctx context.Context + cancel context.CancelFunc + progress *channels.ToolFeedbackAnimator + deleteMessageFn func(context.Context, string, string) error } // NewPicoChannel creates a new Pico Protocol channel. -func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) { - if cfg.Token == "" { +func NewPicoChannel( + bc *config.Channel, + cfg *config.PicoSettings, + messageBus *bus.MessageBus, +) (*PicoChannel, error) { + if cfg.Token.String() == "" { return nil, fmt.Errorf("pico token is required") } - base := channels.NewBaseChannel("pico", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("pico", cfg, messageBus, bc.AllowFrom) allowOrigins := cfg.AllowOrigins checkOrigin := func(r *http.Request) bool { @@ -80,15 +133,114 @@ func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoCha return false } - return &PicoChannel{ + ch := &PicoChannel{ BaseChannel: base, + bc: bc, config: cfg, upgrader: websocket.Upgrader{ CheckOrigin: checkOrigin, ReadBufferSize: 1024, WriteBufferSize: 1024, }, - }, nil + connections: make(map[string]*picoConn), + sessionConnections: make(map[string]map[string]*picoConn), + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + ch.deleteMessageFn = ch.DeleteMessage + return ch, nil +} + +// createAndAddConnection checks MaxConnections and registers a connection atomically. +func (c *PicoChannel) createAndAddConnection(conn *websocket.Conn, sessionID string, maxConns int) (*picoConn, error) { + c.connsMu.Lock() + defer c.connsMu.Unlock() + if len(c.connections) >= maxConns { + return nil, channels.ErrTemporary + } + + var connID string + for { + connID = uuid.New().String() + if _, exists := c.connections[connID]; !exists { + break + } + } + + pc := &picoConn{ + id: connID, + conn: conn, + sessionID: sessionID, + } + + c.connections[pc.id] = pc + bySession, ok := c.sessionConnections[pc.sessionID] + if !ok { + bySession = make(map[string]*picoConn) + c.sessionConnections[pc.sessionID] = bySession + } + bySession[pc.id] = pc + + return pc, nil +} + +// removeConnection deletes a connection from indexes and returns it when found. +func (c *PicoChannel) removeConnection(connID string) *picoConn { + c.connsMu.Lock() + defer c.connsMu.Unlock() + + pc, ok := c.connections[connID] + if !ok { + return nil + } + + delete(c.connections, connID) + if bySession, ok := c.sessionConnections[pc.sessionID]; ok { + delete(bySession, connID) + if len(bySession) == 0 { + delete(c.sessionConnections, pc.sessionID) + } + } + + return pc +} + +// takeAllConnections snapshots and clears all connection indexes. +func (c *PicoChannel) takeAllConnections() []*picoConn { + c.connsMu.Lock() + defer c.connsMu.Unlock() + + all := make([]*picoConn, 0, len(c.connections)) + for _, pc := range c.connections { + all = append(all, pc) + } + clear(c.connections) + clear(c.sessionConnections) + + return all +} + +// sessionConnectionsSnapshot returns all active connections for a session. +func (c *PicoChannel) sessionConnectionsSnapshot(sessionID string) []*picoConn { + c.connsMu.RLock() + defer c.connsMu.RUnlock() + + bySession, ok := c.sessionConnections[sessionID] + if !ok || len(bySession) == 0 { + return nil + } + + conns := make([]*picoConn, 0, len(bySession)) + for _, pc := range bySession { + conns = append(conns, pc) + } + return conns +} + +// currentConnCount returns a lock-protected snapshot of active connection count. +func (c *PicoChannel) currentConnCount() int { + c.connsMu.RLock() + defer c.connsMu.RUnlock() + return len(c.connections) } // Start implements Channel. @@ -106,17 +258,16 @@ func (c *PicoChannel) Stop(ctx context.Context) error { c.SetRunning(false) // Close all connections - c.connections.Range(func(key, value any) bool { - if pc, ok := value.(*picoConn); ok { - pc.close() - } - c.connections.Delete(key) - return true - }) + for _, pc := range c.takeAllConnections() { + pc.close() + } if c.cancel != nil { c.cancel() } + if c.progress != nil { + c.progress.StopAll() + } logger.InfoC("pico", "Pico Protocol channel stopped") return nil @@ -129,36 +280,166 @@ func (c *PicoChannel) WebhookPath() string { return "/pico/" } func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/pico") - switch { - case path == "/ws" || path == "/ws/": + switch path { + case "/ws", "/ws/": c.handleWebSocket(w, r) default: + if strings.HasPrefix(path, "/media/") { + c.handleMediaDownload(w, r) + return + } http.NotFound(w, r) } } // Send implements Channel — sends a message to the appropriate WebSocket connection. -func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning + } + isThought := outboundMessageIsThought(msg) + isToolFeedback := outboundMessageIsToolFeedback(msg) + isToolCalls := outboundMessageIsToolCalls(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled { + if err != nil { + return nil, err + } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + if outboundMessageFinalizesTrackedToolFeedback(msg) { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } } - outMsg := newMessage(TypeMessageCreate, map[string]any{ - "content": msg.Content, - }) + content := msg.Content + if isToolFeedback { + content = channels.InitialAnimatedToolFeedbackContent(msg.Content) + } + msgID := uuid.New().String() - return c.broadcastToSession(msg.ChatID, outMsg) + payload := map[string]any{ + PayloadKeyContent: content, + "message_id": msgID, + } + switch { + case isThought: + payload[PayloadKeyKind] = MessageKindThought + + // This field is kept solely for compatibility with legacy pico clients that + // do not yet support the newer "kind" field. + // DO NOT use it for any purpose other than legacy client compatibility. + payload[PayloadKeyThought] = true + + case isToolCalls: + payload[PayloadKeyKind] = MessageKindToolCalls + if toolCalls, ok := picoToolCallsPayload(msg); ok { + payload[PayloadKeyToolCalls] = toolCalls + } + } + setContextUsagePayload(payload, msg.ContextUsage) + outMsg := newMessage(TypeMessageCreate, payload) + + if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil { + return nil, err + } + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg && outboundMessageFinalizesTrackedToolFeedback(msg) { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // EditMessage implements channels.MessageEditor. func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { - outMsg := newMessage(TypeMessageUpdate, map[string]any{ + return c.editMessage(ctx, chatID, messageID, content, nil) +} + +// DeleteMessage implements channels.MessageDeleter. +func (c *PicoChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + outMsg := newMessage(TypeMessageDelete, map[string]any{ "message_id": messageID, - "content": content, }) return c.broadcastToSession(chatID, outMsg) } +func (c *PicoChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *PicoChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *PicoChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *PicoChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *PicoChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *PicoChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + deleteFn := c.deleteMessageFn + if deleteFn == nil { + deleteFn = c.DeleteMessage + } + _ = deleteFn(ctx, chatID, messageID) +} + +func (c *PicoChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string, *bus.ContextUsage) error, + contextUsage *bus.ContextUsage, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content, contextUsage); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *PicoChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if !outboundMessageFinalizesTrackedToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.editMessage, msg.ContextUsage) +} + // StartTyping implements channels.TypingCapable. func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { startMsg := newMessage(TypeTypingStart, nil) @@ -175,19 +456,16 @@ func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), e // It sends a placeholder message via the Pico Protocol that will later be // edited to the actual response via EditMessage (channels.MessageEditor). func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { - if !c.config.Placeholder.Enabled { + if !c.bc.Placeholder.Enabled { return "", nil } - text := c.config.Placeholder.Text - if text == "" { - text = "Thinking... 💭" - } + text := c.bc.Placeholder.GetRandomText() msgID := uuid.New().String() outMsg := newMessage(TypeMessageCreate, map[string]any{ - "content": text, - "message_id": msgID, + PayloadKeyContent: text, + "message_id": msgID, }) if err := c.broadcastToSession(chatID, outMsg); err != nil { @@ -197,6 +475,210 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin return msgID, nil } +// SendMedia implements channels.MediaSender for the Pico web UI. +// Media is delivered as a normal assistant message carrying structured +// attachments plus an authenticated same-origin download URL. +func (c *PicoChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + + store := c.GetMediaStore() + if store == nil { + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + attachments := make([]map[string]any, 0, len(msg.Parts)) + caption := "" + + for _, part := range msg.Parts { + localPath, meta, err := store.ResolveWithMeta(part.Ref) + if err != nil { + logger.ErrorCF("pico", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + filename := strings.TrimSpace(part.Filename) + if filename == "" { + filename = strings.TrimSpace(meta.Filename) + } + if filename == "" { + filename = filepath.Base(localPath) + } + + contentType := strings.TrimSpace(part.ContentType) + if contentType == "" { + contentType = strings.TrimSpace(meta.ContentType) + } + if contentType == "" { + contentType = "application/octet-stream" + } + + attachmentType := strings.TrimSpace(part.Type) + if attachmentType == "" { + attachmentType = picoInferAttachmentType(filename, contentType) + } + + attachmentURL, err := picoDownloadURLForRef(part.Ref) + if err != nil { + logger.ErrorCF("pico", "Failed to build media download URL", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + attachments = append(attachments, map[string]any{ + "type": attachmentType, + "url": attachmentURL, + "filename": filename, + "content_type": contentType, + }) + + if caption == "" && strings.TrimSpace(part.Caption) != "" { + caption = strings.TrimSpace(part.Caption) + } + } + + if len(attachments) == 0 { + return nil, fmt.Errorf("no deliverable media parts: %w", channels.ErrSendFailed) + } + + msgID := uuid.New().String() + outMsg := newMessage(TypeMessageCreate, map[string]any{ + PayloadKeyContent: caption, + "attachments": attachments, + "message_id": msgID, + }) + + if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil { + return nil, err + } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + + return []string{msgID}, nil +} + +func picoDownloadURLForRef(ref string) (string, error) { + refID, err := picoMediaRefID(ref) + if err != nil { + return "", err + } + return "/pico/media/" + url.PathEscape(refID), nil +} + +func picoMediaRefID(ref string) (string, error) { + refID := strings.TrimSpace(strings.TrimPrefix(ref, "media://")) + if refID == "" || strings.Contains(refID, "/") { + return "", fmt.Errorf("invalid media ref %q", ref) + } + return refID, nil +} + +func picoInferAttachmentType(filename, contentType string) string { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + filename = strings.ToLower(strings.TrimSpace(filename)) + + switch { + case strings.HasPrefix(contentType, "image/"): + return "image" + case strings.HasPrefix(contentType, "audio/"): + return "audio" + case strings.HasPrefix(contentType, "video/"): + return "video" + } + + switch ext := filepath.Ext(filename); ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": + return "image" + case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": + return "audio" + case ".mp4", ".avi", ".mov", ".webm", ".mkv": + return "video" + default: + return "file" + } +} + +func picoAllowsInlineDisplay(filename, contentType string) bool { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + filename = strings.ToLower(strings.TrimSpace(filename)) + + if strings.Contains(contentType, "svg") || filepath.Ext(filename) == ".svg" { + return false + } + + return picoInferAttachmentType(filename, contentType) == "image" +} + +func (c *PicoChannel) handleMediaDownload(w http.ResponseWriter, r *http.Request) { + if !c.IsRunning() { + http.Error(w, "channel not running", http.StatusServiceUnavailable) + return + } + if !c.authenticate(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + refID := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/pico/media/"), "/")) + if refID == "" { + http.NotFound(w, r) + return + } + + store := c.GetMediaStore() + if store == nil { + http.Error(w, "media store unavailable", http.StatusServiceUnavailable) + return + } + + localPath, meta, err := store.ResolveWithMeta("media://" + refID) + if err != nil { + http.NotFound(w, r) + return + } + + file, err := os.Open(localPath) + if err != nil { + http.Error(w, "failed to open media", http.StatusInternalServerError) + return + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + http.Error(w, "failed to stat media", http.StatusInternalServerError) + return + } + + filename := strings.TrimSpace(meta.Filename) + if filename == "" { + filename = filepath.Base(localPath) + } + contentType := strings.TrimSpace(meta.ContentType) + if contentType == "" { + contentType = "application/octet-stream" + } + + dispositionType := "attachment" + if picoAllowsInlineDisplay(filename, contentType) { + dispositionType = "inline" + } + + if cd := mime.FormatMediaType(dispositionType, map[string]string{"filename": filename}); cd != "" { + w.Header().Set("Content-Disposition", cd) + } + w.Header().Set("Content-Type", contentType) + http.ServeContent(w, r, filename, info.ModTime(), file) +} + // broadcastToSession sends a message to all connections with a matching session. func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error { // chatID format: "pico:" @@ -204,23 +686,16 @@ func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error { msg.SessionID = sessionID var sent bool - c.connections.Range(func(key, value any) bool { - pc, ok := value.(*picoConn) - if !ok { - return true + for _, pc := range c.sessionConnectionsSnapshot(sessionID) { + if err := pc.writeJSON(msg); err != nil { + logger.DebugCF("pico", "Write to connection failed", map[string]any{ + "conn_id": pc.id, + "error": err.Error(), + }) + } else { + sent = true } - if pc.sessionID == sessionID { - if err := pc.writeJSON(msg); err != nil { - logger.DebugCF("pico", "Write to connection failed", map[string]any{ - "conn_id": pc.id, - "error": err.Error(), - }) - } else { - sent = true - } - } - return true - }) + } if !sent { return fmt.Errorf("no active connections for session %s: %w", sessionID, channels.ErrSendFailed) @@ -246,12 +721,18 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { if maxConns <= 0 { maxConns = 100 } - if int(c.connCount.Load()) >= maxConns { + if c.currentConnCount() >= maxConns { http.Error(w, "too many connections", http.StatusServiceUnavailable) return } - conn, err := c.upgrader.Upgrade(w, r, nil) + // Echo the matched subprotocol back so the browser accepts the upgrade. + var responseHeader http.Header + if proto := c.matchedSubprotocol(r); proto != "" { + responseHeader = http.Header{"Sec-WebSocket-Protocol": {proto}} + } + + conn, err := c.upgrader.Upgrade(w, r, responseHeader) if err != nil { logger.ErrorCF("pico", "WebSocket upgrade failed", map[string]any{ "error": err.Error(), @@ -265,15 +746,17 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { sessionID = uuid.New().String() } - pc := &picoConn{ - id: uuid.New().String(), - conn: conn, - sessionID: sessionID, + pc, err := c.createAndAddConnection(conn, sessionID, maxConns) + if err != nil { + _ = conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseTryAgainLater, "too many connections"), + time.Now().Add(2*time.Second), + ) + _ = conn.Close() + return } - c.connections.Store(pc.id, pc) - c.connCount.Add(1) - logger.InfoCF("pico", "WebSocket client connected", map[string]any{ "conn_id": pc.id, "session_id": sessionID, @@ -282,10 +765,12 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { go c.readLoop(pc) } -// authenticate checks the Bearer token from the Authorization header. -// Query parameter authentication is only allowed when AllowTokenQuery is explicitly enabled. +// authenticate checks the request for a valid token: +// 1. Authorization: Bearer header +// 2. Sec-WebSocket-Protocol "token." (for browsers that can't set headers) +// 3. Query parameter "token" (only when AllowTokenQuery is on) func (c *PicoChannel) authenticate(r *http.Request) bool { - token := c.config.Token + token := c.config.Token.String() if token == "" { return false } @@ -298,6 +783,11 @@ func (c *PicoChannel) authenticate(r *http.Request) bool { } } + // Check Sec-WebSocket-Protocol subprotocol ("token.") + if c.matchedSubprotocol(r) != "" { + return true + } + // Check query parameter only when explicitly allowed if c.config.AllowTokenQuery { if r.URL.Query().Get("token") == token { @@ -308,16 +798,28 @@ func (c *PicoChannel) authenticate(r *http.Request) bool { return false } +// matchedSubprotocol returns the "token." subprotocol that matches +// the configured token, or "" if none do. +func (c *PicoChannel) matchedSubprotocol(r *http.Request) string { + token := c.config.Token.String() + for _, proto := range websocket.Subprotocols(r) { + if after, ok := strings.CutPrefix(proto, "token."); ok && after == token { + return proto + } + } + return "" +} + // readLoop reads messages from a WebSocket connection. func (c *PicoChannel) readLoop(pc *picoConn) { defer func() { pc.close() - c.connections.Delete(pc.id) - c.connCount.Add(-1) - logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{ - "conn_id": pc.id, - "session_id": pc.sessionID, - }) + if removed := c.removeConnection(pc.id); removed != nil { + logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{ + "conn_id": removed.id, + "session_id": removed.sessionID, + }) + } }() readTimeout := time.Duration(c.config.ReadTimeout) * time.Second @@ -403,6 +905,9 @@ func (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) { case TypeMessageSend: c.handleMessageSend(pc, msg) + case TypeMediaSend: + c.handleMessageSend(pc, msg) + default: errMsg := newError("unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type)) pc.writeJSON(errMsg) @@ -412,8 +917,19 @@ func (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) { // handleMessageSend processes an inbound message.send from a client. func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) { content, _ := msg.Payload["content"].(string) - if strings.TrimSpace(content) == "" { - errMsg := newError("empty_content", "message content is empty") + media, err := parseInlineImageMedia(msg.Payload) + if err != nil { + errMsg := newErrorWithPayload("invalid_media", err.Error(), map[string]any{ + "request_id": msg.ID, + }) + pc.writeJSON(errMsg) + return + } + + if strings.TrimSpace(content) == "" && len(media) == 0 { + errMsg := newErrorWithPayload("empty_content", "message content is empty", map[string]any{ + "request_id": msg.ID, + }) pc.writeJSON(errMsg) return } @@ -426,8 +942,6 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) { chatID := "pico:" + sessionID senderID := "pico-user" - peer := bus.Peer{Kind: "direct", ID: "pico:" + sessionID} - metadata := map[string]string{ "platform": "pico", "session_id": sessionID, @@ -437,6 +951,7 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) { logger.DebugCF("pico", "Received message", map[string]any{ "session_id": sessionID, "preview": truncate(content, 50), + "media": len(media), }) sender := bus.SenderInfo{ @@ -449,7 +964,16 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) { return } - c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata, sender) + inboundCtx := bus.InboundContext{ + Channel: "pico", + ChatID: chatID, + ChatType: "direct", + SenderID: senderID, + MessageID: msg.ID, + Raw: metadata, + } + + c.HandleInboundContext(c.ctx, chatID, content, media, inboundCtx, sender) } // truncate truncates a string to maxLen runes. @@ -460,3 +984,141 @@ func truncate(s string, maxLen int) string { } return string(runes[:maxLen]) + "..." } + +func parseInlineImageMedia(payload map[string]any) ([]string, error) { + if len(payload) == 0 { + return nil, nil + } + + raw, ok := payload["media"] + if !ok || raw == nil { + return nil, nil + } + + switch values := raw.(type) { + case []any: + media := make([]string, 0, len(values)) + for i, item := range values { + value, err := inlineImageValue(item) + if err != nil { + return nil, fmt.Errorf("media[%d]: %w", i, err) + } + if err := validateInlineImageDataURL(value); err != nil { + return nil, fmt.Errorf("media[%d]: %w", i, err) + } + media = append(media, value) + } + return media, nil + case []string: + media := make([]string, 0, len(values)) + for i, value := range values { + value = strings.TrimSpace(value) + if err := validateInlineImageDataURL(value); err != nil { + return nil, fmt.Errorf("media[%d]: %w", i, err) + } + media = append(media, value) + } + return media, nil + case string: + value := strings.TrimSpace(values) + if err := validateInlineImageDataURL(value); err != nil { + return nil, err + } + return []string{value}, nil + default: + return nil, fmt.Errorf("media must be a string or array of strings") + } +} + +func inlineImageValue(item any) (string, error) { + switch value := item.(type) { + case string: + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("image payload is empty") + } + return value, nil + case map[string]any: + for _, key := range []string{"url", "data_url"} { + if raw, ok := value[key].(string); ok && strings.TrimSpace(raw) != "" { + return strings.TrimSpace(raw), nil + } + } + return "", fmt.Errorf("image payload must include url or data_url") + default: + return "", fmt.Errorf("image payload must be a string or object") + } +} + +func validateInlineImageDataURL(mediaURL string) error { + if mediaURL == "" { + return fmt.Errorf("image payload is empty") + } + if !strings.HasPrefix(mediaURL, "data:image/") { + return fmt.Errorf("only inline image data URLs are supported") + } + + header, data, found := strings.Cut(mediaURL, ",") + if !found || strings.TrimSpace(data) == "" { + return fmt.Errorf("image data URL is malformed") + } + if !strings.Contains(header, ";base64") { + return fmt.Errorf("image data URL must be base64 encoded") + } + mimeType, _, _ := strings.Cut(strings.TrimPrefix(header, "data:"), ";") + if _, ok := allowedInlineImageMIMETypes[mimeType]; !ok { + return fmt.Errorf("unsupported image format: %s", mimeType) + } + + data = strings.TrimSpace(data) + if base64.StdEncoding.DecodedLen(len(data)) > config.DefaultMaxMediaSize { + return fmt.Errorf("image exceeds %d byte limit", config.DefaultMaxMediaSize) + } + if _, err := base64.StdEncoding.DecodeString(data); err != nil { + return fmt.Errorf("invalid base64 image data") + } + + return nil +} + +// setContextUsagePayload adds context window usage stats to a pico payload. +func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) { + if u == nil { + return + } + payload["context_usage"] = map[string]any{ + "used_tokens": u.UsedTokens, + "total_tokens": u.TotalTokens, + "compress_at_tokens": u.CompressAtTokens, + "used_percent": u.UsedPercent, + } +} + +func picoToolCallsPayload(msg bus.OutboundMessage) ([]utils.VisibleToolCall, bool) { + raw := strings.TrimSpace(msg.Context.Raw[PayloadKeyToolCalls]) + if raw == "" { + return nil, false + } + + var toolCalls []utils.VisibleToolCall + if err := json.Unmarshal([]byte(raw), &toolCalls); err != nil || len(toolCalls) == 0 { + return nil, false + } + return toolCalls, true +} + +func (c *PicoChannel) editMessage( + ctx context.Context, + chatID string, + messageID string, + content string, + contextUsage *bus.ContextUsage, +) error { + payload := map[string]any{ + "message_id": messageID, + "content": content, + } + setContextUsagePayload(payload, contextUsage) + outMsg := newMessage(TypeMessageUpdate, payload) + return c.broadcastToSession(chatID, outMsg) +} diff --git a/pkg/channels/pico/pico_test.go b/pkg/channels/pico/pico_test.go new file mode 100644 index 000000000..bbe73a222 --- /dev/null +++ b/pkg/channels/pico/pico_test.go @@ -0,0 +1,549 @@ +package pico + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" +) + +func newTestPicoChannel(t *testing.T) *PicoChannel { + t.Helper() + + bc := &config.Channel{Type: config.ChannelPico, Enabled: true} + cfg := &config.PicoSettings{} + cfg.SetToken("test-token") + ch, err := NewPicoChannel(bc, cfg, bus.NewMessageBus()) + if err != nil { + t.Fatalf("NewPicoChannel: %v", err) + } + + ch.ctx = context.Background() + return ch +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &PicoChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("pico:chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "pico:chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string, contextUsage *bus.ContextUsage) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "pico:chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + if contextUsage != nil { + t.Fatalf("unexpected context usage: %+v", contextUsage) + } + return nil + }, + nil, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { + t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want [msg-1]", msgIDs) + } +} + +func TestDismissTrackedToolFeedbackMessage_DeletesProgressMessage(t *testing.T) { + ch := &PicoChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("pico:chat-1", "msg-1", "🔧 `read_file`") + + var deleted struct { + chatID string + messageID string + } + ch.deleteMessageFn = func(_ context.Context, chatID string, messageID string) error { + deleted.chatID = chatID + deleted.messageID = messageID + return nil + } + + ch.DismissToolFeedbackMessage(context.Background(), "pico:chat-1") + + if deleted.chatID != "pico:chat-1" || deleted.messageID != "msg-1" { + t.Fatalf("unexpected delete target: %+v", deleted) + } + if _, ok := ch.currentToolFeedbackMessage("pico:chat-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after dismissal") + } +} + +func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) { + ch := newTestPicoChannel(t) + + if err := ch.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(context.Background()) + + clientConn, received, cleanup := newTestPicoWebSocket(t) + defer cleanup() + ch.addConnForTest(&picoConn{id: "conn-1", conn: clientConn, sessionID: "sess-1"}) + + ch.RecordToolFeedbackMessage("pico:sess-1", "msg-progress", "🔧 `read_file`\nReading config") + + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "pico:sess-1", + Content: "thinking trace", + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:sess-1", + Raw: map[string]string{ + "message_kind": MessageKindThought, + }, + }, + }); err != nil { + t.Fatalf("Send(thought) error = %v", err) + } + + select { + case msg := <-received: + if msg.Type != TypeMessageCreate { + t.Fatalf("thought message type = %q, want %q", msg.Type, TypeMessageCreate) + } + payload := msg.Payload + if got := payload[PayloadKeyContent]; got != "thinking trace" { + t.Fatalf("thought content = %#v, want %q", got, "thinking trace") + } + if got := payload[PayloadKeyKind]; got != MessageKindThought { + t.Fatalf("thought kind = %#v, want %q", got, MessageKindThought) + } + if got := payload["message_id"]; got == "msg-progress" || got == nil || got == "" { + t.Fatalf("thought message_id = %#v, want new non-progress id", got) + } + case <-time.After(time.Second): + t.Fatal("expected thought message to be delivered") + } + + if msgID, ok := ch.currentToolFeedbackMessage("pico:sess-1"); !ok || msgID != "msg-progress" { + t.Fatalf("tracked tool feedback = (%q, %v), want (msg-progress, true)", msgID, ok) + } + + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "pico:sess-1", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:sess-1", + }, + ContextUsage: &bus.ContextUsage{ + UsedTokens: 321, + TotalTokens: 4096, + CompressAtTokens: 3072, + UsedPercent: 8, + }, + }); err != nil { + t.Fatalf("Send(final) error = %v", err) + } + + select { + case msg := <-received: + if msg.Type != TypeMessageUpdate { + t.Fatalf("final message type = %q, want %q", msg.Type, TypeMessageUpdate) + } + payload := msg.Payload + if got := payload["message_id"]; got != "msg-progress" { + t.Fatalf("final message_id = %#v, want %q", got, "msg-progress") + } + if got := payload[PayloadKeyContent]; got != "final reply" { + t.Fatalf("final content = %#v, want %q", got, "final reply") + } + rawUsage, ok := payload["context_usage"].(map[string]any) + if !ok { + t.Fatalf("final context_usage = %#v, want map payload", payload["context_usage"]) + } + if got, ok := rawUsage["used_tokens"].(float64); !ok || got != 321 { + t.Fatalf("used_tokens = %#v, want 321", rawUsage["used_tokens"]) + } + if got, ok := rawUsage["total_tokens"].(float64); !ok || got != 4096 { + t.Fatalf("total_tokens = %#v, want 4096", rawUsage["total_tokens"]) + } + case <-time.After(time.Second): + t.Fatal("expected final reply to finalize tracked tool feedback") + } + + if _, ok := ch.currentToolFeedbackMessage("pico:sess-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after final reply") + } +} + +func TestSendPlaceholder_EmitsNormalMessageWithoutKind(t *testing.T) { + ch := newTestPicoChannel(t) + ch.bc.Placeholder.Enabled = true + + if err := ch.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(context.Background()) + + clientConn, received, cleanup := newTestPicoWebSocket(t) + defer cleanup() + ch.addConnForTest(&picoConn{id: "conn-1", conn: clientConn, sessionID: "sess-1"}) + + msgID, err := ch.SendPlaceholder(context.Background(), "pico:sess-1") + if err != nil { + t.Fatalf("SendPlaceholder() error = %v", err) + } + if msgID == "" { + t.Fatal("expected placeholder message id") + } + + select { + case msg := <-received: + if msg.Type != TypeMessageCreate { + t.Fatalf("placeholder message type = %q, want %q", msg.Type, TypeMessageCreate) + } + payload := msg.Payload + if got := payload["message_id"]; got != msgID { + t.Fatalf("placeholder message_id = %#v, want %q", got, msgID) + } + if got := payload[PayloadKeyContent]; got != "Thinking..." { + t.Fatalf("placeholder content = %#v, want %q", got, "Thinking...") + } + if got, ok := payload[PayloadKeyKind]; ok { + t.Fatalf("placeholder kind = %#v, want absent", got) + } + case <-time.After(time.Second): + t.Fatal("expected placeholder message to be delivered") + } +} + +func TestCreateAndAddConnection_RespectsMaxConnectionsConcurrently(t *testing.T) { + ch := newTestPicoChannel(t) + + const ( + maxConns = 5 + goroutines = 64 + sessionID = "session-a" + ) + + var wg sync.WaitGroup + var mu sync.Mutex + successCount := 0 + errCount := 0 + + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + + pc, err := ch.createAndAddConnection(nil, sessionID, maxConns) + mu.Lock() + defer mu.Unlock() + + if err == nil { + successCount++ + if pc == nil { + t.Errorf("pc is nil on success") + } + return + } + if !errors.Is(err, channels.ErrTemporary) { + t.Errorf("unexpected error: %v", err) + return + } + errCount++ + }() + } + wg.Wait() + + if successCount > maxConns { + t.Fatalf("successCount=%d > maxConns=%d", successCount, maxConns) + } + if successCount+errCount != goroutines { + t.Fatalf("success=%d err=%d total=%d want=%d", successCount, errCount, successCount+errCount, goroutines) + } + if got := ch.currentConnCount(); got != maxConns { + t.Fatalf("currentConnCount=%d want=%d", got, maxConns) + } +} + +func TestRemoveConnection_CleansBothIndexes(t *testing.T) { + ch := newTestPicoChannel(t) + + pc, err := ch.createAndAddConnection(nil, "session-cleanup", 10) + if err != nil { + t.Fatalf("createAndAddConnection: %v", err) + } + + removed := ch.removeConnection(pc.id) + if removed == nil { + t.Fatal("removeConnection returned nil") + } + + ch.connsMu.RLock() + defer ch.connsMu.RUnlock() + + if _, ok := ch.connections[pc.id]; ok { + t.Fatalf("connID %s still exists in connections", pc.id) + } + if _, ok := ch.sessionConnections[pc.sessionID]; ok { + t.Fatalf("session %s still exists in sessionConnections", pc.sessionID) + } + if got := len(ch.connections); got != 0 { + t.Fatalf("len(connections)=%d want=0", got) + } +} + +func TestBroadcastToSession_TargetsOnlyRequestedSession(t *testing.T) { + ch := newTestPicoChannel(t) + + target := &picoConn{id: "target", sessionID: "s-target"} + target.closed.Store(true) + ch.addConnForTest(target) + + other := &picoConn{id: "other", sessionID: "s-other"} + ch.addConnForTest(other) + + err := ch.broadcastToSession("pico:s-target", newMessage(TypeMessageCreate, map[string]any{"content": "hello"})) + if err == nil { + t.Fatal("expected send failure due to closed target connection") + } + if !errors.Is(err, channels.ErrSendFailed) { + t.Fatalf("expected ErrSendFailed, got %v", err) + } +} + +func TestSendMedia_ResolvesMediaBeforeDelivery(t *testing.T) { + ch := newTestPicoChannel(t) + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + if err := ch.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(context.Background()) + + localPath := filepath.Join(t.TempDir(), "report.txt") + if err := os.WriteFile(localPath, []byte("attachment body"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "report.txt", + ContentType: "text/plain", + }, "test-scope") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + closedConn := &picoConn{id: "closed", sessionID: "sess-1"} + closedConn.closed.Store(true) + ch.addConnForTest(closedConn) + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "pico:sess-1", + Parts: []bus.MediaPart{{ + Ref: ref, + Type: "file", + Filename: "report.txt", + ContentType: "text/plain", + }}, + }) + if !errors.Is(err, channels.ErrSendFailed) { + t.Fatalf("SendMedia() error = %v, want ErrSendFailed", err) + } +} + +func TestSendMedia_DismissesTrackedToolFeedbackMessage(t *testing.T) { + ch := newTestPicoChannel(t) + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + if err := ch.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(context.Background()) + + clientConn, received, cleanup := newTestPicoWebSocket(t) + defer cleanup() + ch.addConnForTest(&picoConn{id: "conn-1", conn: clientConn, sessionID: "sess-1"}) + + localPath := filepath.Join(t.TempDir(), "report.txt") + if err := os.WriteFile(localPath, []byte("attachment body"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "report.txt", + ContentType: "text/plain", + }, "test-scope") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + ch.RecordToolFeedbackMessage("pico:sess-1", "msg-progress", "🔧 `read_file`") + + var deleted struct { + chatID string + messageID string + } + ch.deleteMessageFn = func(_ context.Context, chatID string, messageID string) error { + deleted.chatID = chatID + deleted.messageID = messageID + return nil + } + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "pico:sess-1", + Parts: []bus.MediaPart{{ + Ref: ref, + Type: "file", + Filename: "report.txt", + ContentType: "text/plain", + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + select { + case msg := <-received: + if msg.Type != TypeMessageCreate { + t.Fatalf("message type = %q, want %q", msg.Type, TypeMessageCreate) + } + case <-time.After(time.Second): + t.Fatal("expected media message to be delivered") + } + + if deleted.chatID != "pico:sess-1" || deleted.messageID != "msg-progress" { + t.Fatalf("unexpected delete target: %+v", deleted) + } + if _, ok := ch.currentToolFeedbackMessage("pico:sess-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after media delivery") + } +} + +func TestPicoDownloadURLForRef(t *testing.T) { + got, err := picoDownloadURLForRef("media://attachment-1") + if err != nil { + t.Fatalf("picoDownloadURLForRef() error = %v", err) + } + if got != "/pico/media/attachment-1" { + t.Fatalf("picoDownloadURLForRef() = %q, want %q", got, "/pico/media/attachment-1") + } +} + +func TestHandleMediaDownload_ServesStoredFile(t *testing.T) { + ch := newTestPicoChannel(t) + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + if err := ch.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(context.Background()) + + localPath := filepath.Join(t.TempDir(), "report.txt") + if err := os.WriteFile(localPath, []byte("downloadable"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "report.txt", + ContentType: "text/plain", + }, "test-scope") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + refID := strings.TrimPrefix(ref, "media://") + req := httptest.NewRequest("GET", "/pico/media/"+refID, nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + + ch.ServeHTTP(rec, req) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200", rec.Code) + } + if body := rec.Body.String(); body != "downloadable" { + t.Fatalf("body = %q, want %q", body, "downloadable") + } + if got := rec.Header().Get("Content-Type"); got != "text/plain" { + t.Fatalf("Content-Type = %q, want %q", got, "text/plain") + } +} + +func (c *PicoChannel) addConnForTest(pc *picoConn) { + c.connsMu.Lock() + defer c.connsMu.Unlock() + if c.connections == nil { + c.connections = make(map[string]*picoConn) + } + if c.sessionConnections == nil { + c.sessionConnections = make(map[string]map[string]*picoConn) + } + if _, exists := c.connections[pc.id]; exists { + panic(fmt.Sprintf("duplicate conn id in test: %s", pc.id)) + } + c.connections[pc.id] = pc + bySession, ok := c.sessionConnections[pc.sessionID] + if !ok { + bySession = make(map[string]*picoConn) + c.sessionConnections[pc.sessionID] = bySession + } + bySession[pc.id] = pc +} + +func newTestPicoWebSocket(t *testing.T) (*websocket.Conn, <-chan PicoMessage, func()) { + t.Helper() + + received := make(chan PicoMessage, 4) + upgrader := websocket.Upgrader{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("Upgrade() error = %v", err) + return + } + defer conn.Close() + for { + var msg PicoMessage + if err := conn.ReadJSON(&msg); err != nil { + return + } + received <- msg + } + })) + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + clientConn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + server.Close() + t.Fatalf("Dial() error = %v", err) + } + + cleanup := func() { + clientConn.Close() + server.Close() + } + defer resp.Body.Close() + return clientConn, received, cleanup +} diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go index 0a630e193..6e3a5ca89 100644 --- a/pkg/channels/pico/protocol.go +++ b/pkg/channels/pico/protocol.go @@ -1,6 +1,9 @@ package pico -import "time" +import ( + "strings" + "time" +) // Protocol message types. const ( @@ -12,11 +15,20 @@ const ( // TypeMessageCreate is sent from server to client. TypeMessageCreate = "message.create" TypeMessageUpdate = "message.update" + TypeMessageDelete = "message.delete" TypeMediaCreate = "media.create" TypeTypingStart = "typing.start" TypeTypingStop = "typing.stop" TypeError = "error" TypePong = "pong" + + PayloadKeyContent = "content" + PayloadKeyThought = "thought" + PayloadKeyKind = "kind" + PayloadKeyToolCalls = "tool_calls" + + MessageKindThought = "thought" + MessageKindToolCalls = "tool_calls" ) // PicoMessage is the wire format for all Pico Protocol messages. @@ -37,10 +49,30 @@ func newMessage(msgType string, payload map[string]any) PicoMessage { } } -// newError creates an error PicoMessage. -func newError(code, message string) PicoMessage { - return newMessage(TypeError, map[string]any{ +func isThoughtPayload(payload map[string]any) bool { + kind, _ := payload[PayloadKeyKind].(string) + if strings.EqualFold(strings.TrimSpace(kind), MessageKindThought) { + return true + } + + // Keep pico_client inbound-compatible with legacy servers that still send + // the pre-kind boolean thought marker. + thought, _ := payload[PayloadKeyThought].(bool) + return thought +} + +func newErrorWithPayload(code, message string, extra map[string]any) PicoMessage { + payload := map[string]any{ "code": code, "message": message, - }) + } + for key, value := range extra { + payload[key] = value + } + return newMessage(TypeError, payload) +} + +// newError creates an error PicoMessage. +func newError(code, message string) PicoMessage { + return newErrorWithPayload(code, message, nil) } diff --git a/pkg/channels/qq/audio_duration.go b/pkg/channels/qq/audio_duration.go new file mode 100644 index 000000000..28a9b2e83 --- /dev/null +++ b/pkg/channels/qq/audio_duration.go @@ -0,0 +1,231 @@ +package qq + +import ( + "encoding/binary" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +const qqVoiceMaxDuration = 60 * time.Second + +func qqAudioDuration(localPath, filename, contentType string) (time.Duration, bool, error) { + if localPath == "" { + return 0, false, nil + } + + switch qqAudioDurationFormat(localPath, filename, contentType) { + case "wav": + return qqWAVDuration(localPath) + case "ogg": + return qqOggDuration(localPath) + default: + return 0, false, nil + } +} + +func qqAudioDurationFormat(localPath, filename, contentType string) string { + contentType = strings.ToLower(contentType) + + switch { + case strings.HasPrefix(contentType, "audio/wav"), strings.HasPrefix(contentType, "audio/x-wav"): + return "wav" + case strings.HasPrefix(contentType, "audio/ogg"), + contentType == "application/ogg", + contentType == "application/x-ogg": + return "ogg" + } + + switch filepath.Ext(strings.ToLower(filename)) { + case ".wav": + return "wav" + case ".ogg", ".opus": + return "ogg" + } + + switch filepath.Ext(strings.ToLower(localPath)) { + case ".wav": + return "wav" + case ".ogg", ".opus": + return "ogg" + } + + return "" +} + +func qqWAVDuration(localPath string) (time.Duration, bool, error) { + file, err := os.Open(localPath) + if err != nil { + return 0, false, err + } + defer file.Close() + + var header [12]byte + if _, err := io.ReadFull(file, header[:]); err != nil { + return 0, false, err + } + + var order binary.ByteOrder + switch string(header[:4]) { + case "RIFF": + order = binary.LittleEndian + case "RIFX": + order = binary.BigEndian + default: + return 0, false, nil + } + + if string(header[8:12]) != "WAVE" { + return 0, false, nil + } + + var byteRate uint32 + var dataSize uint32 + var foundFmt bool + var foundData bool + + for { + var chunkHeader [8]byte + if _, err := io.ReadFull(file, chunkHeader[:]); err != nil { + if err == io.EOF { + break + } + return 0, false, err + } + + chunkSize := order.Uint32(chunkHeader[4:8]) + switch string(chunkHeader[:4]) { + case "fmt ": + chunkData := make([]byte, chunkSize) + if _, err := io.ReadFull(file, chunkData); err != nil { + return 0, false, err + } + if len(chunkData) >= 12 { + byteRate = order.Uint32(chunkData[8:12]) + foundFmt = true + } + case "data": + dataSize = chunkSize + foundData = true + if _, err := io.CopyN(io.Discard, file, int64(chunkSize)); err != nil { + return 0, false, err + } + default: + if _, err := io.CopyN(io.Discard, file, int64(chunkSize)); err != nil { + return 0, false, err + } + } + + if chunkSize%2 == 1 { + if _, err := io.CopyN(io.Discard, file, 1); err != nil { + return 0, false, err + } + } + + if foundFmt && foundData { + break + } + } + + if !foundFmt || !foundData || byteRate == 0 { + return 0, false, nil + } + + durationNS := int64(dataSize) * int64(time.Second) / int64(byteRate) + return time.Duration(durationNS), true, nil +} + +func qqOggDuration(localPath string) (time.Duration, bool, error) { + file, err := os.Open(localPath) + if err != nil { + return 0, false, err + } + defer file.Close() + + var firstPacket []byte + var codec string + var sampleRate uint32 + var lastGranule uint64 + var haveGranule bool + + for { + var header [27]byte + if _, err := io.ReadFull(file, header[:]); err != nil { + if err == io.EOF { + break + } + return 0, false, err + } + + if string(header[:4]) != "OggS" { + return 0, false, nil + } + + pageSegments := int(header[26]) + segments := make([]byte, pageSegments) + if _, err := io.ReadFull(file, segments); err != nil { + return 0, false, err + } + + payloadLen := 0 + for _, segLen := range segments { + payloadLen += int(segLen) + } + + payload := make([]byte, payloadLen) + if _, err := io.ReadFull(file, payload); err != nil { + return 0, false, err + } + + granule := binary.LittleEndian.Uint64(header[6:14]) + if granule != ^uint64(0) { + lastGranule = granule + haveGranule = true + } + + if codec == "" { + offset := 0 + for _, segLen := range segments { + firstPacket = append(firstPacket, payload[offset:offset+int(segLen)]...) + offset += int(segLen) + if segLen < 255 { + codec, sampleRate = qqParseOggCodec(firstPacket) + break + } + } + } + } + + if !haveGranule || codec == "" { + return 0, false, nil + } + + switch codec { + case "opus": + return time.Duration(lastGranule) * time.Second / 48000, true, nil + case "vorbis": + if sampleRate == 0 { + return 0, false, nil + } + return time.Duration(lastGranule) * time.Second / time.Duration(sampleRate), true, nil + default: + return 0, false, nil + } +} + +func qqParseOggCodec(packet []byte) (string, uint32) { + if len(packet) >= 8 && string(packet[:8]) == "OpusHead" { + return "opus", 48000 + } + + if len(packet) >= 16 && packet[0] == 0x01 && string(packet[1:7]) == "vorbis" { + sampleRate := binary.LittleEndian.Uint32(packet[12:16]) + if sampleRate > 0 { + return "vorbis", sampleRate + } + } + + return "", 0 +} diff --git a/pkg/channels/qq/botgo_logger.go b/pkg/channels/qq/botgo_logger.go new file mode 100644 index 000000000..e1d2462a3 --- /dev/null +++ b/pkg/channels/qq/botgo_logger.go @@ -0,0 +1,41 @@ +package qq + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// botGoLogger preserves useful SDK info logs while demoting noisy heartbeat +// traffic to DEBUG so long-running QQ sessions do not spam the console. +type botGoLogger struct { + *logger.Logger +} + +func newBotGoLogger(component string) *botGoLogger { + return &botGoLogger{Logger: logger.NewLogger(component)} +} + +func (b *botGoLogger) Info(v ...any) { + message := fmt.Sprint(v...) + if shouldDemoteBotGoInfo(message) { + b.Logger.Debug(message) + return + } + b.Logger.Info(message) +} + +func (b *botGoLogger) Infof(format string, v ...any) { + message := fmt.Sprintf(format, v...) + if shouldDemoteBotGoInfo(message) { + b.Logger.Debug(message) + return + } + b.Logger.Info(message) +} + +func shouldDemoteBotGoInfo(message string) bool { + return strings.Contains(message, " write Heartbeat message") || + strings.Contains(message, " receive HeartbeatAck message") +} diff --git a/pkg/channels/qq/init.go b/pkg/channels/qq/init.go index 15b955089..55be732fd 100644 --- a/pkg/channels/qq/init.go +++ b/pkg/channels/qq/init.go @@ -7,7 +7,19 @@ import ( ) func init() { - channels.RegisterFactory("qq", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewQQChannel(cfg.Channels.QQ, b) - }) + channels.RegisterFactory( + config.ChannelQQ, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.QQSettings) + if !ok { + return nil, channels.ErrSendFailed + } + return NewQQChannel(bc, c, b) + }, + ) } diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 112964143..71cba5548 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -2,14 +2,26 @@ package qq import ( "context" + "encoding/base64" + "encoding/json" + "errors" "fmt" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "strings" "sync" + "sync/atomic" "time" "github.com/tencent-connect/botgo" + "github.com/tencent-connect/botgo/constant" "github.com/tencent-connect/botgo/dto" "github.com/tencent-connect/botgo/event" - "github.com/tencent-connect/botgo/openapi" + "github.com/tencent-connect/botgo/openapi/options" "github.com/tencent-connect/botgo/token" "golang.org/x/oauth2" @@ -18,44 +30,91 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/utils" ) +const ( + dedupTTL = 5 * time.Minute + dedupInterval = 60 * time.Second + dedupMaxSize = 10000 // hard cap on dedup map entries + typingResend = 8 * time.Second + typingSeconds = 10 + bytesPerMiB = 1024 * 1024 +) + +type qqAPI interface { + WS(ctx context.Context, params map[string]string, body string) (*dto.WebsocketAP, error) + PostGroupMessage( + ctx context.Context, groupID string, msg dto.APIMessage, opt ...options.Option, + ) (*dto.Message, error) + PostC2CMessage( + ctx context.Context, userID string, msg dto.APIMessage, opt ...options.Option, + ) (*dto.Message, error) + Transport(ctx context.Context, method, url string, body any) ([]byte, error) +} + type QQChannel struct { *channels.BaseChannel - config config.QQConfig - api openapi.OpenAPI + bc *config.Channel + config *config.QQSettings + api qqAPI tokenSource oauth2.TokenSource ctx context.Context cancel context.CancelFunc sessionManager botgo.SessionManager - processedIDs map[string]bool - mu sync.RWMutex + downloadFn func(urlStr, filename string) string + + // Chat routing: track whether a chatID is group or direct. + chatType sync.Map // chatID → "group" | "direct" + + // Passive reply: store last inbound message ID per chat. + lastMsgID sync.Map // chatID → string + + // msg_seq: per-chat atomic counter for multi-part replies. + msgSeqCounters sync.Map // chatID → *atomic.Uint64 + + // Time-based dedup replacing the unbounded map. + dedup map[string]time.Time + muDedup sync.Mutex + + // done is closed on Stop to shut down the dedup janitor. + done chan struct{} + stopOnce sync.Once } -func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { - base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom, - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), +func NewQQChannel(bc *config.Channel, cfg *config.QQSettings, messageBus *bus.MessageBus) (*QQChannel, error) { + base := channels.NewBaseChannel("qq", cfg, messageBus, bc.AllowFrom, + channels.WithMaxMessageLength(cfg.MaxMessageLength), + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &QQChannel{ - BaseChannel: base, - config: cfg, - processedIDs: make(map[string]bool), + BaseChannel: base, + bc: bc, + config: cfg, + dedup: make(map[string]time.Time), + done: make(chan struct{}), }, nil } func (c *QQChannel) Start(ctx context.Context) error { - if c.config.AppID == "" || c.config.AppSecret == "" { + if c.config.AppID == "" || c.config.AppSecret.String() == "" { return fmt.Errorf("QQ app_id and app_secret not configured") } + botgo.SetLogger(newBotGoLogger("botgo")) logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") + // Reinitialize shutdown signal for clean restart. + c.done = make(chan struct{}) + c.stopOnce = sync.Once{} + // create token source credentials := &token.QQBotCredentials{ AppID: c.config.AppID, - AppSecret: c.config.AppSecret, + AppSecret: c.config.AppSecret.String(), } c.tokenSource = token.NewQQBotTokenSource(credentials) @@ -99,6 +158,15 @@ func (c *QQChannel) Start(ctx context.Context) error { } }() + // start dedup janitor goroutine + go c.dedupJanitor() + + // Pre-register reasoning_channel_id as group chat if configured, + // so outbound-only destinations are routed correctly. + if c.bc.ReasoningChannelID != "" { + c.chatType.Store(c.bc.ReasoningChannelID, "group") + } + c.SetRunning(true) logger.InfoC("qq", "QQ bot started successfully") @@ -109,6 +177,9 @@ func (c *QQChannel) Stop(ctx context.Context) error { logger.InfoC("qq", "Stopping QQ bot") c.SetRunning(false) + // Signal the dedup janitor to stop (idempotent). + c.stopOnce.Do(func() { close(c.done) }) + if c.cancel != nil { c.cancel() } @@ -116,29 +187,426 @@ func (c *QQChannel) Stop(ctx context.Context) error { return nil } -func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return channels.ErrNotRunning +// getChatKind returns the chat type for a given chatID ("group" or "direct"). +// Unknown chatIDs default to "group" and log a warning, since QQ group IDs are +// more common as outbound-only destinations (e.g. reasoning_channel_id). +func (c *QQChannel) getChatKind(chatID string) string { + if v, ok := c.chatType.Load(chatID); ok { + if k, ok := v.(string); ok { + return k + } } - - // construct message - msgToCreate := &dto.MessageToCreate{ - Content: msg.Content, - } - - // send C2C message - _, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) - if err != nil { - logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{ - "error": err.Error(), - }) - return fmt.Errorf("qq send: %w", channels.ErrTemporary) - } - - return nil + logger.DebugCF("qq", "Unknown chat type for chatID, defaulting to group", map[string]any{ + "chat_id": chatID, + }) + return "group" } -// handleC2CMessage handles QQ private messages +func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + + chatKind := c.getChatKind(msg.ChatID) + + // Build message with content. + msgToCreate := &dto.MessageToCreate{ + Content: msg.Content, + MsgType: dto.TextMsg, + } + + // Use Markdown message type if enabled in config. + if c.config.SendMarkdown { + msgToCreate.MsgType = dto.MarkdownMsg + msgToCreate.Markdown = &dto.Markdown{ + Content: msg.Content, + } + // Clear plain content to avoid sending duplicate text. + msgToCreate.Content = "" + } + + c.applyPassiveReplyMetadata(msg.ChatID, msgToCreate) + + // Sanitize URLs in group messages to avoid QQ's URL blacklist rejection. + if chatKind == "group" { + if msgToCreate.Content != "" { + msgToCreate.Content = sanitizeURLs(msgToCreate.Content) + } + if msgToCreate.Markdown != nil && msgToCreate.Markdown.Content != "" { + msgToCreate.Markdown.Content = sanitizeURLs(msgToCreate.Markdown.Content) + } + } + + // Route to group or C2C. + var ( + sentMsg *dto.Message + err error + ) + if chatKind == "group" { + sentMsg, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) + } else { + sentMsg, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) + } + + if err != nil { + logger.ErrorCF("qq", "Failed to send message", map[string]any{ + "chat_id": msg.ChatID, + "chat_kind": chatKind, + "error": err.Error(), + }) + return nil, fmt.Errorf("qq send: %w", channels.ErrTemporary) + } + + if sentMsg == nil { + return nil, nil + } + return []string{sentMsg.ID}, nil +} + +// StartTyping implements channels.TypingCapable. +// It sends an InputNotify (msg_type=6) immediately and re-sends every 8 seconds. +// The returned stop function is idempotent and cancels the goroutine. +func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + // We need a stored msg_id for passive InputNotify; skip if none available. + v, ok := c.lastMsgID.Load(chatID) + if !ok { + return func() {}, nil + } + msgID, ok := v.(string) + if !ok || msgID == "" { + return func() {}, nil + } + + chatKind := c.getChatKind(chatID) + + sendTyping := func(sendCtx context.Context) { + typingMsg := &dto.MessageToCreate{ + MsgType: dto.InputNotifyMsg, + MsgID: msgID, + InputNotify: &dto.InputNotify{ + InputType: 1, + InputSecond: typingSeconds, + }, + } + + var err error + if chatKind == "group" { + _, err = c.api.PostGroupMessage(sendCtx, chatID, typingMsg) + } else { + _, err = c.api.PostC2CMessage(sendCtx, chatID, typingMsg) + } + if err != nil { + logger.DebugCF("qq", "Failed to send typing indicator", map[string]any{ + "chat_id": chatID, + "error": err.Error(), + }) + } + } + + // Send immediately. + sendTyping(c.ctx) + + typingCtx, cancel := context.WithCancel(c.ctx) + go func() { + ticker := time.NewTicker(typingResend) + defer ticker.Stop() + for { + select { + case <-typingCtx.Done(): + return + case <-ticker.C: + sendTyping(typingCtx) + } + } + }() + + return cancel, nil +} + +// SendMedia implements the channels.MediaSender interface. +// QQ group/C2C media sending is a two-step flow: +// 1. Upload media to /files using a remote URL or base64-encoded local bytes. +// 2. Send a msg_type=7 message using the returned file_info. +func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + + chatKind := c.getChatKind(msg.ChatID) + + var messageIDs []string + for _, part := range msg.Parts { + fileInfo, err := c.uploadMedia(ctx, chatKind, msg.ChatID, part) + if err != nil { + logger.ErrorCF("qq", "Failed to upload media", map[string]any{ + "type": part.Type, + "chat_id": msg.ChatID, + "error": err.Error(), + }) + if errors.Is(err, channels.ErrSendFailed) { + return nil, err + } + return nil, fmt.Errorf("qq send media: %w", channels.ErrTemporary) + } + + sentMsg, err := c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo) + if err != nil { + logger.ErrorCF("qq", "Failed to send media", map[string]any{ + "type": part.Type, + "chat_id": msg.ChatID, + "error": err.Error(), + }) + return nil, fmt.Errorf("qq send media: %w", channels.ErrTemporary) + } + if sentMsg != nil && sentMsg.ID != "" { + messageIDs = append(messageIDs, sentMsg.ID) + } + } + + return messageIDs, nil +} + +type qqMediaUpload struct { + FileType uint64 `json:"file_type"` + URL string `json:"url,omitempty"` + FileData string `json:"file_data,omitempty"` + FileName string `json:"file_name,omitempty"` + SrvSendMsg bool `json:"srv_send_msg,omitempty"` +} + +func (c *QQChannel) uploadMedia( + ctx context.Context, + chatKind, chatID string, + part bus.MediaPart, +) ([]byte, error) { + payload, err := c.buildMediaUpload(part) + if err != nil { + return nil, err + } + + body, err := c.api.Transport(ctx, http.MethodPost, c.mediaUploadURL(chatKind, chatID), payload) + if err != nil { + return nil, err + } + + var uploaded dto.Message + if err := json.Unmarshal(body, &uploaded); err != nil { + return nil, fmt.Errorf("qq decode media upload response: %w", err) + } + if len(uploaded.FileInfo) == 0 { + return nil, fmt.Errorf("qq upload media: missing file_info") + } + + return uploaded.FileInfo, nil +} + +func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) { + payload := &qqMediaUpload{} + + mediaRef := part.Ref + if isHTTPURL(mediaRef) { + payload.FileType = qqFileType(c.outboundMediaType(part, "")) + payload.URL = mediaRef + payload.FileName = qqUploadFilename(part, mediaRef, payload.FileType) + return payload, nil + } + + store := c.GetMediaStore() + if store == nil { + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + resolved, meta, err := store.ResolveWithMeta(part.Ref) + if err != nil { + return nil, fmt.Errorf("qq resolve media ref %q: %v: %w", part.Ref, err, channels.ErrSendFailed) + } + if part.Filename == "" { + part.Filename = meta.Filename + } + if part.ContentType == "" { + part.ContentType = meta.ContentType + } + + if isHTTPURL(resolved) { + payload.FileType = qqFileType(c.outboundMediaType(part, "")) + payload.URL = resolved + payload.FileName = qqUploadFilename(part, resolved, payload.FileType) + return payload, nil + } + payload.FileType = qqFileType(c.outboundMediaType(part, resolved)) + payload.FileName = qqUploadFilename(part, resolved, payload.FileType) + + if limitBytes := c.maxBase64FileSizeBytes(); limitBytes > 0 { + info, statErr := os.Stat(resolved) + if statErr != nil { + return nil, fmt.Errorf("qq stat local media %q: %v: %w", resolved, statErr, channels.ErrSendFailed) + } + if info.Size() > limitBytes { + return nil, fmt.Errorf( + "qq local media %q exceeds max_base64_file_size_mib (%d > %d bytes): %w", + resolved, + info.Size(), + limitBytes, + channels.ErrSendFailed, + ) + } + } + + data, err := os.ReadFile(resolved) + if err != nil { + return nil, fmt.Errorf("qq read local media %q: %v: %w", resolved, err, channels.ErrSendFailed) + } + + payload.FileData = base64.StdEncoding.EncodeToString(data) + return payload, nil +} + +func qqUploadFilename(part bus.MediaPart, resolved string, fileType uint64) string { + if fileType != qqFileType("file") { + return "" + } + if part.Filename != "" { + return part.Filename + } + if isHTTPURL(resolved) { + if parsed, err := url.Parse(resolved); err == nil { + if base := path.Base(parsed.Path); base != "" && base != "." && base != "/" { + return base + } + } + return "" + } + + if base := filepath.Base(resolved); base != "" && base != "." { + return base + } + return "" +} + +func (c *QQChannel) outboundMediaType(part bus.MediaPart, localPath string) string { + if part.Type != "audio" { + return part.Type + } + + if localPath == "" { + logger.InfoCF("qq", "Sending audio as file because duration is unavailable", map[string]any{ + "ref": part.Ref, + "filename": part.Filename, + }) + return "file" + } + + duration, ok, err := qqAudioDuration(localPath, part.Filename, part.ContentType) + if err != nil { + logger.WarnCF("qq", "Failed to detect audio duration, sending as file", map[string]any{ + "ref": part.Ref, + "filename": part.Filename, + "error": err.Error(), + }) + return "file" + } + if !ok { + logger.InfoCF("qq", "Sending audio as file because duration is unavailable", map[string]any{ + "ref": part.Ref, + "filename": part.Filename, + }) + return "file" + } + if duration > qqVoiceMaxDuration { + logger.InfoCF("qq", "Sending audio as file because it exceeds QQ voice limit", map[string]any{ + "ref": part.Ref, + "filename": part.Filename, + "duration_seconds": duration.Seconds(), + "limit_seconds": qqVoiceMaxDuration.Seconds(), + }) + return "file" + } + + return "audio" +} + +func (c *QQChannel) sendUploadedMedia( + ctx context.Context, + chatKind, chatID string, + part bus.MediaPart, + fileInfo []byte, +) (*dto.Message, error) { + msg := &dto.MessageToCreate{ + Content: part.Caption, + MsgType: dto.RichMediaMsg, + Media: &dto.MediaInfo{ + FileInfo: fileInfo, + }, + } + c.applyPassiveReplyMetadata(chatID, msg) + + if chatKind == "group" && msg.Content != "" { + msg.Content = sanitizeURLs(msg.Content) + } + + if chatKind == "group" { + sentMsg, err := c.api.PostGroupMessage(ctx, chatID, msg) + return sentMsg, err + } + sentMsg, err := c.api.PostC2CMessage(ctx, chatID, msg) + return sentMsg, err +} + +func (c *QQChannel) applyPassiveReplyMetadata(chatID string, msg *dto.MessageToCreate) { + if v, ok := c.lastMsgID.Load(chatID); ok { + if msgID, ok := v.(string); ok && msgID != "" { + msg.MsgID = msgID + + // Increment msg_seq atomically for multi-part replies. + if counterVal, ok := c.msgSeqCounters.Load(chatID); ok { + if counter, ok := counterVal.(*atomic.Uint64); ok { + seq := counter.Add(1) + msg.MsgSeq = uint32(seq) + } + } + } + } +} + +func (c *QQChannel) mediaUploadURL(chatKind, chatID string) string { + base := constant.APIDomain + if chatKind == "group" { + return fmt.Sprintf("%s/v2/groups/%s/files", base, chatID) + } + return fmt.Sprintf("%s/v2/users/%s/files", base, chatID) +} + +func qqFileType(partType string) uint64 { + switch partType { + case "image": + return 1 + case "video": + return 2 + case "audio": + return 3 + default: + return 4 + } +} + +func (c *QQChannel) maxBase64FileSizeBytes() int64 { + if c.config == nil { + return 0 + } + if c.config.MaxBase64FileSizeMiB <= 0 { + return 0 + } + return c.config.MaxBase64FileSizeMiB * bytesPerMiB +} + +func (c *QQChannel) accountID() string { + if c.config == nil { + return "" + } + return c.config.AppID +} + +// handleC2CMessage handles QQ private messages. func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error { // deduplication check @@ -155,21 +623,6 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return nil } - // extract message content - content := data.Content - if content == "" { - logger.DebugC("qq", "Received empty message, ignoring") - return nil - } - - logger.InfoCF("qq", "Received C2C message", map[string]any{ - "sender": senderID, - "length": len(content), - }) - - // 转发到消息总线 - metadata := map[string]string{} - sender := bus.SenderInfo{ Platform: "qq", PlatformID: data.Author.ID, @@ -180,22 +633,49 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return nil } - c.HandleMessage(c.ctx, - bus.Peer{Kind: "direct", ID: senderID}, - data.ID, - senderID, - senderID, - content, - []string{}, - metadata, - sender, - ) + content := strings.TrimSpace(data.Content) + mediaPaths, attachmentNotes := c.extractInboundAttachments(senderID, data.ID, data.Attachments) + for _, note := range attachmentNotes { + content = appendContent(content, note) + } + if content == "" && len(mediaPaths) == 0 { + logger.DebugC("qq", "Received empty C2C message with no attachments, ignoring") + return nil + } + + logger.InfoCF("qq", "Received C2C message", map[string]any{ + "sender": senderID, + "length": len(content), + "media_count": len(mediaPaths), + }) + + // Store chat routing context. + c.chatType.Store(senderID, "direct") + c.lastMsgID.Store(senderID, data.ID) + + // Reset msg_seq counter for new inbound message. + c.msgSeqCounters.Store(senderID, new(atomic.Uint64)) + + metadata := map[string]string{ + "account_id": senderID, + } + inboundCtx := bus.InboundContext{ + Channel: c.Name(), + Account: c.accountID(), + ChatID: senderID, + ChatType: "direct", + SenderID: senderID, + MessageID: data.ID, + Raw: metadata, + } + + c.HandleInboundContext(c.ctx, senderID, content, mediaPaths, inboundCtx, sender) return nil } } -// handleGroupATMessage handles QQ group @ messages +// handleGroupATMessage handles QQ group @ messages. func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error { // deduplication check @@ -212,31 +692,6 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { return nil } - // extract message content (remove @ bot part) - content := data.Content - if content == "" { - logger.DebugC("qq", "Received empty group message, ignoring") - return nil - } - - // GroupAT event means bot is always mentioned; apply group trigger filtering - respond, cleaned := c.ShouldRespondInGroup(true, content) - if !respond { - return nil - } - content = cleaned - - logger.InfoCF("qq", "Received group AT message", map[string]any{ - "sender": senderID, - "group": data.GroupID, - "length": len(content), - }) - - // 转发到消息总线(使用 GroupID 作为 ChatID) - metadata := map[string]string{ - "group_id": data.GroupID, - } - sender := bus.SenderInfo{ Platform: "qq", PlatformID: data.Author.ID, @@ -247,44 +702,321 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { return nil } - c.HandleMessage(c.ctx, - bus.Peer{Kind: "group", ID: data.GroupID}, - data.ID, - senderID, - data.GroupID, - content, - []string{}, - metadata, - sender, - ) + content := strings.TrimSpace(data.Content) + mediaPaths, attachmentNotes := c.extractInboundAttachments(data.GroupID, data.ID, data.Attachments) + for _, note := range attachmentNotes { + content = appendContent(content, note) + } + + // GroupAT event means bot is always mentioned; apply group trigger filtering. + respond, cleaned := c.ShouldRespondInGroup(true, content) + if !respond { + return nil + } + content = cleaned + if content == "" && len(mediaPaths) == 0 { + logger.DebugC("qq", "Received empty group message with no attachments, ignoring") + return nil + } + + logger.InfoCF("qq", "Received group AT message", map[string]any{ + "sender": senderID, + "group": data.GroupID, + "length": len(content), + "media_count": len(mediaPaths), + }) + + // Store chat routing context using GroupID as chatID. + c.chatType.Store(data.GroupID, "group") + c.lastMsgID.Store(data.GroupID, data.ID) + + // Reset msg_seq counter for new inbound message. + c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64)) + + metadata := map[string]string{ + "account_id": senderID, + "group_id": data.GroupID, + } + inboundCtx := bus.InboundContext{ + Channel: c.Name(), + Account: c.accountID(), + ChatID: data.GroupID, + ChatType: "group", + SenderID: senderID, + MessageID: data.ID, + Mentioned: true, + Raw: metadata, + } + + c.HandleInboundContext(c.ctx, data.GroupID, content, mediaPaths, inboundCtx, sender) return nil } } -// isDuplicate 检查消息是否重复 -func (c *QQChannel) isDuplicate(messageID string) bool { - c.mu.Lock() - defer c.mu.Unlock() - - if c.processedIDs[messageID] { - return true +func (c *QQChannel) extractInboundAttachments( + chatID, messageID string, + attachments []*dto.MessageAttachment, +) ([]string, []string) { + if len(attachments) == 0 { + return nil, nil } - c.processedIDs[messageID] = true + scope := channels.BuildMediaScope("qq", chatID, messageID) + mediaPaths := make([]string, 0, len(attachments)) + notes := make([]string, 0, len(attachments)) - // 简单清理:限制 map 大小 - if len(c.processedIDs) > 10000 { - // 清空一半 - count := 0 - for id := range c.processedIDs { - if count >= 5000 { - break + storeMedia := func(localPath string, attachment *dto.MessageAttachment) string { + if store := c.GetMediaStore(); store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: qqAttachmentFilename(attachment), + ContentType: attachment.ContentType, + Source: "qq", + CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, + }, scope) + if err == nil { + return ref + } + } + return localPath + } + + for _, attachment := range attachments { + if attachment == nil { + continue + } + + filename := qqAttachmentFilename(attachment) + if localPath := c.downloadAttachment(attachment.URL, filename); localPath != "" { + mediaPaths = append(mediaPaths, storeMedia(localPath, attachment)) + } else if attachment.URL != "" { + mediaPaths = append(mediaPaths, attachment.URL) + } + + notes = append(notes, qqAttachmentNote(attachment)) + } + + return mediaPaths, notes +} + +func (c *QQChannel) downloadAttachment(urlStr, filename string) string { + if urlStr == "" { + return "" + } + if c.downloadFn != nil { + return c.downloadFn(urlStr, filename) + } + + return utils.DownloadFile(urlStr, filename, utils.DownloadOptions{ + LoggerPrefix: "qq", + ExtraHeaders: c.downloadHeaders(), + }) +} + +func (c *QQChannel) downloadHeaders() map[string]string { + headers := map[string]string{} + + if c.config.AppID != "" { + headers["X-Union-Appid"] = c.config.AppID + } + + if c.tokenSource != nil { + if tk, err := c.tokenSource.Token(); err == nil && tk.AccessToken != "" { + auth := strings.TrimSpace(tk.TokenType + " " + tk.AccessToken) + if auth != "" { + headers["Authorization"] = auth } - delete(c.processedIDs, id) - count++ } } + if len(headers) == 0 { + return nil + } + return headers +} + +func qqAttachmentFilename(attachment *dto.MessageAttachment) string { + if attachment == nil { + return "attachment" + } + if attachment.FileName != "" { + return attachment.FileName + } + if attachment.URL != "" { + if parsed, err := url.Parse(attachment.URL); err == nil { + if base := path.Base(parsed.Path); base != "" && base != "." && base != "/" { + return base + } + } + } + + switch qqAttachmentKind(attachment) { + case "image": + return "image" + case "audio": + return "audio" + case "video": + return "video" + default: + return "attachment" + } +} + +func qqAttachmentKind(attachment *dto.MessageAttachment) string { + if attachment == nil { + return "file" + } + + contentType := strings.ToLower(attachment.ContentType) + filename := strings.ToLower(attachment.FileName) + + switch { + case strings.HasPrefix(contentType, "image/"): + return "image" + case strings.HasPrefix(contentType, "video/"): + return "video" + case strings.HasPrefix(contentType, "audio/"), contentType == "application/ogg", contentType == "application/x-ogg": + return "audio" + } + + switch filepath.Ext(filename) { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": + return "image" + case ".mp4", ".avi", ".mov", ".webm", ".mkv": + return "video" + case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus", ".silk": + return "audio" + default: + return "file" + } +} + +func qqAttachmentNote(attachment *dto.MessageAttachment) string { + filename := qqAttachmentFilename(attachment) + + switch qqAttachmentKind(attachment) { + case "image": + return fmt.Sprintf("[image: %s]", filename) + case "audio": + return fmt.Sprintf("[audio: %s]", filename) + case "video": + return fmt.Sprintf("[video: %s]", filename) + default: + return fmt.Sprintf("[file: %s]", filename) + } +} + +// isDuplicate checks whether a message has been seen within the TTL window. +// It also enforces a hard cap on map size by evicting oldest entries. +func (c *QQChannel) isDuplicate(messageID string) bool { + c.muDedup.Lock() + defer c.muDedup.Unlock() + + if ts, exists := c.dedup[messageID]; exists && time.Since(ts) < dedupTTL { + return true + } + + // Enforce hard cap: evict oldest entries when at capacity. + if len(c.dedup) >= dedupMaxSize { + var oldestID string + var oldestTS time.Time + for id, ts := range c.dedup { + if oldestID == "" || ts.Before(oldestTS) { + oldestID = id + oldestTS = ts + } + } + if oldestID != "" { + delete(c.dedup, oldestID) + } + } + + c.dedup[messageID] = time.Now() return false } + +// dedupJanitor periodically evicts expired entries from the dedup map. +func (c *QQChannel) dedupJanitor() { + ticker := time.NewTicker(dedupInterval) + defer ticker.Stop() + + for { + select { + case <-c.done: + return + case <-ticker.C: + // Collect expired keys under read-like scan. + c.muDedup.Lock() + now := time.Now() + var expired []string + for id, ts := range c.dedup { + if now.Sub(ts) >= dedupTTL { + expired = append(expired, id) + } + } + for _, id := range expired { + delete(c.dedup, id) + } + c.muDedup.Unlock() + } + } +} + +// isHTTPURL returns true if s starts with http:// or https://. +func isHTTPURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +func appendContent(content, suffix string) string { + if suffix == "" { + return content + } + if content == "" { + return suffix + } + return content + "\n" + suffix +} + +// urlPattern matches URLs with explicit http(s):// scheme. +// Only scheme-prefixed URLs are matched to avoid false positives on bare text +// like version numbers (e.g., "1.2.3") or domain-like fragments. +var urlPattern = regexp.MustCompile( + `(?i)` + + `https?://` + // required scheme + `(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+` + // domain parts + `[a-zA-Z]{2,}` + // TLD + `(?:[/?#]\S*)?`, // optional path/query/fragment +) + +// sanitizeURLs replaces dots in URL domains with "。" (fullwidth period) +// to prevent QQ's URL blacklist from rejecting the message. +func sanitizeURLs(text string) string { + return urlPattern.ReplaceAllStringFunc(text, func(match string) string { + // Split into scheme + rest (scheme is always present). + idx := strings.Index(match, "://") + scheme := match[:idx+3] + rest := match[idx+3:] + + // Find where the domain ends (first / ? or #). + domainEnd := len(rest) + for i, ch := range rest { + if ch == '/' || ch == '?' || ch == '#' { + domainEnd = i + break + } + } + + domain := rest[:domainEnd] + path := rest[domainEnd:] + + // Replace dots in domain only. + domain = strings.ReplaceAll(domain, ".", "。") + + return scheme + domain + path + }) +} + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *QQChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/qq/qq_test.go b/pkg/channels/qq/qq_test.go new file mode 100644 index 000000000..2ab03ab54 --- /dev/null +++ b/pkg/channels/qq/qq_test.go @@ -0,0 +1,747 @@ +package qq + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/tencent-connect/botgo/dto" + "github.com/tencent-connect/botgo/openapi/options" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" +) + +func TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + + err := ch.handleC2CMessage()(nil, &dto.WSC2CMessageData{ + ID: "msg-1", + Content: "hello", + Author: &dto.User{ + ID: "7750283E123456", + }, + }) + if err != nil { + t.Fatalf("handleC2CMessage() error = %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + for { + select { + case <-ctx.Done(): + t.Fatal("timeout waiting for inbound message") + return + case inbound, ok := <-messageBus.InboundChan(): + if !ok { + t.Fatal("expected inbound message") + } + if inbound.Context.Raw["account_id"] != "7750283E123456" { + t.Fatalf("account_id raw = %q, want %q", inbound.Context.Raw["account_id"], "7750283E123456") + } + return + } + } +} + +func TestHandleC2CMessage_AttachmentOnlyPublishesMedia(t *testing.T) { + messageBus := bus.NewMessageBus() + store := media.NewFileMediaStore() + localPath := writeTempFile(t, t.TempDir(), "image.png", []byte("fake-image")) + + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + downloadFn: func(urlStr, filename string) string { + if filename != "image.png" { + t.Fatalf("download filename = %q, want image.png", filename) + } + return localPath + }, + } + ch.SetMediaStore(store) + + err := ch.handleC2CMessage()(nil, &dto.WSC2CMessageData{ + ID: "msg-attachment", + Content: "", + Author: &dto.User{ + ID: "7750283E123456", + }, + Attachments: []*dto.MessageAttachment{{ + URL: "https://example.com/image.png", + FileName: "image.png", + ContentType: "image/png", + }}, + }) + if err != nil { + t.Fatalf("handleC2CMessage() error = %v", err) + } + + inbound := waitInboundMessage(t, messageBus) + if inbound.Content != "[image: image.png]" { + t.Fatalf("inbound.Content = %q", inbound.Content) + } + if len(inbound.Media) != 1 { + t.Fatalf("len(inbound.Media) = %d, want 1", len(inbound.Media)) + } + if !strings.HasPrefix(inbound.Media[0], "media://") { + t.Fatalf("inbound.Media[0] = %q, want media:// ref", inbound.Media[0]) + } + _, meta, err := store.ResolveWithMeta(inbound.Media[0]) + if err != nil { + t.Fatalf("ResolveWithMeta() error = %v", err) + } + if meta.Filename != "image.png" { + t.Fatalf("meta.Filename = %q, want image.png", meta.Filename) + } + if meta.ContentType != "image/png" { + t.Fatalf("meta.ContentType = %q, want image/png", meta.ContentType) + } +} + +func TestHandleGroupATMessage_AttachmentOnlyPublishesMedia(t *testing.T) { + messageBus := bus.NewMessageBus() + store := media.NewFileMediaStore() + localPath := writeTempFile(t, t.TempDir(), "report.pdf", []byte("fake-pdf")) + + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + downloadFn: func(urlStr, filename string) string { + if filename != "report.pdf" { + t.Fatalf("download filename = %q, want report.pdf", filename) + } + return localPath + }, + } + ch.SetMediaStore(store) + + err := ch.handleGroupATMessage()(nil, &dto.WSGroupATMessageData{ + ID: "group-attachment", + GroupID: "group-1", + Content: "", + Author: &dto.User{ + ID: "7750283E123456", + }, + Attachments: []*dto.MessageAttachment{{ + URL: "https://example.com/report.pdf", + FileName: "report.pdf", + ContentType: "application/pdf", + }}, + }) + if err != nil { + t.Fatalf("handleGroupATMessage() error = %v", err) + } + + inbound := waitInboundMessage(t, messageBus) + if inbound.Content != "[file: report.pdf]" { + t.Fatalf("inbound.Content = %q", inbound.Content) + } + if len(inbound.Media) != 1 { + t.Fatalf("len(inbound.Media) = %d, want 1", len(inbound.Media)) + } + if !strings.HasPrefix(inbound.Media[0], "media://") { + t.Fatalf("inbound.Media[0] = %q, want media:// ref", inbound.Media[0]) + } + if inbound.Context.ChatType != "group" { + t.Fatalf("inbound.Context.ChatType = %q, want group", inbound.Context.ChatType) + } +} + +func TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) { + messageBus := bus.NewMessageBus() + store := media.NewFileMediaStore() + + tmpFile, err := os.CreateTemp(t.TempDir(), "qq-media-*.png") + if err != nil { + t.Fatalf("CreateTemp() error = %v", err) + } + defer tmpFile.Close() + + content := []byte("local-image-data") + if _, writeErr := tmpFile.Write(content); writeErr != nil { + t.Fatalf("Write() error = %v", writeErr) + } + + ref, err := store.Store(tmpFile.Name(), media.MediaMeta{ + Filename: "reply.png", + ContentType: "image/png", + }, "qq:test") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + api := &fakeQQAPI{ + transportResp: mustJSON(t, dto.Message{FileInfo: []byte("uploaded-file-info")}), + } + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: &config.QQSettings{}, + api: api, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.SetMediaStore(store) + ch.chatType.Store("group-1", "group") + ch.lastMsgID.Store("group-1", "msg-1") + ch.msgSeqCounters.Store("group-1", new(atomic.Uint64)) + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "group-1", + Parts: []bus.MediaPart{{ + Type: "image", + Ref: ref, + Caption: "see https://example.com/image", + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(api.transportCalls) != 1 { + t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls)) + } + upload := api.transportCalls[0] + if upload.method != "POST" { + t.Fatalf("upload method = %q, want POST", upload.method) + } + if upload.url != "https://api.sgroup.qq.com/v2/groups/group-1/files" { + t.Fatalf("upload url = %q", upload.url) + } + if upload.body.URL != "" { + t.Fatalf("upload URL = %q, want empty", upload.body.URL) + } + wantBase64 := base64.StdEncoding.EncodeToString(content) + if upload.body.FileData != wantBase64 { + t.Fatalf("upload file_data = %q, want %q", upload.body.FileData, wantBase64) + } + if upload.body.FileType != 1 { + t.Fatalf("upload file_type = %d, want 1", upload.body.FileType) + } + + if len(api.groupMessages) != 1 { + t.Fatalf("groupMessages = %d, want 1", len(api.groupMessages)) + } + msg, ok := api.groupMessages[0].(*dto.MessageToCreate) + if !ok { + t.Fatalf("groupMessages[0] type = %T, want *dto.MessageToCreate", api.groupMessages[0]) + } + if msg.MsgType != dto.RichMediaMsg { + t.Fatalf("msg.MsgType = %d, want %d", msg.MsgType, dto.RichMediaMsg) + } + if msg.MsgID != "msg-1" { + t.Fatalf("msg.MsgID = %q, want msg-1", msg.MsgID) + } + if msg.MsgSeq != 1 { + t.Fatalf("msg.MsgSeq = %d, want 1", msg.MsgSeq) + } + if msg.Content != "see https://example。com/image" { + t.Fatalf("msg.Content = %q", msg.Content) + } + if msg.Media == nil || string(msg.Media.FileInfo) != "uploaded-file-info" { + t.Fatalf("msg.Media.FileInfo = %q, want uploaded-file-info", string(msg.Media.FileInfo)) + } +} + +func TestSendMedia_AudioAt60SecondsUsesVoiceUpload(t *testing.T) { + assertAudioWAVUploadType(t, 60*time.Second, 3) +} + +func TestSendMedia_AudioOver60SecondsFallsBackToFileUpload(t *testing.T) { + assertAudioWAVUploadType(t, 61*time.Second, 4) +} + +func assertAudioWAVUploadType(t *testing.T, duration time.Duration, wantFileType uint64) { + t.Helper() + + messageBus := bus.NewMessageBus() + store := media.NewFileMediaStore() + + localPath := writeWAVFile(t, t.TempDir(), "voice.wav", duration) + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "voice.wav", + ContentType: "audio/wav", + }, "qq:test") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + api := &fakeQQAPI{ + transportResp: mustJSON(t, dto.Message{FileInfo: []byte("file-info")}), + } + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: &config.QQSettings{}, + api: api, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.SetMediaStore(store) + ch.chatType.Store("group-1", "group") + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "group-1", + Parts: []bus.MediaPart{{ + Type: "audio", + Ref: ref, + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(api.transportCalls) != 1 { + t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls)) + } + if api.transportCalls[0].body.FileType != wantFileType { + t.Fatalf("upload file_type = %d, want %d", api.transportCalls[0].body.FileType, wantFileType) + } +} + +func TestSendMedia_RemoteAudioFallsBackToFileUpload(t *testing.T) { + messageBus := bus.NewMessageBus() + api := &fakeQQAPI{ + transportResp: mustJSON(t, dto.Message{FileInfo: []byte("remote-file-info")}), + } + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: &config.QQSettings{}, + api: api, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.chatType.Store("user-1", "direct") + + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "user-1", + Parts: []bus.MediaPart{{ + Type: "audio", + Ref: "https://cdn.example.com/voice.ogg", + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(api.transportCalls) != 1 { + t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls)) + } + if api.transportCalls[0].body.FileType != 4 { + t.Fatalf("upload file_type = %d, want 4", api.transportCalls[0].body.FileType) + } +} + +func TestSendMedia_LocalAudioWithUnknownDurationFallsBackToFileUpload(t *testing.T) { + messageBus := bus.NewMessageBus() + store := media.NewFileMediaStore() + + localPath := writeTempFile(t, t.TempDir(), "voice.mp3", []byte("not-a-real-mp3")) + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "voice.mp3", + ContentType: "audio/mpeg", + }, "qq:test") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + api := &fakeQQAPI{ + transportResp: mustJSON(t, dto.Message{FileInfo: []byte("file-info")}), + } + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: &config.QQSettings{}, + api: api, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.SetMediaStore(store) + ch.chatType.Store("group-1", "group") + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "group-1", + Parts: []bus.MediaPart{{ + Type: "audio", + Ref: ref, + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(api.transportCalls) != 1 { + t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls)) + } + if api.transportCalls[0].body.FileType != 4 { + t.Fatalf("upload file_type = %d, want 4", api.transportCalls[0].body.FileType) + } +} + +func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) { + messageBus := bus.NewMessageBus() + api := &fakeQQAPI{ + transportResp: mustJSON(t, dto.Message{FileInfo: []byte("remote-file-info")}), + } + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: &config.QQSettings{}, + api: api, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.chatType.Store("user-1", "direct") + + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "user-1", + Parts: []bus.MediaPart{{ + Type: "file", + Ref: "https://cdn.example.com/report.pdf", + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(api.transportCalls) != 1 { + t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls)) + } + upload := api.transportCalls[0] + if upload.url != "https://api.sgroup.qq.com/v2/users/user-1/files" { + t.Fatalf("upload url = %q", upload.url) + } + if upload.body.URL != "https://cdn.example.com/report.pdf" { + t.Fatalf("upload URL = %q", upload.body.URL) + } + if upload.body.FileData != "" { + t.Fatalf("upload file_data = %q, want empty", upload.body.FileData) + } + if upload.body.FileType != 4 { + t.Fatalf("upload file_type = %d, want 4", upload.body.FileType) + } + if upload.body.FileName != "report.pdf" { + t.Fatalf("upload file_name = %q, want report.pdf", upload.body.FileName) + } + + if len(api.c2cMessages) != 1 { + t.Fatalf("c2cMessages = %d, want 1", len(api.c2cMessages)) + } + msg, ok := api.c2cMessages[0].(*dto.MessageToCreate) + if !ok { + t.Fatalf("c2cMessages[0] type = %T, want *dto.MessageToCreate", api.c2cMessages[0]) + } + if msg.MsgType != dto.RichMediaMsg { + t.Fatalf("msg.MsgType = %d, want %d", msg.MsgType, dto.RichMediaMsg) + } + if msg.Media == nil || string(msg.Media.FileInfo) != "remote-file-info" { + t.Fatalf("msg.Media.FileInfo = %q, want remote-file-info", string(msg.Media.FileInfo)) + } +} + +func TestSendMedia_LocalFileUploadIncludesStoredFilename(t *testing.T) { + messageBus := bus.NewMessageBus() + store := media.NewFileMediaStore() + + localPath := writeTempFile(t, t.TempDir(), "report.pdf", []byte("fake-pdf")) + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "report.pdf", + ContentType: "application/pdf", + }, "qq:test") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + api := &fakeQQAPI{ + transportResp: mustJSON(t, dto.Message{FileInfo: []byte("local-file-info")}), + } + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: &config.QQSettings{}, + api: api, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.SetMediaStore(store) + ch.chatType.Store("user-1", "direct") + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "user-1", + Parts: []bus.MediaPart{{ + Type: "file", + Ref: ref, + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(api.transportCalls) != 1 { + t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls)) + } + upload := api.transportCalls[0] + if upload.body.FileType != 4 { + t.Fatalf("upload file_type = %d, want 4", upload.body.FileType) + } + if upload.body.FileName != "report.pdf" { + t.Fatalf("upload file_name = %q, want report.pdf", upload.body.FileName) + } + if upload.body.FileData == "" { + t.Fatal("upload file_data = empty, want base64 payload") + } +} + +func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: &config.QQSettings{}, + api: &fakeQQAPI{}, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.chatType.Store("group-1", "group") + + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "group-1", + Parts: []bus.MediaPart{{ + Type: "image", + Ref: "media://missing", + }}, + }) + if !errors.Is(err, channels.ErrSendFailed) { + t.Fatalf("SendMedia() error = %v, want ErrSendFailed", err) + } +} + +func TestSendMedia_ReturnsSendFailedWhenLocalFileExceedsBase64MiBLimit(t *testing.T) { + messageBus := bus.NewMessageBus() + store := media.NewFileMediaStore() + + tmpFile, err := os.CreateTemp(t.TempDir(), "qq-media-too-large-*.bin") + if err != nil { + t.Fatalf("CreateTemp() error = %v", err) + } + defer tmpFile.Close() + + content := make([]byte, bytesPerMiB+1) + if _, writeErr := tmpFile.Write(content); writeErr != nil { + t.Fatalf("Write() error = %v", writeErr) + } + + ref, err := store.Store(tmpFile.Name(), media.MediaMeta{ + Filename: "large.bin", + ContentType: "application/octet-stream", + }, "qq:test") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + api := &fakeQQAPI{} + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + config: &config.QQSettings{ + MaxBase64FileSizeMiB: 1, + }, + api: api, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.SetMediaStore(store) + ch.chatType.Store("group-1", "group") + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "group-1", + Parts: []bus.MediaPart{{ + Type: "file", + Ref: ref, + }}, + }) + if !errors.Is(err, channels.ErrSendFailed) { + t.Fatalf("SendMedia() error = %v, want ErrSendFailed", err) + } + if len(api.transportCalls) != 0 { + t.Fatalf("transportCalls = %d, want 0", len(api.transportCalls)) + } +} + +type fakeQQAPI struct { + transportResp []byte + transportErr error + groupErr error + c2cErr error + transportCalls []fakeTransportCall + groupMessages []dto.APIMessage + c2cMessages []dto.APIMessage +} + +type fakeTransportCall struct { + method string + url string + body qqMediaUpload +} + +func (f *fakeQQAPI) WS( + context.Context, + map[string]string, + string, +) (*dto.WebsocketAP, error) { + return nil, nil +} + +func (f *fakeQQAPI) PostGroupMessage( + _ context.Context, + _ string, + msg dto.APIMessage, + _ ...options.Option, +) (*dto.Message, error) { + f.groupMessages = append(f.groupMessages, msg) + return &dto.Message{}, f.groupErr +} + +func (f *fakeQQAPI) PostC2CMessage( + _ context.Context, + _ string, + msg dto.APIMessage, + _ ...options.Option, +) (*dto.Message, error) { + f.c2cMessages = append(f.c2cMessages, msg) + return &dto.Message{}, f.c2cErr +} + +func (f *fakeQQAPI) Transport(_ context.Context, method, url string, body any) ([]byte, error) { + upload, ok := body.(*qqMediaUpload) + if !ok { + return nil, errors.New("unexpected transport body type") + } + f.transportCalls = append(f.transportCalls, fakeTransportCall{ + method: method, + url: url, + body: *upload, + }) + return f.transportResp, f.transportErr +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + return b +} + +func waitInboundMessage(t *testing.T, messageBus *bus.MessageBus) bus.InboundMessage { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + for { + select { + case <-ctx.Done(): + t.Fatal("timeout waiting for inbound message") + case inbound, ok := <-messageBus.InboundChan(): + if !ok { + t.Fatal("expected inbound message") + } + return inbound + } + } +} + +func writeTempFile(t *testing.T, dir, name string, content []byte) string { + t.Helper() + + path := dir + "/" + name + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + return path +} + +func writeWAVFile(t *testing.T, dir, name string, duration time.Duration) string { + t.Helper() + + const ( + sampleRate = 8000 + numChannels = 1 + bitsPerSample = 8 + ) + + dataSize := uint32(duration / time.Second * sampleRate * numChannels * (bitsPerSample / 8)) + byteRate := uint32(sampleRate * numChannels * (bitsPerSample / 8)) + blockAlign := uint16(numChannels * (bitsPerSample / 8)) + + var buf bytes.Buffer + buf.WriteString("RIFF") + if err := binary.Write(&buf, binary.LittleEndian, uint32(36)+dataSize); err != nil { + t.Fatalf("binary.Write(riff size) error = %v", err) + } + buf.WriteString("WAVE") + buf.WriteString("fmt ") + if err := binary.Write(&buf, binary.LittleEndian, uint32(16)); err != nil { + t.Fatalf("binary.Write(fmt chunk size) error = %v", err) + } + if err := binary.Write(&buf, binary.LittleEndian, uint16(1)); err != nil { + t.Fatalf("binary.Write(audio format) error = %v", err) + } + if err := binary.Write(&buf, binary.LittleEndian, uint16(numChannels)); err != nil { + t.Fatalf("binary.Write(channels) error = %v", err) + } + if err := binary.Write(&buf, binary.LittleEndian, uint32(sampleRate)); err != nil { + t.Fatalf("binary.Write(sample rate) error = %v", err) + } + if err := binary.Write(&buf, binary.LittleEndian, byteRate); err != nil { + t.Fatalf("binary.Write(byte rate) error = %v", err) + } + if err := binary.Write(&buf, binary.LittleEndian, blockAlign); err != nil { + t.Fatalf("binary.Write(block align) error = %v", err) + } + if err := binary.Write(&buf, binary.LittleEndian, uint16(bitsPerSample)); err != nil { + t.Fatalf("binary.Write(bits per sample) error = %v", err) + } + buf.WriteString("data") + if err := binary.Write(&buf, binary.LittleEndian, dataSize); err != nil { + t.Fatalf("binary.Write(data size) error = %v", err) + } + buf.Write(make([]byte, dataSize)) + + return writeTempFile(t, dir, name, buf.Bytes()) +} diff --git a/pkg/channels/registry.go b/pkg/channels/registry.go index 36a05bf3e..2388d6c54 100644 --- a/pkg/channels/registry.go +++ b/pkg/channels/registry.go @@ -1,6 +1,7 @@ package channels import ( + "fmt" "sync" "github.com/sipeed/picoclaw/pkg/bus" @@ -9,7 +10,9 @@ import ( // ChannelFactory is a constructor function that creates a Channel from config and message bus. // Each channel subpackage registers one or more factories via init(). -type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error) +// channelName is the config map key for this channel instance (may differ from the channel type). +// channelType is the channel type string used to look up the Channel config. +type ChannelFactory func(channelName, channelType string, cfg *config.Config, bus *bus.MessageBus) (Channel, error) var ( factoriesMu sync.RWMutex @@ -23,6 +26,38 @@ func RegisterFactory(name string, f ChannelFactory) { factories[name] = f } +// RegisterSafeFactory is a convenience wrapper that handles GetDecoded() error checking +// and type assertion, reducing boilerplate in channel init() functions. +// +// Usage: +// +// func init() { +// channels.RegisterSafeFactory(config.ChannelTelegram, +// func(bc *config.Channel, c *config.TelegramSettings, b *bus.MessageBus) (channels.Channel, error) { +// return NewTelegramChannel(bc, c, b) +// }) +// } +func RegisterSafeFactory[S any]( + channelType string, + ctor func(bc *config.Channel, settings *S, bus *bus.MessageBus) (Channel, error), +) { + RegisterFactory(channelType, func(channelName, _ string, cfg *config.Config, b *bus.MessageBus) (Channel, error) { + bc := cfg.Channels[channelName] + if bc == nil { + return nil, fmt.Errorf("channel %q: config not found", channelName) + } + decoded, err := bc.GetDecoded() + if err != nil { + return nil, fmt.Errorf("channel %q: failed to decode settings: %w", channelName, err) + } + settings, ok := decoded.(*S) + if !ok { + return nil, fmt.Errorf("channel %q: expected %T settings, got %T", channelName, (*S)(nil), decoded) + } + return ctor(bc, settings, b) + }) +} + // getFactory looks up a channel factory by name. func getFactory(name string) (ChannelFactory, bool) { factoriesMu.RLock() @@ -30,3 +65,14 @@ func getFactory(name string) (ChannelFactory, bool) { f, ok := factories[name] return f, ok } + +// GetRegisteredFactoryNames returns a slice of all registered channel factory names. +func GetRegisteredFactoryNames() []string { + factoriesMu.RLock() + defer factoriesMu.RUnlock() + names := make([]string, 0, len(factories)) + for name := range factories { + names = append(names, name) + } + return names +} diff --git a/pkg/channels/slack/init.go b/pkg/channels/slack/init.go index c131bb291..f1dbf6dd2 100644 --- a/pkg/channels/slack/init.go +++ b/pkg/channels/slack/init.go @@ -7,7 +7,19 @@ import ( ) func init() { - channels.RegisterFactory("slack", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewSlackChannel(cfg.Channels.Slack, b) - }) + channels.RegisterFactory( + config.ChannelSlack, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.SlackSettings) + if !ok { + return nil, channels.ErrSendFailed + } + return NewSlackChannel(bc, c, b) + }, + ) } diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 024b1b023..19e7b737c 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -21,7 +21,7 @@ import ( type SlackChannel struct { *channels.BaseChannel - config config.SlackConfig + config *config.SlackSettings api *slack.Client socketClient *socketmode.Client botUserID string @@ -36,22 +36,26 @@ type slackMessageRef struct { Timestamp string } -func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) { - if cfg.BotToken == "" || cfg.AppToken == "" { +func NewSlackChannel( + bc *config.Channel, + cfg *config.SlackSettings, + messageBus *bus.MessageBus, +) (*SlackChannel, error) { + if cfg.BotToken.String() == "" || cfg.AppToken.String() == "" { return nil, fmt.Errorf("slack bot_token and app_token are required") } api := slack.New( - cfg.BotToken, - slack.OptionAppLevelToken(cfg.AppToken), + cfg.BotToken.String(), + slack.OptionAppLevelToken(cfg.AppToken.String()), ) socketClient := socketmode.New(api) - base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom, + base := channels.NewBaseChannel("slack", cfg, messageBus, bc.AllowFrom, channels.WithMaxMessageLength(40000), - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &SlackChannel{ @@ -108,30 +112,34 @@ func (c *SlackChannel) Stop(ctx context.Context) error { return nil } -func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } - channelID, threadTS := parseSlackChatID(msg.ChatID) + deliveryChatID, channelID, threadTS := resolveSlackOutboundTarget(msg.ChatID, &msg.Context) if channelID == "" { - return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) + return nil, fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) } opts := []slack.MsgOption{ slack.MsgOptionText(msg.Content, false), } - if threadTS != "" { + if msg.ReplyToMessageID != "" && threadTS == "" { + // Answer to the message by creating a Thread under it + opts = append(opts, slack.MsgOptionTS(msg.ReplyToMessageID)) + } else if threadTS != "" { + // If we are already in a thread, continue in the thread opts = append(opts, slack.MsgOptionTS(threadTS)) } - _, _, err := c.api.PostMessageContext(ctx, channelID, opts...) + _, ts, err := c.api.PostMessageContext(ctx, channelID, opts...) if err != nil { - return fmt.Errorf("slack send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("slack send: %w", channels.ErrTemporary) } - if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok { + if ref, ok := c.pendingAcks.LoadAndDelete(deliveryChatID); ok { msgRef := ref.(slackMessageRef) c.api.AddReaction("white_check_mark", slack.ItemRef{ Channel: msgRef.ChannelID, @@ -144,23 +152,23 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error "thread_ts": threadTS, }) - return nil + return []string{ts}, nil } // SendMedia implements the channels.MediaSender interface. -func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } - channelID, _ := parseSlackChatID(msg.ChatID) + _, channelID, threadTS := resolveSlackMediaOutboundTarget(msg.ChatID, &msg.Context) if channelID == "" { - return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) + return nil, fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { @@ -184,21 +192,24 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa } _, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{ - Channel: channelID, - File: localPath, - Filename: filename, - Title: title, + Channel: channelID, + ThreadTimestamp: threadTS, + File: localPath, + Filename: filename, + Title: title, }) if err != nil { logger.ErrorCF("slack", "Failed to upload media", map[string]any{ "filename": filename, "error": err.Error(), }) - return fmt.Errorf("slack send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("slack send media: %w", channels.ErrTemporary) } } - return nil + // UploadFileV2 does not expose the posted message timestamp in its + // response; returning nil avoids conflating file IDs with message IDs. + return nil, nil } // ReactToMessage implements channels.ReactionCapable. @@ -323,8 +334,9 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { storeMedia := func(localPath, filename string) string { if store := c.GetMediaStore(); store != nil { ref, err := store.Store(localPath, media.MediaMeta{ - Filename: filename, - Source: "slack", + Filename: filename, + Source: "slack", + CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, }, scope) if err == nil { return ref @@ -349,14 +361,10 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { } peerKind := "channel" - peerID := channelID if strings.HasPrefix(channelID, "D") { peerKind = "direct" - peerID = senderID } - peer := bus.Peer{Kind: peerKind, ID: peerID} - metadata := map[string]string{ "message_ts": messageTS, "channel_id": channelID, @@ -372,7 +380,22 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { "has_thread": threadTS != "", }) - c.HandleMessage(c.ctx, peer, messageTS, senderID, chatID, content, mediaPaths, metadata, sender) + inboundCtx := bus.InboundContext{ + Channel: c.Name(), + Account: c.teamID, + ChatID: channelID, + ChatType: peerKind, + SenderID: senderID, + MessageID: messageTS, + SpaceID: c.teamID, + SpaceType: "workspace", + Raw: metadata, + } + if threadTS != "" { + inboundCtx.TopicID = threadTS + } + + c.HandleInboundContext(c.ctx, chatID, content, mediaPaths, inboundCtx, sender) } func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { @@ -420,14 +443,10 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { } mentionPeerKind := "channel" - mentionPeerID := channelID if strings.HasPrefix(channelID, "D") { mentionPeerKind = "direct" - mentionPeerID = senderID } - mentionPeer := bus.Peer{Kind: mentionPeerKind, ID: mentionPeerID} - metadata := map[string]string{ "message_ts": messageTS, "channel_id": channelID, @@ -436,8 +455,21 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { "is_mention": "true", "team_id": c.teamID, } + inboundCtx := bus.InboundContext{ + Channel: c.Name(), + Account: c.teamID, + ChatID: channelID, + ChatType: mentionPeerKind, + TopicID: threadTS, + SenderID: senderID, + MessageID: messageTS, + SpaceID: c.teamID, + SpaceType: "workspace", + Mentioned: true, + Raw: metadata, + } - c.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata, mentionSender) + c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, mentionSender) } func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { @@ -484,18 +516,22 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { "command": cmd.Command, "text": utils.Truncate(content, 50), }) + peerKind := "channel" + if strings.HasPrefix(channelID, "D") { + peerKind = "direct" + } + inboundCtx := bus.InboundContext{ + Channel: c.Name(), + Account: c.teamID, + ChatID: channelID, + ChatType: peerKind, + SenderID: senderID, + SpaceID: c.teamID, + SpaceType: "workspace", + Raw: metadata, + } - c.HandleMessage( - c.ctx, - bus.Peer{Kind: "channel", ID: channelID}, - "", - senderID, - chatID, - content, - nil, - metadata, - cmdSender, - ) + c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, cmdSender) } func (c *SlackChannel) downloadSlackFile(file slack.File) string { @@ -511,7 +547,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string { return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{ LoggerPrefix: "slack", ExtraHeaders: map[string]string{ - "Authorization": "Bearer " + c.config.BotToken, + "Authorization": "Bearer " + c.config.BotToken.String(), }, }) } @@ -530,3 +566,33 @@ func parseSlackChatID(chatID string) (channelID, threadTS string) { } return channelID, threadTS } + +func resolveSlackOutboundTarget(chatID string, outboundCtx *bus.InboundContext) (string, string, string) { + deliveryChatID := strings.TrimSpace(chatID) + if deliveryChatID == "" && outboundCtx != nil { + deliveryChatID = strings.TrimSpace(outboundCtx.ChatID) + } + channelID, threadTS := parseSlackChatID(deliveryChatID) + if threadTS == "" && outboundCtx != nil { + threadTS = strings.TrimSpace(outboundCtx.TopicID) + if threadTS != "" && channelID != "" { + deliveryChatID = channelID + "/" + threadTS + } + } + return deliveryChatID, channelID, threadTS +} + +func resolveSlackMediaOutboundTarget(chatID string, outboundCtx *bus.InboundContext) (string, string, string) { + deliveryChatID := strings.TrimSpace(chatID) + if deliveryChatID == "" && outboundCtx != nil { + deliveryChatID = strings.TrimSpace(outboundCtx.ChatID) + } + channelID, threadTS := parseSlackChatID(deliveryChatID) + if threadTS == "" && outboundCtx != nil { + threadTS = strings.TrimSpace(outboundCtx.TopicID) + if threadTS != "" && channelID != "" { + deliveryChatID = channelID + "/" + threadTS + } + } + return deliveryChatID, channelID, threadTS +} diff --git a/pkg/channels/slack/slack_test.go b/pkg/channels/slack/slack_test.go index 30e0d2d73..a72521d67 100644 --- a/pkg/channels/slack/slack_test.go +++ b/pkg/channels/slack/slack_test.go @@ -53,6 +53,24 @@ func TestParseSlackChatID(t *testing.T) { } } +func TestResolveSlackOutboundTarget_PrefersContextTopicID(t *testing.T) { + deliveryChatID, channelID, threadTS := resolveSlackOutboundTarget("C123456", &bus.InboundContext{ + Channel: "slack", + ChatID: "C123456", + TopicID: "1234567890.123456", + }) + + if deliveryChatID != "C123456/1234567890.123456" { + t.Fatalf("deliveryChatID = %q, want %q", deliveryChatID, "C123456/1234567890.123456") + } + if channelID != "C123456" { + t.Fatalf("channelID = %q, want %q", channelID, "C123456") + } + if threadTS != "1234567890.123456" { + t.Fatalf("threadTS = %q, want %q", threadTS, "1234567890.123456") + } +} + func TestStripBotMention(t *testing.T) { ch := &SlackChannel{botUserID: "U12345BOT"} @@ -100,36 +118,32 @@ func TestStripBotMention(t *testing.T) { func TestNewSlackChannel(t *testing.T) { msgBus := bus.NewMessageBus() + bc := &config.Channel{Type: "slack", Enabled: true} t.Run("missing bot token", func(t *testing.T) { - cfg := config.SlackConfig{ - BotToken: "", - AppToken: "xapp-test", - } - _, err := NewSlackChannel(cfg, msgBus) + cfg := &config.SlackSettings{} + cfg.AppToken = *config.NewSecureString("xapp-test") + _, err := NewSlackChannel(bc, cfg, msgBus) if err == nil { t.Error("expected error for missing bot_token, got nil") } }) t.Run("missing app token", func(t *testing.T) { - cfg := config.SlackConfig{ - BotToken: "xoxb-test", - AppToken: "", - } - _, err := NewSlackChannel(cfg, msgBus) + cfg := &config.SlackSettings{} + cfg.BotToken = *config.NewSecureString("xoxb-test") + _, err := NewSlackChannel(bc, cfg, msgBus) if err == nil { t.Error("expected error for missing app_token, got nil") } }) t.Run("valid config", func(t *testing.T) { - cfg := config.SlackConfig{ - BotToken: "xoxb-test", - AppToken: "xapp-test", - AllowFrom: []string{"U123"}, - } - ch, err := NewSlackChannel(cfg, msgBus) + cfg := &config.SlackSettings{} + cfg.BotToken = *config.NewSecureString("xoxb-test") + cfg.AppToken = *config.NewSecureString("xapp-test") + bc := &config.Channel{Type: "slack", Enabled: true, AllowFrom: []string{"U123"}} + ch, err := NewSlackChannel(bc, cfg, msgBus) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -146,24 +160,22 @@ func TestSlackChannelIsAllowed(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("empty allowlist allows all", func(t *testing.T) { - cfg := config.SlackConfig{ - BotToken: "xoxb-test", - AppToken: "xapp-test", - AllowFrom: []string{}, - } - ch, _ := NewSlackChannel(cfg, msgBus) + bc := &config.Channel{Type: config.ChannelSlack, Enabled: true, AllowFrom: []string{}} + cfg := &config.SlackSettings{} + cfg.BotToken = *config.NewSecureString("xoxb-test") + cfg.AppToken = *config.NewSecureString("xapp-test") + ch, _ := NewSlackChannel(bc, cfg, msgBus) if !ch.IsAllowed("U_ANYONE") { t.Error("empty allowlist should allow all users") } }) t.Run("allowlist restricts users", func(t *testing.T) { - cfg := config.SlackConfig{ - BotToken: "xoxb-test", - AppToken: "xapp-test", - AllowFrom: []string{"U_ALLOWED"}, - } - ch, _ := NewSlackChannel(cfg, msgBus) + bc := &config.Channel{Type: config.ChannelSlack, Enabled: true, AllowFrom: []string{"U_ALLOWED"}} + cfg := &config.SlackSettings{} + cfg.BotToken = *config.NewSecureString("xoxb-test") + cfg.AppToken = *config.NewSecureString("xapp-test") + ch, _ := NewSlackChannel(bc, cfg, msgBus) if !ch.IsAllowed("U_ALLOWED") { t.Error("allowed user should pass allowlist check") } diff --git a/pkg/channels/teams_webhook/init.go b/pkg/channels/teams_webhook/init.go new file mode 100644 index 000000000..6f05b661f --- /dev/null +++ b/pkg/channels/teams_webhook/init.go @@ -0,0 +1,32 @@ +package teamswebhook + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory( + config.ChannelTeamsWebHook, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.TeamsWebhookSettings) + if !ok { + return nil, channels.ErrSendFailed + } + ch, err := NewTeamsWebhookChannel(bc, c, b) + if err != nil { + return nil, err + } + if channelName != config.ChannelTeamsWebHook { + ch.SetName(channelName) + } + return ch, nil + }, + ) +} diff --git a/pkg/channels/teams_webhook/teams_webhook.go b/pkg/channels/teams_webhook/teams_webhook.go new file mode 100644 index 000000000..837563453 --- /dev/null +++ b/pkg/channels/teams_webhook/teams_webhook.go @@ -0,0 +1,425 @@ +package teamswebhook + +import ( + "context" + "fmt" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + + goteamsnotify "github.com/atc0005/go-teams-notify/v2" + "github.com/atc0005/go-teams-notify/v2/adaptivecard" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// statusCodeRe extracts HTTP status codes from error messages like "401 Unauthorized". +var statusCodeRe = regexp.MustCompile(`\b([45]\d{2})\b`) + +// markdownTableRe matches a markdown table block (header + separator + rows). +// It captures the entire table including all rows. +var markdownTableRe = regexp.MustCompile(`(?m)^(\|[^\n]+\|)\n(\|[-:\|\s]+\|)\n((?:\|[^\n]+\|\n?)+)`) + +// teamsMessageSender abstracts the Teams client for testability. +type teamsMessageSender interface { + SendWithContext(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error +} + +// classifyTeamsError extracts HTTP status code from error message and classifies it. +// The go-teams-notify library returns errors like "error on notification: 401 Unauthorized, ...". +// This allows proper retry behavior: 4xx errors are permanent, 5xx are temporary. +func classifyTeamsError(err error) error { + if err == nil { + return nil + } + errMsg := err.Error() + if matches := statusCodeRe.FindStringSubmatch(errMsg); len(matches) > 1 { + if statusCode, parseErr := strconv.Atoi(matches[1]); parseErr == nil { + return channels.ClassifySendError(statusCode, err) + } + } + // Fallback: treat as temporary network error (retryable) + return channels.ClassifyNetError(err) +} + +// TeamsWebhookChannel is an output-only channel that sends messages +// to Microsoft Teams via Power Automate workflow webhooks. +// Multiple webhook targets can be configured and selected via ChatID. +type TeamsWebhookChannel struct { + *channels.BaseChannel + bc *config.Channel + config *config.TeamsWebhookSettings + client teamsMessageSender +} + +// NewTeamsWebhookChannel creates a new Teams webhook channel. +func NewTeamsWebhookChannel( + bc *config.Channel, + cfg *config.TeamsWebhookSettings, + bus *bus.MessageBus, +) (*TeamsWebhookChannel, error) { + if len(cfg.Webhooks) == 0 { + return nil, fmt.Errorf("teams_webhook: at least one webhook target is required") + } + + // Require "default" webhook target + if _, hasDefault := cfg.Webhooks["default"]; !hasDefault { + return nil, fmt.Errorf("teams_webhook: a 'default' webhook target is required") + } + + // Validate all webhook targets have valid HTTPS URLs + for name, target := range cfg.Webhooks { + webhookURL := target.WebhookURL.String() + if webhookURL == "" { + return nil, fmt.Errorf("teams_webhook: webhook %q has empty webhook_url", name) + } + parsed, err := url.Parse(webhookURL) + if err != nil { + return nil, fmt.Errorf("teams_webhook: webhook %q has invalid URL: %w", name, err) + } + if !strings.EqualFold(parsed.Scheme, "https") { + return nil, fmt.Errorf("teams_webhook: webhook %q must use HTTPS (got %q)", name, parsed.Scheme) + } + } + + base := channels.NewBaseChannel( + "teams_webhook", + cfg, + bus, + []string{ + "*", + }, // Output-only channel; "*" suppresses misleading "allows EVERYONE" audit warning + channels.WithMaxMessageLength(24000), // Power Automate webhook payload limit is 28KB + ) + + client := goteamsnotify.NewTeamsClient() + + return &TeamsWebhookChannel{ + BaseChannel: base, + bc: bc, + config: cfg, + client: client, + }, nil +} + +// Start initializes the channel. For output-only channels, this is a no-op. +func (c *TeamsWebhookChannel) Start(ctx context.Context) error { + targets := make([]string, 0, len(c.config.Webhooks)) + for name := range c.config.Webhooks { + targets = append(targets, name) + } + sort.Strings(targets) + logger.InfoCF("teams_webhook", "Starting Teams webhook channel (output-only)", map[string]any{ + "targets": targets, + }) + c.SetRunning(true) + return nil +} + +// Stop shuts down the channel. +func (c *TeamsWebhookChannel) Stop(ctx context.Context) error { + logger.InfoC("teams_webhook", "Stopping Teams webhook channel") + c.SetRunning(false) + return nil +} + +// Send delivers a message to the specified Teams webhook target. +// The target is selected by msg.ChatID which must match a key in the webhooks map. +func (c *TeamsWebhookChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + // Look up webhook target by ChatID, fall back to "default" if empty or unknown + targetName := msg.ChatID + if targetName == "" { + targetName = "default" + } + + target, ok := c.config.Webhooks[targetName] + if !ok { + // Log warning and fall back to default target + logger.WarnCF("teams_webhook", "Unknown target, falling back to default", map[string]any{ + "requested": msg.ChatID, + "using": "default", + }) + target = c.config.Webhooks["default"] + } + + // Build an Adaptive Card for rich formatting + card, err := c.buildAdaptiveCard(msg, target) + if err != nil { + return nil, fmt.Errorf("teams_webhook: failed to build card: %w", err) + } + + // Create the message with the card + teamsMsg, err := adaptivecard.NewMessageFromCard(card) + if err != nil { + return nil, fmt.Errorf("teams_webhook: failed to create message: %w", err) + } + + // Send to Teams + if err := c.client.SendWithContext(ctx, target.WebhookURL.String(), teamsMsg); err != nil { + // Log without raw error to avoid leaking webhook URL (embedded in net/http errors) + logger.ErrorCF("teams_webhook", "Failed to send message to Teams webhook", map[string]any{ + "target": msg.ChatID, + }) + // Classify error based on status code extracted from error message. + // The go-teams-notify library includes status in errors like "401 Unauthorized". + // Use ClassifySendError for proper retry behavior (4xx = permanent, 5xx = temporary). + classifiedErr := classifyTeamsError(err) + return nil, fmt.Errorf("teams_webhook: send failed: %w", classifiedErr) + } + + logger.DebugCF("teams_webhook", "Message sent successfully", map[string]any{ + "target": msg.ChatID, + }) + + return nil, nil +} + +// buildAdaptiveCard creates a formatted Adaptive Card from the outbound message. +// It detects markdown tables and converts them to native Adaptive Card Table elements, +// since TextBlocks only support a limited markdown subset (no tables). +func (c *TeamsWebhookChannel) buildAdaptiveCard( + msg bus.OutboundMessage, + target config.TeamsWebhookTarget, +) (adaptivecard.Card, error) { + card := adaptivecard.NewCard() + card.Type = adaptivecard.TypeAdaptiveCard + + // Set full width for Teams rendering + card.MSTeams.Width = "Full" + + // Add title if configured on the target + title := target.Title + if title == "" { + title = "PicoClaw Notification" + } + + titleBlock := adaptivecard.NewTextBlock(title, true) + titleBlock.Size = adaptivecard.SizeLarge + titleBlock.Weight = adaptivecard.WeightBolder + titleBlock.Style = adaptivecard.TextBlockStyleHeading + + if err := card.AddElement(false, titleBlock); err != nil { + return card, err + } + + content := msg.Content + if content == "" { + content = "(empty message)" + } + + // Split content into text segments and tables + // TextBlocks support: bold, italic, bullet/numbered lists, links + // TextBlocks do NOT support: headers, tables, images + segments := splitContentWithTables(content) + + for _, seg := range segments { + if seg.isTable { + // Convert markdown table to Adaptive Card Table element + tableElement, err := parseMarkdownTable(seg.content) + if err != nil { + // Fallback: render as preformatted text if parsing fails + logger.WarnCF("teams_webhook", "Failed to parse markdown table, using fallback", map[string]any{ + "error": err.Error(), + }) + block := adaptivecard.NewTextBlock("```\n"+seg.content+"\n```", true) + block.Wrap = true + if err := card.AddElement(false, block); err != nil { + return card, err + } + continue + } + if err := card.AddElement(false, tableElement); err != nil { + return card, err + } + } else { + // Regular text content + text := strings.TrimSpace(seg.content) + if text == "" { + continue + } + block := adaptivecard.NewTextBlock(text, true) + block.Wrap = true + if err := card.AddElement(false, block); err != nil { + return card, err + } + } + } + + return card, nil +} + +// contentSegment represents either a text block or a table in the message content. +type contentSegment struct { + content string + isTable bool +} + +// splitContentWithTables splits content into alternating text and table segments. +func splitContentWithTables(content string) []contentSegment { + var segments []contentSegment + + matches := markdownTableRe.FindAllStringSubmatchIndex(content, -1) + if len(matches) == 0 { + // No tables found, return entire content as text + return []contentSegment{{content: content, isTable: false}} + } + + lastEnd := 0 + for _, match := range matches { + // Text before this table + if match[0] > lastEnd { + segments = append(segments, contentSegment{ + content: content[lastEnd:match[0]], + isTable: false, + }) + } + // The table itself + segments = append(segments, contentSegment{ + content: content[match[0]:match[1]], + isTable: true, + }) + lastEnd = match[1] + } + + // Text after the last table + if lastEnd < len(content) { + segments = append(segments, contentSegment{ + content: content[lastEnd:], + isTable: false, + }) + } + + return segments +} + +// parseMarkdownTable converts a markdown table string to an Adaptive Card Table element. +func parseMarkdownTable(tableStr string) (adaptivecard.Element, error) { + lines := strings.Split(strings.TrimSpace(tableStr), "\n") + if len(lines) < 2 { + return adaptivecard.Element{}, fmt.Errorf("table must have at least header and separator rows") + } + + // Track header content length per column for width calculation + var headerLengths []int + + // Parse all rows (header + data rows, skip separator) + var allRows [][]adaptivecard.TableCell + for i, line := range lines { + // Skip separator row (contains only |, -, :, and spaces) + if i == 1 && isSeparatorRow(line) { + continue + } + + cells := parseTableRow(line) + if len(cells) == 0 { + continue + } + + var tableCells []adaptivecard.TableCell + for _, cellText := range cells { + trimmedText := strings.TrimSpace(cellText) + + // Use header row (first row) to determine column widths + if i == 0 { + headerLengths = append(headerLengths, len(trimmedText)) + } + + textBlock := adaptivecard.Element{ + Type: adaptivecard.TypeElementTextBlock, + Text: trimmedText, + Wrap: true, + } + cell := adaptivecard.TableCell{ + Type: adaptivecard.TypeTableCell, + Items: []*adaptivecard.Element{&textBlock}, + } + tableCells = append(tableCells, cell) + } + allRows = append(allRows, tableCells) + } + + if len(allRows) == 0 { + return adaptivecard.Element{}, fmt.Errorf("no valid rows found in table") + } + + // Create table with first row as headers + firstRowAsHeaders := true + showGridLines := true + + table, err := adaptivecard.NewTableFromTableCells(allRows, 0, firstRowAsHeaders, showGridLines) + if err != nil { + return adaptivecard.Element{}, fmt.Errorf("failed to create table: %w", err) + } + + // Set column widths based on header content length + table.Columns = calculateColumnWidths(headerLengths) + + return table, nil +} + +// calculateColumnWidths creates TableColumnDefinition entries with widths +// proportional to the max content length of each column. +func calculateColumnWidths(maxLengths []int) []adaptivecard.Column { + if len(maxLengths) == 0 { + return nil + } + + // Use content length as relative weight, with a minimum of 1 + columns := make([]adaptivecard.Column, len(maxLengths)) + for i, length := range maxLengths { + weight := length + if weight < 1 { + weight = 1 + } + columns[i] = adaptivecard.Column{ + Type: "TableColumnDefinition", + Width: weight, + } + } + + return columns +} + +// isSeparatorRow checks if a line is a markdown table separator (e.g., |---|---|). +func isSeparatorRow(line string) bool { + // Remove pipes and spaces, check if only dashes and colons remain + cleaned := strings.ReplaceAll(line, "|", "") + cleaned = strings.ReplaceAll(cleaned, " ", "") + cleaned = strings.ReplaceAll(cleaned, "-", "") + cleaned = strings.ReplaceAll(cleaned, ":", "") + return cleaned == "" +} + +// parseTableRow extracts cell values from a markdown table row. +func parseTableRow(line string) []string { + // Trim leading/trailing pipes and split by | + line = strings.TrimSpace(line) + line = strings.TrimPrefix(line, "|") + line = strings.TrimSuffix(line, "|") + + if line == "" { + return nil + } + + parts := strings.Split(line, "|") + var cells []string + for _, p := range parts { + cells = append(cells, strings.TrimSpace(p)) + } + return cells +} diff --git a/pkg/channels/teams_webhook/teams_webhook_test.go b/pkg/channels/teams_webhook/teams_webhook_test.go new file mode 100644 index 000000000..cc1570038 --- /dev/null +++ b/pkg/channels/teams_webhook/teams_webhook_test.go @@ -0,0 +1,582 @@ +package teamswebhook + +import ( + "context" + "errors" + "testing" + + goteamsnotify "github.com/atc0005/go-teams-notify/v2" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +// mockTeamsClient implements teamsMessageSender for testing. +type mockTeamsClient struct { + sendFunc func(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error +} + +func (m *mockTeamsClient) SendWithContext( + ctx context.Context, + webhookURL string, + message goteamsnotify.TeamsMessage, +) error { + if m.sendFunc != nil { + return m.sendFunc(ctx, webhookURL, message) + } + return nil +} + +func TestNewTeamsWebhookChannel(t *testing.T) { + msgBus := bus.NewMessageBus() + + // Test missing webhooks + bc := &config.Channel{Type: config.ChannelTeamsWebHook, Enabled: true} + cfg := config.TeamsWebhookSettings{ + Webhooks: nil, + } + _, err := NewTeamsWebhookChannel(bc, &cfg, msgBus) + if err == nil { + t.Error("expected error for missing webhooks") + } + + // Test missing "default" webhook + cfg.Webhooks = map[string]config.TeamsWebhookTarget{ + "alerts": { + WebhookURL: *config.NewSecureString("https://example.com/webhook"), + Title: "Alerts", + }, + } + _, err = NewTeamsWebhookChannel(bc, &cfg, msgBus) + if err == nil { + t.Error("expected error for missing 'default' webhook") + } + + // Test empty webhook URL + cfg.Webhooks = map[string]config.TeamsWebhookTarget{ + "default": {Title: "Default"}, + } + _, err = NewTeamsWebhookChannel(bc, &cfg, msgBus) + if err == nil { + t.Error("expected error for empty webhook_url") + } + + // Test HTTP URL (should fail, must be HTTPS) + cfg.Webhooks = map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("http://example.com/webhook"), + Title: "Default", + }, + } + _, err = NewTeamsWebhookChannel(bc, &cfg, msgBus) + if err == nil { + t.Error("expected error for HTTP webhook URL (must be HTTPS)") + } + + // Test valid config with HTTPS (must include "default") + cfg.Webhooks = map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-default"), + Title: "Default", + }, + "alerts": { + WebhookURL: *config.NewSecureString("https://example.com/webhook1"), + Title: "Alerts", + }, + } + ch, err := NewTeamsWebhookChannel(bc, &cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ch.Name() != "teams_webhook" { + t.Errorf("expected name 'teams_webhook', got %q", ch.Name()) + } +} + +func TestTeamsWebhookChannel_StartStop(t *testing.T) { + msgBus := bus.NewMessageBus() + bc := &config.Channel{Type: config.ChannelTeamsWebHook, Enabled: true} + cfg := config.TeamsWebhookSettings{ + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/webhook"), + }, + }, + } + ch, err := NewTeamsWebhookChannel(bc, &cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ctx := context.Background() + + if ch.IsRunning() { + t.Error("channel should not be running before Start") + } + + if err := ch.Start(ctx); err != nil { + t.Fatalf("Start failed: %v", err) + } + + if !ch.IsRunning() { + t.Error("channel should be running after Start") + } + + if err := ch.Stop(ctx); err != nil { + t.Fatalf("Stop failed: %v", err) + } + + if ch.IsRunning() { + t.Error("channel should not be running after Stop") + } +} + +func TestTeamsWebhookChannel_BuildAdaptiveCard(t *testing.T) { + msgBus := bus.NewMessageBus() + bc := &config.Channel{Type: config.ChannelTeamsWebHook, Enabled: true} + cfg := config.TeamsWebhookSettings{ + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-default"), + Title: "Default", + }, + "alerts": { + WebhookURL: *config.NewSecureString("https://example.com/webhook"), + Title: "Custom Title", + }, + }, + } + ch, err := NewTeamsWebhookChannel(bc, &cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + target := ch.config.Webhooks["alerts"] + msg := bus.OutboundMessage{ + Content: "Test message content", + ChatID: "alerts", + } + + card, err := ch.buildAdaptiveCard(msg, target) + if err != nil { + t.Fatalf("buildAdaptiveCard failed: %v", err) + } + + if card.Type != "AdaptiveCard" { + t.Errorf("expected card type 'AdaptiveCard', got %q", card.Type) + } +} + +func TestTeamsWebhookChannel_SendNotRunning(t *testing.T) { + msgBus := bus.NewMessageBus() + bc := &config.Channel{Type: config.ChannelTeamsWebHook, Enabled: true} + cfg := config.TeamsWebhookSettings{ + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/webhook"), + }, + }, + } + ch, err := NewTeamsWebhookChannel(bc, &cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ctx := context.Background() + msg := bus.OutboundMessage{Content: "test", ChatID: "default"} + + _, err = ch.Send(ctx, msg) + if err == nil { + t.Error("expected error when sending while not running") + } +} + +func TestTeamsWebhookChannel_SendDefaultTargetFallback(t *testing.T) { + tests := []struct { + name string + chatID string + }{ + {"unknown target falls back to default", "unknown"}, + {"empty ChatID uses default", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msgBus := bus.NewMessageBus() + bc := &config.Channel{Type: config.ChannelTeamsWebHook, Enabled: true} + cfg := config.TeamsWebhookSettings{ + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-default"), + }, + "alerts": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-alerts"), + }, + }, + } + ch, err := NewTeamsWebhookChannel(bc, &cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sentURL string + ch.client = &mockTeamsClient{ + sendFunc: func(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error { + sentURL = webhookURL + return nil + }, + } + + ctx := context.Background() + _ = ch.Start(ctx) + defer ch.Stop(ctx) + + msg := bus.OutboundMessage{Content: "test", ChatID: tt.chatID} + _, err = ch.Send(ctx, msg) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + + if sentURL != "https://example.com/webhook-default" { + t.Errorf("expected default webhook URL, got %q", sentURL) + } + }) + } +} + +func TestTeamsWebhookChannel_SendSuccess(t *testing.T) { + msgBus := bus.NewMessageBus() + bc := &config.Channel{Type: config.ChannelTeamsWebHook, Enabled: true} + cfg := config.TeamsWebhookSettings{ + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-default"), + Title: "Default", + }, + "alerts": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-alerts"), + Title: "Test Alerts", + }, + }, + } + ch, err := NewTeamsWebhookChannel(bc, &cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Inject mock client + var sentURL string + ch.client = &mockTeamsClient{ + sendFunc: func(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error { + sentURL = webhookURL + return nil + }, + } + + ctx := context.Background() + _ = ch.Start(ctx) + defer ch.Stop(ctx) + + msg := bus.OutboundMessage{Content: "Hello Teams!", ChatID: "alerts"} + + _, err = ch.Send(ctx, msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if sentURL != "https://example.com/webhook-alerts" { + t.Errorf("expected webhook URL 'https://example.com/webhook-alerts', got %q", sentURL) + } +} + +func TestTeamsWebhookChannel_SendError(t *testing.T) { + msgBus := bus.NewMessageBus() + bc := &config.Channel{Type: config.ChannelTeamsWebHook, Enabled: true} + cfg := config.TeamsWebhookSettings{ + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-default"), + }, + "alerts": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-alerts"), + }, + }, + } + ch, err := NewTeamsWebhookChannel(bc, &cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Inject mock client that returns an error + ch.client = &mockTeamsClient{ + sendFunc: func(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error { + return errors.New("error on notification: 401 Unauthorized, forbidden") + }, + } + + ctx := context.Background() + _ = ch.Start(ctx) + defer ch.Stop(ctx) + + msg := bus.OutboundMessage{Content: "test", ChatID: "alerts"} + + _, err = ch.Send(ctx, msg) + if err == nil { + t.Error("expected error from failed send") + } +} + +func TestSplitContentWithTables(t *testing.T) { + tests := []struct { + name string + content string + wantSegs int + wantTbl int // number of table segments + }{ + { + name: "no tables", + content: "Just some text\nwith multiple lines", + wantSegs: 1, + wantTbl: 0, + }, + { + name: "single table", + content: `| Col1 | Col2 | +|------|------| +| A | B | +| C | D |`, + wantSegs: 1, + wantTbl: 1, + }, + { + name: "text before table", + content: `Here is some text. + +| Col1 | Col2 | +|------|------| +| A | B |`, + wantSegs: 2, + wantTbl: 1, + }, + { + name: "text before and after table", + content: `Before table. + +| Col1 | Col2 | +|------|------| +| A | B | + +After table.`, + wantSegs: 3, + wantTbl: 1, + }, + { + name: "multiple tables", + content: `First table: + +| A | B | +|---|---| +| 1 | 2 | + +Second table: + +| X | Y | +|---|---| +| 3 | 4 |`, + wantSegs: 4, + wantTbl: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + segs := splitContentWithTables(tt.content) + if len(segs) != tt.wantSegs { + t.Errorf("got %d segments, want %d", len(segs), tt.wantSegs) + } + tableCount := 0 + for _, s := range segs { + if s.isTable { + tableCount++ + } + } + if tableCount != tt.wantTbl { + t.Errorf("got %d tables, want %d", tableCount, tt.wantTbl) + } + }) + } +} + +func TestParseMarkdownTable(t *testing.T) { + tableStr := `| Name | Value | +|------|-------| +| foo | 123 | +| bar | 456 |` + + elem, err := parseMarkdownTable(tableStr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if elem.Type != "Table" { + t.Errorf("expected type 'Table', got %q", elem.Type) + } + + // Should have 3 rows (header + 2 data rows) + if len(elem.Rows) != 3 { + t.Errorf("expected 3 rows, got %d", len(elem.Rows)) + } + + // Should have 2 columns with widths based on content length + if len(elem.Columns) != 2 { + t.Errorf("expected 2 columns, got %d", len(elem.Columns)) + } +} + +func TestParseMarkdownTableColumnWidths(t *testing.T) { + // Column widths are based on HEADER row only: + // Col1: "Description" (11 chars) + // Col2: "X" (1 char) + // Col3: "Amount" (6 chars) + tableStr := `| Description | X | Amount | +|-------------|---|--------| +| Short | Y | 100 | +| Longer text | Z | 50 |` + + elem, err := parseMarkdownTable(tableStr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(elem.Columns) != 3 { + t.Fatalf("expected 3 columns, got %d", len(elem.Columns)) + } + + // Verify column widths are based on header content length + w1, ok1 := elem.Columns[0].Width.(int) + w2, ok2 := elem.Columns[1].Width.(int) + w3, ok3 := elem.Columns[2].Width.(int) + + if !ok1 || !ok2 || !ok3 { + t.Fatalf("expected int widths, got types: %T, %T, %T", + elem.Columns[0].Width, elem.Columns[1].Width, elem.Columns[2].Width) + } + + // Header lengths: "Description" = 11, "X" = 1, "Amount" = 6 + if w1 != 11 { + t.Errorf("expected col1 width 11 (from 'Description'), got %d", w1) + } + if w2 != 1 { + t.Errorf("expected col2 width 1 (from 'X'), got %d", w2) + } + if w3 != 6 { + t.Errorf("expected col3 width 6 (from 'Amount'), got %d", w3) + } +} + +func TestCalculateColumnWidths(t *testing.T) { + tests := []struct { + name string + maxLengths []int + wantWidths []int + }{ + { + name: "equal lengths", + maxLengths: []int{10, 10, 10}, + wantWidths: []int{10, 10, 10}, + }, + { + name: "varying lengths", + maxLengths: []int{5, 20, 10}, + wantWidths: []int{5, 20, 10}, + }, + { + name: "zero length gets minimum of 1", + maxLengths: []int{0, 5, 0}, + wantWidths: []int{1, 5, 1}, + }, + { + name: "empty input", + maxLengths: []int{}, + wantWidths: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cols := calculateColumnWidths(tt.maxLengths) + + if tt.wantWidths == nil { + if cols != nil { + t.Errorf("expected nil, got %v", cols) + } + return + } + + if len(cols) != len(tt.wantWidths) { + t.Fatalf("expected %d columns, got %d", len(tt.wantWidths), len(cols)) + } + + for i, col := range cols { + width, ok := col.Width.(int) + if !ok { + t.Errorf("column %d: expected int width, got %T", i, col.Width) + continue + } + if width != tt.wantWidths[i] { + t.Errorf("column %d: expected width %d, got %d", i, tt.wantWidths[i], width) + } + if col.Type != "TableColumnDefinition" { + t.Errorf("column %d: expected type 'TableColumnDefinition', got %q", i, col.Type) + } + } + }) + } +} + +func TestParseTableRow(t *testing.T) { + tests := []struct { + line string + want []string + }{ + {"| A | B | C |", []string{"A", "B", "C"}}, + {"|A|B|C|", []string{"A", "B", "C"}}, + {"| foo | bar |", []string{"foo", "bar"}}, + {"", nil}, + } + + for _, tt := range tests { + got := parseTableRow(tt.line) + if len(got) != len(tt.want) { + t.Errorf("parseTableRow(%q): got %v, want %v", tt.line, got, tt.want) + continue + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("parseTableRow(%q)[%d]: got %q, want %q", tt.line, i, got[i], tt.want[i]) + } + } + } +} + +func TestIsSeparatorRow(t *testing.T) { + tests := []struct { + line string + want bool + }{ + {"|---|---|", true}, + {"| --- | --- |", true}, + {"|:---|---:|", true}, + {"| :---: | :---: |", true}, + {"| A | B |", false}, + {"| foo | bar |", false}, + } + + for _, tt := range tests { + got := isSeparatorRow(tt.line) + if got != tt.want { + t.Errorf("isSeparatorRow(%q): got %v, want %v", tt.line, got, tt.want) + } + } +} diff --git a/pkg/channels/telegram/command_registration.go b/pkg/channels/telegram/command_registration.go index d3152ec3d..c6b362601 100644 --- a/pkg/channels/telegram/command_registration.go +++ b/pkg/channels/telegram/command_registration.go @@ -66,6 +66,10 @@ func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []c if register == nil { register = c.RegisterCommands } + delayFn := c.commandRegDelayFn + if delayFn == nil { + delayFn = commandRegistrationDelay + } regCtx, cancel := context.WithCancel(ctx) c.commandRegCancel = cancel @@ -91,7 +95,7 @@ func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []c return } - delay := commandRegistrationDelay(attempt) + delay := delayFn(attempt) logger.WarnCF("telegram", "Telegram command registration failed; will retry", map[string]any{ "error": err.Error(), "retry_after": delay.String(), diff --git a/pkg/channels/telegram/command_registration_test.go b/pkg/channels/telegram/command_registration_test.go index 26f891b2e..c30c6f68d 100644 --- a/pkg/channels/telegram/command_registration_test.go +++ b/pkg/channels/telegram/command_registration_test.go @@ -31,14 +31,12 @@ func TestStartCommandRegistration_DoesNotBlock(t *testing.T) { } func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) { - ch := &TelegramChannel{} + ch := &TelegramChannel{ + commandRegDelayFn: func(int) time.Duration { return 5 * time.Millisecond }, + } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - origBackoff := commandRegistrationBackoff - commandRegistrationBackoff = []time.Duration{5 * time.Millisecond} - defer func() { commandRegistrationBackoff = origBackoff }() - var attempts atomic.Int32 ch.registerFunc = func(context.Context, []commands.Definition) error { n := attempts.Add(1) @@ -69,12 +67,10 @@ func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) { } func TestStartCommandRegistration_StopsAfterCancel(t *testing.T) { - ch := &TelegramChannel{} + ch := &TelegramChannel{ + commandRegDelayFn: func(int) time.Duration { return 5 * time.Millisecond }, + } ctx, cancel := context.WithCancel(context.Background()) - - origBackoff := commandRegistrationBackoff - commandRegistrationBackoff = []time.Duration{5 * time.Millisecond} - defer func() { commandRegistrationBackoff = origBackoff }() defer cancel() var attempts atomic.Int32 diff --git a/pkg/channels/telegram/init.go b/pkg/channels/telegram/init.go index ac87bb805..dc461b324 100644 --- a/pkg/channels/telegram/init.go +++ b/pkg/channels/telegram/init.go @@ -7,7 +7,19 @@ import ( ) func init() { - channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewTelegramChannel(cfg, b) - }) + channels.RegisterFactory( + config.ChannelTelegram, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.TelegramSettings) + if !ok { + return nil, channels.ErrSendFailed + } + return NewTelegramChannel(bc, c, b) + }, + ) } diff --git a/pkg/channels/telegram/parse_markdown_to_md_v2.go b/pkg/channels/telegram/parse_markdown_to_md_v2.go new file mode 100644 index 000000000..8cae312c5 --- /dev/null +++ b/pkg/channels/telegram/parse_markdown_to_md_v2.go @@ -0,0 +1,197 @@ +package telegram + +import ( + "regexp" + "strings" +) + +// mdV2SpecialChars are all characters that must be escaped in Telegram MarkdownV2 +var mdV2SpecialChars = map[rune]bool{ + '*': true, + '_': true, + '[': true, + ']': true, + '(': true, + ')': true, + '~': true, + '`': true, + '>': true, + '<': true, + '#': true, + '+': true, + '-': true, + '=': true, + '|': true, + '{': true, + '}': true, + '.': true, + '!': true, + '\\': true, +} + +// entityPattern describes one Telegram MarkdownV2 inline entity type. +type entityPattern struct { + re *regexp.Regexp + open string + close string +} + +// allEntityPatterns lists every recognized entity in priority order +// (longer / more-specific delimiters first so they win over shorter ones). +// Each entry's regex is anchored to find the first occurrence in a string. +var allEntityPatterns = []entityPattern{ + // fenced code block — content is completely verbatim + {re: regexp.MustCompile("(?s)```(?:[\\w]*\\n)?[\\s\\S]*?```"), open: "```", close: "```"}, + // inline code — content is completely verbatim + {re: regexp.MustCompile("`(?:[^`\\\n]|\\\\.)*`"), open: "`", close: "`"}, + // expandable block-quote opener **>… + {re: regexp.MustCompile(`(?m)\*\*>(?:[^\n]*)`), open: "**>", close: ""}, + // block-quote line >… + {re: regexp.MustCompile(`(?m)^>(?:[^\n]*)`), open: ">", close: ""}, + // custom emoji / timestamp ![…](…) — must come before plain link + {re: regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`), open: "!", close: ""}, + // inline URL / user mention […](…) + {re: regexp.MustCompile(`\[[^\]]*\]\([^)]*\)`), open: "[", close: ""}, + // spoiler ||…|| — before single | so it wins + {re: regexp.MustCompile(`\|\|(?:[^|\\\n]|\\.)*\|\|`), open: "||", close: "||"}, + // underline __…__ — before single _ so it wins + {re: regexp.MustCompile(`__(?:[^_\\\n]|\\.)*__`), open: "__", close: "__"}, + // bold *…* + {re: regexp.MustCompile(`\*(?:[^*\\\n]|\\.)*\*`), open: "*", close: "*"}, + // italic _…_ + {re: regexp.MustCompile(`_(?:[^_\\\n]|\\.)*_`), open: "_", close: "_"}, + // strikethrough ~…~ + {re: regexp.MustCompile(`~(?:[^~\\\n]|\\.)*~`), open: "~", close: "~"}, +} + +// verbatimEntities are entity types whose inner content must never be +// touched (code blocks, URLs, quotes, custom emoji). +// Their content is passed through completely unchanged. +var verbatimEntities = map[string]bool{ + "```": true, + "`": true, + "**>": true, + ">": true, + "!": true, + "[": true, +} + +// markdownToTelegramMarkdownV2 converts a Markdown string into a string safe +// for sending with Telegram's MarkdownV2 parse mode. +// +// Rules: +// - Markdown headings (# … ######) are converted to *bold*. +// - **bold** Markdown syntax is converted to *bold*. +// - Recognized Telegram MarkdownV2 entity spans are preserved; their inner +// content is processed recursively so that nested valid entities are kept +// intact while stray special characters are escaped. +// - All plain-text segments have their MarkdownV2 special characters escaped. +// +// Reference: https://core.telegram.org/bots/api#formatting-options +func markdownToTelegramMarkdownV2(text string) string { + // 1. Convert Markdown headings → *escaped heading text* + text = reHeading.ReplaceAllStringFunc(text, func(match string) string { + sub := reHeading.FindStringSubmatch(match) + if len(sub) < 2 { + return match + } + // The heading content is fresh plain text — escape everything + // including * so the resulting *…* bold span stays valid. + return "*" + escapeMarkdownV2(sub[1]) + "*" + }) + + // 2. Convert **bold** → *bold* + text = reBoldStar.ReplaceAllString(text, "*$1*") + + // 3. Recursively escape the full string. + return processText(text) +} + +// processText walks `text`, finds the leftmost / longest matching entity, +// escapes the gap before it, processes the entity (recursing into its inner +// content when appropriate), then continues with the remainder. +func processText(text string) string { + if text == "" { + return "" + } + + // Find the leftmost match among all entity patterns. + bestStart := -1 + bestEnd := -1 + var bestPat *entityPattern + + for i := range allEntityPatterns { + p := &allEntityPatterns[i] + loc := p.re.FindStringIndex(text) + if loc == nil { + continue + } + if bestStart == -1 || loc[0] < bestStart || + (loc[0] == bestStart && (loc[1]-loc[0]) > (bestEnd-bestStart)) { + bestStart = loc[0] + bestEnd = loc[1] + bestPat = p + } + } + + if bestPat == nil { + // No entity found — escape everything. + return escapeMarkdownV2(text) + } + + var b strings.Builder + + // Plain text before the entity. + if bestStart > 0 { + b.WriteString(escapeMarkdownV2(text[:bestStart])) + } + + // The matched entity span. + matched := text[bestStart:bestEnd] + + if verbatimEntities[bestPat.open] { + // Code blocks, URLs, quotes: pass through completely untouched. + b.WriteString(matched) + } else { + // Inline formatting (bold, italic, underline, strikethrough, spoiler): + // keep the delimiters and recursively process the inner content so that + // nested entities survive but stray specials get escaped. + openLen := len(bestPat.open) + closeLen := len(bestPat.close) + inner := matched[openLen : len(matched)-closeLen] + + b.WriteString(bestPat.open) + b.WriteString(processText(inner)) + b.WriteString(bestPat.close) + } + + // Continue with the remainder of the string. + b.WriteString(processText(text[bestEnd:])) + + return b.String() +} + +// escapeMarkdownV2 escapes every MarkdownV2 special character in a plain-text +// segment (i.e. a segment that is not part of any recognized entity). +// Already-escaped sequences (backslash + char) are forwarded verbatim to avoid +// double-escaping. +func escapeMarkdownV2(s string) string { + var b strings.Builder + b.Grow(len(s) + 8) + runes := []rune(s) + for i := 0; i < len(runes); i++ { + ch := runes[i] + // Forward an existing escape sequence verbatim. + if ch == '\\' && i+1 < len(runes) { + b.WriteRune(ch) + b.WriteRune(runes[i+1]) + i++ + continue + } + if mdV2SpecialChars[ch] { + b.WriteByte('\\') + } + b.WriteRune(ch) + } + return b.String() +} diff --git a/pkg/channels/telegram/parse_markdown_to_md_v2_test.go b/pkg/channels/telegram/parse_markdown_to_md_v2_test.go new file mode 100644 index 000000000..fd68a9b83 --- /dev/null +++ b/pkg/channels/telegram/parse_markdown_to_md_v2_test.go @@ -0,0 +1,68 @@ +package telegram + +import ( + _ "embed" + "testing" + + "github.com/stretchr/testify/require" +) + +//go:embed testdata/md2_all_formats.txt +var md2AllFormats string + +func Test_markdownToTelegramMarkdownV2(t *testing.T) { + cases := []struct { + name string + input string + expected string + }{ + { + name: "heading -> bolding", + input: `## HeadingH2 #`, + expected: "*HeadingH2 \\#*", + }, + { + name: "strikethrough", + input: "~strikethroughMD~", + expected: "~strikethroughMD~", + }, + { + name: "inline URL", + input: "[inline URL](http://www.example.com/)", + expected: "[inline URL](http://www.example.com/)", + }, + { + name: "all telegram formats", + input: md2AllFormats, + expected: md2AllFormats, + }, + { + name: "empty", + input: "", + expected: "", + }, + { + name: "one letter", + input: "o", + expected: "o", + }, + { + name: "", + input: "*Last update: ~10 24h*", + expected: "*Last update: \\~10 24h*", + }, + { + name: "", + input: "", + expected: "\\", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + actual := markdownToTelegramMarkdownV2(tc.input) + + require.EqualValues(t, tc.expected, actual) + }) + } +} diff --git a/pkg/channels/telegram/parser_markdown_to_html.go b/pkg/channels/telegram/parser_markdown_to_html.go new file mode 100644 index 000000000..0614b6e32 --- /dev/null +++ b/pkg/channels/telegram/parser_markdown_to_html.go @@ -0,0 +1,184 @@ +package telegram + +import ( + "fmt" + "html" + "regexp" + "strings" +) + +var reRawURL = regexp.MustCompile(`https?://[^\s<]+`) + +func markdownToTelegramHTML(text string) string { + if text == "" { + return "" + } + + codeBlocks := extractCodeBlocks(text) + text = codeBlocks.text + + inlineCodes := extractInlineCodes(text) + text = inlineCodes.text + + links := extractLinks(text) + text = links.text + + rawURLs := extractRawURLs(text) + text = rawURLs.text + + text = reHeading.ReplaceAllString(text, "$1") + + text = reBlockquote.ReplaceAllString(text, "$1") + + text = escapeHTML(text) + + text = reBoldStar.ReplaceAllString(text, "$1") + + text = reBoldUnder.ReplaceAllString(text, "$1") + + text = reItalic.ReplaceAllStringFunc(text, func(s string) string { + match := reItalic.FindStringSubmatch(s) + if len(match) < 2 { + return s + } + return "" + match[1] + "" + }) + + text = reStrike.ReplaceAllString(text, "$1") + + text = reListItem.ReplaceAllString(text, "• ") + + for i, lnk := range links.links { + label := escapeHTML(lnk[0]) + url := escapeHTMLAttr(lnk[1]) + text = strings.ReplaceAll(text, fmt.Sprintf("\x00LK%d\x00", i), fmt.Sprintf(`%s`, url, label)) + } + + for i, rawURL := range rawURLs.urls { + escaped := escapeHTML(rawURL) + text = strings.ReplaceAll( + text, + fmt.Sprintf("\x00RU%d\x00", i), + fmt.Sprintf(`%s`, escapeHTMLAttr(rawURL), escaped), + ) + } + + for i, code := range inlineCodes.codes { + escaped := escapeHTML(code) + text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) + } + + for i, code := range codeBlocks.codes { + escaped := escapeHTML(code) + text = strings.ReplaceAll( + text, + fmt.Sprintf("\x00CB%d\x00", i), + fmt.Sprintf("
%s
", escaped), + ) + } + + return text +} + +type linkMatch struct { + text string + links [][2]string // [label, url] +} + +func extractLinks(text string) linkMatch { + matches := reLink.FindAllStringSubmatch(text, -1) + + extracted := make([][2]string, 0, len(matches)) + for _, match := range matches { + extracted = append(extracted, [2]string{match[1], match[2]}) + } + + i := 0 + text = reLink.ReplaceAllStringFunc(text, func(m string) string { + placeholder := fmt.Sprintf("\x00LK%d\x00", i) + i++ + return placeholder + }) + + return linkMatch{text: text, links: extracted} +} + +type codeBlockMatch struct { + text string + codes []string +} + +type rawURLMatch struct { + text string + urls []string +} + +func extractCodeBlocks(text string) codeBlockMatch { + matches := reCodeBlock.FindAllStringSubmatch(text, -1) + + codes := make([]string, 0, len(matches)) + for _, match := range matches { + codes = append(codes, match[1]) + } + + i := 0 + text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string { + placeholder := fmt.Sprintf("\x00CB%d\x00", i) + i++ + return placeholder + }) + + return codeBlockMatch{text: text, codes: codes} +} + +func extractRawURLs(text string) rawURLMatch { + matches := reRawURL.FindAllString(text, -1) + + urls := make([]string, 0, len(matches)) + for _, match := range matches { + urls = append(urls, match) + } + + i := 0 + text = reRawURL.ReplaceAllStringFunc(text, func(string) string { + placeholder := fmt.Sprintf("\x00RU%d\x00", i) + i++ + return placeholder + }) + + return rawURLMatch{text: text, urls: urls} +} + +type inlineCodeMatch struct { + text string + codes []string +} + +func extractInlineCodes(text string) inlineCodeMatch { + matches := reInlineCode.FindAllStringSubmatch(text, -1) + + codes := make([]string, 0, len(matches)) + for _, match := range matches { + codes = append(codes, match[1]) + } + + i := 0 + text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string { + placeholder := fmt.Sprintf("\x00IC%d\x00", i) + i++ + return placeholder + }) + + return inlineCodeMatch{text: text, codes: codes} +} + +func escapeHTML(text string) string { + text = strings.ReplaceAll(text, "&", "&") + text = strings.ReplaceAll(text, "<", "<") + text = strings.ReplaceAll(text, ">", ">") + return text +} + +func escapeHTMLAttr(text string) string { + return html.EscapeString(text) +} diff --git a/pkg/channels/telegram/parser_markdown_to_html_test.go b/pkg/channels/telegram/parser_markdown_to_html_test.go new file mode 100644 index 000000000..a54a1c2c7 --- /dev/null +++ b/pkg/channels/telegram/parser_markdown_to_html_test.go @@ -0,0 +1,81 @@ +package telegram + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_markdownToTelegramHTML(t *testing.T) { + cases := []struct { + name string + input string + expected string + }{ + { + name: "plain text", + input: "hello world", + expected: "hello world", + }, + { + name: "bold", + input: "**bold text**", + expected: "bold text", + }, + { + name: "italic", + input: "_italic text_", + expected: "italic text", + }, + { + name: "link without underscores in URL", + input: "[click here](https://example.com/path)", + expected: `click here`, + }, + { + name: "raw oauth url with underscores survives", + input: "Apri https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A8001%2Foauth2callback&code_challenge=abc_def&code_challenge_method=S256", + expected: `Apri https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A8001%2Foauth2callback&code_challenge=abc_def&code_challenge_method=S256`, + }, + { + name: "link with underscores in URL is not corrupted by italic regex", + // Google Flights URLs use URL-safe base64 with underscores in the tfs param. + // Previously reItalic ran after reLink, matching _text_ inside href and injecting + // tags into the URL, which broke the link in Telegram. + input: "[3 → 10 сентября — от $202](https://www.google.com/travel/flights/search?tfs=CBwQAho_EgoyURL_safe_base64)", + expected: `3 → 10 сентября — от $202`, + }, + { + name: "multiple links all survive", + input: "[first](https://a.com/path_one) and [second](https://b.com/path_two_x)", + expected: `first and second`, + }, + { + name: "markdown link query params are escaped in href", + input: "[oauth](https://example.com/cb?response_type=code&client_id=test-client)", + expected: `oauth`, + }, + { + name: "link label with HTML special chars is escaped", + input: "[a & b](https://example.com)", + expected: `a & b`, + }, + { + name: "HTML special chars in plain text are escaped", + input: "a & b < c > d", + expected: "a & b < c > d", + }, + { + name: "code block with language", + input: "```json\n{\n \"path\": \"README.md\"\n}\n```", + expected: "
{\n  \"path\": \"README.md\"\n}\n
", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + actual := markdownToTelegramHTML(tc.input) + require.Equal(t, tc.expected, actual) + }) + } +} diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 0a36247a6..cebebfed6 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -2,13 +2,18 @@ package telegram import ( "context" + "crypto/rand" + "encoding/binary" + "errors" "fmt" + "io" "net/http" "net/url" "os" "regexp" "strconv" "strings" + "sync" "time" "github.com/mymmrac/telego" @@ -26,7 +31,7 @@ import ( ) var ( - reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`) + reHeading = regexp.MustCompile(`(?m)^#{1,6}\s+([^\n]+)`) reBlockquote = regexp.MustCompile(`^>\s*(.*)$`) reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`) @@ -40,20 +45,27 @@ var ( type TelegramChannel struct { *channels.BaseChannel - bot *telego.Bot - bh *th.BotHandler - config *config.Config - chatIDs map[string]int64 - ctx context.Context - cancel context.CancelFunc + bot *telego.Bot + bh *th.BotHandler + bc *config.Channel + chatIDs map[string]int64 + ctx context.Context + cancel context.CancelFunc + tgCfg *config.TelegramSettings + progress *channels.ToolFeedbackAnimator - registerFunc func(context.Context, []commands.Definition) error - commandRegCancel context.CancelFunc + registerFunc func(context.Context, []commands.Definition) error + commandRegDelayFn func(int) time.Duration + commandRegCancel context.CancelFunc } -func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { +func NewTelegramChannel( + bc *config.Channel, + telegramCfg *config.TelegramSettings, + bus *bus.MessageBus, +) (*TelegramChannel, error) { + channelName := bc.Name() var opts []telego.BotOption - telegramCfg := cfg.Channels.Telegram if telegramCfg.Proxy != "" { proxyURL, parseErr := url.Parse(telegramCfg.Proxy) @@ -77,28 +89,32 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" { opts = append(opts, telego.WithAPIServer(baseURL)) } + opts = append(opts, telego.WithLogger(logger.NewLogger("telego"))) - bot, err := telego.NewBot(telegramCfg.Token, opts...) + bot, err := telego.NewBot(telegramCfg.Token.String(), opts...) if err != nil { return nil, fmt.Errorf("failed to create telegram bot: %w", err) } base := channels.NewBaseChannel( - "telegram", + channelName, telegramCfg, bus, - telegramCfg.AllowFrom, + bc.AllowFrom, channels.WithMaxMessageLength(4000), - channels.WithGroupTrigger(telegramCfg.GroupTrigger), - channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID), + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - return &TelegramChannel{ + ch := &TelegramChannel{ BaseChannel: base, bot: bot, - config: cfg, + bc: bc, chatIDs: make(map[string]int64), - }, nil + tgCfg: telegramCfg, + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } func (c *TelegramChannel) Start(ctx context.Context) error { @@ -156,6 +172,9 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } + if c.progress != nil { + c.progress.StopAll() + } if c.commandRegCancel != nil { c.commandRegCancel() } @@ -163,93 +182,225 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { return nil } -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } - chatID, err := parseChatID(msg.ChatID) + useMarkdownV2 := c.tgCfg.UseMarkdownV2 + + chatID, threadID, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context) if err != nil { - return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) + return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } if msg.Content == "" { - return nil + return nil, nil + } + + isToolFeedback := outboundMessageIsToolFeedback(msg) + toolFeedbackContent := msg.Content + if isToolFeedback { + toolFeedbackContent = fitToolFeedbackForTelegram(msg.Content, useMarkdownV2, 4096) + } + trackedChatID := telegramToolFeedbackChatKey(msg.ChatID, &msg.Context) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, trackedChatID, toolFeedbackContent); handled { + if err != nil { + return nil, err + } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(trackedChatID) + if !isToolFeedback { + if msgIDs, handled := c.finalizeToolFeedbackMessageForChat(ctx, trackedChatID, msg); handled { + return msgIDs, nil + } } // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength), // so msg.Content is guaranteed to be within that limit. We still need to // check if HTML expansion pushes it beyond Telegram's 4096-char API limit. + replyToID := msg.ReplyToMessageID + var messageIDs []string queue := []string{msg.Content} + if isToolFeedback { + queue = []string{channels.InitialAnimatedToolFeedbackContent(toolFeedbackContent)} + } for len(queue) > 0 { chunk := queue[0] queue = queue[1:] - htmlContent := markdownToTelegramHTML(chunk) + content := parseContent(chunk, useMarkdownV2) - if len([]rune(htmlContent)) > 4096 { - ratio := float64(len([]rune(chunk))) / float64(len([]rune(htmlContent))) - smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin - if smallerLen < 100 { - smallerLen = 100 + if len([]rune(content)) > 4096 { + if isToolFeedback { + fittedChunk := fitToolFeedbackForTelegram(chunk, useMarkdownV2, 4096) + if fittedChunk != "" && fittedChunk != chunk { + queue = append([]string{fittedChunk}, queue...) + continue + } } - // Push sub-chunks back to the front of the queue for - // re-validation instead of sending them blindly. + runeChunk := []rune(chunk) + ratio := float64(len(runeChunk)) / float64(len([]rune(content))) + smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin + + // Guarantee progress: if estimated length is >= chunk length, force it smaller + if smallerLen >= len(runeChunk) { + smallerLen = len(runeChunk) - 1 + } + + if smallerLen <= 0 { + msgID, err := c.sendChunk(ctx, sendChunkParams{ + chatID: chatID, + threadID: threadID, + content: content, + replyToID: replyToID, + mdFallback: chunk, + useMarkdownV2: useMarkdownV2, + }) + if err != nil { + return nil, err + } + messageIDs = append(messageIDs, msgID) + replyToID = "" + continue + } + + // Use the estimated smaller length as a guide for SplitMessage. + // SplitMessage will find natural break points (newlines/spaces) and respect code blocks. subChunks := channels.SplitMessage(chunk, smallerLen) - queue = append(subChunks, queue...) + + // Safety fallback: If SplitMessage failed to shorten the chunk, force a manual hard split. + if len(subChunks) == 1 && subChunks[0] == chunk { + part1 := string(runeChunk[:smallerLen]) + part2 := string(runeChunk[smallerLen:]) + subChunks = []string{part1, part2} + } + + // Filter out empty chunks to avoid sending empty messages to Telegram. + nonEmpty := make([]string, 0, len(subChunks)) + for _, s := range subChunks { + if s != "" { + nonEmpty = append(nonEmpty, s) + } + } + + // Push sub-chunks back to the front of the queue + queue = append(nonEmpty, queue...) continue } - if err := c.sendHTMLChunk(ctx, chatID, htmlContent, chunk); err != nil { - return err - } - } - - return nil -} - -// sendHTMLChunk sends a single HTML message, falling back to the original -// markdown as plain text on parse failure so users never see raw HTML tags. -func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent, mdFallback string) error { - tgMsg := tu.Message(tu.ID(chatID), htmlContent) - tgMsg.ParseMode = telego.ModeHTML - - if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { - logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ - "error": err.Error(), + msgID, err := c.sendChunk(ctx, sendChunkParams{ + chatID: chatID, + threadID: threadID, + content: content, + replyToID: replyToID, + mdFallback: chunk, + useMarkdownV2: useMarkdownV2, }) - tgMsg.Text = mdFallback - tgMsg.ParseMode = "" - if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - return fmt.Errorf("telegram send: %w", channels.ErrTemporary) + if err != nil { + return nil, err + } + messageIDs = append(messageIDs, msgID) + // Only the first chunk should be a reply; subsequent chunks are normal messages. + replyToID = "" + } + + if isToolFeedback && len(messageIDs) > 0 { + c.RecordToolFeedbackMessage(trackedChatID, messageIDs[0], toolFeedbackContent) + } else if !isToolFeedback && hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, trackedChatID, trackedMsgID) + } + + return messageIDs, nil +} + +type sendChunkParams struct { + chatID int64 + threadID int + content string + replyToID string + mdFallback string + useMarkdownV2 bool +} + +// sendChunk sends a single HTML/MarkdownV2 message, falling back to the original +// markdown as plain text on parse failure so users never see raw HTML/MarkdownV2 tags. +func (c *TelegramChannel) sendChunk( + ctx context.Context, + params sendChunkParams, +) (string, error) { + tgMsg := tu.Message(tu.ID(params.chatID), params.content) + tgMsg.MessageThreadID = params.threadID + if params.useMarkdownV2 { + tgMsg.WithParseMode(telego.ModeMarkdownV2) + } else { + tgMsg.WithParseMode(telego.ModeHTML) + } + + if params.replyToID != "" { + if mid, parseErr := strconv.Atoi(params.replyToID); parseErr == nil { + tgMsg.ReplyParameters = &telego.ReplyParameters{ + MessageID: mid, + } } } - return nil + + pMsg, err := c.bot.SendMessage(ctx, tgMsg) + if err != nil { + logParseFailed(err, params.useMarkdownV2) + + tgMsg.Text = params.mdFallback + tgMsg.ParseMode = "" + pMsg, err = c.bot.SendMessage(ctx, tgMsg) + if err != nil { + return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary) + } + } + + return strconv.Itoa(pMsg.MessageID), nil } +// maxTypingDuration limits how long the typing indicator can run. +// Prevents endless typing when the LLM fails/hangs and preSend never invokes cancel. +// Matches channels.Manager's typingStopTTL (5 min) so behavior is consistent. +const maxTypingDuration = 5 * time.Minute + // StartTyping implements channels.TypingCapable. // It sends ChatAction(typing) immediately and then repeats every 4 seconds // (Telegram's typing indicator expires after ~5s) in a background goroutine. // The returned stop function is idempotent and cancels the goroutine. +// The goroutine also exits automatically after maxTypingDuration if cancel is +// never called (e.g. when the LLM fails or times out without publishing). func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { - cid, err := parseChatID(chatID) + cid, threadID, err := parseTelegramChatID(chatID) if err != nil { return func() {}, err } + action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) + action.MessageThreadID = threadID + // Send the first typing action immediately - _ = c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)) + _ = c.bot.SendChatAction(ctx, action) typingCtx, cancel := context.WithCancel(ctx) + // Cap lifetime so the goroutine cannot run indefinitely if cancel is never called + maxCtx, maxCancel := context.WithTimeout(typingCtx, maxTypingDuration) go func() { + defer maxCancel() ticker := time.NewTicker(4 * time.Second) defer ticker.Stop() for { select { - case <-typingCtx.Done(): + case <-maxCtx.Done(): return case <-ticker.C: - _ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)) + a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) + a.MessageThreadID = threadID + _ = c.bot.SendChatAction(typingCtx, a) } } }() @@ -259,7 +410,8 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func( // EditMessage implements channels.MessageEditor. func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { - cid, err := parseChatID(chatID) + useMarkdownV2 := c.tgCfg.UseMarkdownV2 + cid, _, err := parseTelegramChatID(chatID) if err != nil { return err } @@ -267,33 +419,170 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag if err != nil { return err } - htmlContent := markdownToTelegramHTML(content) - editMsg := tu.EditMessageText(tu.ID(cid), mid, htmlContent) - editMsg.ParseMode = telego.ModeHTML + parsedContent := parseContent(content, useMarkdownV2) + editMsg := tu.EditMessageText(tu.ID(cid), mid, parsedContent) + if useMarkdownV2 { + editMsg.WithParseMode(telego.ModeMarkdownV2) + } else { + editMsg.WithParseMode(telego.ModeHTML) + } _, err = c.bot.EditMessageText(ctx, editMsg) + if err != nil { + // If it failed because it was already modified (likely from a previous + // attempt that timed out on our end but landed on Telegram), we treat + // it as success to prevent the Manager from sending a duplicate message. + if strings.Contains(err.Error(), "message is not modified") { + return nil + } + + // Only fallback to plain text if the error looks like a parsing failure (Bad Request). + // Network errors or timeouts should NOT trigger a retry with different content. + if strings.Contains(err.Error(), "Bad Request") { + logParseFailed(err, useMarkdownV2) + _, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content)) + } + } + + if err != nil { + if strings.Contains(err.Error(), "message is not modified") { + return nil + } + + if isPostConnectError(err) { + logger.WarnCF( + "telegram", + "EditMessage likely landed but result is unknown; swallowing error to prevent duplicate", + map[string]any{ + "chat_id": chatID, + "mid": mid, + "error": err.Error(), + }, + ) + return nil // Swallow to prevent Manager fallback to a new SendMessage + } + } + return err } +// DeleteMessage implements channels.MessageDeleter. +func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + cid, _, err := parseTelegramChatID(chatID) + if err != nil { + return err + } + mid, err := strconv.Atoi(messageID) + if err != nil { + return err + } + return c.bot.DeleteMessage(ctx, &telego.DeleteMessageParams{ + ChatID: tu.ID(cid), + MessageID: mid, + }) +} + +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func (c *TelegramChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *TelegramChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *TelegramChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *TelegramChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *TelegramChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *TelegramChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + _ = c.DeleteMessage(ctx, chatID, messageID) +} + +func (c *TelegramChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *TelegramChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeToolFeedbackMessageForChat(ctx, telegramToolFeedbackChatKey(msg.ChatID, &msg.Context), msg) +} + +func (c *TelegramChannel) finalizeToolFeedbackMessageForChat( + ctx context.Context, + chatID string, + msg bus.OutboundMessage, +) ([]string, bool) { + return c.finalizeTrackedToolFeedbackMessage(ctx, chatID, msg.Content, c.EditMessage) +} + // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message (e.g. "Thinking... 💭") that will later be // edited to the actual response via EditMessage (channels.MessageEditor). func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { - phCfg := c.config.Channels.Telegram.Placeholder + phCfg := c.bc.Placeholder if !phCfg.Enabled { return "", nil } - text := phCfg.Text - if text == "" { - text = "Thinking... 💭" - } + text := phCfg.GetRandomText() - cid, err := parseChatID(chatID) + cid, threadID, err := parseTelegramChatID(chatID) if err != nil { return "", err } - pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text)) + phMsg := tu.Message(tu.ID(cid), text) + phMsg.MessageThreadID = threadID + pMsg, err := c.bot.SendMessage(ctx, phMsg) if err != nil { return "", err } @@ -302,21 +591,24 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s } // SendMedia implements the channels.MediaSender interface. -func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } + trackedChatID := telegramToolFeedbackChatKey(msg.ChatID, &msg.Context) + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(trackedChatID) - chatID, err := parseChatID(msg.ChatID) + chatID, threadID, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context) if err != nil { - return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) + return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } + var messageIDs []string for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { @@ -336,37 +628,72 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe continue } + var tgResult *telego.Message switch part.Type { case "image": params := &telego.SendPhotoParams{ - ChatID: tu.ID(chatID), - Photo: telego.InputFile{File: file}, - Caption: part.Caption, + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Photo: telego.InputFile{File: file}, + Caption: part.Caption, + } + tgResult, err = c.bot.SendPhoto(ctx, params) + if err != nil && strings.Contains(err.Error(), "PHOTO_INVALID_DIMENSIONS") { + if _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil { + file.Close() + return nil, fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary) + } + + docParams := &telego.SendDocumentParams{ + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Document: telego.InputFile{File: file}, + Caption: part.Caption, + } + tgResult, err = c.bot.SendDocument(ctx, docParams) } - _, err = c.bot.SendPhoto(ctx, params) case "audio": - params := &telego.SendAudioParams{ - ChatID: tu.ID(chatID), - Audio: telego.InputFile{File: file}, - Caption: part.Caption, + // Send OGG files with "voice" in the filename as Telegram voice + // bubbles (SendVoice) instead of audio attachments (SendAudio). + fn := strings.ToLower(part.Filename) + if strings.Contains(fn, "voice") && (strings.HasSuffix(fn, ".ogg") || strings.HasSuffix(fn, ".oga")) { + vparams := &telego.SendVoiceParams{ + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Voice: telego.InputFile{File: file}, + Caption: part.Caption, + } + tgResult, err = c.bot.SendVoice(ctx, vparams) + } else { + params := &telego.SendAudioParams{ + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Audio: telego.InputFile{File: file}, + Caption: part.Caption, + } + tgResult, err = c.bot.SendAudio(ctx, params) } - _, err = c.bot.SendAudio(ctx, params) case "video": params := &telego.SendVideoParams{ - ChatID: tu.ID(chatID), - Video: telego.InputFile{File: file}, - Caption: part.Caption, + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Video: telego.InputFile{File: file}, + Caption: part.Caption, } - _, err = c.bot.SendVideo(ctx, params) + tgResult, err = c.bot.SendVideo(ctx, params) default: // "file" or unknown types params := &telego.SendDocumentParams{ - ChatID: tu.ID(chatID), - Document: telego.InputFile{File: file}, - Caption: part.Caption, + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Document: telego.InputFile{File: file}, + Caption: part.Caption, } - _, err = c.bot.SendDocument(ctx, params) + tgResult, err = c.bot.SendDocument(ctx, params) } + if tgResult != nil { + messageIDs = append(messageIDs, strconv.Itoa(tgResult.MessageID)) + } file.Close() if err != nil { @@ -374,11 +701,15 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe "type": part.Type, "error": err.Error(), }) - return fmt.Errorf("telegram send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("telegram send media: %w", channels.ErrTemporary) } } - return nil + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, trackedChatID, trackedMsgID) + } + + return messageIDs, nil } func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { @@ -422,8 +753,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes storeMedia := func(localPath, filename string) string { if store := c.GetMediaStore(); store != nil { ref, err := store.Store(localPath, media.MediaMeta{ - Filename: filename, - Source: "telegram", + Filename: filename, + Source: "telegram", + CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, }, scope) if err == nil { return ref @@ -489,13 +821,18 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes } } + if content == "" && len(mediaPaths) == 0 { + return nil + } + if content == "" { - content = "[empty message]" + content = "[media only]" } // In group chats, apply unified group trigger filtering + isMentioned := false if message.Chat.Type != "private" { - isMentioned := c.isBotMentioned(message) + isMentioned = c.isBotMentioned(message) if isMentioned { content = c.stripBotMention(content) } @@ -506,22 +843,44 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content = cleaned } + if message.ReplyToMessage != nil { + quotedMedia := quotedTelegramMediaRefs( + message.ReplyToMessage, + func(fileID, ext, filename string) string { + localPath := c.downloadFile(ctx, fileID, ext) + if localPath == "" { + return "" + } + return storeMedia(localPath, filename) + }, + ) + if len(quotedMedia) > 0 { + mediaPaths = append(quotedMedia, mediaPaths...) + } + content = c.prependTelegramQuotedReply(content, message.ReplyToMessage) + } + + // For forum topics, embed the thread ID as "chatID/threadID" so replies + // route to the correct topic and each topic gets its own session. + // Only forum groups (IsForum) are handled; regular group reply threads + // must share one session per group. + compositeChatID := fmt.Sprintf("%d", chatID) + threadID := message.MessageThreadID + if message.Chat.IsForum && threadID != 0 { + compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID) + } + logger.DebugCF("telegram", "Received message", map[string]any{ "sender_id": sender.CanonicalID, - "chat_id": fmt.Sprintf("%d", chatID), + "chat_id": compositeChatID, + "thread_id": threadID, "preview": utils.Truncate(content, 50), }) - // Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable - peerKind := "direct" - peerID := fmt.Sprintf("%d", user.ID) if message.Chat.Type != "private" { peerKind = "group" - peerID = fmt.Sprintf("%d", chatID) } - - peer := bus.Peer{Kind: peerKind, ID: peerID} messageID := fmt.Sprintf("%d", message.MessageID) metadata := map[string]string{ @@ -531,19 +890,149 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } - c.HandleMessage(c.ctx, - peer, - messageID, - platformID, - fmt.Sprintf("%d", chatID), + inboundCtx := bus.InboundContext{ + Channel: c.Name(), + ChatID: fmt.Sprintf("%d", chatID), + ChatType: peerKind, + SenderID: platformID, + MessageID: messageID, + Mentioned: isMentioned, + Raw: metadata, + } + if message.Chat.IsForum && threadID != 0 { + inboundCtx.TopicID = fmt.Sprintf("%d", threadID) + } + if message.ReplyToMessage != nil { + inboundCtx.ReplyToMessageID = fmt.Sprintf("%d", message.ReplyToMessage.MessageID) + } + + c.HandleMessageWithContext( + c.ctx, + compositeChatID, content, mediaPaths, - metadata, + inboundCtx, sender, ) return nil } +func (c *TelegramChannel) prependTelegramQuotedReply(content string, reply *telego.Message) string { + quoted := strings.TrimSpace(telegramQuotedContent(reply)) + if quoted == "" { + return content + } + + author := telegramQuotedAuthor(reply) + role := c.telegramQuotedRole(reply) + if strings.TrimSpace(content) == "" { + return fmt.Sprintf("[quoted %s message from %s]: %s", role, author, quoted) + } + return fmt.Sprintf("[quoted %s message from %s]: %s\n\n%s", role, author, quoted, content) +} + +func (c *TelegramChannel) telegramQuotedRole(message *telego.Message) string { + if message == nil { + return "unknown" + } + + if message.From != nil { + if !message.From.IsBot { + return "user" + } + if c.isOwnBotUser(message.From) { + return "assistant" + } + return "bot" + } + + if message.SenderChat != nil { + return "chat" + } + + return "unknown" +} + +func (c *TelegramChannel) isOwnBotUser(user *telego.User) bool { + if c == nil || c.bot == nil || user == nil || !user.IsBot { + return false + } + + if botID := c.bot.ID(); botID != 0 && user.ID == botID { + return true + } + + botUsername := strings.TrimPrefix(strings.TrimSpace(c.bot.Username()), "@") + if botUsername == "" { + return false + } + return strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(user.Username), "@"), botUsername) +} + +func telegramQuotedAuthor(message *telego.Message) string { + if message == nil || message.From == nil { + return "unknown" + } + if username := strings.TrimSpace(message.From.Username); username != "" { + return username + } + if firstName := strings.TrimSpace(message.From.FirstName); firstName != "" { + return firstName + } + return "unknown" +} + +func telegramQuotedContent(message *telego.Message) string { + if message == nil { + return "" + } + + var parts []string + if text := strings.TrimSpace(message.Text); text != "" { + parts = append(parts, text) + } + if caption := strings.TrimSpace(message.Caption); caption != "" { + parts = append(parts, caption) + } + switch { + case len(message.Photo) > 0: + parts = append(parts, "[image: photo]") + } + switch { + case message.Voice != nil: + parts = append(parts, "[voice]") + case message.Audio != nil: + parts = append(parts, "[audio]") + } + if message.Document != nil { + parts = append(parts, "[file]") + } + + return strings.Join(parts, "\n") +} + +func quotedTelegramMediaRefs( + message *telego.Message, + resolve func(fileID, ext, filename string) string, +) []string { + if message == nil || resolve == nil { + return nil + } + + var refs []string + if message.Voice != nil { + if ref := resolve(message.Voice.FileID, ".ogg", "voice.ogg"); ref != "" { + refs = append(refs, ref) + } + } + if message.Audio != nil { + if ref := resolve(message.Audio.FileID, ".mp3", "audio.mp3"); ref != "" { + refs = append(refs, ref) + } + } + return refs +} + func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { @@ -583,115 +1072,121 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) return c.downloadFileWithInfo(file, ext) } -func parseChatID(chatIDStr string) (int64, error) { - var id int64 - _, err := fmt.Sscanf(chatIDStr, "%d", &id) - return id, err +func parseContent(text string, useMarkdownV2 bool) string { + if useMarkdownV2 { + return markdownToTelegramMarkdownV2(text) + } + + return markdownToTelegramHTML(text) } -func markdownToTelegramHTML(text string) string { - if text == "" { +func fitToolFeedbackForTelegram(content string, useMarkdownV2 bool, maxParsedLen int) string { + content = strings.TrimSpace(content) + if content == "" || maxParsedLen <= 0 { return "" } + animationSafeLen := maxParsedLen - channels.MaxToolFeedbackAnimationFrameLength() + if animationSafeLen <= 0 { + animationSafeLen = maxParsedLen + } + if len([]rune(parseContent(content, useMarkdownV2))) <= animationSafeLen { + return content + } - codeBlocks := extractCodeBlocks(text) - text = codeBlocks.text + low := 1 + high := len([]rune(content)) + best := utils.Truncate(content, 1) - inlineCodes := extractInlineCodes(text) - text = inlineCodes.text - - text = reHeading.ReplaceAllString(text, "$1") - - text = reBlockquote.ReplaceAllString(text, "$1") - - text = escapeHTML(text) - - text = reLink.ReplaceAllString(text, `$1`) - - text = reBoldStar.ReplaceAllString(text, "$1") - - text = reBoldUnder.ReplaceAllString(text, "$1") - - text = reItalic.ReplaceAllStringFunc(text, func(s string) string { - match := reItalic.FindStringSubmatch(s) - if len(match) < 2 { - return s + for low <= high { + mid := (low + high) / 2 + candidate := utils.FitToolFeedbackMessage(content, mid) + if candidate == "" { + high = mid - 1 + continue } - return "" + match[1] + "" - }) - - text = reStrike.ReplaceAllString(text, "$1") - - text = reListItem.ReplaceAllString(text, "• ") - - for i, code := range inlineCodes.codes { - escaped := escapeHTML(code) - text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) + if len([]rune(parseContent(candidate, useMarkdownV2))) <= animationSafeLen { + best = candidate + low = mid + 1 + continue + } + high = mid - 1 } - for i, code := range codeBlocks.codes { - escaped := escapeHTML(code) - text = strings.ReplaceAll( - text, - fmt.Sprintf("\x00CB%d\x00", i), - fmt.Sprintf("
%s
", escaped), - ) + return best +} + +func (c *TelegramChannel) PrepareToolFeedbackMessageContent(content string) string { + if c == nil || c.tgCfg == nil { + return strings.TrimSpace(content) + } + return fitToolFeedbackForTelegram(content, c.tgCfg.UseMarkdownV2, 4096) +} + +func telegramToolFeedbackChatKey(chatID string, outboundCtx *bus.InboundContext) string { + resolvedChatID, threadID, err := resolveTelegramOutboundTarget(chatID, outboundCtx) + if err != nil || threadID == 0 { + return strings.TrimSpace(chatID) + } + return fmt.Sprintf("%d/%d", resolvedChatID, threadID) +} + +func (c *TelegramChannel) ToolFeedbackMessageChatID(chatID string, outboundCtx *bus.InboundContext) string { + return telegramToolFeedbackChatKey(chatID, outboundCtx) +} + +// parseTelegramChatID splits "chatID/threadID" into its components. +// Returns threadID=0 when no "/" is present (non-forum messages). +func parseTelegramChatID(chatID string) (int64, int, error) { + idx := strings.Index(chatID, "/") + if idx == -1 { + cid, err := strconv.ParseInt(chatID, 10, 64) + return cid, 0, err + } + cid, err := strconv.ParseInt(chatID[:idx], 10, 64) + if err != nil { + return 0, 0, err + } + tid, err := strconv.Atoi(chatID[idx+1:]) + if err != nil { + return 0, 0, fmt.Errorf("invalid thread ID in chat ID %q: %w", chatID, err) + } + return cid, tid, nil +} + +func resolveTelegramOutboundTarget(chatID string, outboundCtx *bus.InboundContext) (int64, int, error) { + targetChatID := strings.TrimSpace(chatID) + if targetChatID == "" && outboundCtx != nil { + targetChatID = strings.TrimSpace(outboundCtx.ChatID) + } + resolvedChatID, resolvedThreadID, err := parseTelegramChatID(targetChatID) + if err != nil { + return 0, 0, err + } + if resolvedThreadID != 0 || outboundCtx == nil { + return resolvedChatID, resolvedThreadID, nil + } + topicID := strings.TrimSpace(outboundCtx.TopicID) + if topicID == "" { + return resolvedChatID, resolvedThreadID, nil + } + if threadID, convErr := strconv.Atoi(topicID); convErr == nil { + return resolvedChatID, threadID, nil + } + return resolvedChatID, resolvedThreadID, nil +} + +func logParseFailed(err error, useMarkdownV2 bool) { + parsingName := "HTML" + if useMarkdownV2 { + parsingName = "MarkdownV2" } - return text -} - -type codeBlockMatch struct { - text string - codes []string -} - -func extractCodeBlocks(text string) codeBlockMatch { - matches := reCodeBlock.FindAllStringSubmatch(text, -1) - - codes := make([]string, 0, len(matches)) - for _, match := range matches { - codes = append(codes, match[1]) - } - - i := 0 - text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string { - placeholder := fmt.Sprintf("\x00CB%d\x00", i) - i++ - return placeholder - }) - - return codeBlockMatch{text: text, codes: codes} -} - -type inlineCodeMatch struct { - text string - codes []string -} - -func extractInlineCodes(text string) inlineCodeMatch { - matches := reInlineCode.FindAllStringSubmatch(text, -1) - - codes := make([]string, 0, len(matches)) - for _, match := range matches { - codes = append(codes, match[1]) - } - - i := 0 - text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string { - placeholder := fmt.Sprintf("\x00IC%d\x00", i) - i++ - return placeholder - }) - - return inlineCodeMatch{text: text, codes: codes} -} - -func escapeHTML(text string) string { - text = strings.ReplaceAll(text, "&", "&") - text = strings.ReplaceAll(text, "<", "<") - text = strings.ReplaceAll(text, ">", ">") - return text + logger.ErrorCF("telegram", + fmt.Sprintf("%s parse failed, falling back to plain text", parsingName), + map[string]any{ + "error": err.Error(), + }, + ) } // isBotMentioned checks if the bot is mentioned in the message via entities. @@ -782,3 +1277,140 @@ func (c *TelegramChannel) stripBotMention(content string) string { content = re.ReplaceAllString(content, "") return strings.TrimSpace(content) } + +// BeginStream implements channels.StreamingCapable. +func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (channels.Streamer, error) { + if !c.tgCfg.Streaming.Enabled { + return nil, fmt.Errorf("streaming disabled in config") + } + + cid, threadID, err := parseTelegramChatID(chatID) + if err != nil { + return nil, err + } + + streamCfg := c.tgCfg.Streaming + return &telegramStreamer{ + bot: c.bot, + chatID: cid, + threadID: threadID, + draftID: cryptoRandInt(), + throttleInterval: time.Duration(streamCfg.ThrottleSeconds) * time.Second, + minGrowth: streamCfg.MinGrowthChars, + }, nil +} + +// telegramStreamer streams partial LLM output via Telegram's sendMessageDraft API. +// On first API error (e.g. bot lacks forum mode), it silently degrades: Update +// becomes a no-op, while Finalize still delivers the final message. +type telegramStreamer struct { + bot *telego.Bot + chatID int64 + threadID int + draftID int + throttleInterval time.Duration + minGrowth int + lastLen int + lastAt time.Time + failed bool + mu sync.Mutex +} + +func (s *telegramStreamer) Update(ctx context.Context, content string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.failed { + return nil + } + + // Throttle: skip if not enough time or content has passed + now := time.Now() + growth := len(content) - s.lastLen + if s.lastLen > 0 && now.Sub(s.lastAt) < s.throttleInterval && growth < s.minGrowth { + return nil + } + + htmlContent := markdownToTelegramHTML(content) + + err := s.bot.SendMessageDraft(ctx, &telego.SendMessageDraftParams{ + ChatID: s.chatID, + MessageThreadID: s.threadID, + DraftID: s.draftID, + Text: htmlContent, + ParseMode: telego.ModeHTML, + }) + if err != nil { + // First error → degrade silently (e.g. no forum mode) + logger.WarnCF("telegram", "sendMessageDraft failed, disabling streaming", map[string]any{ + "error": err.Error(), + }) + s.failed = true + return nil // don't propagate — Finalize will still deliver + } + + s.lastLen = len(content) + s.lastAt = now + return nil +} + +func (s *telegramStreamer) Finalize(ctx context.Context, content string) error { + htmlContent := markdownToTelegramHTML(content) + tgMsg := tu.Message(tu.ID(s.chatID), htmlContent) + tgMsg.MessageThreadID = s.threadID + tgMsg.ParseMode = telego.ModeHTML + + if _, err := s.bot.SendMessage(ctx, tgMsg); err != nil { + // Fallback to plain text + tgMsg.ParseMode = "" + if _, err = s.bot.SendMessage(ctx, tgMsg); err != nil { + logger.ErrorCF("telegram", "Finalize failed after HTML and plain-text attempts", map[string]any{ + "chat_id": s.chatID, + "error": err.Error(), + "len": len(content), + }) + return fmt.Errorf("telegram finalize: %w", err) + } + } + return nil +} + +func (s *telegramStreamer) Cancel(ctx context.Context) { + // Draft auto-expires on Telegram's side; nothing to clean up. +} + +// cryptoRandInt returns a non-zero random int using crypto/rand. +func cryptoRandInt() int { + var b [4]byte + _, _ = rand.Read(b[:]) + return int(binary.BigEndian.Uint32(b[:])) | 1 // ensure non-zero +} + +// isPostConnectError identifies network errors that likely occurred after +// the request was transmitted to Telegram (e.g. dropped connection while +// waiting for response). Swallowing these for edits prevents duplicate +// fallbacks, at the small risk of leaving a stale placeholder if the +// edit never actually reached the server. +func isPostConnectError(err error) bool { + if err == nil { + return false + } + + // Context errors (timeout/canceled) are too broad; they can be triggered + // locally before any data is sent. Never swallow them. + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return false + } + + msg := strings.ToLower(err.Error()) + // Narrowly target connection dropouts where the request likely landed. + return strings.Contains(msg, "connection reset by peer") || + strings.Contains(msg, "unexpected eof") || + strings.Contains(msg, "connection closed by foreign host") || + strings.Contains(msg, "broken pipe") +} + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *TelegramChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/telegram/telegram_dispatch_test.go b/pkg/channels/telegram/telegram_dispatch_test.go index 1ea4a4824..0eb1de5ea 100644 --- a/pkg/channels/telegram/telegram_dispatch_test.go +++ b/pkg/channels/telegram/telegram_dispatch_test.go @@ -3,7 +3,6 @@ package telegram import ( "context" "testing" - "time" "github.com/mymmrac/telego" @@ -36,10 +35,7 @@ func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) { t.Fatalf("handleMessage error: %v", err) } - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - inbound, ok := messageBus.ConsumeInbound(ctx) + inbound, ok := <-messageBus.InboundChan() if !ok { t.Fatal("expected inbound message to be forwarded") } diff --git a/pkg/channels/telegram/telegram_group_command_filter_test.go b/pkg/channels/telegram/telegram_group_command_filter_test.go index 0d5b985fe..20b2004a9 100644 --- a/pkg/channels/telegram/telegram_group_command_filter_test.go +++ b/pkg/channels/telegram/telegram_group_command_filter_test.go @@ -108,22 +108,24 @@ func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) { t.Fatalf("handleMessage error: %v", err) } - ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() - - inbound, ok := messageBus.ConsumeInbound(ctx) - if tc.wantForwarded { - if !ok { - t.Fatal("expected inbound message to be forwarded") + select { + case <-ctx.Done(): + if tc.wantForwarded { + t.Fatal("timeout waiting for message to be forwarded") + return } - if inbound.Content != tc.wantContent { - t.Fatalf("content=%q want=%q", inbound.Content, tc.wantContent) + case inbound, ok := <-messageBus.InboundChan(): + if tc.wantForwarded { + if !ok { + t.Fatal("expected inbound message to be forwarded") + } + if inbound.Content != tc.wantContent { + t.Fatalf("content=%q want=%q", inbound.Content, tc.wantContent) + } + return } - return - } - - if ok { - t.Fatalf("expected message to be filtered, got content=%q", inbound.Content) } }) } diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 3a2f1aa66..69c76b430 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -4,6 +4,10 @@ import ( "context" "encoding/json" "errors" + "io" + "os" + "path/filepath" + "strconv" "strings" "testing" @@ -14,6 +18,8 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" ) const testToken = "1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc" @@ -37,8 +43,20 @@ func (s *stubCaller) Call(ctx context.Context, url string, data *ta.RequestData) // stubConstructor implements ta.RequestConstructor for testing. type stubConstructor struct{} +type multipartCall struct { + Parameters map[string]string + FileSizes map[string]int +} + func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) { - return &ta.RequestData{}, nil + b, err := json.Marshal(parameters) + if err != nil { + return nil, err + } + return &ta.RequestData{ + ContentType: "application/json", + BodyRaw: b, + }, nil } func (s *stubConstructor) MultipartRequest( @@ -48,22 +66,71 @@ func (s *stubConstructor) MultipartRequest( return &ta.RequestData{}, nil } +type multipartRecordingConstructor struct { + stubConstructor + calls []multipartCall +} + +func (s *multipartRecordingConstructor) MultipartRequest( + parameters map[string]string, + files map[string]ta.NamedReader, +) (*ta.RequestData, error) { + call := multipartCall{ + Parameters: make(map[string]string, len(parameters)), + FileSizes: make(map[string]int, len(files)), + } + for k, v := range parameters { + call.Parameters[k] = v + } + for field, file := range files { + if file == nil { + continue + } + data, err := io.ReadAll(file) + if err != nil { + return nil, err + } + call.FileSizes[field] = len(data) + } + s.calls = append(s.calls, call) + return &ta.RequestData{}, nil +} + // successResponse returns a ta.Response that telego will treat as a successful SendMessage. func successResponse(t *testing.T) *ta.Response { + return successResponseWithMessageID(t, 1) +} + +func successResponseWithMessageID(t *testing.T, messageID int) *ta.Response { t.Helper() - msg := &telego.Message{MessageID: 1} + msg := &telego.Message{MessageID: messageID} b, err := json.Marshal(msg) require.NoError(t, err) return &ta.Response{Ok: true, Result: b} } +func successUserResponse(t *testing.T, user *telego.User) *ta.Response { + t.Helper() + b, err := json.Marshal(user) + require.NoError(t, err) + return &ta.Response{Ok: true, Result: b} +} + // newTestChannel creates a TelegramChannel with a mocked bot for unit testing. func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel { + return newTestChannelWithConstructor(t, caller, &stubConstructor{}) +} + +func newTestChannelWithConstructor( + t *testing.T, + caller *stubCaller, + constructor ta.RequestConstructor, +) *TelegramChannel { t.Helper() bot, err := telego.NewBot(testToken, telego.WithAPICaller(caller), - telego.WithRequestConstructor(&stubConstructor{}), + telego.WithRequestConstructor(constructor), telego.WithDiscardLogger(), ) require.NoError(t, err) @@ -77,9 +144,98 @@ func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel { BaseChannel: base, bot: bot, chatIDs: make(map[string]int64), + bc: &config.Channel{Type: config.ChannelTelegram, Enabled: true}, + tgCfg: &config.TelegramSettings{}, + progress: channels.NewToolFeedbackAnimator(nil), } } +func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) { + constructor := &multipartRecordingConstructor{} + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + switch { + case strings.Contains(url, "sendPhoto"): + return nil, errors.New(`api: 400 "Bad Request: PHOTO_INVALID_DIMENSIONS"`) + case strings.Contains(url, "sendDocument"): + return successResponse(t), nil + default: + t.Fatalf("unexpected API call: %s", url) + return nil, nil + } + }, + } + ch := newTestChannelWithConstructor(t, caller, constructor) + + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "woodstock-en-10s.png") + content := []byte("fake-png-content") + require.NoError(t, os.WriteFile(localPath, content, 0o644)) + + ref, err := store.Store( + localPath, + media.MediaMeta{Filename: "woodstock-en-10s.png", ContentType: "image/png"}, + "scope-1", + ) + require.NoError(t, err) + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "12345", + Parts: []bus.MediaPart{{ + Type: "image", + Ref: ref, + Caption: "caption", + }}, + }) + + require.NoError(t, err) + require.Len(t, caller.calls, 2) + assert.Contains(t, caller.calls[0].URL, "sendPhoto") + assert.Contains(t, caller.calls[1].URL, "sendDocument") + require.Len(t, constructor.calls, 2) + assert.Equal(t, len(content), constructor.calls[0].FileSizes["photo"]) + assert.Equal(t, len(content), constructor.calls[1].FileSizes["document"]) + assert.Equal(t, "caption", constructor.calls[1].Parameters["caption"]) +} + +func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) { + constructor := &multipartRecordingConstructor{} + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return nil, errors.New("api: 500 \"server exploded\"") + }, + } + ch := newTestChannelWithConstructor(t, caller, constructor) + + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "image.png") + require.NoError(t, os.WriteFile(localPath, []byte("fake-png-content"), 0o644)) + + ref, err := store.Store(localPath, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1") + require.NoError(t, err) + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "12345", + Parts: []bus.MediaPart{{ + Type: "image", + Ref: ref, + }}, + }) + + require.Error(t, err) + assert.ErrorIs(t, err, channels.ErrTemporary) + require.Len(t, caller.calls, 1) + assert.Contains(t, caller.calls[0].URL, "sendPhoto") + require.Len(t, constructor.calls, 1) + assert.NotContains(t, caller.calls[0].URL, "sendDocument") +} + func TestSend_EmptyContent(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { @@ -89,7 +245,7 @@ func TestSend_EmptyContent(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "", }) @@ -106,7 +262,7 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello, world!", }) @@ -115,6 +271,176 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) { assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call") } +func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + switch { + case strings.Contains(url, "editMessageText"): + return successResponseWithMessageID(t, 1), nil + default: + t.Fatalf("unexpected API call: %s", url) + return nil, nil + } + }, + } + ch := newTestChannel(t, caller) + ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`") + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: "final reply", + }) + + assert.NoError(t, err) + assert.Equal(t, []string{"1"}, ids) + require.Len(t, caller.calls, 1) + assert.Contains(t, caller.calls[0].URL, "editMessageText") + _, ok := ch.currentToolFeedbackMessage("12345") + assert.False(t, ok, "tracked tool feedback should be cleared after final reply") +} + +func TestSend_ToolFeedbackTrackingIsTopicScoped(t *testing.T) { + nextMessageID := 0 + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + nextMessageID++ + return successResponseWithMessageID(t, nextMessageID), nil + }, + } + ch := newTestChannel(t, caller) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890", + Content: "🔧 `read_file`", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "-1001234567890", + TopicID: "42", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + require.NoError(t, err) + + _, ok := ch.currentToolFeedbackMessage("-1001234567890") + assert.False(t, ok, "base chat should not track topic-specific tool feedback") + + msgID, ok := ch.currentToolFeedbackMessage("-1001234567890/42") + require.True(t, ok, "topic chat should track tool feedback") + assert.Equal(t, "1", msgID) +} + +func TestSend_TopicReplyDoesNotFinalizeDifferentTopicToolFeedback(t *testing.T) { + nextMessageID := 0 + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + nextMessageID++ + return successResponseWithMessageID(t, nextMessageID), nil + }, + } + ch := newTestChannel(t, caller) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890", + Content: "🔧 `read_file`", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "-1001234567890", + TopicID: "42", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + require.NoError(t, err) + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890", + Content: "final reply in another topic", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "-1001234567890", + TopicID: "43", + }, + }) + require.NoError(t, err) + require.Len(t, caller.calls, 2) + assert.Equal(t, []string{"2"}, ids) + assert.Contains(t, caller.calls[1].URL, "sendMessage") + assert.NotContains(t, caller.calls[1].URL, "editMessageText") + + _, ok := ch.currentToolFeedbackMessage("-1001234567890/42") + assert.True(t, ok, "tool feedback in the original topic should remain tracked") +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := newTestChannel(t, &stubCaller{ + callFn: func(context.Context, string, *ta.RequestData) (*ta.Response, error) { + t.Fatal("unexpected API call") + return nil, nil + }, + }) + ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "12345", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + _, ok := ch.currentToolFeedbackMessage(chatID) + assert.False(t, ok, "tracked tool feedback should be stopped before edit") + assert.Equal(t, "12345", chatID) + assert.Equal(t, "1", messageID) + assert.Equal(t, "final reply", content) + return nil + }, + ) + + assert.True(t, handled) + assert.Equal(t, []string{"1"}, msgIDs) +} + +func TestSend_ToolFeedbackStaysSingleMessageAfterHTMLExpansion(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: "🔧 `read_file`\n" + strings.Repeat("<", 2000), + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "12345", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + assert.NoError(t, err) + assert.Len(t, caller.calls, 1, "tool feedback should stay a single Telegram message after HTML escaping") +} + +func TestFitToolFeedbackForTelegram_ReservesAnimationFrame(t *testing.T) { + content := "🔧 `read_file`\n" + strings.Repeat("a", 4096) + + fitted := fitToolFeedbackForTelegram(content, false, 4096) + animated := strings.Replace( + fitted, + "`\n", + strings.Repeat(".", channels.MaxToolFeedbackAnimationFrameLength())+"`\n", + 1, + ) + + if got := len([]rune(parseContent(animated, false))); got > 4096 { + t.Fatalf("animated parsed length = %d, want <= 4096", got) + } +} + func TestSend_LongMessage_SingleCall(t *testing.T) { // With WithMaxMessageLength(4000), the Manager pre-splits messages before // they reach Send(). A message at exactly 4000 chars should go through @@ -129,7 +455,7 @@ func TestSend_LongMessage_SingleCall(t *testing.T) { longContent := strings.Repeat("a", 4000) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: longContent, }) @@ -152,7 +478,7 @@ func TestSend_HTMLFallback_PerChunk(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello **world**", }) @@ -170,7 +496,7 @@ func TestSend_HTMLFallback_BothFail(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello", }) @@ -192,7 +518,7 @@ func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) { longContent := strings.Repeat("x", 4001) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: longContent, }) @@ -222,7 +548,7 @@ func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) { "HTML expansion must exceed Telegram limit for this test to be meaningful", ) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: markdownContent, }) @@ -234,6 +560,55 @@ func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) { ) } +func TestSend_HTMLOverflow_WordBoundary(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + // We want to force a split near index ~2600 while keeping markdown length <= 4000. + // Prefix of 430 bold units (6 chars each) = 2580 chars. + // Expansion per unit is +3 chars when converted to HTML, so 2580 + 430*3 = 3870. + prefix := strings.Repeat("**a** ", 430) + targetWord := "TARGETWORDTHATSTAYSTOGETHER" + // Suffix of 230 bold units (6 chars each) = 1380 chars. + // Total markdown length: 2580 (prefix) + 27 (target word) + 1380 (suffix) = 3987 <= 4000. + // HTML expansion adds ~3 chars per bold unit: (430 + 230)*3 = 1980 extra chars, + // so total HTML length comfortably exceeds 4096. + suffix := strings.Repeat(" **b**", 230) + content := prefix + targetWord + suffix + + // Ensure the test content matches the intended boundary conditions. + assert.LessOrEqual(t, len([]rune(content)), 4000, "markdown content must not exceed chunk size for this test") + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "123456", + Content: content, + }) + + assert.NoError(t, err) + + foundFullWord := false + for i, call := range caller.calls { + var params map[string]any + err := json.Unmarshal(call.Data.BodyRaw, ¶ms) + require.NoError(t, err) + text, _ := params["text"].(string) + + hasWord := strings.Contains(text, targetWord) + t.Logf("Chunk %d length: %d, contains target word: %v", i, len(text), hasWord) + + if hasWord { + foundFullWord = true + break + } + } + + assert.True(t, foundFullWord, "The target word should not be split between chunks") +} + func TestSend_NotRunning(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { @@ -244,7 +619,7 @@ func TestSend_NotRunning(t *testing.T) { ch := newTestChannel(t, caller) ch.SetRunning(false) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello", }) @@ -262,7 +637,7 @@ func TestSend_InvalidChatID(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "not-a-number", Content: "Hello", }) @@ -271,3 +646,457 @@ func TestSend_InvalidChatID(t *testing.T) { assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed") assert.Empty(t, caller.calls) } + +func TestParseTelegramChatID_Plain(t *testing.T) { + cid, tid, err := parseTelegramChatID("12345") + assert.NoError(t, err) + assert.Equal(t, int64(12345), cid) + assert.Equal(t, 0, tid) +} + +func TestParseTelegramChatID_NegativeGroup(t *testing.T) { + cid, tid, err := parseTelegramChatID("-1001234567890") + assert.NoError(t, err) + assert.Equal(t, int64(-1001234567890), cid) + assert.Equal(t, 0, tid) +} + +func TestParseTelegramChatID_WithThreadID(t *testing.T) { + cid, tid, err := parseTelegramChatID("-1001234567890/42") + assert.NoError(t, err) + assert.Equal(t, int64(-1001234567890), cid) + assert.Equal(t, 42, tid) +} + +func TestParseTelegramChatID_GeneralTopic(t *testing.T) { + cid, tid, err := parseTelegramChatID("-100123/1") + assert.NoError(t, err) + assert.Equal(t, int64(-100123), cid) + assert.Equal(t, 1, tid) +} + +func TestParseTelegramChatID_Invalid(t *testing.T) { + _, _, err := parseTelegramChatID("not-a-number") + assert.Error(t, err) +} + +func TestParseTelegramChatID_InvalidThreadID(t *testing.T) { + _, _, err := parseTelegramChatID("-100123/not-a-thread") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid thread ID") +} + +func TestSend_WithForumThreadID(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890/42", + Content: "Hello from topic", + }) + + assert.NoError(t, err) + assert.Len(t, caller.calls, 1) +} + +func TestSend_UsesContextTopicIDWhenChatIDDoesNotIncludeThread(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890", + Content: "Hello from topic context", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "-1001234567890", + TopicID: "42", + }, + }) + + require.NoError(t, err) + require.Len(t, caller.calls, 1) + + var params struct { + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id"` + Text string `json:"text"` + } + require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms)) + assert.Equal(t, int64(-1001234567890), params.ChatID) + assert.Equal(t, 42, params.MessageThreadID) + assert.Equal(t, "Hello from topic context", params.Text) +} + +func TestBeginStream_UpdateUsesForumThreadID(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return &ta.Response{Ok: true, Result: []byte("true")}, nil + }, + } + ch := newTestChannel(t, caller) + ch.tgCfg.Streaming.Enabled = true + + streamer, err := ch.BeginStream(context.Background(), "-1001234567890/42") + require.NoError(t, err) + require.NoError(t, streamer.Update(context.Background(), "partial")) + require.Len(t, caller.calls, 1) + assert.Contains(t, caller.calls[0].URL, "sendMessageDraft") + + var params struct { + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id"` + Text string `json:"text"` + } + require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms)) + assert.Equal(t, int64(-1001234567890), params.ChatID) + assert.Equal(t, 42, params.MessageThreadID) + assert.Equal(t, "partial", params.Text) +} + +func TestBeginStream_FinalizeUsesForumThreadID(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + ch.tgCfg.Streaming.Enabled = true + + streamer, err := ch.BeginStream(context.Background(), "-1001234567890/42") + require.NoError(t, err) + require.NoError(t, streamer.Finalize(context.Background(), "final")) + require.Len(t, caller.calls, 1) + assert.Contains(t, caller.calls[0].URL, "sendMessage") + + var params struct { + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id"` + Text string `json:"text"` + } + require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms)) + assert.Equal(t, int64(-1001234567890), params.ChatID) + assert.Equal(t, 42, params.MessageThreadID) + assert.Equal(t, "final", params.Text) +} + +func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "hello from topic", + MessageID: 10, + MessageThreadID: 42, + Chat: telego.Chat{ + ID: -1001234567890, + Type: "supergroup", + IsForum: true, + }, + From: &telego.User{ + ID: 7, + FirstName: "Alice", + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok, "expected inbound message") + + // ChatID remains the parent chat; TopicID isolates the sub-conversation. + assert.Equal(t, "-1001234567890", inbound.ChatID) + assert.Equal(t, "group", inbound.Context.ChatType) + assert.Equal(t, "42", inbound.Context.TopicID) +} + +func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "regular group message", + MessageID: 11, + Chat: telego.Chat{ + ID: -100999, + Type: "group", + }, + From: &telego.User{ + ID: 8, + FirstName: "Bob", + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok) + + // Plain chatID without thread suffix + assert.Equal(t, "-100999", inbound.ChatID) + + assert.Equal(t, "group", inbound.Context.ChatType) + assert.Empty(t, inbound.Context.TopicID) +} + +func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + // In regular groups, reply threads set MessageThreadID to the original + // message ID. This should NOT trigger per-thread session isolation. + msg := &telego.Message{ + Text: "reply in thread", + MessageID: 20, + MessageThreadID: 15, + Chat: telego.Chat{ + ID: -100999, + Type: "supergroup", + IsForum: false, + }, + From: &telego.User{ + ID: 9, + FirstName: "Carol", + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok) + + // chatID should NOT include thread suffix for non-forum groups + assert.Equal(t, "-100999", inbound.ChatID) + + assert.Equal(t, "group", inbound.Context.ChatType) + assert.Empty(t, inbound.Context.TopicID) +} + +func assertHandleMessageQuotedUserReply( + t *testing.T, + chatID int64, + messageID int, + userID int64, + userName string, + userText string, + replyMessageID int, + replyText string, + replyCaption string, + replyAuthorID int64, + replyAuthorName string, + expectedContent string, +) { + t.Helper() + + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: userText, + MessageID: messageID, + Chat: telego.Chat{ + ID: chatID, + Type: "private", + }, + From: &telego.User{ + ID: userID, + FirstName: userName, + }, + ReplyToMessage: &telego.Message{ + MessageID: replyMessageID, + Text: replyText, + Caption: replyCaption, + From: &telego.User{ + ID: replyAuthorID, + FirstName: replyAuthorName, + }, + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok) + assert.Equal(t, strconv.Itoa(replyMessageID), inbound.Context.ReplyToMessageID) + assert.Equal(t, expectedContent, inbound.Content) +} + +func TestHandleMessage_ReplyToMessage_PrependsQuotedTextAndMetadata(t *testing.T) { + assertHandleMessageQuotedUserReply( + t, + 456, + 21, + 11, + "Alice", + "follow up", + 99, + "old context", + "", + 12, + "Bob", + "[quoted user message from Bob]: old context\n\nfollow up", + ) +} + +func TestHandleMessage_ReplyToMessage_UsesCaptionWhenQuotedTextMissing(t *testing.T) { + assertHandleMessageQuotedUserReply( + t, + 789, + 22, + 13, + "Carol", + "answer this", + 100, + "", + "caption context", + 14, + "Dave", + "[quoted user message from Dave]: caption context\n\nanswer this", + ) +} + +func TestHandleMessage_ReplyToOwnBotMessage_UsesAssistantRole(t *testing.T) { + messageBus := bus.NewMessageBus() + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + if strings.Contains(url, "getMe") { + return successUserResponse(t, &telego.User{ + ID: 42, + IsBot: true, + FirstName: "Pico", + Username: "afjcjsbx_picoclaw_bot", + }), nil + } + t.Fatalf("unexpected API call: %s", url) + return nil, nil + }, + } + ch := newTestChannel(t, caller) + ch.BaseChannel = channels.NewBaseChannel("telegram", nil, messageBus, nil) + ch.ctx = context.Background() + + msg := &telego.Message{ + Text: "ti ricordi questo file?", + MessageID: 23, + Chat: telego.Chat{ + ID: 999, + Type: "private", + }, + From: &telego.User{ + ID: 15, + FirstName: "Eve", + }, + ReplyToMessage: &telego.Message{ + MessageID: 101, + Text: "Fatto! Ho creato il file notizie_2026_03_28.md", + From: &telego.User{ + ID: 42, + IsBot: true, + FirstName: "Pico", + Username: "afjcjsbx_picoclaw_bot", + }, + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok) + assert.Equal(t, "101", inbound.Context.ReplyToMessageID) + assert.Equal( + t, + "[quoted assistant message from afjcjsbx_picoclaw_bot]: Fatto! Ho creato il file notizie_2026_03_28.md\n\nti ricordi questo file?", + inbound.Content, + ) +} + +func TestTelegramQuotedContent_IncludesVoiceMarkerAlongsideCaption(t *testing.T) { + msg := &telego.Message{ + Caption: "listen to this", + Voice: &telego.Voice{ + FileID: "voice-file", + }, + } + + assert.Equal(t, "listen to this\n[voice]", telegramQuotedContent(msg)) +} + +func TestQuotedTelegramMediaRefs_ResolvesQuotedAudioInOrder(t *testing.T) { + msg := &telego.Message{ + Voice: &telego.Voice{FileID: "voice-file"}, + Audio: &telego.Audio{FileID: "audio-file"}, + } + + var calls []string + refs := quotedTelegramMediaRefs(msg, func(fileID, ext, filename string) string { + calls = append(calls, fileID+"|"+ext+"|"+filename) + return "ref://" + filename + }) + + assert.Equal( + t, + []string{"voice-file|.ogg|voice.ogg", "audio-file|.mp3|audio.mp3"}, + calls, + ) + assert.Equal(t, []string{"ref://voice.ogg", "ref://audio.mp3"}, refs) +} + +func TestHandleMessage_EmptyContent_Ignored(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + // Service message with no text/caption/media (like ForumTopicCreated) + msg := &telego.Message{ + MessageID: 123, + Chat: telego.Chat{ + ID: 456, + Type: "group", + }, + From: &telego.User{ + ID: 789, + FirstName: "User", + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + // Should NOT publish to message bus + select { + case <-messageBus.InboundChan(): + t.Fatal("Empty message should not be published to message bus") + default: + } +} diff --git a/pkg/channels/telegram/testdata/md2_all_formats.txt b/pkg/channels/telegram/testdata/md2_all_formats.txt new file mode 100644 index 000000000..f78fcc72f --- /dev/null +++ b/pkg/channels/telegram/testdata/md2_all_formats.txt @@ -0,0 +1,31 @@ +*bold \*text* +_italic \*text_ +__underline__ +~strikethrough~ +||spoiler|| +*bold _italic bold ~italic bold strikethrough ||italic bold strikethrough spoiler||~ __underline italic bold___ bold* +[inline URL](http://www.example.com/) +[inline mention of a user](tg://user?id=123456789) +![👍](tg://emoji?id=5368324170671202286) +![22:45 tomorrow](tg://time?unix=1647531900&format=wDT) +![22:45 tomorrow](tg://time?unix=1647531900&format=t) +![22:45 tomorrow](tg://time?unix=1647531900&format=r) +![22:45 tomorrow](tg://time?unix=1647531900) +`inline fixed-width code` +``` +pre-formatted fixed-width code block +``` +```python +pre-formatted fixed-width code block written in the Python programming language +``` +>Block quotation started +>Block quotation continued +>Block quotation continued +>Block quotation continued +>The last line of the block quotation +**>The expandable block quotation started right after the previous block quotation +>It is separated from the previous block quotation by an empty bold entity +>Expandable block quotation continued +>Hidden by default part of the expandable block quotation started +>Expandable block quotation continued +>The last line of the expandable block quotation with the expandability mark|| diff --git a/pkg/channels/tool_feedback_animator.go b/pkg/channels/tool_feedback_animator.go new file mode 100644 index 000000000..b424612bf --- /dev/null +++ b/pkg/channels/tool_feedback_animator.go @@ -0,0 +1,240 @@ +package channels + +import ( + "context" + "strings" + "sync" + "time" +) + +const toolFeedbackAnimationInterval = 3 * time.Second + +const initialToolFeedbackAnimationFrame = "" + +var toolFeedbackAnimationFrames = []string{"..", "."} + +// MaxToolFeedbackAnimationFrameLength returns the largest frame suffix length +// so callers can reserve room before sending messages to length-limited APIs. +func MaxToolFeedbackAnimationFrameLength() int { + maxLen := len([]rune(initialToolFeedbackAnimationFrame)) + for _, frame := range toolFeedbackAnimationFrames { + if frameLen := len([]rune(frame)); frameLen > maxLen { + maxLen = frameLen + } + } + return maxLen +} + +type toolFeedbackAnimationState struct { + messageID string + baseContent string + stop chan struct{} + done chan struct{} +} + +type ToolFeedbackAnimator struct { + mu sync.Mutex + editFn func(ctx context.Context, chatID, messageID, content string) error + entries map[string]*toolFeedbackAnimationState +} + +func NewToolFeedbackAnimator( + editFn func(ctx context.Context, chatID, messageID, content string) error, +) *ToolFeedbackAnimator { + return &ToolFeedbackAnimator{ + editFn: editFn, + entries: make(map[string]*toolFeedbackAnimationState), + } +} + +func (a *ToolFeedbackAnimator) Current(chatID string) (string, bool) { + if a == nil || strings.TrimSpace(chatID) == "" { + return "", false + } + a.mu.Lock() + defer a.mu.Unlock() + entry, ok := a.entries[chatID] + if !ok || strings.TrimSpace(entry.messageID) == "" { + return "", false + } + return entry.messageID, true +} + +func (a *ToolFeedbackAnimator) Record(chatID, messageID, content string) { + if a == nil { + return + } + chatID = strings.TrimSpace(chatID) + messageID = strings.TrimSpace(messageID) + content = strings.TrimSpace(content) + if chatID == "" || messageID == "" || content == "" { + return + } + + entry := &toolFeedbackAnimationState{ + messageID: messageID, + baseContent: content, + stop: make(chan struct{}), + done: make(chan struct{}), + } + + var previous *toolFeedbackAnimationState + a.mu.Lock() + if old, ok := a.entries[chatID]; ok { + previous = old + } + a.entries[chatID] = entry + a.mu.Unlock() + + stopToolFeedbackAnimation(previous) + go a.run(chatID, entry) +} + +func (a *ToolFeedbackAnimator) Clear(chatID string) { + if a == nil || strings.TrimSpace(chatID) == "" { + return + } + entry := a.detach(chatID) + stopToolFeedbackAnimation(entry) +} + +func (a *ToolFeedbackAnimator) Take(chatID string) (string, string, bool) { + if a == nil || strings.TrimSpace(chatID) == "" { + return "", "", false + } + entry := a.detach(chatID) + if entry == nil || strings.TrimSpace(entry.messageID) == "" { + return "", "", false + } + stopToolFeedbackAnimation(entry) + return entry.messageID, entry.baseContent, true +} + +// Update edits an existing tracked feedback message. If the edit fails, the +// previous feedback state is restored so callers can retry without orphaning +// the old progress message. +func (a *ToolFeedbackAnimator) Update(ctx context.Context, chatID, content string) (string, bool, error) { + if a == nil || a.editFn == nil { + return "", false, nil + } + msgID, baseContent, ok := a.Take(chatID) + if !ok { + return "", false, nil + } + + animatedContent := InitialAnimatedToolFeedbackContent(content) + if err := a.editFn(ctx, strings.TrimSpace(chatID), msgID, animatedContent); err != nil { + a.Record(chatID, msgID, baseContent) + return "", true, err + } + + a.Record(chatID, msgID, content) + return msgID, true, nil +} + +func (a *ToolFeedbackAnimator) StopAll() { + if a == nil { + return + } + a.mu.Lock() + entries := make([]*toolFeedbackAnimationState, 0, len(a.entries)) + for chatID, entry := range a.entries { + entries = append(entries, entry) + delete(a.entries, chatID) + } + a.mu.Unlock() + + for _, entry := range entries { + stopToolFeedbackAnimation(entry) + } +} + +func (a *ToolFeedbackAnimator) detach(chatID string) *toolFeedbackAnimationState { + if a == nil || strings.TrimSpace(chatID) == "" { + return nil + } + a.mu.Lock() + defer a.mu.Unlock() + entry := a.entries[chatID] + delete(a.entries, chatID) + return entry +} + +func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationState) { + defer close(entry.done) + + ticker := time.NewTicker(toolFeedbackAnimationInterval) + defer ticker.Stop() + + frameIdx := 1 + + for { + select { + case <-entry.stop: + return + case <-ticker.C: + if a.editFn == nil { + continue + } + frame := toolFeedbackAnimationFrames[frameIdx%len(toolFeedbackAnimationFrames)] + content := formatAnimatedToolFeedbackContent(entry.baseContent, frame) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = a.editFn(ctx, chatID, entry.messageID, content) + cancel() + frameIdx++ + } + } +} + +func InitialAnimatedToolFeedbackContent(baseContent string) string { + return formatAnimatedToolFeedbackContent(baseContent, initialToolFeedbackAnimationFrame) +} + +func formatAnimatedToolFeedbackContent(baseContent, frame string) string { + baseContent = strings.TrimSpace(baseContent) + frame = strings.TrimSpace(frame) + if baseContent == "" { + return "" + } + if frame == "" { + return baseContent + } + lineBreak := strings.IndexByte(baseContent, '\n') + if lineBreak < 0 { + return appendToolFeedbackFrame(baseContent, frame) + } + return appendToolFeedbackFrame(baseContent[:lineBreak], frame) + baseContent[lineBreak:] +} + +func appendToolFeedbackFrame(firstLine, frame string) string { + firstLine = strings.TrimSpace(firstLine) + frame = strings.TrimSpace(frame) + if firstLine == "" { + return "" + } + if frame == "" { + return firstLine + } + + openTick := strings.IndexByte(firstLine, '`') + if openTick >= 0 { + if closeOffset := strings.IndexByte(firstLine[openTick+1:], '`'); closeOffset >= 0 { + closeTick := openTick + 1 + closeOffset + return firstLine[:closeTick] + frame + firstLine[closeTick:] + } + } + + return firstLine + frame +} + +func stopToolFeedbackAnimation(entry *toolFeedbackAnimationState) { + if entry == nil { + return + } + select { + case <-entry.stop: + default: + close(entry.stop) + } + <-entry.done +} diff --git a/pkg/channels/tool_feedback_animator_test.go b/pkg/channels/tool_feedback_animator_test.go new file mode 100644 index 000000000..a23284548 --- /dev/null +++ b/pkg/channels/tool_feedback_animator_test.go @@ -0,0 +1,121 @@ +package channels + +import ( + "context" + "errors" + "testing" +) + +func TestFormatAnimatedToolFeedbackContent(t *testing.T) { + got := formatAnimatedToolFeedbackContent("🔧 `read_file`\nReading config file", "running..") + want := "🔧 `read_filerunning..`\nReading config file" + if got != want { + t.Fatalf("formatAnimatedToolFeedbackContent() = %q, want %q", got, want) + } +} + +func TestInitialAnimatedToolFeedbackContent(t *testing.T) { + got := InitialAnimatedToolFeedbackContent("🔧 `exec`\nRunning command") + want := "🔧 `exec`\nRunning command" + if got != want { + t.Fatalf("InitialAnimatedToolFeedbackContent() = %q, want %q", got, want) + } +} + +func TestFormatAnimatedToolFeedbackContent_WithoutCodeSpan(t *testing.T) { + got := formatAnimatedToolFeedbackContent("hello", "running..") + want := "hellorunning.." + if got != want { + t.Fatalf("formatAnimatedToolFeedbackContent() without code span = %q, want %q", got, want) + } +} + +func TestToolFeedbackAnimator_RecordCurrentAndClear(t *testing.T) { + animator := NewToolFeedbackAnimator(nil) + animator.Record("chat-1", "msg-1", "🔧 `read_file`") + + msgID, ok := animator.Current("chat-1") + if !ok || msgID != "msg-1" { + t.Fatalf("Current() = (%q, %v), want (msg-1, true)", msgID, ok) + } + + animator.Clear("chat-1") + + msgID, ok = animator.Current("chat-1") + if ok || msgID != "" { + t.Fatalf("Current() after Clear = (%q, %v), want (\"\", false)", msgID, ok) + } +} + +func TestToolFeedbackAnimator_TakeStopsTrackingAndReturnsState(t *testing.T) { + animator := NewToolFeedbackAnimator(nil) + animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") + + msgID, baseContent, ok := animator.Take("chat-1") + if !ok { + t.Fatal("Take() = not found, want tracked message") + } + if msgID != "msg-1" { + t.Fatalf("Take() msgID = %q, want msg-1", msgID) + } + if baseContent != "🔧 `read_file`\nChecking config" { + t.Fatalf("Take() baseContent = %q", baseContent) + } + if _, ok := animator.Current("chat-1"); ok { + t.Fatal("expected tracked message to be removed after Take()") + } +} + +func TestToolFeedbackAnimator_UpdateStopsTrackingBeforeEdit(t *testing.T) { + var animator *ToolFeedbackAnimator + animator = NewToolFeedbackAnimator(func(_ context.Context, chatID, messageID, content string) error { + if _, ok := animator.Current(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if messageID != "msg-1" { + t.Fatalf("messageID = %q, want msg-1", messageID) + } + if content != "🔧 `write_file`\nUpdating config" { + t.Fatalf("content = %q, want updated animated content", content) + } + return nil + }) + defer animator.StopAll() + + animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") + + msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config") + if err != nil { + t.Fatalf("Update() error = %v", err) + } + if !handled { + t.Fatal("Update() handled = false, want true") + } + if msgID != "msg-1" { + t.Fatalf("Update() msgID = %q, want msg-1", msgID) + } +} + +func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) { + editErr := errors.New("edit failed") + animator := NewToolFeedbackAnimator(func(context.Context, string, string, string) error { + return editErr + }) + defer animator.StopAll() + + animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") + + msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config") + if !handled { + t.Fatal("Update() handled = false, want true") + } + if !errors.Is(err, editErr) { + t.Fatalf("Update() error = %v, want editErr", err) + } + if msgID != "" { + t.Fatalf("Update() msgID = %q, want empty on failed edit", msgID) + } + if currentID, ok := animator.Current("chat-1"); !ok || currentID != "msg-1" { + t.Fatalf("Current() after failed Update = (%q, %v), want (msg-1, true)", currentID, ok) + } +} diff --git a/pkg/channels/vk/init.go b/pkg/channels/vk/init.go new file mode 100644 index 000000000..deca297d5 --- /dev/null +++ b/pkg/channels/vk/init.go @@ -0,0 +1,20 @@ +package vk + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory( + config.ChannelVK, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + if bc == nil { + return nil, channels.ErrSendFailed + } + return NewVKChannel(channelName, bc, b) + }, + ) +} diff --git a/pkg/channels/vk/vk.go b/pkg/channels/vk/vk.go new file mode 100644 index 000000000..b27431ba0 --- /dev/null +++ b/pkg/channels/vk/vk.go @@ -0,0 +1,295 @@ +package vk + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/SevereCloud/vksdk/v3/api" + "github.com/SevereCloud/vksdk/v3/api/params" + "github.com/SevereCloud/vksdk/v3/events" + "github.com/SevereCloud/vksdk/v3/longpoll-bot" + "github.com/SevereCloud/vksdk/v3/object" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type VKChannel struct { + *channels.BaseChannel + vk *api.VK + lp *longpoll.LongPoll + channelName string + bc *config.Channel + ctx context.Context + cancel context.CancelFunc +} + +func NewVKChannel(channelName string, bc *config.Channel, bus *bus.MessageBus) (*VKChannel, error) { + var vkCfg config.VKSettings + if err := bc.Decode(&vkCfg); err != nil { + return nil, err + } + + vk := api.NewVK(vkCfg.Token.String()) + + base := channels.NewBaseChannel( + channelName, + &vkCfg, + bus, + bc.AllowFrom, + channels.WithMaxMessageLength(4000), + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), + ) + + return &VKChannel{ + BaseChannel: base, + vk: vk, + channelName: channelName, + bc: bc, + }, nil +} + +func (c *VKChannel) getVKCfg() *config.VKSettings { + var v config.VKSettings + if err := c.bc.Decode(&v); err != nil { + return nil + } + return &v +} + +func (c *VKChannel) Start(ctx context.Context) error { + logger.InfoC("vk", "Starting VK bot (Long Poll mode)...") + + c.ctx, c.cancel = context.WithCancel(ctx) + + groupID := c.getVKCfg().GroupID + if groupID == 0 { + c.cancel() + return fmt.Errorf("group_id is required for VK bot") + } + + lp, err := longpoll.NewLongPoll(c.vk, groupID) + if err != nil { + c.cancel() + return fmt.Errorf("failed to create long poll: %w", err) + } + c.lp = lp + + lp.MessageNew(func(_ context.Context, obj events.MessageNewObject) { + c.handleMessage(obj.Message) + }) + + c.SetRunning(true) + + logger.InfoCF("vk", "VK bot connected", map[string]any{ + "group_id": groupID, + }) + + go func() { + if err := lp.Run(); err != nil { + logger.ErrorCF("vk", "Long poll failed", map[string]any{ + "error": err.Error(), + }) + } + }() + + return nil +} + +func (c *VKChannel) Stop(ctx context.Context) error { + logger.InfoC("vk", "Stopping VK bot...") + c.SetRunning(false) + + if c.lp != nil { + c.lp.Shutdown() + } + + if c.cancel != nil { + c.cancel() + } + + return nil +} + +func (c *VKChannel) handleMessage(msg object.MessagesMessage) { + if msg.Action.Type != "" { + return + } + + if bool(msg.Out) { + return + } + + peerID := msg.PeerID + chatID := strconv.Itoa(peerID) + + fromID := msg.FromID + userID := strconv.Itoa(fromID) + + platformID := userID + sender := bus.SenderInfo{ + Platform: "vk", + PlatformID: platformID, + CanonicalID: identity.BuildCanonicalID("vk", platformID), + DisplayName: c.getUserName(fromID), + } + + if !c.IsAllowedSender(sender) { + logger.DebugCF("vk", "Message from unauthorized user", map[string]any{ + "peer_id": peerID, + }) + return + } + + text := msg.Text + if text == "" && len(msg.Attachments) > 0 { + text = c.processAttachments(msg.Attachments) + } + + if text == "" { + return + } + + groupTrigger := c.bc.GroupTrigger + isGroupChat := peerID != fromID + + if isGroupChat { + isMentioned := c.isMentioned(msg) + if isMentioned { + text = c.stripBotMention(text) + } + respond, cleaned := c.ShouldRespondInGroup(isMentioned, text) + if !respond { + return + } + text = cleaned + _ = groupTrigger + } + + chatType := "direct" + if isGroupChat { + chatType = "group" + } + + messageID := strconv.Itoa(msg.ConversationMessageID) + + metadata := map[string]string{ + "user_id": userID, + "is_group": fmt.Sprintf("%t", isGroupChat), + } + + c.HandleInboundContext(c.ctx, chatID, text, nil, bus.InboundContext{ + Channel: "vk", + ChatID: chatID, + ChatType: chatType, + SenderID: userID, + MessageID: messageID, + Mentioned: isGroupChat && c.isMentioned(msg), + Raw: metadata, + }, sender) +} + +func (c *VKChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + + peerID, err := strconv.Atoi(msg.ChatID) + if err != nil { + return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) + } + + if msg.Content == "" { + return nil, nil + } + + var messageIDs []string + chunks := channels.SplitMessage(msg.Content, 4000) + + for _, chunk := range chunks { + if chunk == "" { + continue + } + + b := params.NewMessagesSendBuilder() + b.Message(chunk) + b.RandomID(0) + b.PeerID(peerID) + + if msg.ReplyToMessageID != "" { + if replyID, err := strconv.Atoi(msg.ReplyToMessageID); err == nil { + b.ReplyTo(replyID) + } + } + + resp, err := c.vk.MessagesSend(b.Params) + if err != nil { + logger.ErrorCF("vk", "Failed to send message", map[string]any{ + "error": err.Error(), + "peer_id": peerID, + }) + return messageIDs, fmt.Errorf("failed to send message: %w", err) + } + + messageIDs = append(messageIDs, strconv.Itoa(resp)) + } + + return messageIDs, nil +} + +func (c *VKChannel) isMentioned(msg object.MessagesMessage) bool { + return false +} + +func (c *VKChannel) stripBotMention(text string) string { + return strings.TrimSpace(text) +} + +func (c *VKChannel) getUserName(userID int) string { + users, err := c.vk.UsersGet(api.Params{ + "user_ids": userID, + }) + if err != nil || len(users) == 0 { + return strconv.Itoa(userID) + } + + user := users[0] + return fmt.Sprintf("%s %s", user.FirstName, user.LastName) +} + +func (c *VKChannel) processAttachments(attachments []object.MessagesMessageAttachment) string { + var parts []string + + for _, att := range attachments { + switch att.Type { + case "photo": + parts = append(parts, "[photo]") + case "video": + parts = append(parts, "[video]") + case "audio": + parts = append(parts, "[audio]") + case "doc": + if att.Doc.Title != "" { + parts = append(parts, fmt.Sprintf("[document: %s]", att.Doc.Title)) + } else { + parts = append(parts, "[document]") + } + case "audio_message": + parts = append(parts, "[voice]") + case "sticker": + parts = append(parts, "[sticker]") + } + } + + return strings.Join(parts, " ") +} + +func (c *VKChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/vk/vk_test.go b/pkg/channels/vk/vk_test.go new file mode 100644 index 000000000..9583cbf44 --- /dev/null +++ b/pkg/channels/vk/vk_test.go @@ -0,0 +1,252 @@ +package vk + +import ( + "encoding/json" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func makeVKTestBaseChannel(vkCfg config.VKSettings) *config.Channel { + settings, _ := json.Marshal(vkCfg) + return &config.Channel{ + Enabled: true, + Type: config.ChannelVK, + Settings: settings, + } +} + +func TestNewVKChannel(t *testing.T) { + msgBus := bus.NewMessageBus() + + t.Run("missing group_id", func(t *testing.T) { + bc := makeVKTestBaseChannel(config.VKSettings{ + Token: *config.NewSecureString("test_token"), + }) + ch, err := NewVKChannel("vk", bc, msgBus) + if err != nil { + t.Fatalf("unexpected error during creation: %v", err) + } + if ch.Name() != "vk" { + t.Errorf("Name() = %q, want %q", ch.Name(), "vk") + } + if ch.IsRunning() { + t.Error("new channel should not be running") + } + }) + + t.Run("valid config with group_id", func(t *testing.T) { + bc := makeVKTestBaseChannel(config.VKSettings{ + Token: *config.NewSecureString("test_token"), + GroupID: 123456789, + }) + ch, err := NewVKChannel("vk", bc, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ch.Name() != "vk" { + t.Errorf("Name() = %q, want %q", ch.Name(), "vk") + } + if ch.IsRunning() { + t.Error("new channel should not be running") + } + }) + + t.Run("with allow_from", func(t *testing.T) { + vkCfg := config.VKSettings{ + Token: *config.NewSecureString("test_token"), + GroupID: 123456789, + } + settings, _ := json.Marshal(vkCfg) + bc := &config.Channel{ + Enabled: true, + Type: "vk", + AllowFrom: []string{"123456789"}, + Settings: settings, + } + ch, err := NewVKChannel("vk", bc, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ch.IsAllowedSender(bus.SenderInfo{PlatformID: "123456789"}) { + t.Error("user 123456789 should be allowed") + } + if ch.IsAllowedSender(bus.SenderInfo{PlatformID: "999999999"}) { + t.Error("user 999999999 should not be allowed") + } + }) + + t.Run("with group_trigger", func(t *testing.T) { + vkCfg := config.VKSettings{ + Token: *config.NewSecureString("test_token"), + GroupID: 123456789, + } + settings, _ := json.Marshal(vkCfg) + bc := &config.Channel{ + Enabled: true, + Type: "vk", + GroupTrigger: config.GroupTriggerConfig{ + MentionOnly: false, + Prefixes: []string{"/bot", "!bot"}, + }, + Settings: settings, + } + ch, err := NewVKChannel("vk", bc, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ch.Name() != "vk" { + t.Errorf("Name() = %q, want %q", ch.Name(), "vk") + } + }) +} + +func TestVKChannel_MaxMessageLength(t *testing.T) { + msgBus := bus.NewMessageBus() + bc := makeVKTestBaseChannel(config.VKSettings{ + Token: *config.NewSecureString("test_token"), + GroupID: 123456789, + }) + ch, err := NewVKChannel("vk", bc, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + maxLen := ch.MaxMessageLength() + if maxLen != 4000 { + t.Errorf("MaxMessageLength() = %d, want 4000", maxLen) + } +} + +func TestVKChannel_SplitMessage(t *testing.T) { + tests := []struct { + name string + content string + maxLen int + want int + }{ + { + name: "short message", + content: "hello", + maxLen: 4000, + want: 1, + }, + { + name: "exact length", + content: string(make([]byte, 4000)), + maxLen: 4000, + want: 1, + }, + { + name: "needs split", + content: string(make([]byte, 5000)), + maxLen: 4000, + want: 2, + }, + { + name: "empty message", + content: "", + maxLen: 4000, + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := channels.SplitMessage(tt.content, tt.maxLen) + if len(got) != tt.want { + t.Errorf("SplitMessage() got %d parts, want %d parts", len(got), tt.want) + } + }) + } +} + +func TestVKChannel_ProcessAttachments(t *testing.T) { + tests := []struct { + name string + attachments []string + want string + }{ + { + name: "empty attachments", + attachments: []string{}, + want: "", + }, + { + name: "photo attachment", + attachments: []string{"photo"}, + want: "[photo]", + }, + { + name: "video attachment", + attachments: []string{"video"}, + want: "[video]", + }, + { + name: "audio attachment", + attachments: []string{"audio"}, + want: "[audio]", + }, + { + name: "document attachment", + attachments: []string{"doc"}, + want: "[doc]", + }, + { + name: "sticker attachment", + attachments: []string{"sticker"}, + want: "[sticker]", + }, + { + name: "audio_message attachment", + attachments: []string{"audio_message"}, + want: "[voice]", + }, + { + name: "multiple attachments", + attachments: []string{"photo", "video", "audio"}, + want: "[photo] [video] [audio]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var result string + for i, att := range tt.attachments { + if i > 0 { + result += " " + } + if att == "audio_message" { + result += "[voice]" + } else { + result += "[" + att + "]" + } + } + if result != tt.want { + t.Errorf("processAttachments() = %q, want %q", result, tt.want) + } + }) + } +} + +func TestVKChannel_VoiceCapabilities(t *testing.T) { + msgBus := bus.NewMessageBus() + bc := makeVKTestBaseChannel(config.VKSettings{ + Token: *config.NewSecureString("test_token"), + GroupID: 123456789, + }) + ch, err := NewVKChannel("vk", bc, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + caps := ch.VoiceCapabilities() + if !caps.ASR { + t.Error("VoiceCapabilities().ASR should be true") + } + if !caps.TTS { + t.Error("VoiceCapabilities().TTS should be true") + } +} diff --git a/pkg/channels/voice_capabilities.go b/pkg/channels/voice_capabilities.go new file mode 100644 index 000000000..34fd24269 --- /dev/null +++ b/pkg/channels/voice_capabilities.go @@ -0,0 +1,58 @@ +package channels + +// VoiceCapabilities describes whether ASR (speech-to-text) and TTS (text-to-speech) +// are available for a channel under the current configuration. +type VoiceCapabilities struct { + ASR bool + TTS bool +} + +// VoiceCapabilityProvider is an optional interface for channels that want to +// explicitly declare their ASR/TTS support. +type VoiceCapabilityProvider interface { + VoiceCapabilities() VoiceCapabilities +} + +// Deprecated: Channels should implement VoiceCapabilityProvider instead. +// To be removed once all existing capable channels conform to the interface. +var asrCapableChannels = map[string]bool{ + "discord": true, + "telegram": true, + "matrix": true, + "qq": true, + "weixin": true, + "line": true, + "feishu": true, + "onebot": true, +} + +// DetectVoiceCapabilities returns ASR/TTS availability for a channel, gated by +// whether providers are configured. +func DetectVoiceCapabilities(channelName string, ch Channel, asrAvailable bool, ttsAvailable bool) VoiceCapabilities { + if ch == nil { + return VoiceCapabilities{} + } + + if vcp, ok := ch.(VoiceCapabilityProvider); ok { + caps := vcp.VoiceCapabilities() + if !asrAvailable { + caps.ASR = false + } + if !ttsAvailable { + caps.TTS = false + } + return caps + } + + caps := VoiceCapabilities{} + if asrAvailable { + caps.ASR = asrCapableChannels[channelName] + } + if ttsAvailable { + if _, ok := ch.(MediaSender); ok { + caps.TTS = true + } + } + + return caps +} diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go deleted file mode 100644 index 93fe8c36d..000000000 --- a/pkg/channels/wecom/aibot.go +++ /dev/null @@ -1,1017 +0,0 @@ -package wecom - -import ( - "bytes" - "context" - "crypto/rand" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "math/big" - "net/http" - "strings" - "sync" - "time" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/channels" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/identity" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" -) - -// WeComAIBotChannel implements the Channel interface for WeCom AI Bot (企业微信智能机器人) -type WeComAIBotChannel struct { - *channels.BaseChannel - config config.WeComAIBotConfig - ctx context.Context - cancel context.CancelFunc - streamTasks map[string]*streamTask // streamID -> task (for poll lookups) - chatTasks map[string][]*streamTask // chatID -> in-flight tasks queue (FIFO) - taskMu sync.RWMutex -} - -// streamTask represents a streaming task for AI Bot. -// -// Mutable fields (Finished, StreamClosed, StreamClosedAt) must be read/written -// while holding WeComAIBotChannel.taskMu. Immutable fields (StreamID, ChatID, -// ResponseURL, Question, CreatedTime, Deadline, answerCh, ctx, cancel) are set -// once at creation and never modified, so they are safe to read without a lock. -type streamTask struct { - // immutable after creation - StreamID string - ChatID string // used by Send() to find this task - ResponseURL string // temporary URL for proactive reply (valid 1 hour, use once) - Question string - CreatedTime time.Time - Deadline time.Time // ~30s, we close the stream here and switch to response_url - answerCh chan string // receives agent reply from Send() - ctx context.Context // canceled when task is removed; used to interrupt the agent goroutine - cancel context.CancelFunc // call on task removal to cancel ctx - - // mutable — guarded by WeComAIBotChannel.taskMu - StreamClosed bool // stream returned finish:true; waiting for agent to reply via response_url - StreamClosedAt time.Time // set when StreamClosed becomes true; used for accelerated cleanup - Finished bool // fully done -} - -// WeComAIBotMessage represents the decrypted JSON message from WeCom AI Bot -// Ref: https://developer.work.weixin.qq.com/document/path/100719 -type WeComAIBotMessage struct { - MsgID string `json:"msgid"` - AIBotID string `json:"aibotid"` - ChatID string `json:"chatid"` // only for group chat - ChatType string `json:"chattype"` // "single" or "group" - From struct { - UserID string `json:"userid"` - } `json:"from"` - ResponseURL string `json:"response_url"` // temporary URL for proactive reply - MsgType string `json:"msgtype"` - // text message - Text *struct { - Content string `json:"content"` - } `json:"text,omitempty"` - // stream polling refresh - Stream *struct { - ID string `json:"id"` - } `json:"stream,omitempty"` - // image message - Image *struct { - URL string `json:"url"` - } `json:"image,omitempty"` - // mixed message (text + image) - Mixed *struct { - MsgItem []struct { - MsgType string `json:"msgtype"` - Text *struct { - Content string `json:"content"` - } `json:"text,omitempty"` - Image *struct { - URL string `json:"url"` - } `json:"image,omitempty"` - } `json:"msg_item"` - } `json:"mixed,omitempty"` - // event field - Event *struct { - EventType string `json:"eventtype"` - } `json:"event,omitempty"` -} - -// WeComAIBotMsgItemImage holds the image payload inside a stream message item. -type WeComAIBotMsgItemImage struct { - Base64 string `json:"base64"` - MD5 string `json:"md5"` -} - -// WeComAIBotMsgItem is a single item inside a stream's msg_item list. -type WeComAIBotMsgItem struct { - MsgType string `json:"msgtype"` - Image *WeComAIBotMsgItemImage `json:"image,omitempty"` -} - -// WeComAIBotStreamInfo represents the detailed stream content in streaming responses. -type WeComAIBotStreamInfo struct { - ID string `json:"id"` - Finish bool `json:"finish"` - Content string `json:"content,omitempty"` - MsgItem []WeComAIBotMsgItem `json:"msg_item,omitempty"` -} - -// WeComAIBotStreamResponse represents the streaming response format -type WeComAIBotStreamResponse struct { - MsgType string `json:"msgtype"` - Stream WeComAIBotStreamInfo `json:"stream"` -} - -// WeComAIBotEncryptedResponse represents the encrypted response wrapper -// Fields match WXBizJsonMsgCrypt.generate() in Python SDK -type WeComAIBotEncryptedResponse struct { - Encrypt string `json:"encrypt"` - MsgSignature string `json:"msgsignature"` - Timestamp string `json:"timestamp"` - Nonce string `json:"nonce"` -} - -// NewWeComAIBotChannel creates a new WeCom AI Bot channel instance -func NewWeComAIBotChannel( - cfg config.WeComAIBotConfig, - messageBus *bus.MessageBus, -) (*WeComAIBotChannel, error) { - if cfg.Token == "" || cfg.EncodingAESKey == "" { - return nil, fmt.Errorf("token and encoding_aes_key are required for WeCom AI Bot") - } - - base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom, - channels.WithMaxMessageLength(2048), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), - ) - - return &WeComAIBotChannel{ - BaseChannel: base, - config: cfg, - streamTasks: make(map[string]*streamTask), - chatTasks: make(map[string][]*streamTask), - }, nil -} - -// Name returns the channel name -func (c *WeComAIBotChannel) Name() string { - return "wecom_aibot" -} - -// Start initializes the WeCom AI Bot channel -func (c *WeComAIBotChannel) Start(ctx context.Context) error { - logger.InfoC("wecom_aibot", "Starting WeCom AI Bot channel...") - - c.ctx, c.cancel = context.WithCancel(ctx) - - // Start cleanup goroutine for old tasks - go c.cleanupLoop() - - c.SetRunning(true) - logger.InfoC("wecom_aibot", "WeCom AI Bot channel started") - - return nil -} - -// Stop gracefully stops the WeCom AI Bot channel -func (c *WeComAIBotChannel) Stop(ctx context.Context) error { - logger.InfoC("wecom_aibot", "Stopping WeCom AI Bot channel...") - - if c.cancel != nil { - c.cancel() - } - - c.SetRunning(false) - logger.InfoC("wecom_aibot", "WeCom AI Bot channel stopped") - return nil -} - -// Send delivers the agent reply into the active streamTask for msg.ChatID. -// It writes into the earliest unfinished task in the queue (FIFO per chatID). -// If the stream has already closed (deadline passed), it posts directly to response_url. -func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return channels.ErrNotRunning - } - c.taskMu.Lock() - queue := c.chatTasks[msg.ChatID] - // Only compact Finished tasks at the head of the queue. - // Tasks that are Finished in the middle are NOT removed here: doing a full - // scan on every Send() call would be O(n) and is unnecessary given that - // removeTask() always splices the task out of the queue immediately. - // Any Finished task left stranded in the middle (e.g. due to an unexpected - // code path) will be collected by cleanupOldTasks. - for len(queue) > 0 && queue[0].Finished { - queue = queue[1:] - } - c.chatTasks[msg.ChatID] = queue - var task *streamTask - var streamClosed bool - var responseURL string - if len(queue) > 0 { - task = queue[0] - // Read mutable fields while holding c.taskMu to avoid data races. - streamClosed = task.StreamClosed - responseURL = task.ResponseURL - } - c.taskMu.Unlock() - - if task == nil { - logger.DebugCF( - "wecom_aibot", - "Send: no active task for chat (may have timed out)", - map[string]any{ - "chat_id": msg.ChatID, - }, - ) - return nil - } - - if streamClosed { - // Stream already ended with a "please wait" notice; send the real reply via response_url. - // Note: task.StreamID and task.ChatID are immutable, safe to read without a lock. - logger.InfoCF("wecom_aibot", "Sending reply via response_url", map[string]any{ - "stream_id": task.StreamID, - "chat_id": msg.ChatID, - }) - if responseURL != "" { - if err := c.sendViaResponseURL(responseURL, msg.Content); err != nil { - logger.ErrorCF("wecom_aibot", "Failed to send via response_url", map[string]any{ - "error": err, - "stream_id": task.StreamID, - }) - c.removeTask(task) - return fmt.Errorf("response_url delivery failed: %w", channels.ErrSendFailed) - } - } else { - logger.WarnCF("wecom_aibot", "Stream closed but no response_url available", map[string]any{ - "stream_id": task.StreamID, - }) - } - c.removeTask(task) - return nil - } - - // Stream still open: deliver via answerCh for the next poll response. - select { - case task.answerCh <- msg.Content: - case <-task.ctx.Done(): - // Task was canceled (cleanup removed it); silently drop the reply. - return nil - case <-ctx.Done(): - return ctx.Err() - } - return nil -} - -// WebhookPath returns the path for registering on the shared HTTP server -func (c *WeComAIBotChannel) WebhookPath() string { - if c.config.WebhookPath == "" { - return "/webhook/wecom-aibot" - } - return c.config.WebhookPath -} - -// ServeHTTP implements http.Handler for the shared HTTP server -func (c *WeComAIBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { - c.handleWebhook(w, r) -} - -// HealthPath returns the health check endpoint path -func (c *WeComAIBotChannel) HealthPath() string { - return c.WebhookPath() + "/health" -} - -// HealthHandler handles health check requests -func (c *WeComAIBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { - c.handleHealth(w, r) -} - -// handleWebhook handles incoming webhook requests from WeCom AI Bot -func (c *WeComAIBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Log all incoming requests for debugging - logger.DebugCF("wecom_aibot", "Received webhook request", map[string]any{ - "method": r.Method, - "path": r.URL.Path, - "query": r.URL.RawQuery, - }) - - switch r.Method { - case http.MethodGet: - // URL verification - c.handleVerification(ctx, w, r) - case http.MethodPost: - // Message callback - c.handleMessageCallback(ctx, w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// handleVerification handles the URL verification request from WeCom -func (c *WeComAIBotChannel) handleVerification( - ctx context.Context, - w http.ResponseWriter, - r *http.Request, -) { - msgSignature := r.URL.Query().Get("msg_signature") - timestamp := r.URL.Query().Get("timestamp") - nonce := r.URL.Query().Get("nonce") - echostr := r.URL.Query().Get("echostr") - - logger.DebugCF("wecom_aibot", "URL verification request", map[string]any{ - "msg_signature": msgSignature, - "timestamp": timestamp, - "nonce": nonce, - }) - - // Verify signature - if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { - logger.ErrorC("wecom_aibot", "Signature verification failed") - http.Error(w, "Signature verification failed", http.StatusUnauthorized) - return - } - - // Decrypt echostr - // For WeCom AI Bot (智能机器人), receiveid should be empty string - decrypted, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "") - if err != nil { - logger.ErrorCF("wecom_aibot", "Failed to decrypt echostr", map[string]any{ - "error": err, - }) - http.Error(w, "Decryption failed", http.StatusInternalServerError) - return - } - - // Remove BOM and whitespace as per WeCom documentation - decrypted = strings.TrimPrefix(decrypted, "\ufeff") - decrypted = strings.TrimSpace(decrypted) - - logger.InfoC("wecom_aibot", "URL verification successful") - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.WriteHeader(http.StatusOK) - w.Write([]byte(decrypted)) -} - -// handleMessageCallback handles incoming messages from WeCom AI Bot -func (c *WeComAIBotChannel) handleMessageCallback( - ctx context.Context, - w http.ResponseWriter, - r *http.Request, -) { - msgSignature := r.URL.Query().Get("msg_signature") - timestamp := r.URL.Query().Get("timestamp") - nonce := r.URL.Query().Get("nonce") - - // Read request body (limit to 4 MB to prevent memory exhaustion) - const maxBodySize = 4 << 20 // 4 MB - body, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize+1)) - if err != nil { - logger.ErrorCF("wecom_aibot", "Failed to read request body", map[string]any{ - "error": err, - }) - http.Error(w, "Failed to read body", http.StatusBadRequest) - return - } - if len(body) > maxBodySize { - http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge) - return - } - - // Parse JSON body to get encrypted message - // Format: {"encrypt": "base64_encrypted_string"} - var encryptedMsg struct { - Encrypt string `json:"encrypt"` - } - if unmarshalErr := json.Unmarshal(body, &encryptedMsg); unmarshalErr != nil { - logger.ErrorCF("wecom_aibot", "Failed to parse JSON body", map[string]any{ - "error": unmarshalErr, - "body": string(body), - }) - http.Error(w, "Failed to parse JSON", http.StatusBadRequest) - return - } - - // Verify signature - if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { - logger.ErrorC("wecom_aibot", "Signature verification failed") - http.Error(w, "Signature verification failed", http.StatusUnauthorized) - return - } - - // Decrypt message - // For WeCom AI Bot (智能机器人), receiveid is empty string - decrypted, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "") - if err != nil { - logger.ErrorCF("wecom_aibot", "Failed to decrypt message", map[string]any{ - "error": err, - }) - http.Error(w, "Decryption failed", http.StatusInternalServerError) - return - } - - // Parse decrypted JSON message - var msg WeComAIBotMessage - if unmarshalErr := json.Unmarshal([]byte(decrypted), &msg); unmarshalErr != nil { - logger.ErrorCF("wecom_aibot", "Failed to parse decrypted JSON", map[string]any{ - "error": unmarshalErr, - "decrypted": decrypted, - }) - http.Error(w, "Failed to parse message", http.StatusInternalServerError) - return - } - - logger.DebugCF("wecom_aibot", "Decrypted message", map[string]any{ - "msgtype": msg.MsgType, - }) - - // Process the message and get streaming response - response := c.processMessage(ctx, msg, timestamp, nonce) - - // Check if response is empty (e.g. due to unsupported message type) - if response == "" { - response = c.encryptEmptyResponse(timestamp, nonce) - } - - // Return encrypted JSON response - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusOK) - w.Write([]byte(response)) -} - -// processMessage processes the received message and returns encrypted response -func (c *WeComAIBotChannel) processMessage( - ctx context.Context, - msg WeComAIBotMessage, - timestamp, nonce string, -) string { - logger.DebugCF("wecom_aibot", "Processing message", map[string]any{ - "msgtype": msg.MsgType, - }) - - switch msg.MsgType { - case "text": - return c.handleTextMessage(ctx, msg, timestamp, nonce) - case "stream": - return c.handleStreamMessage(ctx, msg, timestamp, nonce) - case "image": - return c.handleImageMessage(ctx, msg, timestamp, nonce) - case "mixed": - return c.handleMixedMessage(ctx, msg, timestamp, nonce) - case "event": - return c.handleEventMessage(ctx, msg, timestamp, nonce) - default: - logger.WarnCF("wecom_aibot", "Unsupported message type", map[string]any{ - "msgtype": msg.MsgType, - }) - return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ - MsgType: "stream", - Stream: WeComAIBotStreamInfo{ - ID: c.generateStreamID(), - Finish: true, - Content: "Unsupported message type: " + msg.MsgType, - }, - }) - } -} - -// handleTextMessage handles text messages by starting a new streaming task -func (c *WeComAIBotChannel) handleTextMessage( - ctx context.Context, - msg WeComAIBotMessage, - timestamp, nonce string, -) string { - if msg.Text == nil { - logger.ErrorC("wecom_aibot", "text message missing text field") - return c.encryptEmptyResponse(timestamp, nonce) - } - - content := msg.Text.Content - userID := msg.From.UserID - if userID == "" { - userID = "unknown" - } - - // chatID: group chat uses chatid, single chat uses userid - chatID := msg.ChatID - if chatID == "" { - chatID = userID - } - - streamID := c.generateStreamID() - - // WeCom stops sending stream-refresh callbacks after 6 minutes. - // Set a slightly shorter deadline so we can send a timeout notice before it gives up. - deadline := time.Now().Add(30 * time.Second) - - // Each task gets its own context derived from the channel lifetime context. - // Canceling taskCancel interrupts the agent goroutine when the task is removed. - taskCtx, taskCancel := context.WithCancel(c.ctx) - - task := &streamTask{ - StreamID: streamID, - ChatID: chatID, - ResponseURL: msg.ResponseURL, - Question: content, - CreatedTime: time.Now(), - Deadline: deadline, - Finished: false, - answerCh: make(chan string, 1), - ctx: taskCtx, - cancel: taskCancel, - } - - c.taskMu.Lock() - c.streamTasks[streamID] = task - c.chatTasks[chatID] = append(c.chatTasks[chatID], task) - c.taskMu.Unlock() - - // Publish to agent asynchronously; agent will call Send() with reply. - // Use task.ctx (not c.ctx) so the agent goroutine is canceled when the task is removed. - go func() { - sender := bus.SenderInfo{ - Platform: "wecom_aibot", - PlatformID: userID, - CanonicalID: identity.BuildCanonicalID("wecom_aibot", userID), - DisplayName: userID, - } - peerKind := "direct" - if msg.ChatType == "group" { - peerKind = "group" - } - peer := bus.Peer{Kind: peerKind, ID: chatID} - metadata := map[string]string{ - "channel": "wecom_aibot", - "chat_type": msg.ChatType, - "msg_type": "text", - "msgid": msg.MsgID, - "aibotid": msg.AIBotID, - "stream_id": streamID, - "response_url": msg.ResponseURL, - } - c.HandleMessage(task.ctx, peer, msg.MsgID, userID, chatID, - content, nil, metadata, sender) - }() - - // Return first streaming response immediately (finish=false, content empty) - return c.getStreamResponse(task, timestamp, nonce) -} - -// handleStreamMessage handles stream polling requests -func (c *WeComAIBotChannel) handleStreamMessage( - ctx context.Context, - msg WeComAIBotMessage, - timestamp, nonce string, -) string { - if msg.Stream == nil { - logger.ErrorC("wecom_aibot", "Stream message missing stream field") - return c.encryptEmptyResponse(timestamp, nonce) - } - - streamID := msg.Stream.ID - - c.taskMu.RLock() - task, exists := c.streamTasks[streamID] - c.taskMu.RUnlock() - - if !exists { - logger.DebugCF( - "wecom_aibot", - "Stream task not found (may be from previous session)", - map[string]any{ - "stream_id": streamID, - }, - ) - return c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{ - MsgType: "stream", - Stream: WeComAIBotStreamInfo{ - ID: streamID, - Finish: true, - Content: "Task not found or already finished. Please resend your message to start a new session.", - }, - }) - } - - // Get next response - return c.getStreamResponse(task, timestamp, nonce) -} - -// handleImageMessage handles image messages -func (c *WeComAIBotChannel) handleImageMessage( - ctx context.Context, - msg WeComAIBotMessage, - timestamp, nonce string, -) string { - logger.WarnC("wecom_aibot", "Image message type not yet fully implemented") - if msg.Image == nil { - logger.ErrorC("wecom_aibot", "Image message missing image field") - return c.encryptEmptyResponse(timestamp, nonce) - } - - imageURL := msg.Image.URL - - // For now, just acknowledge receipt without echoing the image - return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ - MsgType: "stream", - Stream: WeComAIBotStreamInfo{ - ID: c.generateStreamID(), - Finish: true, - Content: fmt.Sprintf( - "Image received (URL: %s), but image messages are not yet supported", - imageURL, - ), - }, - }) -} - -// handleMixedMessage handles mixed (text + image) messages -func (c *WeComAIBotChannel) handleMixedMessage( - ctx context.Context, - msg WeComAIBotMessage, - timestamp, nonce string, -) string { - logger.WarnC("wecom_aibot", "Mixed message type not yet fully implemented") - return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ - MsgType: "stream", - Stream: WeComAIBotStreamInfo{ - ID: c.generateStreamID(), - Finish: true, - Content: "Mixed message type is not yet supported", - }, - }) -} - -// handleEventMessage handles event messages -func (c *WeComAIBotChannel) handleEventMessage( - ctx context.Context, - msg WeComAIBotMessage, - timestamp, nonce string, -) string { - eventType := "" - if msg.Event != nil { - eventType = msg.Event.EventType - } - logger.DebugCF("wecom_aibot", "Received event", map[string]any{ - "event_type": eventType, - }) - - // Send welcome message when user opens the chat window - if eventType == "enter_chat" && c.config.WelcomeMessage != "" { - streamID := c.generateStreamID() - return c.encryptResponse(streamID, timestamp, nonce, WeComAIBotStreamResponse{ - MsgType: "stream", - Stream: WeComAIBotStreamInfo{ - ID: streamID, - Finish: true, - Content: c.config.WelcomeMessage, - }, - }) - } - - return c.encryptEmptyResponse(timestamp, nonce) -} - -// getStreamResponse gets the next streaming response for a task. -// - If agent replied: return finish=true with the real answer. -// - If deadline passed: return finish=true with a "please wait" notice, keep task alive for response_url. -// - Otherwise: return finish=false (empty), client will poll again. -func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce string) string { - var content string - var finish bool - var closeStreamOnly bool // close stream but do NOT remove task (response_url still pending) - - select { - case answer := <-task.answerCh: - // Agent replied before deadline — normal finish. - content = answer - finish = true - default: - if time.Now().After(task.Deadline) { - // Deadline reached: close the stream with a notice, then wait for agent via response_url. - content = "⏳ Processing, please wait. The results will be sent shortly." - finish = true - closeStreamOnly = true - logger.InfoCF( - "wecom_aibot", - "Stream deadline reached, switching to response_url mode", - map[string]any{ - "stream_id": task.StreamID, - "chat_id": task.ChatID, - "response_url": task.ResponseURL != "", - }, - ) - } - // else: still waiting, return finish=false - } - - if finish && !closeStreamOnly { - // Normal finish: remove from all maps. - c.removeTask(task) - } else if closeStreamOnly { - // Mark stream as closed and remove from streamTasks under a single lock - // to keep StreamClosed/StreamClosedAt consistent with map membership. - c.taskMu.Lock() - task.StreamClosed = true - task.StreamClosedAt = time.Now() - delete(c.streamTasks, task.StreamID) - c.taskMu.Unlock() - } - - response := WeComAIBotStreamResponse{ - MsgType: "stream", - Stream: WeComAIBotStreamInfo{ - ID: task.StreamID, - Finish: finish, - Content: content, - }, - } - - return c.encryptResponse(task.StreamID, timestamp, nonce, response) -} - -// removeTask removes a task from both streamTasks and chatTasks, marks it finished, -// and cancels its context to interrupt the associated agent goroutine. -func (c *WeComAIBotChannel) removeTask(task *streamTask) { - // Cancel first so the agent goroutine stops as soon as possible, - // before we acquire the write lock. - task.cancel() - - c.taskMu.Lock() - task.Finished = true // written under c.taskMu, consistent with all readers - delete(c.streamTasks, task.StreamID) - queue := c.chatTasks[task.ChatID] - for i, t := range queue { - if t == task { - c.chatTasks[task.ChatID] = append(queue[:i], queue[i+1:]...) - break - } - } - if len(c.chatTasks[task.ChatID]) == 0 { - delete(c.chatTasks, task.ChatID) - } - c.taskMu.Unlock() -} - -// sendViaResponseURL posts a markdown reply to the WeCom response_url. -// response_url is valid for 1 hour and can only be used once per callback. -// Returned errors are wrapped with channels.ErrRateLimit, channels.ErrTemporary, -// or channels.ErrSendFailed so the manager can apply the right retry policy. -func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) error { - payload := map[string]any{ - "msgtype": "markdown", - "markdown": map[string]string{ - "content": content, - }, - } - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal payload: %w", err) - } - - ctx, cancel := context.WithTimeout(c.ctx, 15*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, responseURL, bytes.NewBuffer(body)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json; charset=utf-8") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("post to response_url failed: %w: %w", channels.ErrTemporary, err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusOK { - return nil - } - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("reading response_url body: %w: %w", channels.ErrTemporary, err) - } - switch { - case resp.StatusCode == http.StatusTooManyRequests: - return fmt.Errorf("response_url rate limited (%d): %s: %w", - resp.StatusCode, respBody, channels.ErrRateLimit) - case resp.StatusCode >= 500: - return fmt.Errorf("response_url server error (%d): %s: %w", - resp.StatusCode, respBody, channels.ErrTemporary) - default: - return fmt.Errorf("response_url returned %d: %s: %w", - resp.StatusCode, respBody, channels.ErrSendFailed) - } -} - -// encryptResponse encrypts a streaming response -func (c *WeComAIBotChannel) encryptResponse( - streamID, timestamp, nonce string, - response WeComAIBotStreamResponse, -) string { - // Marshal response to JSON - plaintext, err := json.Marshal(response) - if err != nil { - logger.ErrorCF("wecom_aibot", "Failed to marshal response", map[string]any{ - "error": err, - }) - return "" - } - - logger.DebugCF("wecom_aibot", "Encrypting response", map[string]any{ - "stream_id": streamID, - "finish": response.Stream.Finish, - "preview": utils.Truncate(response.Stream.Content, 100), - }) - - // Encrypt message - encrypted, err := c.encryptMessage(string(plaintext), "") - if err != nil { - logger.ErrorCF("wecom_aibot", "Failed to encrypt message", map[string]any{ - "error": err, - }) - return "" - } - - // Generate signature - signature := computeSignature(c.config.Token, timestamp, nonce, encrypted) - - // Build encrypted response - encryptedResp := WeComAIBotEncryptedResponse{ - Encrypt: encrypted, - MsgSignature: signature, - Timestamp: timestamp, - Nonce: nonce, - } - - respJSON, err := json.Marshal(encryptedResp) - if err != nil { - logger.ErrorCF("wecom_aibot", "Failed to marshal encrypted response", map[string]any{ - "error": err, - }) - return "" - } - - logger.DebugCF("wecom_aibot", "Response encrypted", map[string]any{ - "stream_id": streamID, - }) - - return string(respJSON) -} - -// encryptEmptyResponse returns a minimal valid encrypted response -func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string { - // Construct a zero-value stream response and encrypt it so that - // WeCom always receives a syntactically valid encrypted JSON object. - emptyResp := WeComAIBotStreamResponse{} - return c.encryptResponse("", timestamp, nonce, emptyResp) -} - -// encryptMessage encrypts a plain text message for WeCom AI Bot -func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string, error) { - aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey) - if err != nil { - return "", err - } - - frame, err := packWeComFrame(plaintext, receiveid) - if err != nil { - return "", err - } - - // PKCS7 padding then AES-CBC encrypt - paddedFrame := pkcs7Pad(frame, blockSize) - ciphertext, err := encryptAESCBC(aesKey, paddedFrame) - if err != nil { - return "", err - } - - return base64.StdEncoding.EncodeToString(ciphertext), nil -} - -// generateStreamID generates a random stream ID -func (c *WeComAIBotChannel) generateStreamID() string { - const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - b := make([]byte, 10) - for i := range b { - n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) - b[i] = letters[n.Int64()] - } - return string(b) -} - -// cleanupLoop periodically cleans up old streaming tasks -func (c *WeComAIBotChannel) cleanupLoop() { - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - c.cleanupOldTasks() - case <-c.ctx.Done(): - return - } - } -} - -// cleanupOldTasks removes tasks that have exceeded their expected lifetime: -// - Active tasks (in streamTasks): cleaned up after 1 hour (response_url validity window). -// - StreamClosed tasks (in chatTasks only): cleaned up after streamClosedGracePeriod. -// These tasks are waiting for the agent to call Send() via response_url. If the agent -// crashes or times out without calling Send(), we must not let them accumulate indefinitely. -// The grace period is generous enough to cover typical LLM latency but far shorter than 1 hour, -// preventing chatTasks from filling up when many requests time out in quick succession. -const ( - streamClosedGracePeriod = 10 * time.Minute // max wait for agent after stream closes - taskMaxLifetime = 1 * time.Hour // absolute max (≈ response_url validity) -) - -func (c *WeComAIBotChannel) cleanupOldTasks() { - c.taskMu.Lock() - defer c.taskMu.Unlock() - - now := time.Now() - cutoff := now.Add(-taskMaxLifetime) - for id, task := range c.streamTasks { - if task.CreatedTime.Before(cutoff) { - delete(c.streamTasks, id) - task.cancel() // interrupt agent goroutine still waiting for LLM - queue := c.chatTasks[task.ChatID] - for i, t := range queue { - if t == task { - c.chatTasks[task.ChatID] = append(queue[:i], queue[i+1:]...) - break - } - } - if len(c.chatTasks[task.ChatID]) == 0 { - delete(c.chatTasks, task.ChatID) - } - logger.DebugCF("wecom_aibot", "Cleaned up expired task", map[string]any{ - "stream_id": id, - }) - } - } - // Clean up StreamClosed tasks from chatTasks. - // Two expiry conditions are checked: - // 1. Absolute expiry: task was created more than taskMaxLifetime ago. - // 2. Grace expiry: stream closed more than streamClosedGracePeriod ago - // (agent had enough time to reply; it is not coming back). - for chatID, queue := range c.chatTasks { - filtered := queue[:0] - for i, t := range queue { - absoluteExpired := t.CreatedTime.Before(cutoff) - graceExpired := t.StreamClosed && - !t.StreamClosedAt.IsZero() && - t.StreamClosedAt.Before(now.Add(-streamClosedGracePeriod)) - if t.Finished { - // Finished tasks should have been removed by removeTask(). - // Finding one here (especially not at position 0) means an - // unexpected code path left it stranded, causing the queue to - // grow silently. Log a warning so it is visible, then drop it. - if i > 0 { - logger.WarnCF("wecom_aibot", - "Found stranded Finished task in the middle of chatTasks queue; "+ - "this should not happen — removeTask() should have spliced it out", - map[string]any{ - "chat_id": chatID, - "stream_id": t.StreamID, - "position": i, - }) - } - // The task is already finished; its context was already canceled - // by removeTask(), so no further action is required. - continue - } else if !absoluteExpired && !graceExpired { - filtered = append(filtered, t) - } else { - t.cancel() // cancel any lingering agent goroutine - } - } - if len(filtered) == 0 { - delete(c.chatTasks, chatID) - } else { - c.chatTasks[chatID] = filtered - } - } -} - -// handleHealth handles health check requests -func (c *WeComAIBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) { - status := "ok" - if !c.IsRunning() { - status = "not running" - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{ - "status": status, - }) -} diff --git a/pkg/channels/wecom/aibot_test.go b/pkg/channels/wecom/aibot_test.go deleted file mode 100644 index 6f0664187..000000000 --- a/pkg/channels/wecom/aibot_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package wecom - -import ( - "context" - "testing" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" -) - -func TestNewWeComAIBotChannel(t *testing.T) { - t.Run("success with valid config", func(t *testing.T) { - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "testkey1234567890123456789012345678901234567", - WebhookPath: "/webhook/test", - } - - messageBus := bus.NewMessageBus() - ch, err := NewWeComAIBotChannel(cfg, messageBus) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - if ch == nil { - t.Fatal("Expected channel to be created") - } - - if ch.Name() != "wecom_aibot" { - t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name()) - } - }) - - t.Run("error with missing token", func(t *testing.T) { - cfg := config.WeComAIBotConfig{ - Enabled: true, - EncodingAESKey: "testkey1234567890123456789012345678901234567", - } - - messageBus := bus.NewMessageBus() - _, err := NewWeComAIBotChannel(cfg, messageBus) - - if err == nil { - t.Fatal("Expected error for missing token, got nil") - } - }) - - t.Run("error with missing encoding key", func(t *testing.T) { - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - } - - messageBus := bus.NewMessageBus() - _, err := NewWeComAIBotChannel(cfg, messageBus) - - if err == nil { - t.Fatal("Expected error for missing encoding key, got nil") - } - }) -} - -func TestWeComAIBotChannelStartStop(t *testing.T) { - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "testkey1234567890123456789012345678901234567", - } - - messageBus := bus.NewMessageBus() - ch, err := NewWeComAIBotChannel(cfg, messageBus) - if err != nil { - t.Fatalf("Failed to create channel: %v", err) - } - - ctx := context.Background() - - // Test Start - if err := ch.Start(ctx); err != nil { - t.Fatalf("Failed to start channel: %v", err) - } - - if !ch.IsRunning() { - t.Error("Expected channel to be running") - } - - // Test Stop - if err := ch.Stop(ctx); err != nil { - t.Fatalf("Failed to stop channel: %v", err) - } - - if ch.IsRunning() { - t.Error("Expected channel to be stopped") - } -} - -func TestWeComAIBotChannelWebhookPath(t *testing.T) { - t.Run("default path", func(t *testing.T) { - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "testkey1234567890123456789012345678901234567", - } - - messageBus := bus.NewMessageBus() - ch, _ := NewWeComAIBotChannel(cfg, messageBus) - - expectedPath := "/webhook/wecom-aibot" - if ch.WebhookPath() != expectedPath { - t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, ch.WebhookPath()) - } - }) - - t.Run("custom path", func(t *testing.T) { - customPath := "/custom/webhook" - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "testkey1234567890123456789012345678901234567", - WebhookPath: customPath, - } - - messageBus := bus.NewMessageBus() - ch, _ := NewWeComAIBotChannel(cfg, messageBus) - - if ch.WebhookPath() != customPath { - t.Errorf("Expected webhook path '%s', got '%s'", customPath, ch.WebhookPath()) - } - }) -} - -func TestGenerateStreamID(t *testing.T) { - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "testkey1234567890123456789012345678901234567", - } - - messageBus := bus.NewMessageBus() - ch, _ := NewWeComAIBotChannel(cfg, messageBus) - - // Generate multiple IDs and check they are unique - ids := make(map[string]bool) - for i := 0; i < 100; i++ { - id := ch.generateStreamID() - - if len(id) != 10 { - t.Errorf("Expected stream ID length 10, got %d", len(id)) - } - - if ids[id] { - t.Errorf("Duplicate stream ID generated: %s", id) - } - ids[id] = true - } -} - -func TestEncryptDecrypt(t *testing.T) { - // Use a valid 43-character base64 key (企业微信标准格式) - cfg := config.WeComAIBotConfig{ - Enabled: true, - Token: "test_token", - EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters - } - - messageBus := bus.NewMessageBus() - ch, _ := NewWeComAIBotChannel(cfg, messageBus) - - plaintext := "Hello, World!" - receiveid := "" - - // Encrypt - encrypted, err := ch.encryptMessage(plaintext, receiveid) - if err != nil { - t.Fatalf("Failed to encrypt message: %v", err) - } - - if encrypted == "" { - t.Fatal("Encrypted message is empty") - } - - // Decrypt - decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid) - if err != nil { - t.Fatalf("Failed to decrypt message: %v", err) - } - - if decrypted != plaintext { - t.Errorf("Expected decrypted message '%s', got '%s'", plaintext, decrypted) - } -} - -func TestGenerateSignature(t *testing.T) { - token := "test_token" - timestamp := "1234567890" - nonce := "test_nonce" - encrypt := "encrypted_msg" - - signature := computeSignature(token, timestamp, nonce, encrypt) - - if signature == "" { - t.Error("Generated signature is empty") - } - - // Verify signature using verifySignature function - if !verifySignature(token, signature, timestamp, nonce, encrypt) { - t.Error("Generated signature does not verify correctly") - } -} diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go deleted file mode 100644 index 2098fcd4e..000000000 --- a/pkg/channels/wecom/app.go +++ /dev/null @@ -1,756 +0,0 @@ -package wecom - -import ( - "bytes" - "context" - "encoding/json" - "encoding/xml" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/channels" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/identity" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" -) - -const ( - wecomAPIBase = "https://qyapi.weixin.qq.com" -) - -// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用) -type WeComAppChannel struct { - *channels.BaseChannel - config config.WeComAppConfig - client *http.Client - accessToken string - tokenExpiry time.Time - tokenMu sync.RWMutex - ctx context.Context - cancel context.CancelFunc - processedMsgs *MessageDeduplicator -} - -// WeComXMLMessage represents the XML message structure from WeCom -type WeComXMLMessage struct { - XMLName xml.Name `xml:"xml"` - ToUserName string `xml:"ToUserName"` - FromUserName string `xml:"FromUserName"` - CreateTime int64 `xml:"CreateTime"` - MsgType string `xml:"MsgType"` - Content string `xml:"Content"` - MsgId int64 `xml:"MsgId"` - AgentID int64 `xml:"AgentID"` - PicUrl string `xml:"PicUrl"` - MediaId string `xml:"MediaId"` - Format string `xml:"Format"` - ThumbMediaId string `xml:"ThumbMediaId"` - LocationX float64 `xml:"Location_X"` - LocationY float64 `xml:"Location_Y"` - Scale int `xml:"Scale"` - Label string `xml:"Label"` - Title string `xml:"Title"` - Description string `xml:"Description"` - Url string `xml:"Url"` - Event string `xml:"Event"` - EventKey string `xml:"EventKey"` -} - -// WeComTextMessage represents text message for sending -type WeComTextMessage struct { - ToUser string `json:"touser"` - MsgType string `json:"msgtype"` - AgentID int64 `json:"agentid"` - Text struct { - Content string `json:"content"` - } `json:"text"` - Safe int `json:"safe,omitempty"` -} - -// WeComMarkdownMessage represents markdown message for sending -type WeComMarkdownMessage struct { - ToUser string `json:"touser"` - MsgType string `json:"msgtype"` - AgentID int64 `json:"agentid"` - Markdown struct { - Content string `json:"content"` - } `json:"markdown"` -} - -// WeComImageMessage represents image message for sending -type WeComImageMessage struct { - ToUser string `json:"touser"` - MsgType string `json:"msgtype"` - AgentID int64 `json:"agentid"` - Image struct { - MediaID string `json:"media_id"` - } `json:"image"` -} - -// WeComAccessTokenResponse represents the access token API response -type WeComAccessTokenResponse struct { - ErrCode int `json:"errcode"` - ErrMsg string `json:"errmsg"` - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` -} - -// WeComSendMessageResponse represents the send message API response -type WeComSendMessageResponse struct { - ErrCode int `json:"errcode"` - ErrMsg string `json:"errmsg"` - InvalidUser string `json:"invaliduser"` - InvalidParty string `json:"invalidparty"` - InvalidTag string `json:"invalidtag"` -} - -// PKCS7Padding adds PKCS7 padding -type PKCS7Padding struct{} - -// NewWeComAppChannel creates a new WeCom App channel instance -func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) { - if cfg.CorpID == "" || cfg.CorpSecret == "" || cfg.AgentID == 0 { - return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required") - } - - base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom, - channels.WithMaxMessageLength(2048), - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), - ) - - // Client timeout must be >= the configured ReplyTimeout so the - // per-request context deadline is always the effective limit. - clientTimeout := 30 * time.Second - if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout { - clientTimeout = d - } - - ctx, cancel := context.WithCancel(context.Background()) - return &WeComAppChannel{ - BaseChannel: base, - config: cfg, - client: &http.Client{Timeout: clientTimeout}, - ctx: ctx, - cancel: cancel, - processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages), - }, nil -} - -// Name returns the channel name -func (c *WeComAppChannel) Name() string { - return "wecom_app" -} - -// Start initializes the WeCom App channel -func (c *WeComAppChannel) Start(ctx context.Context) error { - logger.InfoC("wecom_app", "Starting WeCom App channel...") - - // Cancel the context created in the constructor to avoid a resource leak. - if c.cancel != nil { - c.cancel() - } - c.ctx, c.cancel = context.WithCancel(ctx) - - // Get initial access token - if err := c.refreshAccessToken(); err != nil { - logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]any{ - "error": err.Error(), - }) - } - - // Start token refresh goroutine - go c.tokenRefreshLoop() - - c.SetRunning(true) - logger.InfoC("wecom_app", "WeCom App channel started") - - return nil -} - -// Stop gracefully stops the WeCom App channel -func (c *WeComAppChannel) Stop(ctx context.Context) error { - logger.InfoC("wecom_app", "Stopping WeCom App channel...") - - if c.cancel != nil { - c.cancel() - } - - c.SetRunning(false) - logger.InfoC("wecom_app", "WeCom App channel stopped") - return nil -} - -// Send sends a message to WeCom user proactively using access token -func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return channels.ErrNotRunning - } - - accessToken := c.getAccessToken() - if accessToken == "" { - return fmt.Errorf("no valid access token available") - } - - logger.DebugCF("wecom_app", "Sending message", map[string]any{ - "chat_id": msg.ChatID, - "preview": utils.Truncate(msg.Content, 100), - }) - - return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content) -} - -// SendMedia implements the channels.MediaSender interface. -func (c *WeComAppChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { - if !c.IsRunning() { - return channels.ErrNotRunning - } - - accessToken := c.getAccessToken() - if accessToken == "" { - return fmt.Errorf("no valid access token available: %w", channels.ErrTemporary) - } - - store := c.GetMediaStore() - if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) - } - - for _, part := range msg.Parts { - localPath, err := store.Resolve(part.Ref) - if err != nil { - logger.ErrorCF("wecom_app", "Failed to resolve media ref", map[string]any{ - "ref": part.Ref, - "error": err.Error(), - }) - continue - } - - // Map part type to WeCom media type - var mediaType string - switch part.Type { - case "image": - mediaType = "image" - case "audio": - mediaType = "voice" - case "video": - mediaType = "video" - default: - mediaType = "file" - } - - // Upload media to get media_id - mediaID, err := c.uploadMedia(ctx, accessToken, mediaType, localPath) - if err != nil { - logger.ErrorCF("wecom_app", "Failed to upload media", map[string]any{ - "type": mediaType, - "error": err.Error(), - }) - // Fallback: send caption as text - if part.Caption != "" { - _ = c.sendTextMessage(ctx, accessToken, msg.ChatID, part.Caption) - } - continue - } - - // Send media message using the media_id - if mediaType == "image" { - err = c.sendImageMessage(ctx, accessToken, msg.ChatID, mediaID) - } else { - // For non-image types, send as text fallback with caption - caption := part.Caption - if caption == "" { - caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename) - } - err = c.sendTextMessage(ctx, accessToken, msg.ChatID, caption) - } - - if err != nil { - return err - } - } - - return nil -} - -// uploadMedia uploads a local file to WeCom temporary media storage. -func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaType, localPath string) (string, error) { - apiURL := fmt.Sprintf("%s/cgi-bin/media/upload?access_token=%s&type=%s", - wecomAPIBase, url.QueryEscape(accessToken), url.QueryEscape(mediaType)) - - file, err := os.Open(localPath) - if err != nil { - return "", fmt.Errorf("failed to open file: %w", err) - } - defer file.Close() - - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - filename := filepath.Base(localPath) - formFile, err := writer.CreateFormFile("media", filename) - if err != nil { - return "", fmt.Errorf("failed to create form file: %w", err) - } - - if _, err = io.Copy(formFile, file); err != nil { - return "", fmt.Errorf("failed to copy file content: %w", err) - } - writer.Close() - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, body) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", writer.FormDataContentType()) - - resp, err := c.client.Do(req) - if err != nil { - return "", channels.ClassifyNetError(err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - respBody, readErr := io.ReadAll(resp.Body) - if readErr != nil { - return "", channels.ClassifySendError( - resp.StatusCode, - fmt.Errorf("reading wecom upload error response: %w", readErr), - ) - } - return "", channels.ClassifySendError( - resp.StatusCode, - fmt.Errorf("wecom upload error: %s", string(respBody)), - ) - } - - var result struct { - ErrCode int `json:"errcode"` - ErrMsg string `json:"errmsg"` - MediaID string `json:"media_id"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("failed to parse upload response: %w", err) - } - - if result.ErrCode != 0 { - return "", fmt.Errorf("upload API error: %s (code: %d)", result.ErrMsg, result.ErrCode) - } - - return result.MediaID, nil -} - -// sendWeComMessage marshals payload and POSTs it to the WeCom message API. -func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken string, payload any) error { - apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken) - - jsonData, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) - } - - timeout := c.config.ReplyTimeout - if timeout <= 0 { - timeout = 5 - } - - reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.client.Do(req) - if err != nil { - return channels.ClassifyNetError(err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - respBody, readErr := io.ReadAll(resp.Body) - if readErr != nil { - return channels.ClassifySendError( - resp.StatusCode, - fmt.Errorf("reading wecom_app error response: %w", readErr), - ) - } - return channels.ClassifySendError( - resp.StatusCode, - fmt.Errorf("wecom_app API error: %s", string(respBody)), - ) - } - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - var sendResp WeComSendMessageResponse - if err := json.Unmarshal(respBody, &sendResp); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if sendResp.ErrCode != 0 { - return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode) - } - - return nil -} - -// sendImageMessage sends an image message using a media_id. -func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error { - msg := WeComImageMessage{ - ToUser: userID, - MsgType: "image", - AgentID: c.config.AgentID, - } - msg.Image.MediaID = mediaID - return c.sendWeComMessage(ctx, accessToken, msg) -} - -// WebhookPath returns the path for registering on the shared HTTP server. -func (c *WeComAppChannel) WebhookPath() string { - if c.config.WebhookPath != "" { - return c.config.WebhookPath - } - return "/webhook/wecom-app" -} - -// ServeHTTP implements http.Handler for the shared HTTP server. -func (c *WeComAppChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { - c.handleWebhook(w, r) -} - -// HealthPath returns the health check endpoint path. -func (c *WeComAppChannel) HealthPath() string { - return "/health/wecom-app" -} - -// HealthHandler handles health check requests. -func (c *WeComAppChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { - c.handleHealth(w, r) -} - -// handleWebhook handles incoming webhook requests from WeCom -func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Log all incoming requests for debugging - logger.DebugCF("wecom_app", "Received webhook request", map[string]any{ - "method": r.Method, - "url": r.URL.String(), - "path": r.URL.Path, - "query": r.URL.RawQuery, - }) - - if r.Method == http.MethodGet { - // Handle verification request - c.handleVerification(ctx, w, r) - return - } - - if r.Method == http.MethodPost { - // Handle message callback - c.handleMessageCallback(ctx, w, r) - return - } - - logger.WarnCF("wecom_app", "Method not allowed", map[string]any{ - "method": r.Method, - }) - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) -} - -// handleVerification handles the URL verification request from WeCom -func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - msgSignature := query.Get("msg_signature") - timestamp := query.Get("timestamp") - nonce := query.Get("nonce") - echostr := query.Get("echostr") - - logger.DebugCF("wecom_app", "Handling verification request", map[string]any{ - "msg_signature": msgSignature, - "timestamp": timestamp, - "nonce": nonce, - "echostr": echostr, - "corp_id": c.config.CorpID, - }) - - if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" { - logger.ErrorC("wecom_app", "Missing parameters in verification request") - http.Error(w, "Missing parameters", http.StatusBadRequest) - return - } - - // Verify signature - if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { - logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{ - "token": c.config.Token, - "msg_signature": msgSignature, - "timestamp": timestamp, - "nonce": nonce, - }) - http.Error(w, "Invalid signature", http.StatusForbidden) - return - } - - logger.DebugC("wecom_app", "Signature verification passed") - - // Decrypt echostr with CorpID verification - // For WeCom App (自建应用), receiveid should be corp_id - logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{ - "encoding_aes_key": c.config.EncodingAESKey, - "corp_id": c.config.CorpID, - }) - decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID) - if err != nil { - logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{ - "error": err.Error(), - "encoding_aes_key": c.config.EncodingAESKey, - "corp_id": c.config.CorpID, - }) - http.Error(w, "Decryption failed", http.StatusInternalServerError) - return - } - - logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]any{ - "decrypted": decryptedEchoStr, - }) - - // Remove BOM and whitespace as per WeCom documentation - // The response must be plain text without quotes, BOM, or newlines - decryptedEchoStr = strings.TrimSpace(decryptedEchoStr) - decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM - w.Write([]byte(decryptedEchoStr)) -} - -// handleMessageCallback handles incoming messages from WeCom -func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - msgSignature := query.Get("msg_signature") - timestamp := query.Get("timestamp") - nonce := query.Get("nonce") - - if msgSignature == "" || timestamp == "" || nonce == "" { - http.Error(w, "Missing parameters", http.StatusBadRequest) - return - } - - // Read request body - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "Failed to read body", http.StatusBadRequest) - return - } - defer r.Body.Close() - - // Parse XML to get encrypted message - var encryptedMsg struct { - XMLName xml.Name `xml:"xml"` - ToUserName string `xml:"ToUserName"` - Encrypt string `xml:"Encrypt"` - AgentID string `xml:"AgentID"` - } - - if err = xml.Unmarshal(body, &encryptedMsg); err != nil { - logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Invalid XML", http.StatusBadRequest) - return - } - - // Verify signature - if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { - logger.WarnC("wecom_app", "Message signature verification failed") - http.Error(w, "Invalid signature", http.StatusForbidden) - return - } - - // Decrypt message with CorpID verification - // For WeCom App (自建应用), receiveid should be corp_id - decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID) - if err != nil { - logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Decryption failed", http.StatusInternalServerError) - return - } - - // Parse decrypted XML message - var msg WeComXMLMessage - if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil { - logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Invalid message format", http.StatusBadRequest) - return - } - - // Process the message with the channel's long-lived context (not the HTTP - // request context, which is canceled as soon as we return the response). - go c.processMessage(c.ctx, msg) - - // Return success response immediately - // WeCom App requires response within configured timeout (default 5 seconds) - w.Write([]byte("success")) -} - -// processMessage processes the received message -func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) { - // Skip non-text messages for now (can be extended) - if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" { - logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]any{ - "msg_type": msg.MsgType, - }) - return - } - - // Message deduplication: Use msg_id to prevent duplicate processing - // As per WeCom documentation, use msg_id for deduplication - msgID := fmt.Sprintf("%d", msg.MsgId) - if !c.processedMsgs.MarkMessageProcessed(msgID) { - logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{ - "msg_id": msgID, - }) - return - } - - senderID := msg.FromUserName - chatID := senderID // WeCom App uses user ID as chat ID for direct messages - - // Build metadata - // WeCom App only supports direct messages (private chat) - peer := bus.Peer{Kind: "direct", ID: senderID} - messageID := fmt.Sprintf("%d", msg.MsgId) - - metadata := map[string]string{ - "msg_type": msg.MsgType, - "msg_id": fmt.Sprintf("%d", msg.MsgId), - "agent_id": fmt.Sprintf("%d", msg.AgentID), - "platform": "wecom_app", - "media_id": msg.MediaId, - "create_time": fmt.Sprintf("%d", msg.CreateTime), - } - - content := msg.Content - - logger.DebugCF("wecom_app", "Received message", map[string]any{ - "sender_id": senderID, - "msg_type": msg.MsgType, - "preview": utils.Truncate(content, 50), - }) - - // Build sender info - appSender := bus.SenderInfo{ - Platform: "wecom", - PlatformID: senderID, - CanonicalID: identity.BuildCanonicalID("wecom", senderID), - } - - // Handle the message through the base channel - c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, appSender) -} - -// tokenRefreshLoop periodically refreshes the access token -func (c *WeComAppChannel) tokenRefreshLoop() { - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - - for { - select { - case <-c.ctx.Done(): - return - case <-ticker.C: - if err := c.refreshAccessToken(); err != nil { - logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]any{ - "error": err.Error(), - }) - } - } - } -} - -// refreshAccessToken gets a new access token from WeCom API -func (c *WeComAppChannel) refreshAccessToken() error { - apiURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s", - wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret)) - - resp, err := http.Get(apiURL) - if err != nil { - return fmt.Errorf("failed to request access token: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - var tokenResp WeComAccessTokenResponse - if err := json.Unmarshal(body, &tokenResp); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if tokenResp.ErrCode != 0 { - return fmt.Errorf("API error: %s (code: %d)", tokenResp.ErrMsg, tokenResp.ErrCode) - } - - c.tokenMu.Lock() - c.accessToken = tokenResp.AccessToken - c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second) // Refresh 5 minutes early - c.tokenMu.Unlock() - - logger.DebugC("wecom_app", "Access token refreshed successfully") - return nil -} - -// getAccessToken returns the current valid access token -func (c *WeComAppChannel) getAccessToken() string { - c.tokenMu.RLock() - defer c.tokenMu.RUnlock() - - if time.Now().After(c.tokenExpiry) { - return "" - } - - return c.accessToken -} - -// sendTextMessage sends a text message to a user. -func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, userID, content string) error { - msg := WeComTextMessage{ - ToUser: userID, - MsgType: "text", - AgentID: c.config.AgentID, - } - msg.Text.Content = content - return c.sendWeComMessage(ctx, accessToken, msg) -} - -// handleHealth handles health check requests -func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) { - status := map[string]any{ - "status": "ok", - "running": c.IsRunning(), - "has_token": c.getAccessToken() != "", - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(status) -} diff --git a/pkg/channels/wecom/app_test.go b/pkg/channels/wecom/app_test.go deleted file mode 100644 index 7f230494f..000000000 --- a/pkg/channels/wecom/app_test.go +++ /dev/null @@ -1,1069 +0,0 @@ -package wecom - -import ( - "bytes" - "context" - "crypto/aes" - "crypto/cipher" - "crypto/sha1" - "encoding/base64" - "encoding/binary" - "encoding/json" - "encoding/xml" - "fmt" - "net/http" - "net/http/httptest" - "sort" - "strings" - "testing" - "time" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" -) - -// generateTestAESKeyApp generates a valid test AES key for WeCom App -func generateTestAESKeyApp() string { - // AES key needs to be 32 bytes (256 bits) for AES-256 - key := make([]byte, 32) - for i := range key { - key[i] = byte(i + 1) - } - // Return base64 encoded key without padding - return base64.StdEncoding.EncodeToString(key)[:43] -} - -// encryptTestMessageApp encrypts a message for testing WeCom App -func encryptTestMessageApp(message, aesKey string) (string, error) { - // Decode AES key - key, err := base64.StdEncoding.DecodeString(aesKey + "=") - if err != nil { - return "", err - } - - // Prepare message: random(16) + msg_len(4) + msg + corp_id - random := make([]byte, 0, 16) - for i := range 16 { - random = append(random, byte(i+1)) - } - - msgBytes := []byte(message) - corpID := []byte("test_corp_id") - - msgLen := uint32(len(msgBytes)) - lenBytes := make([]byte, 4) - binary.BigEndian.PutUint32(lenBytes, msgLen) - - plainText := append(random, lenBytes...) - plainText = append(plainText, msgBytes...) - plainText = append(plainText, corpID...) - - // PKCS7 padding - blockSize := aes.BlockSize - padding := blockSize - len(plainText)%blockSize - padText := bytes.Repeat([]byte{byte(padding)}, padding) - plainText = append(plainText, padText...) - - // Encrypt - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - - mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize]) - cipherText := make([]byte, len(plainText)) - mode.CryptBlocks(cipherText, plainText) - - return base64.StdEncoding.EncodeToString(cipherText), nil -} - -// generateSignatureApp generates a signature for testing WeCom App -func generateSignatureApp(token, timestamp, nonce, msgEncrypt string) string { - params := []string{token, timestamp, nonce, msgEncrypt} - sort.Strings(params) - str := strings.Join(params, "") - hash := sha1.Sum([]byte(str)) - return fmt.Sprintf("%x", hash) -} - -func TestNewWeComAppChannel(t *testing.T) { - msgBus := bus.NewMessageBus() - - t.Run("missing corp_id", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "", - CorpSecret: "test_secret", - AgentID: 1000002, - } - _, err := NewWeComAppChannel(cfg, msgBus) - if err == nil { - t.Error("expected error for missing corp_id, got nil") - } - }) - - t.Run("missing corp_secret", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "", - AgentID: 1000002, - } - _, err := NewWeComAppChannel(cfg, msgBus) - if err == nil { - t.Error("expected error for missing corp_secret, got nil") - } - }) - - t.Run("missing agent_id", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 0, - } - _, err := NewWeComAppChannel(cfg, msgBus) - if err == nil { - t.Error("expected error for missing agent_id, got nil") - } - }) - - t.Run("valid config", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - AllowFrom: []string{"user1", "user2"}, - } - ch, err := NewWeComAppChannel(cfg, msgBus) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ch.Name() != "wecom_app" { - t.Errorf("Name() = %q, want %q", ch.Name(), "wecom_app") - } - if ch.IsRunning() { - t.Error("new channel should not be running") - } - }) -} - -func TestWeComAppChannelIsAllowed(t *testing.T) { - msgBus := bus.NewMessageBus() - - t.Run("empty allowlist allows all", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - AllowFrom: []string{}, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - if !ch.IsAllowed("any_user") { - t.Error("empty allowlist should allow all users") - } - }) - - t.Run("allowlist restricts users", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - AllowFrom: []string{"allowed_user"}, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - if !ch.IsAllowed("allowed_user") { - t.Error("allowed user should pass allowlist check") - } - if ch.IsAllowed("blocked_user") { - t.Error("non-allowed user should be blocked") - } - }) -} - -func TestWeComAppVerifySignature(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "test_token", - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - t.Run("valid signature", func(t *testing.T) { - timestamp := "1234567890" - nonce := "test_nonce" - msgEncrypt := "test_message" - expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt) - - if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) { - t.Error("valid signature should pass verification") - } - }) - - t.Run("invalid signature", func(t *testing.T) { - timestamp := "1234567890" - nonce := "test_nonce" - msgEncrypt := "test_message" - - if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) { - t.Error("invalid signature should fail verification") - } - }) - - t.Run("empty token skips verification", func(t *testing.T) { - cfgEmpty := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "", - } - chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus) - - if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { - t.Error("empty token should skip verification and return true") - } - }) -} - -func TestWeComAppDecryptMessage(t *testing.T) { - msgBus := bus.NewMessageBus() - - t.Run("decrypt without AES key", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: "", - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - // Without AES key, message should be base64 decoded only - plainText := "hello world" - encoded := base64.StdEncoding.EncodeToString([]byte(plainText)) - - result, err := decryptMessage(encoded, ch.config.EncodingAESKey) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result != plainText { - t.Errorf("decryptMessage() = %q, want %q", result, plainText) - } - }) - - t.Run("decrypt with AES key", func(t *testing.T) { - aesKey := generateTestAESKeyApp() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: aesKey, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - originalMsg := "Hello" - encrypted, err := encryptTestMessageApp(originalMsg, aesKey) - if err != nil { - t.Fatalf("failed to encrypt test message: %v", err) - } - - result, err := decryptMessage(encrypted, ch.config.EncodingAESKey) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result != originalMsg { - t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg) - } - }) - - t.Run("invalid base64", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: "", - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey) - if err == nil { - t.Error("expected error for invalid base64, got nil") - } - }) - - t.Run("invalid AES key", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: "invalid_key", - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey) - if err == nil { - t.Error("expected error for invalid AES key, got nil") - } - }) - - t.Run("ciphertext too short", func(t *testing.T) { - aesKey := generateTestAESKeyApp() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: aesKey, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - // Encrypt a very short message that results in ciphertext less than block size - shortData := make([]byte, 8) - _, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey) - if err == nil { - t.Error("expected error for short ciphertext, got nil") - } - }) -} - -func TestWeComAppHandleVerification(t *testing.T) { - msgBus := bus.NewMessageBus() - aesKey := generateTestAESKeyApp() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "test_token", - EncodingAESKey: aesKey, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - t.Run("valid verification request", func(t *testing.T) { - echostr := "test_echostr_123" - encryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey) - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignatureApp("test_token", timestamp, nonce, encryptedEchostr) - - req := httptest.NewRequest( - http.MethodGet, - "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, - nil, - ) - w := httptest.NewRecorder() - - ch.handleVerification(context.Background(), w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - if w.Body.String() != echostr { - t.Errorf("response body = %q, want %q", w.Body.String(), echostr) - } - }) - - t.Run("missing parameters", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature=sig×tamp=ts", nil) - w := httptest.NewRecorder() - - ch.handleVerification(context.Background(), w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) - } - }) - - t.Run("invalid signature", func(t *testing.T) { - echostr := "test_echostr" - encryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey) - timestamp := "1234567890" - nonce := "test_nonce" - - req := httptest.NewRequest( - http.MethodGet, - "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, - nil, - ) - w := httptest.NewRecorder() - - ch.handleVerification(context.Background(), w, req) - - if w.Code != http.StatusForbidden { - t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) - } - }) -} - -func TestWeComAppHandleMessageCallback(t *testing.T) { - msgBus := bus.NewMessageBus() - aesKey := generateTestAESKeyApp() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "test_token", - EncodingAESKey: aesKey, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - t.Run("valid message callback", func(t *testing.T) { - // Create XML message - xmlMsg := WeComXMLMessage{ - ToUserName: "corp_id", - FromUserName: "user123", - CreateTime: 1234567890, - MsgType: "text", - Content: "Hello World", - MsgId: 123456, - AgentID: 1000002, - } - xmlData, _ := xml.Marshal(xmlMsg) - - // Encrypt message - encrypted, _ := encryptTestMessageApp(string(xmlData), aesKey) - - // Create encrypted XML wrapper - encryptedWrapper := struct { - XMLName xml.Name `xml:"xml"` - Encrypt string `xml:"Encrypt"` - }{ - Encrypt: encrypted, - } - wrapperData, _ := xml.Marshal(encryptedWrapper) - - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignatureApp("test_token", timestamp, nonce, encrypted) - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, - bytes.NewReader(wrapperData), - ) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - if w.Body.String() != "success" { - t.Errorf("response body = %q, want %q", w.Body.String(), "success") - } - }) - - t.Run("missing parameters", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature=sig", nil) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) - } - }) - - t.Run("invalid XML", func(t *testing.T) { - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignatureApp("test_token", timestamp, nonce, "") - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, - strings.NewReader("invalid xml"), - ) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) - } - }) - - t.Run("invalid signature", func(t *testing.T) { - encryptedWrapper := struct { - XMLName xml.Name `xml:"xml"` - Encrypt string `xml:"Encrypt"` - }{ - Encrypt: "encrypted_data", - } - wrapperData, _ := xml.Marshal(encryptedWrapper) - - timestamp := "1234567890" - nonce := "test_nonce" - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, - bytes.NewReader(wrapperData), - ) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusForbidden { - t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) - } - }) -} - -func TestWeComAppProcessMessage(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - t.Run("process text message", func(t *testing.T) { - msg := WeComXMLMessage{ - ToUserName: "corp_id", - FromUserName: "user123", - CreateTime: 1234567890, - MsgType: "text", - Content: "Hello World", - MsgId: 123456, - AgentID: 1000002, - } - - // Should not panic - ch.processMessage(context.Background(), msg) - }) - - t.Run("process image message", func(t *testing.T) { - msg := WeComXMLMessage{ - ToUserName: "corp_id", - FromUserName: "user123", - CreateTime: 1234567890, - MsgType: "image", - PicUrl: "https://example.com/image.jpg", - MediaId: "media_123", - MsgId: 123456, - AgentID: 1000002, - } - - // Should not panic - ch.processMessage(context.Background(), msg) - }) - - t.Run("process voice message", func(t *testing.T) { - msg := WeComXMLMessage{ - ToUserName: "corp_id", - FromUserName: "user123", - CreateTime: 1234567890, - MsgType: "voice", - MediaId: "media_123", - Format: "amr", - MsgId: 123456, - AgentID: 1000002, - } - - // Should not panic - ch.processMessage(context.Background(), msg) - }) - - t.Run("skip unsupported message type", func(t *testing.T) { - msg := WeComXMLMessage{ - ToUserName: "corp_id", - FromUserName: "user123", - CreateTime: 1234567890, - MsgType: "video", - MsgId: 123456, - AgentID: 1000002, - } - - // Should not panic - ch.processMessage(context.Background(), msg) - }) - - t.Run("process event message", func(t *testing.T) { - msg := WeComXMLMessage{ - ToUserName: "corp_id", - FromUserName: "user123", - CreateTime: 1234567890, - MsgType: "event", - Event: "subscribe", - MsgId: 123456, - AgentID: 1000002, - } - - // Should not panic - ch.processMessage(context.Background(), msg) - }) -} - -func TestWeComAppHandleWebhook(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "test_token", - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - t.Run("GET request calls verification", func(t *testing.T) { - echostr := "test_echostr" - encoded := base64.StdEncoding.EncodeToString([]byte(echostr)) - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignatureApp("test_token", timestamp, nonce, encoded) - - req := httptest.NewRequest( - http.MethodGet, - "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, - nil, - ) - w := httptest.NewRecorder() - - ch.handleWebhook(w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - }) - - t.Run("POST request calls message callback", func(t *testing.T) { - encryptedWrapper := struct { - XMLName xml.Name `xml:"xml"` - Encrypt string `xml:"Encrypt"` - }{ - Encrypt: base64.StdEncoding.EncodeToString([]byte("test")), - } - wrapperData, _ := xml.Marshal(encryptedWrapper) - - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignatureApp("test_token", timestamp, nonce, encryptedWrapper.Encrypt) - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, - bytes.NewReader(wrapperData), - ) - w := httptest.NewRecorder() - - ch.handleWebhook(w, req) - - // Should not be method not allowed - if w.Code == http.StatusMethodNotAllowed { - t.Error("POST request should not return Method Not Allowed") - } - }) - - t.Run("unsupported method", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/webhook/wecom-app", nil) - w := httptest.NewRecorder() - - ch.handleWebhook(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed) - } - }) -} - -func TestWeComAppHandleHealth(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - req := httptest.NewRequest(http.MethodGet, "/health/wecom-app", nil) - w := httptest.NewRecorder() - - ch.handleHealth(w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - - contentType := w.Header().Get("Content-Type") - if contentType != "application/json" { - t.Errorf("Content-Type = %q, want %q", contentType, "application/json") - } - - body := w.Body.String() - if !strings.Contains(body, "status") || !strings.Contains(body, "running") || !strings.Contains(body, "has_token") { - t.Errorf("response body should contain status, running, and has_token fields, got: %s", body) - } -} - -func TestWeComAppAccessToken(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - t.Run("get empty access token initially", func(t *testing.T) { - token := ch.getAccessToken() - if token != "" { - t.Errorf("getAccessToken() = %q, want empty string", token) - } - }) - - t.Run("set and get access token", func(t *testing.T) { - ch.tokenMu.Lock() - ch.accessToken = "test_token_123" - ch.tokenExpiry = time.Now().Add(1 * time.Hour) - ch.tokenMu.Unlock() - - token := ch.getAccessToken() - if token != "test_token_123" { - t.Errorf("getAccessToken() = %q, want %q", token, "test_token_123") - } - }) - - t.Run("expired token returns empty", func(t *testing.T) { - ch.tokenMu.Lock() - ch.accessToken = "expired_token" - ch.tokenExpiry = time.Now().Add(-1 * time.Hour) - ch.tokenMu.Unlock() - - token := ch.getAccessToken() - if token != "" { - t.Errorf("getAccessToken() = %q, want empty string for expired token", token) - } - }) -} - -func TestWeComAppMessageStructures(t *testing.T) { - t.Run("WeComTextMessage structure", func(t *testing.T) { - msg := WeComTextMessage{ - ToUser: "user123", - MsgType: "text", - AgentID: 1000002, - } - msg.Text.Content = "Hello World" - - if msg.ToUser != "user123" { - t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123") - } - if msg.MsgType != "text" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") - } - if msg.AgentID != 1000002 { - t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002) - } - if msg.Text.Content != "Hello World" { - t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World") - } - - // Test JSON marshaling - jsonData, err := json.Marshal(msg) - if err != nil { - t.Fatalf("failed to marshal JSON: %v", err) - } - - var unmarshaled WeComTextMessage - err = json.Unmarshal(jsonData, &unmarshaled) - if err != nil { - t.Fatalf("failed to unmarshal JSON: %v", err) - } - - if unmarshaled.ToUser != msg.ToUser { - t.Errorf("JSON round-trip failed for ToUser") - } - }) - - t.Run("WeComMarkdownMessage structure", func(t *testing.T) { - msg := WeComMarkdownMessage{ - ToUser: "user123", - MsgType: "markdown", - AgentID: 1000002, - } - msg.Markdown.Content = "# Hello\nWorld" - - if msg.Markdown.Content != "# Hello\nWorld" { - t.Errorf("Markdown.Content = %q, want %q", msg.Markdown.Content, "# Hello\nWorld") - } - - // Test JSON marshaling - jsonData, err := json.Marshal(msg) - if err != nil { - t.Fatalf("failed to marshal JSON: %v", err) - } - - if !bytes.Contains(jsonData, []byte("markdown")) { - t.Error("JSON should contain 'markdown' field") - } - }) - - t.Run("WeComImageMessage structure", func(t *testing.T) { - msg := WeComImageMessage{ - ToUser: "user123", - MsgType: "image", - AgentID: 1000002, - } - msg.Image.MediaID = "media_123456" - - if msg.Image.MediaID != "media_123456" { - t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456") - } - if msg.ToUser != "user123" { - t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123") - } - if msg.MsgType != "image" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "image") - } - if msg.AgentID != 1000002 { - t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002) - } - }) - - t.Run("WeComAccessTokenResponse structure", func(t *testing.T) { - jsonData := `{ - "errcode": 0, - "errmsg": "ok", - "access_token": "test_access_token", - "expires_in": 7200 - }` - - var resp WeComAccessTokenResponse - err := json.Unmarshal([]byte(jsonData), &resp) - if err != nil { - t.Fatalf("failed to unmarshal JSON: %v", err) - } - - if resp.ErrCode != 0 { - t.Errorf("ErrCode = %d, want %d", resp.ErrCode, 0) - } - if resp.ErrMsg != "ok" { - t.Errorf("ErrMsg = %q, want %q", resp.ErrMsg, "ok") - } - if resp.AccessToken != "test_access_token" { - t.Errorf("AccessToken = %q, want %q", resp.AccessToken, "test_access_token") - } - if resp.ExpiresIn != 7200 { - t.Errorf("ExpiresIn = %d, want %d", resp.ExpiresIn, 7200) - } - }) - - t.Run("WeComSendMessageResponse structure", func(t *testing.T) { - jsonData := `{ - "errcode": 0, - "errmsg": "ok", - "invaliduser": "", - "invalidparty": "", - "invalidtag": "" - }` - - var resp WeComSendMessageResponse - err := json.Unmarshal([]byte(jsonData), &resp) - if err != nil { - t.Fatalf("failed to unmarshal JSON: %v", err) - } - - if resp.ErrCode != 0 { - t.Errorf("ErrCode = %d, want %d", resp.ErrCode, 0) - } - if resp.ErrMsg != "ok" { - t.Errorf("ErrMsg = %q, want %q", resp.ErrMsg, "ok") - } - }) -} - -func TestWeComAppXMLMessageStructure(t *testing.T) { - xmlData := ` - - - - 1234567890 - - - 1234567890123456 - 1000002 -` - - var msg WeComXMLMessage - err := xml.Unmarshal([]byte(xmlData), &msg) - if err != nil { - t.Fatalf("failed to unmarshal XML: %v", err) - } - - if msg.ToUserName != "corp_id" { - t.Errorf("ToUserName = %q, want %q", msg.ToUserName, "corp_id") - } - if msg.FromUserName != "user123" { - t.Errorf("FromUserName = %q, want %q", msg.FromUserName, "user123") - } - if msg.CreateTime != 1234567890 { - t.Errorf("CreateTime = %d, want %d", msg.CreateTime, 1234567890) - } - if msg.MsgType != "text" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") - } - if msg.Content != "Hello World" { - t.Errorf("Content = %q, want %q", msg.Content, "Hello World") - } - if msg.MsgId != 1234567890123456 { - t.Errorf("MsgId = %d, want %d", msg.MsgId, 1234567890123456) - } - if msg.AgentID != 1000002 { - t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002) - } -} - -func TestWeComAppXMLMessageImage(t *testing.T) { - xmlData := ` - - - - 1234567890 - - - - 1234567890123456 - 1000002 -` - - var msg WeComXMLMessage - err := xml.Unmarshal([]byte(xmlData), &msg) - if err != nil { - t.Fatalf("failed to unmarshal XML: %v", err) - } - - if msg.MsgType != "image" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "image") - } - if msg.PicUrl != "https://example.com/image.jpg" { - t.Errorf("PicUrl = %q, want %q", msg.PicUrl, "https://example.com/image.jpg") - } - if msg.MediaId != "media_123" { - t.Errorf("MediaId = %q, want %q", msg.MediaId, "media_123") - } -} - -func TestWeComAppXMLMessageVoice(t *testing.T) { - xmlData := ` - - - - 1234567890 - - - - 1234567890123456 - 1000002 -` - - var msg WeComXMLMessage - err := xml.Unmarshal([]byte(xmlData), &msg) - if err != nil { - t.Fatalf("failed to unmarshal XML: %v", err) - } - - if msg.MsgType != "voice" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "voice") - } - if msg.Format != "amr" { - t.Errorf("Format = %q, want %q", msg.Format, "amr") - } -} - -func TestWeComAppXMLMessageLocation(t *testing.T) { - xmlData := ` - - - - 1234567890 - - 39.9042 - 116.4074 - 16 - - 1234567890123456 - 1000002 -` - - var msg WeComXMLMessage - err := xml.Unmarshal([]byte(xmlData), &msg) - if err != nil { - t.Fatalf("failed to unmarshal XML: %v", err) - } - - if msg.MsgType != "location" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "location") - } - if msg.LocationX != 39.9042 { - t.Errorf("LocationX = %f, want %f", msg.LocationX, 39.9042) - } - if msg.LocationY != 116.4074 { - t.Errorf("LocationY = %f, want %f", msg.LocationY, 116.4074) - } - if msg.Scale != 16 { - t.Errorf("Scale = %d, want %d", msg.Scale, 16) - } - if msg.Label != "Beijing" { - t.Errorf("Label = %q, want %q", msg.Label, "Beijing") - } -} - -func TestWeComAppXMLMessageLink(t *testing.T) { - xmlData := ` - - - - 1234567890 - - <![CDATA[Link Title]]> - - - 1234567890123456 - 1000002 -` - - var msg WeComXMLMessage - err := xml.Unmarshal([]byte(xmlData), &msg) - if err != nil { - t.Fatalf("failed to unmarshal XML: %v", err) - } - - if msg.MsgType != "link" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "link") - } - if msg.Title != "Link Title" { - t.Errorf("Title = %q, want %q", msg.Title, "Link Title") - } - if msg.Description != "Link Description" { - t.Errorf("Description = %q, want %q", msg.Description, "Link Description") - } - if msg.Url != "https://example.com" { - t.Errorf("Url = %q, want %q", msg.Url, "https://example.com") - } -} - -func TestWeComAppXMLMessageEvent(t *testing.T) { - xmlData := ` - - - - 1234567890 - - - - 1000002 -` - - var msg WeComXMLMessage - err := xml.Unmarshal([]byte(xmlData), &msg) - if err != nil { - t.Fatalf("failed to unmarshal XML: %v", err) - } - - if msg.MsgType != "event" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "event") - } - if msg.Event != "subscribe" { - t.Errorf("Event = %q, want %q", msg.Event, "subscribe") - } - if msg.EventKey != "event_key_123" { - t.Errorf("EventKey = %q, want %q", msg.EventKey, "event_key_123") - } -} diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go deleted file mode 100644 index 96d5a961f..000000000 --- a/pkg/channels/wecom/bot.go +++ /dev/null @@ -1,499 +0,0 @@ -package wecom - -import ( - "bytes" - "context" - "encoding/json" - "encoding/xml" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/channels" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/identity" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" -) - -// WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人) -// Uses webhook callback mode - simpler than WeCom App but only supports passive replies -type WeComBotChannel struct { - *channels.BaseChannel - config config.WeComConfig - client *http.Client - ctx context.Context - cancel context.CancelFunc - processedMsgs *MessageDeduplicator -} - -// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT) -type WeComBotMessage struct { - MsgID string `json:"msgid"` - AIBotID string `json:"aibotid"` - ChatID string `json:"chatid"` // Session ID, only present for group chats - ChatType string `json:"chattype"` // "single" for DM, "group" for group chat - From struct { - UserID string `json:"userid"` - } `json:"from"` - ResponseURL string `json:"response_url"` - MsgType string `json:"msgtype"` // text, image, voice, file, mixed - Text struct { - Content string `json:"content"` - } `json:"text"` - Image struct { - URL string `json:"url"` - } `json:"image"` - Voice struct { - Content string `json:"content"` // Voice to text content - } `json:"voice"` - File struct { - URL string `json:"url"` - } `json:"file"` - Mixed struct { - MsgItem []struct { - MsgType string `json:"msgtype"` - Text struct { - Content string `json:"content"` - } `json:"text"` - Image struct { - URL string `json:"url"` - } `json:"image"` - } `json:"msg_item"` - } `json:"mixed"` - Quote struct { - MsgType string `json:"msgtype"` - Text struct { - Content string `json:"content"` - } `json:"text"` - } `json:"quote"` -} - -// WeComBotReplyMessage represents the reply message structure -type WeComBotReplyMessage struct { - MsgType string `json:"msgtype"` - Text struct { - Content string `json:"content"` - } `json:"text,omitempty"` -} - -// NewWeComBotChannel creates a new WeCom Bot channel instance -func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComBotChannel, error) { - if cfg.Token == "" || cfg.WebhookURL == "" { - return nil, fmt.Errorf("wecom token and webhook_url are required") - } - - base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom, - channels.WithMaxMessageLength(2048), - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), - ) - - // Client timeout must be >= the configured ReplyTimeout so the - // per-request context deadline is always the effective limit. - clientTimeout := 30 * time.Second - if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout { - clientTimeout = d - } - - ctx, cancel := context.WithCancel(context.Background()) - return &WeComBotChannel{ - BaseChannel: base, - config: cfg, - client: &http.Client{Timeout: clientTimeout}, - ctx: ctx, - cancel: cancel, - processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages), - }, nil -} - -// Name returns the channel name -func (c *WeComBotChannel) Name() string { - return "wecom" -} - -// Start initializes the WeCom Bot channel -func (c *WeComBotChannel) Start(ctx context.Context) error { - logger.InfoC("wecom", "Starting WeCom Bot channel...") - - // Cancel the context created in the constructor to avoid a resource leak. - if c.cancel != nil { - c.cancel() - } - c.ctx, c.cancel = context.WithCancel(ctx) - - c.SetRunning(true) - logger.InfoC("wecom", "WeCom Bot channel started") - - return nil -} - -// Stop gracefully stops the WeCom Bot channel -func (c *WeComBotChannel) Stop(ctx context.Context) error { - logger.InfoC("wecom", "Stopping WeCom Bot channel...") - - if c.cancel != nil { - c.cancel() - } - - c.SetRunning(false) - logger.InfoC("wecom", "WeCom Bot channel stopped") - return nil -} - -// Send sends a message to WeCom user via webhook API -// Note: WeCom Bot can only reply within the configured timeout (default 5 seconds) of receiving a message -// For delayed responses, we use the webhook URL -func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return channels.ErrNotRunning - } - - logger.DebugCF("wecom", "Sending message via webhook", map[string]any{ - "chat_id": msg.ChatID, - "preview": utils.Truncate(msg.Content, 100), - }) - - return c.sendWebhookReply(ctx, msg.ChatID, msg.Content) -} - -// WebhookPath returns the path for registering on the shared HTTP server. -func (c *WeComBotChannel) WebhookPath() string { - if c.config.WebhookPath != "" { - return c.config.WebhookPath - } - return "/webhook/wecom" -} - -// ServeHTTP implements http.Handler for the shared HTTP server. -func (c *WeComBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { - c.handleWebhook(w, r) -} - -// HealthPath returns the health check endpoint path. -func (c *WeComBotChannel) HealthPath() string { - return "/health/wecom" -} - -// HealthHandler handles health check requests. -func (c *WeComBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { - c.handleHealth(w, r) -} - -// handleWebhook handles incoming webhook requests from WeCom -func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - if r.Method == http.MethodGet { - // Handle verification request - c.handleVerification(ctx, w, r) - return - } - - if r.Method == http.MethodPost { - // Handle message callback - c.handleMessageCallback(ctx, w, r) - return - } - - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) -} - -// handleVerification handles the URL verification request from WeCom -func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - msgSignature := query.Get("msg_signature") - timestamp := query.Get("timestamp") - nonce := query.Get("nonce") - echostr := query.Get("echostr") - - if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" { - http.Error(w, "Missing parameters", http.StatusBadRequest) - return - } - - // Verify signature - if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { - logger.WarnC("wecom", "Signature verification failed") - http.Error(w, "Invalid signature", http.StatusForbidden) - return - } - - // Decrypt echostr - // For AIBOT (智能机器人), receiveid should be empty string "" - // Reference: https://developer.work.weixin.qq.com/document/path/101033 - decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "") - if err != nil { - logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Decryption failed", http.StatusInternalServerError) - return - } - - // Remove BOM and whitespace as per WeCom documentation - // The response must be plain text without quotes, BOM, or newlines - decryptedEchoStr = strings.TrimSpace(decryptedEchoStr) - decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM - w.Write([]byte(decryptedEchoStr)) -} - -// handleMessageCallback handles incoming messages from WeCom -func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - msgSignature := query.Get("msg_signature") - timestamp := query.Get("timestamp") - nonce := query.Get("nonce") - - if msgSignature == "" || timestamp == "" || nonce == "" { - http.Error(w, "Missing parameters", http.StatusBadRequest) - return - } - - // Read request body - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "Failed to read body", http.StatusBadRequest) - return - } - defer r.Body.Close() - - // Parse XML to get encrypted message - var encryptedMsg struct { - XMLName xml.Name `xml:"xml"` - ToUserName string `xml:"ToUserName"` - Encrypt string `xml:"Encrypt"` - AgentID string `xml:"AgentID"` - } - - if err = xml.Unmarshal(body, &encryptedMsg); err != nil { - logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Invalid XML", http.StatusBadRequest) - return - } - - // Verify signature - if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { - logger.WarnC("wecom", "Message signature verification failed") - http.Error(w, "Invalid signature", http.StatusForbidden) - return - } - - // Decrypt message - // For AIBOT (智能机器人), receiveid should be empty string "" - // Reference: https://developer.work.weixin.qq.com/document/path/101033 - decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "") - if err != nil { - logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Decryption failed", http.StatusInternalServerError) - return - } - - // Parse decrypted JSON message (AIBOT uses JSON format) - var msg WeComBotMessage - if err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil { - logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Invalid message format", http.StatusBadRequest) - return - } - - // Process the message with the channel's long-lived context (not the HTTP - // request context, which is canceled as soon as we return the response). - go c.processMessage(c.ctx, msg) - - // Return success response immediately - // WeCom Bot requires response within configured timeout (default 5 seconds) - w.Write([]byte("success")) -} - -// processMessage processes the received message -func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) { - // Skip unsupported message types - if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" && - msg.MsgType != "mixed" { - logger.DebugCF("wecom", "Skipping non-supported message type", map[string]any{ - "msg_type": msg.MsgType, - }) - return - } - - // Message deduplication: Use msg_id to prevent duplicate processing - msgID := msg.MsgID - if !c.processedMsgs.MarkMessageProcessed(msgID) { - logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{ - "msg_id": msgID, - }) - return - } - - senderID := msg.From.UserID - - // Determine if this is a group chat or direct message - // ChatType: "single" for DM, "group" for group chat - isGroupChat := msg.ChatType == "group" - - var chatID, peerKind, peerID string - if isGroupChat { - // Group chat: use ChatID as chatID and peer_id - chatID = msg.ChatID - peerKind = "group" - peerID = msg.ChatID - } else { - // Direct message: use senderID as chatID and peer_id - chatID = senderID - peerKind = "direct" - peerID = senderID - } - - // Extract content based on message type - var content string - switch msg.MsgType { - case "text": - content = msg.Text.Content - case "voice": - content = msg.Voice.Content // Voice to text content - case "mixed": - // For mixed messages, concatenate text items - for _, item := range msg.Mixed.MsgItem { - if item.MsgType == "text" { - content += item.Text.Content - } - } - case "image", "file": - // For image and file, we don't have text content - content = "" - } - - // Build metadata - peer := bus.Peer{Kind: peerKind, ID: peerID} - - // In group chats, apply unified group trigger filtering - if isGroupChat { - respond, cleaned := c.ShouldRespondInGroup(false, content) - if !respond { - return - } - content = cleaned - } - - metadata := map[string]string{ - "msg_type": msg.MsgType, - "msg_id": msg.MsgID, - "platform": "wecom", - "response_url": msg.ResponseURL, - } - if isGroupChat { - metadata["chat_id"] = msg.ChatID - metadata["sender_id"] = senderID - } - - logger.DebugCF("wecom", "Received message", map[string]any{ - "sender_id": senderID, - "msg_type": msg.MsgType, - "peer_kind": peerKind, - "is_group_chat": isGroupChat, - "preview": utils.Truncate(content, 50), - }) - - // Build sender info - sender := bus.SenderInfo{ - Platform: "wecom", - PlatformID: senderID, - CanonicalID: identity.BuildCanonicalID("wecom", senderID), - } - - if !c.IsAllowedSender(sender) { - return - } - - // Handle the message through the base channel - c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata, sender) -} - -// sendWebhookReply sends a reply using the webhook URL -func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content string) error { - reply := WeComBotReplyMessage{ - MsgType: "text", - } - reply.Text.Content = content - - jsonData, err := json.Marshal(reply) - if err != nil { - return fmt.Errorf("failed to marshal reply: %w", err) - } - - // Use configurable timeout (default 5 seconds) - timeout := c.config.ReplyTimeout - if timeout <= 0 { - timeout = 5 - } - - reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.config.WebhookURL, bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.client.Do(req) - if err != nil { - return channels.ClassifyNetError(err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - return channels.ClassifySendError( - resp.StatusCode, - fmt.Errorf("reading webhook error response: %w", readErr), - ) - } - return channels.ClassifySendError( - resp.StatusCode, - fmt.Errorf("webhook API error: %s", string(body)), - ) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - // Check response - var result struct { - ErrCode int `json:"errcode"` - ErrMsg string `json:"errmsg"` - } - if err := json.Unmarshal(body, &result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if result.ErrCode != 0 { - return fmt.Errorf("webhook API error: %s (code: %d)", result.ErrMsg, result.ErrCode) - } - - return nil -} - -// handleHealth handles health check requests -func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) { - status := map[string]any{ - "status": "ok", - "running": c.IsRunning(), - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(status) -} diff --git a/pkg/channels/wecom/bot_test.go b/pkg/channels/wecom/bot_test.go deleted file mode 100644 index c053578b1..000000000 --- a/pkg/channels/wecom/bot_test.go +++ /dev/null @@ -1,751 +0,0 @@ -package wecom - -import ( - "bytes" - "context" - "crypto/aes" - "crypto/cipher" - "crypto/sha1" - "encoding/base64" - "encoding/binary" - "encoding/json" - "encoding/xml" - "fmt" - "net/http" - "net/http/httptest" - "sort" - "strings" - "testing" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" -) - -// generateTestAESKey generates a valid test AES key -func generateTestAESKey() string { - // AES key needs to be 32 bytes (256 bits) for AES-256 - key := make([]byte, 32) - for i := range key { - key[i] = byte(i) - } - // Return base64 encoded key without padding - return base64.StdEncoding.EncodeToString(key)[:43] -} - -// encryptTestMessage encrypts a message for testing (AIBOT JSON format) -func encryptTestMessage(message, aesKey string) (string, error) { - // Decode AES key - key, err := base64.StdEncoding.DecodeString(aesKey + "=") - if err != nil { - return "", err - } - - // Prepare message: random(16) + msg_len(4) + msg + receiveid - random := make([]byte, 0, 16) - for i := range 16 { - random = append(random, byte(i)) - } - - msgBytes := []byte(message) - receiveID := []byte("test_aibot_id") - - msgLen := uint32(len(msgBytes)) - lenBytes := make([]byte, 4) - binary.BigEndian.PutUint32(lenBytes, msgLen) - - plainText := append(random, lenBytes...) - plainText = append(plainText, msgBytes...) - plainText = append(plainText, receiveID...) - - // PKCS7 padding - blockSize := aes.BlockSize - padding := blockSize - len(plainText)%blockSize - padText := bytes.Repeat([]byte{byte(padding)}, padding) - plainText = append(plainText, padText...) - - // Encrypt - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - - mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize]) - cipherText := make([]byte, len(plainText)) - mode.CryptBlocks(cipherText, plainText) - - return base64.StdEncoding.EncodeToString(cipherText), nil -} - -// generateSignature generates a signature for testing -func generateSignature(token, timestamp, nonce, msgEncrypt string) string { - params := []string{token, timestamp, nonce, msgEncrypt} - sort.Strings(params) - str := strings.Join(params, "") - hash := sha1.Sum([]byte(str)) - return fmt.Sprintf("%x", hash) -} - -func TestNewWeComBotChannel(t *testing.T) { - msgBus := bus.NewMessageBus() - - t.Run("missing token", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - _, err := NewWeComBotChannel(cfg, msgBus) - if err == nil { - t.Error("expected error for missing token, got nil") - } - }) - - t.Run("missing webhook_url", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "", - } - _, err := NewWeComBotChannel(cfg, msgBus) - if err == nil { - t.Error("expected error for missing webhook_url, got nil") - } - }) - - t.Run("valid config", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - AllowFrom: []string{"user1", "user2"}, - } - ch, err := NewWeComBotChannel(cfg, msgBus) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ch.Name() != "wecom" { - t.Errorf("Name() = %q, want %q", ch.Name(), "wecom") - } - if ch.IsRunning() { - t.Error("new channel should not be running") - } - }) -} - -func TestWeComBotChannelIsAllowed(t *testing.T) { - msgBus := bus.NewMessageBus() - - t.Run("empty allowlist allows all", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - AllowFrom: []string{}, - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - if !ch.IsAllowed("any_user") { - t.Error("empty allowlist should allow all users") - } - }) - - t.Run("allowlist restricts users", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - AllowFrom: []string{"allowed_user"}, - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - if !ch.IsAllowed("allowed_user") { - t.Error("allowed user should pass allowlist check") - } - if ch.IsAllowed("blocked_user") { - t.Error("non-allowed user should be blocked") - } - }) -} - -func TestWeComBotVerifySignature(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - t.Run("valid signature", func(t *testing.T) { - timestamp := "1234567890" - nonce := "test_nonce" - msgEncrypt := "test_message" - expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt) - - if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) { - t.Error("valid signature should pass verification") - } - }) - - t.Run("invalid signature", func(t *testing.T) { - timestamp := "1234567890" - nonce := "test_nonce" - msgEncrypt := "test_message" - - if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) { - t.Error("invalid signature should fail verification") - } - }) - - t.Run("empty token skips verification", func(t *testing.T) { - // Create a channel manually with empty token to test the behavior - cfgEmpty := config.WeComConfig{ - Token: "", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - chEmpty := &WeComBotChannel{ - config: cfgEmpty, - } - - if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { - t.Error("empty token should skip verification and return true") - } - }) -} - -func TestWeComBotDecryptMessage(t *testing.T) { - msgBus := bus.NewMessageBus() - - t.Run("decrypt without AES key", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - EncodingAESKey: "", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - // Without AES key, message should be base64 decoded only - plainText := "hello world" - encoded := base64.StdEncoding.EncodeToString([]byte(plainText)) - - result, err := decryptMessage(encoded, ch.config.EncodingAESKey) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result != plainText { - t.Errorf("decryptMessage() = %q, want %q", result, plainText) - } - }) - - t.Run("decrypt with AES key", func(t *testing.T) { - aesKey := generateTestAESKey() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - EncodingAESKey: aesKey, - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - originalMsg := "Hello" - encrypted, err := encryptTestMessage(originalMsg, aesKey) - if err != nil { - t.Fatalf("failed to encrypt test message: %v", err) - } - - result, err := decryptMessage(encrypted, ch.config.EncodingAESKey) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result != originalMsg { - t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg) - } - }) - - t.Run("invalid base64", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - EncodingAESKey: "", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey) - if err == nil { - t.Error("expected error for invalid base64, got nil") - } - }) - - t.Run("invalid AES key", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - EncodingAESKey: "invalid_key", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey) - if err == nil { - t.Error("expected error for invalid AES key, got nil") - } - }) -} - -func TestWeComBotPKCS7Unpad(t *testing.T) { - tests := []struct { - name string - input []byte - expected []byte - }{ - { - name: "empty input", - input: []byte{}, - expected: []byte{}, - }, - { - name: "valid padding 3 bytes", - input: append([]byte("hello"), bytes.Repeat([]byte{3}, 3)...), - expected: []byte("hello"), - }, - { - name: "valid padding 16 bytes (full block)", - input: append([]byte("123456789012345"), bytes.Repeat([]byte{16}, 16)...), - expected: []byte("123456789012345"), - }, - { - name: "invalid padding larger than data", - input: []byte{20}, - expected: nil, // should return error - }, - { - name: "invalid padding zero", - input: append([]byte("test"), byte(0)), - expected: nil, // should return error - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := pkcs7Unpad(tt.input) - if tt.expected == nil { - // This case should return an error - if err == nil { - t.Errorf("pkcs7Unpad() expected error for invalid padding, got result: %v", result) - } - return - } - if err != nil { - t.Errorf("pkcs7Unpad() unexpected error: %v", err) - return - } - if !bytes.Equal(result, tt.expected) { - t.Errorf("pkcs7Unpad() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestWeComBotHandleVerification(t *testing.T) { - msgBus := bus.NewMessageBus() - aesKey := generateTestAESKey() - cfg := config.WeComConfig{ - Token: "test_token", - EncodingAESKey: aesKey, - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - t.Run("valid verification request", func(t *testing.T) { - echostr := "test_echostr_123" - encryptedEchostr, _ := encryptTestMessage(echostr, aesKey) - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr) - - req := httptest.NewRequest( - http.MethodGet, - "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, - nil, - ) - w := httptest.NewRecorder() - - ch.handleVerification(context.Background(), w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - if w.Body.String() != echostr { - t.Errorf("response body = %q, want %q", w.Body.String(), echostr) - } - }) - - t.Run("missing parameters", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=sig×tamp=ts", nil) - w := httptest.NewRecorder() - - ch.handleVerification(context.Background(), w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) - } - }) - - t.Run("invalid signature", func(t *testing.T) { - echostr := "test_echostr" - encryptedEchostr, _ := encryptTestMessage(echostr, aesKey) - timestamp := "1234567890" - nonce := "test_nonce" - - req := httptest.NewRequest( - http.MethodGet, - "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, - nil, - ) - w := httptest.NewRecorder() - - ch.handleVerification(context.Background(), w, req) - - if w.Code != http.StatusForbidden { - t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) - } - }) -} - -func TestWeComBotHandleMessageCallback(t *testing.T) { - msgBus := bus.NewMessageBus() - aesKey := generateTestAESKey() - cfg := config.WeComConfig{ - Token: "test_token", - EncodingAESKey: aesKey, - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - runBotMessageCallback := func(t *testing.T, jsonMsg string) *httptest.ResponseRecorder { - t.Helper() - encrypted, _ := encryptTestMessage(jsonMsg, aesKey) - encryptedWrapper := struct { - XMLName xml.Name `xml:"xml"` - Encrypt string `xml:"Encrypt"` - }{ - Encrypt: encrypted, - } - wrapperData, _ := xml.Marshal(encryptedWrapper) - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignature("test_token", timestamp, nonce, encrypted) - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, - bytes.NewReader(wrapperData), - ) - w := httptest.NewRecorder() - ch.handleMessageCallback(context.Background(), w, req) - return w - } - - t.Run("valid direct message callback", func(t *testing.T) { - w := runBotMessageCallback(t, `{ - "msgid": "test_msg_id_123", - "aibotid": "test_aibot_id", - "chattype": "single", - "from": {"userid": "user123"}, - "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - "msgtype": "text", - "text": {"content": "Hello World"} - }`) - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - if w.Body.String() != "success" { - t.Errorf("response body = %q, want %q", w.Body.String(), "success") - } - }) - - t.Run("valid group message callback", func(t *testing.T) { - w := runBotMessageCallback(t, `{ - "msgid": "test_msg_id_456", - "aibotid": "test_aibot_id", - "chatid": "group_chat_id_123", - "chattype": "group", - "from": {"userid": "user456"}, - "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - "msgtype": "text", - "text": {"content": "Hello Group"} - }`) - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - if w.Body.String() != "success" { - t.Errorf("response body = %q, want %q", w.Body.String(), "success") - } - }) - - t.Run("missing parameters", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=sig", nil) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) - } - }) - - t.Run("invalid XML", func(t *testing.T) { - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignature("test_token", timestamp, nonce, "") - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, - strings.NewReader("invalid xml"), - ) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) - } - }) - - t.Run("invalid signature", func(t *testing.T) { - encryptedWrapper := struct { - XMLName xml.Name `xml:"xml"` - Encrypt string `xml:"Encrypt"` - }{ - Encrypt: "encrypted_data", - } - wrapperData, _ := xml.Marshal(encryptedWrapper) - - timestamp := "1234567890" - nonce := "test_nonce" - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, - bytes.NewReader(wrapperData), - ) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusForbidden { - t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) - } - }) -} - -func TestWeComBotProcessMessage(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - t.Run("process direct text message", func(t *testing.T) { - msg := WeComBotMessage{ - MsgID: "test_msg_id_123", - AIBotID: "test_aibot_id", - ChatType: "single", - ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - MsgType: "text", - } - msg.From.UserID = "user123" - msg.Text.Content = "Hello World" - - // Should not panic - ch.processMessage(context.Background(), msg) - }) - - t.Run("process group text message", func(t *testing.T) { - msg := WeComBotMessage{ - MsgID: "test_msg_id_456", - AIBotID: "test_aibot_id", - ChatID: "group_chat_id_123", - ChatType: "group", - ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - MsgType: "text", - } - msg.From.UserID = "user456" - msg.Text.Content = "Hello Group" - - // Should not panic - ch.processMessage(context.Background(), msg) - }) - - t.Run("process voice message", func(t *testing.T) { - msg := WeComBotMessage{ - MsgID: "test_msg_id_789", - AIBotID: "test_aibot_id", - ChatType: "single", - ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - MsgType: "voice", - } - msg.From.UserID = "user123" - msg.Voice.Content = "Voice message text" - - // Should not panic - ch.processMessage(context.Background(), msg) - }) - - t.Run("skip unsupported message type", func(t *testing.T) { - msg := WeComBotMessage{ - MsgID: "test_msg_id_000", - AIBotID: "test_aibot_id", - ChatType: "single", - ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - MsgType: "video", - } - msg.From.UserID = "user123" - - // Should not panic - ch.processMessage(context.Background(), msg) - }) -} - -func TestWeComBotHandleWebhook(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - t.Run("GET request calls verification", func(t *testing.T) { - echostr := "test_echostr" - encoded := base64.StdEncoding.EncodeToString([]byte(echostr)) - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignature("test_token", timestamp, nonce, encoded) - - req := httptest.NewRequest( - http.MethodGet, - "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, - nil, - ) - w := httptest.NewRecorder() - - ch.handleWebhook(w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - }) - - t.Run("POST request calls message callback", func(t *testing.T) { - encryptedWrapper := struct { - XMLName xml.Name `xml:"xml"` - Encrypt string `xml:"Encrypt"` - }{ - Encrypt: base64.StdEncoding.EncodeToString([]byte("test")), - } - wrapperData, _ := xml.Marshal(encryptedWrapper) - - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt) - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, - bytes.NewReader(wrapperData), - ) - w := httptest.NewRecorder() - - ch.handleWebhook(w, req) - - // Should not be method not allowed - if w.Code == http.StatusMethodNotAllowed { - t.Error("POST request should not return Method Not Allowed") - } - }) - - t.Run("unsupported method", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/webhook/wecom", nil) - w := httptest.NewRecorder() - - ch.handleWebhook(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed) - } - }) -} - -func TestWeComBotHandleHealth(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - req := httptest.NewRequest(http.MethodGet, "/health/wecom", nil) - w := httptest.NewRecorder() - - ch.handleHealth(w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - - contentType := w.Header().Get("Content-Type") - if contentType != "application/json" { - t.Errorf("Content-Type = %q, want %q", contentType, "application/json") - } - - body := w.Body.String() - if !strings.Contains(body, "status") || !strings.Contains(body, "running") { - t.Errorf("response body should contain status and running fields, got: %s", body) - } -} - -func TestWeComBotReplyMessage(t *testing.T) { - msg := WeComBotReplyMessage{ - MsgType: "text", - } - msg.Text.Content = "Hello World" - - if msg.MsgType != "text" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") - } - if msg.Text.Content != "Hello World" { - t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World") - } -} - -func TestWeComBotMessageStructure(t *testing.T) { - jsonData := `{ - "msgid": "test_msg_id_123", - "aibotid": "test_aibot_id", - "chatid": "group_chat_id_123", - "chattype": "group", - "from": {"userid": "user123"}, - "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - "msgtype": "text", - "text": {"content": "Hello World"} - }` - - var msg WeComBotMessage - err := json.Unmarshal([]byte(jsonData), &msg) - if err != nil { - t.Fatalf("failed to unmarshal JSON: %v", err) - } - - if msg.MsgID != "test_msg_id_123" { - t.Errorf("MsgID = %q, want %q", msg.MsgID, "test_msg_id_123") - } - if msg.AIBotID != "test_aibot_id" { - t.Errorf("AIBotID = %q, want %q", msg.AIBotID, "test_aibot_id") - } - if msg.ChatID != "group_chat_id_123" { - t.Errorf("ChatID = %q, want %q", msg.ChatID, "group_chat_id_123") - } - if msg.ChatType != "group" { - t.Errorf("ChatType = %q, want %q", msg.ChatType, "group") - } - if msg.From.UserID != "user123" { - t.Errorf("From.UserID = %q, want %q", msg.From.UserID, "user123") - } - if msg.MsgType != "text" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") - } - if msg.Text.Content != "Hello World" { - t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World") - } -} diff --git a/pkg/channels/wecom/common.go b/pkg/channels/wecom/common.go deleted file mode 100644 index 6510e6f81..000000000 --- a/pkg/channels/wecom/common.go +++ /dev/null @@ -1,199 +0,0 @@ -package wecom - -import ( - "bytes" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha1" - "encoding/base64" - "encoding/binary" - "fmt" - "math/big" - "sort" - "strings" -) - -// blockSize is the PKCS7 block size used by WeCom (32) -const blockSize = 32 - -// computeSignature computes the WeCom message signature from the given parameters. -// It sorts [token, timestamp, nonce, encrypt], concatenates them and returns the SHA1 hex digest. -func computeSignature(token, timestamp, nonce, encrypt string) string { - params := []string{token, timestamp, nonce, encrypt} - sort.Strings(params) - str := strings.Join(params, "") - hash := sha1.Sum([]byte(str)) - return fmt.Sprintf("%x", hash) -} - -// verifySignature verifies the message signature for WeCom -// This is a common function used by both WeCom Bot and WeCom App -func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool { - if token == "" { - return true // Skip verification if token is not set - } - return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature -} - -// decryptMessage decrypts the encrypted message using AES -// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id -func decryptMessage(encryptedMsg, encodingAESKey string) (string, error) { - return decryptMessageWithVerify(encryptedMsg, encodingAESKey, "") -} - -// decryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid -// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification. -func decryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) { - if encodingAESKey == "" { - // No encryption, return as is (base64 decode) - decoded, err := base64.StdEncoding.DecodeString(encryptedMsg) - if err != nil { - return "", err - } - return string(decoded), nil - } - - aesKey, err := decodeWeComAESKey(encodingAESKey) - if err != nil { - return "", err - } - - cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg) - if err != nil { - return "", fmt.Errorf("failed to decode message: %w", err) - } - - plainText, err := decryptAESCBC(aesKey, cipherText) - if err != nil { - return "", err - } - - return unpackWeComFrame(plainText, receiveid) -} - -// decodeWeComAESKey base64-decodes the 43-character EncodingAESKey (trailing "=" is -// appended automatically) and validates that the result is exactly 32 bytes. -// It is the single place that handles this repeated pattern in both encrypt and decrypt paths. -func decodeWeComAESKey(encodingAESKey string) ([]byte, error) { - aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") - if err != nil { - return nil, fmt.Errorf("failed to decode AES key: %w", err) - } - if len(aesKey) != 32 { - return nil, fmt.Errorf("invalid AES key length: %d", len(aesKey)) - } - return aesKey, nil -} - -// encryptAESCBC encrypts plaintext using AES-CBC with the given key, mirroring -// decryptAESCBC. IV = aesKey[:aes.BlockSize]. The caller must PKCS7-pad the -// plaintext to a multiple of aes.BlockSize before calling. -func encryptAESCBC(aesKey, plaintext []byte) ([]byte, error) { - block, err := aes.NewCipher(aesKey) - if err != nil { - return nil, fmt.Errorf("failed to create cipher: %w", err) - } - iv := aesKey[:aes.BlockSize] - ciphertext := make([]byte, len(plaintext)) - cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plaintext) - return ciphertext, nil -} - -// packWeComFrame builds the WeCom wire format: -// -// random(16 ASCII digits) + msg_len(4, big-endian) + msg + receiveid -func packWeComFrame(msg, receiveid string) ([]byte, error) { - randomBytes := make([]byte, 16) - for i := range 16 { - n, err := rand.Int(rand.Reader, big.NewInt(10)) - if err != nil { - return nil, fmt.Errorf("failed to generate random: %w", err) - } - randomBytes[i] = byte('0' + n.Int64()) - } - msgBytes := []byte(msg) - msgLenBytes := make([]byte, 4) - binary.BigEndian.PutUint32(msgLenBytes, uint32(len(msgBytes))) - var buf bytes.Buffer - buf.Write(randomBytes) - buf.Write(msgLenBytes) - buf.Write(msgBytes) - buf.WriteString(receiveid) - return buf.Bytes(), nil -} - -// unpackWeComFrame parses the WeCom wire format produced by packWeComFrame. -// If receiveid is non-empty it verifies the frame's trailing receiveid field. -func unpackWeComFrame(data []byte, receiveid string) (string, error) { - if len(data) < 20 { - return "", fmt.Errorf("decrypted frame too short: %d bytes", len(data)) - } - msgLen := binary.BigEndian.Uint32(data[16:20]) - if int(msgLen) > len(data)-20 { - return "", fmt.Errorf("invalid message length: %d", msgLen) - } - msg := data[20 : 20+msgLen] - if receiveid != "" && len(data) > 20+int(msgLen) { - actualReceiveID := string(data[20+msgLen:]) - if actualReceiveID != receiveid { - return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID) - } - } - return string(msg), nil -} - -// decryptAESCBC decrypts ciphertext using AES-CBC with the given key. -// IV = aesKey[:aes.BlockSize]. PKCS7 padding is stripped from the returned plaintext. -func decryptAESCBC(aesKey, ciphertext []byte) ([]byte, error) { - if len(ciphertext) == 0 { - return nil, fmt.Errorf("ciphertext is empty") - } - if len(ciphertext)%aes.BlockSize != 0 { - return nil, fmt.Errorf("ciphertext length %d is not a multiple of block size", len(ciphertext)) - } - block, err := aes.NewCipher(aesKey) - if err != nil { - return nil, fmt.Errorf("failed to create cipher: %w", err) - } - iv := aesKey[:aes.BlockSize] - plaintext := make([]byte, len(ciphertext)) - cipher.NewCBCDecrypter(block, iv).CryptBlocks(plaintext, ciphertext) - plaintext, err = pkcs7Unpad(plaintext) - if err != nil { - return nil, fmt.Errorf("failed to unpad: %w", err) - } - return plaintext, nil -} - -// pkcs7Pad adds PKCS7 padding -func pkcs7Pad(data []byte, blockSize int) []byte { - padding := blockSize - (len(data) % blockSize) - if padding == 0 { - padding = blockSize - } - padText := bytes.Repeat([]byte{byte(padding)}, padding) - return append(data, padText...) -} - -// pkcs7Unpad removes PKCS7 padding with validation -func pkcs7Unpad(data []byte) ([]byte, error) { - if len(data) == 0 { - return data, nil - } - padding := int(data[len(data)-1]) - // WeCom uses 32-byte block size for PKCS7 padding - if padding == 0 || padding > blockSize { - return nil, fmt.Errorf("invalid padding size: %d", padding) - } - if padding > len(data) { - return nil, fmt.Errorf("padding size larger than data") - } - // Verify all padding bytes - for i := range padding { - if data[len(data)-1-i] != byte(padding) { - return nil, fmt.Errorf("invalid padding byte at position %d", i) - } - } - return data[:len(data)-padding], nil -} diff --git a/pkg/channels/wecom/dedupe.go b/pkg/channels/wecom/dedupe.go deleted file mode 100644 index 865be668e..000000000 --- a/pkg/channels/wecom/dedupe.go +++ /dev/null @@ -1,54 +0,0 @@ -package wecom - -import "sync" - -const wecomMaxProcessedMessages = 1000 - -// MessageDeduplicator provides thread-safe message deduplication using a circular queue (ring buffer) -// combined with a hash map. This ensures fast O(1) lookups while naturally evicting the oldest -// messages without causing "amnesia cliffs" when the limit is reached. -type MessageDeduplicator struct { - mu sync.Mutex - msgs map[string]bool - ring []string - idx int - max int -} - -// NewMessageDeduplicator creates a new deduplicator with the specified capacity. -func NewMessageDeduplicator(maxEntries int) *MessageDeduplicator { - if maxEntries <= 0 { - maxEntries = wecomMaxProcessedMessages - } - return &MessageDeduplicator{ - msgs: make(map[string]bool, maxEntries), - ring: make([]string, maxEntries), - max: maxEntries, - } -} - -// MarkMessageProcessed marks msgID as processed and returns false for duplicates. -func (d *MessageDeduplicator) MarkMessageProcessed(msgID string) bool { - d.mu.Lock() - defer d.mu.Unlock() - - // 1. Check for duplicate - if d.msgs[msgID] { - return false - } - - // 2. Evict the oldest message at our current ring position (if any) - oldestID := d.ring[d.idx] - if oldestID != "" { - delete(d.msgs, oldestID) - } - - // 3. Store the new message - d.msgs[msgID] = true - d.ring[d.idx] = msgID - - // 4. Advance the circle queue index - d.idx = (d.idx + 1) % d.max - - return true -} diff --git a/pkg/channels/wecom/dedupe_test.go b/pkg/channels/wecom/dedupe_test.go deleted file mode 100644 index 10dff4cfe..000000000 --- a/pkg/channels/wecom/dedupe_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package wecom - -import ( - "sync" - "testing" -) - -func TestMessageDeduplicator_DuplicateDetection(t *testing.T) { - d := NewMessageDeduplicator(wecomMaxProcessedMessages) - - if ok := d.MarkMessageProcessed("msg-1"); !ok { - t.Fatalf("first message should be accepted") - } - - if ok := d.MarkMessageProcessed("msg-1"); ok { - t.Fatalf("duplicate message should be rejected") - } -} - -func TestMessageDeduplicator_ConcurrentSameMessage(t *testing.T) { - d := NewMessageDeduplicator(wecomMaxProcessedMessages) - - const goroutines = 64 - var wg sync.WaitGroup - wg.Add(goroutines) - - results := make(chan bool, goroutines) - for i := 0; i < goroutines; i++ { - go func() { - defer wg.Done() - results <- d.MarkMessageProcessed("msg-concurrent") - }() - } - - wg.Wait() - close(results) - - successes := 0 - for ok := range results { - if ok { - successes++ - } - } - - if successes != 1 { - t.Fatalf("expected exactly 1 successful mark, got %d", successes) - } -} - -func TestMessageDeduplicator_CircularQueueEviction(t *testing.T) { - // Create a deduplicator with a very small capacity to test eviction easily. - capacity := 3 - d := NewMessageDeduplicator(capacity) - - // Fill the queue. - d.MarkMessageProcessed("msg-1") - d.MarkMessageProcessed("msg-2") - d.MarkMessageProcessed("msg-3") - - // At this point, the queue is full. msg-1 is the oldest. - if len(d.msgs) != 3 { - t.Fatalf("expected map size to be 3, got %d", len(d.msgs)) - } - - // This should evict msg-1 and add msg-4. - if ok := d.MarkMessageProcessed("msg-4"); !ok { - t.Fatalf("msg-4 should be accepted") - } - - if len(d.msgs) != 3 { - t.Fatalf("expected map size to remain at max capacity (3), got %d", len(d.msgs)) - } - - // msg-1 should now be forgotten (evicted). - if ok := d.MarkMessageProcessed("msg-1"); !ok { - t.Fatalf("msg-1 should be accepted again because it was evicted") - } - - // msg-2 should have been evicted when we added msg-1 back. - if ok := d.MarkMessageProcessed("msg-2"); !ok { - t.Fatalf("msg-2 should be accepted again because it was evicted") - } -} diff --git a/pkg/channels/wecom/init.go b/pkg/channels/wecom/init.go index bc5a70fa3..78e51d18e 100644 --- a/pkg/channels/wecom/init.go +++ b/pkg/channels/wecom/init.go @@ -7,13 +7,19 @@ import ( ) func init() { - channels.RegisterFactory("wecom", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewWeComBotChannel(cfg.Channels.WeCom, b) - }) - channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewWeComAppChannel(cfg.Channels.WeComApp, b) - }) - channels.RegisterFactory("wecom_aibot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewWeComAIBotChannel(cfg.Channels.WeComAIBot, b) - }) + channels.RegisterFactory( + config.ChannelWeCom, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.WeComSettings) + if !ok { + return nil, channels.ErrSendFailed + } + return NewChannel(bc, c, b) + }, + ) } diff --git a/pkg/channels/wecom/media.go b/pkg/channels/wecom/media.go new file mode 100644 index 000000000..974a3bf4d --- /dev/null +++ b/pkg/channels/wecom/media.go @@ -0,0 +1,802 @@ +package wecom + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/h2non/filetype" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/media" +) + +const ( + wecomOutboundMediaMaxBytes = 20 << 20 + wecomOutboundImageMaxBytes = 2 << 20 + wecomOutboundVoiceMaxBytes = 2 << 20 + wecomOutboundVideoMaxBytes = 10 << 20 + wecomUploadChunkMaxBytes = 512 << 10 + wecomUploadMaxChunks = 100 + wecomUploadMinBytes = 5 +) + +type wecomOutboundMedia struct { + MsgType string + MediaID string + Title string + Description string +} + +func (m *wecomOutboundMedia) respondBody() wecomRespondMsgBody { + body := wecomRespondMsgBody{MsgType: m.MsgType} + switch m.MsgType { + case "file": + body.File = &wecomMediaRefContent{MediaID: m.MediaID} + case "image": + body.Image = &wecomMediaRefContent{MediaID: m.MediaID} + case "voice": + body.Voice = &wecomMediaRefContent{MediaID: m.MediaID} + case "video": + body.Video = &wecomVideoContent{ + MediaID: m.MediaID, + Title: m.Title, + Description: m.Description, + } + } + return body +} + +func (m *wecomOutboundMedia) sendBody(chatID string, chatType uint32) wecomSendMsgBody { + body := wecomSendMsgBody{ + ChatID: chatID, + ChatType: chatType, + MsgType: m.MsgType, + } + switch m.MsgType { + case "file": + body.File = &wecomMediaRefContent{MediaID: m.MediaID} + case "image": + body.Image = &wecomMediaRefContent{MediaID: m.MediaID} + case "voice": + body.Voice = &wecomMediaRefContent{MediaID: m.MediaID} + case "video": + body.Video = &wecomVideoContent{ + MediaID: m.MediaID, + Title: m.Title, + Description: m.Description, + } + } + return body +} + +func decodeMediaAESKey(value string) ([]byte, error) { + if value == "" { + return nil, nil + } + key, err := base64.StdEncoding.DecodeString(value) + if err == nil && len(key) == 32 { + return key, nil + } + key, err = base64.StdEncoding.DecodeString(value + "=") + if err != nil { + return nil, fmt.Errorf("decode AES key: %w", err) + } + if len(key) != 32 { + return nil, fmt.Errorf("invalid AES key length %d", len(key)) + } + return key, nil +} + +func decryptAESCBC(key, ciphertext []byte) ([]byte, error) { + if len(ciphertext) == 0 { + return nil, fmt.Errorf("ciphertext is empty") + } + if len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("ciphertext length %d is not a multiple of block size", len(ciphertext)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("create cipher: %w", err) + } + plaintext := make([]byte, len(ciphertext)) + iv := key[:aes.BlockSize] + cipher.NewCBCDecrypter(block, iv).CryptBlocks(plaintext, ciphertext) + return pkcs7Unpad(plaintext) +} + +func pkcs7Unpad(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, fmt.Errorf("empty plaintext") + } + padding := int(data[len(data)-1]) + if padding == 0 || padding > 32 || padding > len(data) { + return nil, fmt.Errorf("invalid padding size %d", padding) + } + for i := 0; i < padding; i++ { + if data[len(data)-1-i] != byte(padding) { + return nil, fmt.Errorf("invalid padding byte") + } + } + return data[:len(data)-padding], nil +} + +func inferMediaExt(contentType, fallback string) string { + contentType = normalizeWeComContentType(contentType) + switch contentType { + case "image/jpeg", "image/jpg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "application/pdf": + return ".pdf" + case "video/mp4": + return ".mp4" + default: + return fallback + } +} + +func normalizeWeComContentType(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if idx := strings.Index(value, ";"); idx >= 0 { + value = strings.TrimSpace(value[:idx]) + } + return value +} + +func isGenericWeComContentType(value string) bool { + switch normalizeWeComContentType(value) { + case "", "application/octet-stream", "binary/octet-stream", "application/unknown", "application/binary": + return true + default: + return false + } +} + +func sanitizeWeComFilename(name string) string { + name = filepath.Base(strings.TrimSpace(name)) + if name == "." || name == "/" || name == "" { + return "" + } + return name +} + +func candidateWeComFilename(resourceURL, contentDisposition, fallbackName string) string { + if _, params, err := mime.ParseMediaType(contentDisposition); err == nil { + if name := sanitizeWeComFilename(params["filename"]); name != "" { + return name + } + if name := sanitizeWeComFilename(params["filename*"]); name != "" { + return name + } + } + + if parsed, err := url.Parse(resourceURL); err == nil { + query := parsed.Query() + for _, key := range []string{"filename", "file_name", "name"} { + if name := sanitizeWeComFilename(query.Get(key)); name != "" { + return name + } + } + if name := sanitizeWeComFilename(parsed.Path); name != "" { + return name + } + } + + return sanitizeWeComFilename(fallbackName) +} + +func detectWeComFiletype(data []byte) (string, string) { + kind, err := filetype.Match(data) + if err != nil || kind == filetype.Unknown { + return "", "" + } + ext := "" + if kind.Extension != "" { + ext = "." + strings.ToLower(kind.Extension) + } + return normalizeWeComContentType(kind.MIME.Value), ext +} + +func detectWeComMediaMetadata( + data []byte, + fallbackName, fallbackContentType, resourceURL, contentDisposition string, +) (string, string) { + filename := candidateWeComFilename(resourceURL, contentDisposition, fallbackName) + if filename == "" { + filename = "media" + } + + ext := strings.ToLower(filepath.Ext(filename)) + contentType := normalizeWeComContentType(fallbackContentType) + detectedType, detectedExt := detectWeComFiletype(data) + + if ext != "" && isGenericWeComContentType(contentType) { + if byExt := normalizeWeComContentType(mime.TypeByExtension(ext)); byExt != "" { + contentType = byExt + } + } + + if detectedType != "" { + switch { + case contentType == "": + contentType = detectedType + case isGenericWeComContentType(contentType): + contentType = detectedType + case strings.HasPrefix(detectedType, "image/") && !strings.HasPrefix(contentType, "image/"): + contentType = detectedType + case strings.HasPrefix(detectedType, "audio/") && !strings.HasPrefix(contentType, "audio/"): + contentType = detectedType + case strings.HasPrefix(detectedType, "video/") && !strings.HasPrefix(contentType, "video/"): + contentType = detectedType + } + } + + if contentType == "" && ext != "" { + contentType = normalizeWeComContentType(mime.TypeByExtension(ext)) + } + if contentType == "" { + contentType = normalizeWeComContentType(http.DetectContentType(data)) + } + + if ext == "" { + ext = detectedExt + } + if ext == "" && contentType != "" { + if exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 { + ext = strings.ToLower(exts[0]) + } + } + + if filepath.Ext(filename) == "" && ext != "" { + filename += ext + } + return filename, contentType +} + +func (c *WeComChannel) storeRemoteMedia( + ctx context.Context, + scope, msgID, resourceURL, aesKey, fallbackExt string, +) (string, error) { + store := c.GetMediaStore() + if store == nil { + return "", fmt.Errorf("no media store available") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + resp, err := c.mediaClient.Do(req) + if err != nil { + return "", fmt.Errorf("download media: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download media returned HTTP %d", resp.StatusCode) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, wecomOutboundMediaMaxBytes+1)) + if err != nil { + return "", fmt.Errorf("read media: %w", err) + } + if len(data) > wecomOutboundMediaMaxBytes { + return "", fmt.Errorf("media too large") + } + + if aesKey != "" { + key, keyErr := decodeMediaAESKey(aesKey) + if keyErr != nil { + return "", keyErr + } + data, err = decryptAESCBC(key, data) + if err != nil { + return "", fmt.Errorf("decrypt media: %w", err) + } + } + + filename, contentType := detectWeComMediaMetadata( + data, + msgID+fallbackExt, + resp.Header.Get("Content-Type"), + resourceURL, + resp.Header.Get("Content-Disposition"), + ) + ext := filepath.Ext(filename) + if ext == "" { + ext = inferMediaExt(contentType, fallbackExt) + } + mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") + if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil { + return "", fmt.Errorf("mkdir media dir: %w", mkdirErr) + } + tmpFile, err := os.CreateTemp(mediaDir, msgID+"-*"+ext) + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmpFile.Name() + if _, writeErr := tmpFile.Write(data); writeErr != nil { + tmpFile.Close() + _ = os.Remove(tmpPath) + return "", fmt.Errorf("write temp file: %w", writeErr) + } + if closeErr := tmpFile.Close(); closeErr != nil { + _ = os.Remove(tmpPath) + return "", fmt.Errorf("close temp file: %w", closeErr) + } + + ref, err := store.Store(tmpPath, media.MediaMeta{ + Filename: filename, + ContentType: contentType, + Source: "wecom", + CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, + }, scope) + if err != nil { + _ = os.Remove(tmpPath) + return "", err + } + return ref, nil +} + +func detectLocalWeComContentType(localPath, hint string) string { + contentType := normalizeWeComContentType(hint) + if !isGenericWeComContentType(contentType) { + return contentType + } + + if kind, err := filetype.MatchFile(localPath); err == nil && kind != filetype.Unknown { + return normalizeWeComContentType(kind.MIME.Value) + } + + if ext := strings.ToLower(filepath.Ext(localPath)); ext != "" { + if byExt := normalizeWeComContentType(mime.TypeByExtension(ext)); byExt != "" { + return byExt + } + } + + file, err := os.Open(localPath) + if err != nil { + return contentType + } + defer file.Close() + + buf := make([]byte, 512) + n, err := file.Read(buf) + if err != nil && err != io.EOF { + return contentType + } + if n == 0 { + return contentType + } + return normalizeWeComContentType(http.DetectContentType(buf[:n])) +} + +func writeWeComTempFile(prefix, filename string, data []byte) (string, error) { + mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") + if err := os.MkdirAll(mediaDir, 0o700); err != nil { + return "", fmt.Errorf("mkdir media dir: %w", err) + } + + ext := strings.ToLower(filepath.Ext(filename)) + tmpFile, err := os.CreateTemp(mediaDir, prefix+"-*"+ext) + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmpFile.Name() + + if _, err := tmpFile.Write(data); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpPath) + return "", fmt.Errorf("write temp file: %w", err) + } + if err := tmpFile.Close(); err != nil { + _ = os.Remove(tmpPath) + return "", fmt.Errorf("close temp file: %w", err) + } + return tmpPath, nil +} + +func (c *WeComChannel) downloadRemoteMediaToTemp( + ctx context.Context, + resourceURL, fallbackName string, +) (string, string, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil) + if err != nil { + return "", "", "", fmt.Errorf("create request: %w", err) + } + + resp, err := c.mediaClient.Do(req) + if err != nil { + return "", "", "", fmt.Errorf("download media: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return "", "", "", fmt.Errorf("download media returned HTTP %d: %s", resp.StatusCode, string(body)) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, wecomOutboundMediaMaxBytes+1)) + if err != nil { + return "", "", "", fmt.Errorf("read media: %w", err) + } + if len(data) > wecomOutboundMediaMaxBytes { + return "", "", "", fmt.Errorf("media too large") + } + + filename, contentType := detectWeComMediaMetadata( + data, + fallbackName, + resp.Header.Get("Content-Type"), + resourceURL, + resp.Header.Get("Content-Disposition"), + ) + tmpPath, err := writeWeComTempFile("wecom-outbound", filename, data) + if err != nil { + return "", "", "", err + } + return tmpPath, filename, contentType, nil +} + +func (c *WeComChannel) resolveOutboundPart( + ctx context.Context, + part bus.MediaPart, +) (string, string, string, func(), error) { + cleanup := func() {} + filename := sanitizeWeComFilename(part.Filename) + contentType := normalizeWeComContentType(part.ContentType) + ref := strings.TrimSpace(part.Ref) + + switch { + case ref == "": + return "", filename, contentType, cleanup, nil + + case strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://"): + localPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, ref, filename) + if err != nil { + return "", "", "", cleanup, err + } + return localPath, name, ct, func() { _ = os.Remove(localPath) }, nil + + case strings.HasPrefix(ref, "media://"): + store := c.GetMediaStore() + if store == nil { + return "", "", "", cleanup, fmt.Errorf("no media store available") + } + + localPath, meta, err := store.ResolveWithMeta(ref) + if err != nil { + return "", "", "", cleanup, err + } + if filename == "" { + filename = sanitizeWeComFilename(meta.Filename) + } + if contentType == "" { + contentType = normalizeWeComContentType(meta.ContentType) + } + if strings.HasPrefix(localPath, "http://") || strings.HasPrefix(localPath, "https://") { + tmpPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, localPath, filename) + if err != nil { + return "", "", "", cleanup, err + } + return tmpPath, name, ct, func() { _ = os.Remove(tmpPath) }, nil + } + if _, err := os.Stat(localPath); err != nil { + return "", "", "", cleanup, err + } + if filename == "" { + filename = sanitizeWeComFilename(filepath.Base(localPath)) + } + if contentType == "" { + contentType = detectLocalWeComContentType(localPath, "") + } + return localPath, filename, contentType, cleanup, nil + + case strings.HasPrefix(ref, "file://"): + u, err := url.Parse(ref) + if err != nil { + return "", "", "", cleanup, err + } + localPath := u.Path + if _, err := os.Stat(localPath); err != nil { + return "", "", "", cleanup, err + } + if filename == "" { + filename = sanitizeWeComFilename(filepath.Base(localPath)) + } + if contentType == "" { + contentType = detectLocalWeComContentType(localPath, "") + } + return localPath, filename, contentType, cleanup, nil + + default: + if _, err := os.Stat(ref); err != nil { + return "", "", "", cleanup, err + } + if filename == "" { + filename = sanitizeWeComFilename(filepath.Base(ref)) + } + if contentType == "" { + contentType = detectLocalWeComContentType(ref, "") + } + return ref, filename, contentType, cleanup, nil + } +} + +func canWeComSendImage(contentType, ext string, size int64) bool { + if size > wecomOutboundImageMaxBytes { + return false + } + switch normalizeWeComContentType(contentType) { + case "image/jpeg", "image/jpg", "image/png", "image/gif": + return true + } + switch strings.ToLower(ext) { + case ".jpg", ".jpeg", ".png", ".gif": + return true + default: + return false + } +} + +func canWeComSendVoice(contentType, ext string, size int64) bool { + if size > wecomOutboundVoiceMaxBytes { + return false + } + contentType = normalizeWeComContentType(contentType) + return strings.Contains(contentType, "amr") || strings.EqualFold(ext, ".amr") +} + +func canWeComSendVideo(contentType, ext string, size int64) bool { + if size > wecomOutboundVideoMaxBytes { + return false + } + return normalizeWeComContentType(contentType) == "video/mp4" || strings.EqualFold(ext, ".mp4") +} + +func outboundWeComMediaKind(partType, filename, contentType string, size int64) string { + if size < wecomUploadMinBytes { + return "" + } + + partType = strings.ToLower(strings.TrimSpace(partType)) + contentType = normalizeWeComContentType(contentType) + ext := strings.ToLower(filepath.Ext(filename)) + + if partType == "file" { + if size <= wecomOutboundMediaMaxBytes { + return "file" + } + return "" + } + + if (partType == "image" || partType == "") && canWeComSendImage(contentType, ext, size) { + return "image" + } + if (partType == "audio" || partType == "voice" || partType == "") && canWeComSendVoice(contentType, ext, size) { + return "voice" + } + if (partType == "video" || partType == "") && canWeComSendVideo(contentType, ext, size) { + return "video" + } + if size <= wecomOutboundMediaMaxBytes { + return "file" + } + return "" +} + +func trimWeComBytes(value string, limit int) string { + value = strings.TrimSpace(value) + if limit <= 0 || len(value) <= limit { + return value + } + size := 0 + var out strings.Builder + for _, r := range value { + width := len(string(r)) + if size+width > limit { + break + } + size += width + out.WriteRune(r) + } + return out.String() +} + +func ensureWeComOutboundFilename(filename, localPath, contentType string) string { + filename = sanitizeWeComFilename(filename) + if filename == "" { + filename = sanitizeWeComFilename(filepath.Base(localPath)) + } + if filename == "" { + filename = "media" + } + if filepath.Ext(filename) == "" { + fallbackExt := inferMediaExt(contentType, strings.ToLower(filepath.Ext(localPath))) + if fallbackExt != "" { + filename += fallbackExt + } + } + filename = trimWeComBytes(filename, 256) + if filename == "" { + return "media" + } + return filename +} + +func buildWeComVideoContent(mediaID, filename, description string) *wecomVideoContent { + title := strings.TrimSuffix(filename, filepath.Ext(filename)) + title = trimWeComBytes(title, 64) + if title == "" { + title = "video" + } + description = trimWeComBytes(description, 512) + return &wecomVideoContent{ + MediaID: mediaID, + Title: title, + Description: description, + } +} + +func decodeWeComEnvelopeBody[T any](env wecomEnvelope) (T, error) { + var out T + if len(env.Body) == 0 { + return out, fmt.Errorf("wecom response body is empty") + } + if err := json.Unmarshal(env.Body, &out); err != nil { + return out, fmt.Errorf("decode wecom response body: %w", err) + } + return out, nil +} + +func (c *WeComChannel) uploadOutboundMedia( + ctx context.Context, + localPath, filename, contentType string, + part bus.MediaPart, +) (*wecomOutboundMedia, error) { + _ = ctx + + contentType = detectLocalWeComContentType(localPath, contentType) + filename = ensureWeComOutboundFilename(filename, localPath, contentType) + + data, err := os.ReadFile(localPath) + if err != nil { + return nil, fmt.Errorf("read media file: %w", err) + } + size := int64(len(data)) + kind := outboundWeComMediaKind(part.Type, filename, contentType, size) + if kind == "" { + return nil, fmt.Errorf("unsupported wecom media type or size for %q", filename) + } + + totalChunks := (len(data) + wecomUploadChunkMaxBytes - 1) / wecomUploadChunkMaxBytes + if totalChunks <= 0 || totalChunks > wecomUploadMaxChunks { + return nil, fmt.Errorf("wecom upload requires 1-%d chunks, got %d", wecomUploadMaxChunks, totalChunks) + } + + sum := md5.Sum(data) + initEnv, err := c.sendCommandAck(wecomCommand{ + Cmd: wecomCmdUploadMediaInit, + Headers: wecomHeaders{ReqID: randomID(10)}, + Body: wecomUploadMediaInitBody{ + Type: kind, + Filename: filename, + TotalSize: size, + TotalChunks: totalChunks, + MD5: hex.EncodeToString(sum[:]), + }, + }, wecomUploadTimeout) + if err != nil { + return nil, err + } + initResp, err := decodeWeComEnvelopeBody[wecomUploadMediaInitResponse](initEnv) + if err != nil { + return nil, err + } + if strings.TrimSpace(initResp.UploadID) == "" { + return nil, fmt.Errorf("wecom upload init returned empty upload_id") + } + + for idx, offset := 0, 0; offset < len(data); idx, offset = idx+1, offset+wecomUploadChunkMaxBytes { + end := offset + wecomUploadChunkMaxBytes + if end > len(data) { + end = len(data) + } + sendErr := c.sendCommand(wecomCommand{ + Cmd: wecomCmdUploadMediaChunk, + Headers: wecomHeaders{ReqID: randomID(10)}, + Body: wecomUploadMediaChunkBody{ + UploadID: initResp.UploadID, + ChunkIndex: idx, + Base64Data: base64.StdEncoding.EncodeToString(data[offset:end]), + }, + }, wecomUploadTimeout) + if sendErr != nil { + return nil, sendErr + } + } + + finishEnv, err := c.sendCommandAck(wecomCommand{ + Cmd: wecomCmdUploadMediaEnd, + Headers: wecomHeaders{ReqID: randomID(10)}, + Body: wecomUploadMediaFinishBody{ + UploadID: initResp.UploadID, + }, + }, wecomUploadTimeout) + if err != nil { + return nil, err + } + finishResp, err := decodeWeComEnvelopeBody[wecomUploadMediaFinishResponse](finishEnv) + if err != nil { + return nil, err + } + if strings.TrimSpace(finishResp.MediaID) == "" { + return nil, fmt.Errorf("wecom upload finish returned empty media_id") + } + + uploaded := &wecomOutboundMedia{ + MsgType: kind, + MediaID: finishResp.MediaID, + } + if kind == "video" { + video := buildWeComVideoContent(finishResp.MediaID, filename, part.Caption) + uploaded.Title = video.Title + uploaded.Description = video.Description + } + return uploaded, nil +} + +func fallbackWeComMediaText(part bus.MediaPart, kind, filename string) string { + var lines []string + if caption := strings.TrimSpace(part.Caption); caption != "" { + lines = append(lines, caption) + } + + label := kind + if label == "" { + label = "media" + } + if filename != "" { + lines = append(lines, fmt.Sprintf("[%s: %s]", label, filename)) + } else { + lines = append(lines, fmt.Sprintf("[%s attachment]", label)) + } + + ref := strings.TrimSpace(part.Ref) + if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") { + lines = append(lines, ref) + } + + return strings.Join(lines, "\n") +} + +func (c *WeComChannel) resolveMediaRoute(chatID string) (wecomTurn, uint32, bool) { + if turn, ok := c.getTurn(chatID); ok { + if time.Since(turn.CreatedAt) <= wecomStreamMaxDuration { + return turn, turn.ChatType, true + } + c.deleteTurn(chatID) + } + if route, ok := c.routes.Get(chatID); ok { + return wecomTurn{ChatID: route.ChatID, ChatType: route.ChatType}, route.ChatType, false + } + return wecomTurn{ChatID: chatID}, 0, false +} diff --git a/pkg/channels/wecom/media_test.go b/pkg/channels/wecom/media_test.go new file mode 100644 index 000000000..d5307e5d2 --- /dev/null +++ b/pkg/channels/wecom/media_test.go @@ -0,0 +1,180 @@ +package wecom + +import ( + "bytes" + "context" + "encoding/base64" + "io" + "net/http" + "strings" + "testing" + + basechannels "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/media" +) + +func TestStoreRemoteMedia_DetectsJPEGContentTypeFromBody(t *testing.T) { + t.Parallel() + + const jpegBase64 = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////" + + "//////////////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////" + + "//////////////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAVEQEBAAAAAAAAAAAAAAAAAAAABf/aAAwDAQACEAMQAAAB6A//xAAVEAEBAAAAAAAAAAAAAAAAAAAAEf/aAAgBAQABBQJf/8QAFBEBAAAAAAAAAAAAAAAAAAAAEP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAAAAAAEP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAEP/aAAgBAQAGPwJf/8QAFBABAAAAAAAAAAAAAAAAAAAAEP/aAAgBAQABPyFf/9k=" + + jpegData := decodeTestBase64(t, jpegBase64) + store := media.NewFileMediaStore() + ch := &WeComChannel{ + BaseChannel: basechannels.NewBaseChannel("wecom", nil, nil, nil), + mediaClient: &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/octet-stream"}}, + Body: io.NopCloser(bytes.NewReader(jpegData)), + }, nil + }), + }, + } + ch.SetMediaStore(store) + + ref, err := ch.storeRemoteMedia(context.Background(), "test-scope", "msg-1", "https://wecom.example/media", "", "") + if err != nil { + t.Fatalf("storeRemoteMedia returned error: %v", err) + } + t.Cleanup(func() { + _ = store.ReleaseAll("test-scope") + }) + + _, meta, err := store.ResolveWithMeta(ref) + if err != nil { + t.Fatalf("resolve media ref: %v", err) + } + if meta.ContentType != "image/jpeg" { + t.Fatalf("expected image/jpeg content type, got %q", meta.ContentType) + } + if !strings.HasSuffix(meta.Filename, ".jpg") && !strings.HasSuffix(meta.Filename, ".jpeg") { + t.Fatalf("expected jpeg filename, got %q", meta.Filename) + } +} + +func TestDetectWeComMediaMetadata_UsesFallbackExtensionWhenBodyUnknown(t *testing.T) { + t.Parallel() + + filename, contentType := detectWeComMediaMetadata([]byte("not a real image"), "msg-2.pdf", "", "", "") + if filename != "msg-2.pdf" { + t.Fatalf("expected fallback filename to be preserved, got %q", filename) + } + if contentType != "application/pdf" { + t.Fatalf("expected application/pdf from fallback extension, got %q", contentType) + } +} + +func TestStoreRemoteMedia_PreservesSuffixFromURL(t *testing.T) { + t.Parallel() + + docxLikeData := []byte("PK\x03\x04fake office payload") + store := media.NewFileMediaStore() + ch := &WeComChannel{ + BaseChannel: basechannels.NewBaseChannel("wecom", nil, nil, nil), + mediaClient: &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/octet-stream"}}, + Body: io.NopCloser(bytes.NewReader(docxLikeData)), + }, nil + }), + }, + } + ch.SetMediaStore(store) + + ref, err := ch.storeRemoteMedia( + context.Background(), + "test-scope", + "msg-docx", + "https://wecom.example/media/report.docx?signature=1", + "", + ".bin", + ) + if err != nil { + t.Fatalf("storeRemoteMedia returned error: %v", err) + } + t.Cleanup(func() { + _ = store.ReleaseAll("test-scope") + }) + + localPath, meta, err := store.ResolveWithMeta(ref) + if err != nil { + t.Fatalf("resolve media ref: %v", err) + } + if !strings.HasSuffix(meta.Filename, ".docx") { + t.Fatalf("expected docx filename, got %q", meta.Filename) + } + if !strings.HasSuffix(strings.ToLower(localPath), ".docx") { + t.Fatalf("expected docx temp path, got %q", localPath) + } +} + +func TestStoreRemoteMedia_PreservesSuffixFromContentDisposition(t *testing.T) { + t.Parallel() + + pptxLikeData := []byte("PK\x03\x04fake office payload") + store := media.NewFileMediaStore() + ch := &WeComChannel{ + BaseChannel: basechannels.NewBaseChannel("wecom", nil, nil, nil), + mediaClient: &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/octet-stream"}, + "Content-Disposition": []string{`attachment; filename="slides.pptx"`}, + }, + Body: io.NopCloser(bytes.NewReader(pptxLikeData)), + }, nil + }), + }, + } + ch.SetMediaStore(store) + + ref, err := ch.storeRemoteMedia( + context.Background(), + "test-scope", + "msg-pptx", + "https://wecom.example/media/download", + "", + ".bin", + ) + if err != nil { + t.Fatalf("storeRemoteMedia returned error: %v", err) + } + t.Cleanup(func() { + _ = store.ReleaseAll("test-scope") + }) + + localPath, meta, err := store.ResolveWithMeta(ref) + if err != nil { + t.Fatalf("resolve media ref: %v", err) + } + if !strings.HasSuffix(meta.Filename, ".pptx") { + t.Fatalf("expected pptx filename, got %q", meta.Filename) + } + if !strings.HasSuffix(strings.ToLower(localPath), ".pptx") { + t.Fatalf("expected pptx temp path, got %q", localPath) + } +} + +func decodeTestBase64(t *testing.T, value string) []byte { + t.Helper() + + data, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(value))) + if err != nil { + t.Fatalf("decode base64 fixture: %v", err) + } + return data +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/pkg/channels/wecom/protocol.go b/pkg/channels/wecom/protocol.go new file mode 100644 index 000000000..f42ce3bf4 --- /dev/null +++ b/pkg/channels/wecom/protocol.go @@ -0,0 +1,173 @@ +package wecom + +import "encoding/json" + +const ( + wecomDefaultWebSocketURL = "wss://openws.work.weixin.qq.com" + wecomCmdSubscribe = "aibot_subscribe" + wecomCmdPing = "ping" + wecomCmdMsgCallback = "aibot_msg_callback" + wecomCmdEventCallback = "aibot_event_callback" + wecomCmdRespondMsg = "aibot_respond_msg" + wecomCmdSendMsg = "aibot_send_msg" + wecomCmdUploadMediaInit = "aibot_upload_media_init" + wecomCmdUploadMediaChunk = "aibot_upload_media_chunk" + wecomCmdUploadMediaEnd = "aibot_upload_media_finish" +) + +type wecomEnvelope struct { + Cmd string `json:"cmd,omitempty"` + Headers wecomHeaders `json:"headers"` + Body json.RawMessage `json:"body,omitempty"` + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` +} + +type wecomHeaders struct { + ReqID string `json:"req_id,omitempty"` +} + +type wecomCommand struct { + Cmd string `json:"cmd"` + Headers wecomHeaders `json:"headers"` + Body any `json:"body,omitempty"` +} + +type wecomSendMsgBody struct { + ChatID string `json:"chatid"` + ChatType uint32 `json:"chat_type,omitempty"` + MsgType string `json:"msgtype"` + Markdown *wecomMarkdownContent `json:"markdown,omitempty"` + File *wecomMediaRefContent `json:"file,omitempty"` + Image *wecomMediaRefContent `json:"image,omitempty"` + Voice *wecomMediaRefContent `json:"voice,omitempty"` + Video *wecomVideoContent `json:"video,omitempty"` + TemplateCard map[string]any `json:"template_card,omitempty"` +} + +type wecomRespondMsgBody struct { + MsgType string `json:"msgtype"` + Stream *wecomStreamContent `json:"stream,omitempty"` + Markdown *wecomMarkdownContent `json:"markdown,omitempty"` + File *wecomMediaRefContent `json:"file,omitempty"` + Image *wecomMediaRefContent `json:"image,omitempty"` + Voice *wecomMediaRefContent `json:"voice,omitempty"` + Video *wecomVideoContent `json:"video,omitempty"` + TemplateCard map[string]any `json:"template_card,omitempty"` +} + +type wecomStreamContent struct { + ID string `json:"id"` + Finish bool `json:"finish"` + Content string `json:"content,omitempty"` +} + +type wecomMarkdownContent struct { + Content string `json:"content"` +} + +type wecomMediaRefContent struct { + MediaID string `json:"media_id"` +} + +type wecomVideoContent struct { + MediaID string `json:"media_id"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` +} + +type wecomUploadMediaInitBody struct { + Type string `json:"type"` + Filename string `json:"filename"` + TotalSize int64 `json:"total_size"` + TotalChunks int `json:"total_chunks"` + MD5 string `json:"md5,omitempty"` +} + +type wecomUploadMediaInitResponse struct { + UploadID string `json:"upload_id"` +} + +type wecomUploadMediaChunkBody struct { + UploadID string `json:"upload_id"` + ChunkIndex int `json:"chunk_index"` + Base64Data string `json:"base64_data"` +} + +type wecomUploadMediaFinishBody struct { + UploadID string `json:"upload_id"` +} + +type wecomUploadMediaFinishResponse struct { + Type string `json:"type"` + MediaID string `json:"media_id"` + CreatedAt json.RawMessage `json:"created_at"` +} + +type wecomIncomingMessage struct { + MsgID string `json:"msgid"` + AIBotID string `json:"aibotid"` + ChatID string `json:"chatid,omitempty"` + ChatType string `json:"chattype,omitempty"` + From struct { + UserID string `json:"userid"` + } `json:"from"` + MsgType string `json:"msgtype"` + Text *struct { + Content string `json:"content"` + } `json:"text,omitempty"` + Image *struct { + URL string `json:"url"` + AESKey string `json:"aeskey,omitempty"` + } `json:"image,omitempty"` + File *struct { + URL string `json:"url"` + AESKey string `json:"aeskey,omitempty"` + } `json:"file,omitempty"` + Video *struct { + URL string `json:"url"` + AESKey string `json:"aeskey,omitempty"` + } `json:"video,omitempty"` + Voice *struct { + Content string `json:"content"` + } `json:"voice,omitempty"` + Mixed *struct { + MsgItem []struct { + MsgType string `json:"msgtype"` + Text *struct { + Content string `json:"content"` + } `json:"text,omitempty"` + Image *struct { + URL string `json:"url"` + AESKey string `json:"aeskey,omitempty"` + } `json:"image,omitempty"` + File *struct { + URL string `json:"url"` + AESKey string `json:"aeskey,omitempty"` + } `json:"file,omitempty"` + } `json:"msg_item"` + } `json:"mixed,omitempty"` + Quote *struct { + MsgType string `json:"msgtype"` + Text *struct { + Content string `json:"content"` + } `json:"text,omitempty"` + } `json:"quote,omitempty"` + Event *struct { + EventType string `json:"eventtype"` + } `json:"event,omitempty"` +} + +func incomingChatID(msg wecomIncomingMessage) string { + if msg.ChatID != "" { + return msg.ChatID + } + return msg.From.UserID +} + +func incomingChatTypeCode(kind string) uint32 { + if kind == "group" { + return 2 + } + return 1 +} diff --git a/pkg/channels/wecom/reqid_store.go b/pkg/channels/wecom/reqid_store.go new file mode 100644 index 000000000..59e64e63d --- /dev/null +++ b/pkg/channels/wecom/reqid_store.go @@ -0,0 +1,113 @@ +package wecom + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" + "time" +) + +type wecomRoute struct { + ReqID string `json:"req_id"` + ChatID string `json:"chat_id"` + ChatType uint32 `json:"chat_type"` + ExpiresAt time.Time `json:"expires_at"` +} + +type reqIDStore struct { + mu sync.Mutex + path string + routes map[string]wecomRoute +} + +func newReqIDStore(path string) *reqIDStore { + if path == "" { + path = defaultReqIDStorePath() + } + s := &reqIDStore{ + path: path, + routes: make(map[string]wecomRoute), + } + _ = s.load() + return s +} + +func defaultReqIDStorePath() string { + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".picoclaw", "wecom", "reqid-store.json") + } + return filepath.Join(os.TempDir(), "picoclaw-wecom-reqid-store.json") +} + +func (s *reqIDStore) Put(chatID, reqID string, chatType uint32, ttl time.Duration) error { + if reqID == "" || chatID == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + s.deleteExpiredLocked(time.Now()) + s.routes[chatID] = wecomRoute{ + ReqID: reqID, + ChatID: chatID, + ChatType: chatType, + ExpiresAt: time.Now().Add(ttl), + } + return s.saveLocked() +} + +func (s *reqIDStore) Get(chatID string) (wecomRoute, bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.deleteExpiredLocked(time.Now()) + route, ok := s.routes[chatID] + return route, ok +} + +func (s *reqIDStore) Delete(chatID string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.routes, chatID) + return s.saveLocked() +} + +func (s *reqIDStore) load() error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + + var routes map[string]wecomRoute + if err := json.Unmarshal(data, &routes); err != nil { + return err + } + s.routes = routes + s.deleteExpiredLocked(time.Now()) + return nil +} + +func (s *reqIDStore) deleteExpiredLocked(now time.Time) { + for chatID, route := range s.routes { + if !route.ExpiresAt.IsZero() && now.After(route.ExpiresAt) { + delete(s.routes, chatID) + } + } +} + +func (s *reqIDStore) saveLocked() error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(s.routes, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, data, 0o600) +} diff --git a/pkg/channels/wecom/reqid_store_test.go b/pkg/channels/wecom/reqid_store_test.go new file mode 100644 index 000000000..e68e82500 --- /dev/null +++ b/pkg/channels/wecom/reqid_store_test.go @@ -0,0 +1,24 @@ +package wecom + +import ( + "path/filepath" + "testing" + "time" +) + +func TestReqIDStorePersistsRoutes(t *testing.T) { + storePath := filepath.Join(t.TempDir(), "reqids.json") + store := newReqIDStore(storePath) + if err := store.Put("chat-1", "req-1", 2, time.Hour); err != nil { + t.Fatalf("Put() error = %v", err) + } + + reloaded := newReqIDStore(storePath) + route, ok := reloaded.Get("chat-1") + if !ok { + t.Fatal("expected persisted route to be loaded") + } + if route.ChatID != "chat-1" || route.ReqID != "req-1" || route.ChatType != 2 { + t.Fatalf("loaded route = %+v", route) + } +} diff --git a/pkg/channels/wecom/wecom.go b/pkg/channels/wecom/wecom.go new file mode 100644 index 000000000..a0a23feda --- /dev/null +++ b/pkg/channels/wecom/wecom.go @@ -0,0 +1,982 @@ +package wecom + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + "math/big" + "net/http" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const ( + wecomConnectTimeout = 15 * time.Second + wecomCommandTimeout = 10 * time.Second + wecomUploadTimeout = 30 * time.Second + wecomHeartbeatInterval = 30 * time.Second + wecomStreamMaxDuration = 5*time.Minute + 30*time.Second + wecomStreamMinInterval = 500 * time.Millisecond + wecomRouteTTL = 30 * time.Minute + wecomMediaTimeout = 30 * time.Second + wecomRecentMessageMax = 1000 +) + +type WeComChannel struct { + *channels.BaseChannel + config *config.WeComSettings + + ctx context.Context + cancel context.CancelFunc + + conn *websocket.Conn + connMu sync.Mutex + + pendingMu sync.Mutex + pending map[string]chan wecomEnvelope + + turnsMu sync.Mutex + turns map[string][]wecomTurn + + recent *recentMessageSet + routes *reqIDStore + mediaClient *http.Client + commandSend func(wecomCommand, time.Duration) (wecomEnvelope, error) +} + +type wecomTurn struct { + ReqID string + ChatID string + ChatType uint32 + StreamID string + CreatedAt time.Time +} + +type wecomStreamer struct { + channel *WeComChannel + chatID string + turn wecomTurn + + mu sync.Mutex + closed bool + lastSentAt time.Time + content string +} + +type recentMessageSet struct { + mu sync.Mutex + seen map[string]struct{} + ring []string + idx int +} + +func newRecentMessageSet(capacity int) *recentMessageSet { + if capacity <= 0 { + capacity = wecomRecentMessageMax + } + return &recentMessageSet{ + seen: make(map[string]struct{}, capacity), + ring: make([]string, capacity), + } +} + +func (s *recentMessageSet) Mark(id string) bool { + if id == "" { + return true + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.seen[id]; ok { + return false + } + if old := s.ring[s.idx]; old != "" { + delete(s.seen, old) + } + s.ring[s.idx] = id + s.idx = (s.idx + 1) % len(s.ring) + s.seen[id] = struct{}{} + return true +} + +func NewChannel(bc *config.Channel, cfg *config.WeComSettings, messageBus *bus.MessageBus) (*WeComChannel, error) { + if cfg.BotID == "" || cfg.Secret.String() == "" { + return nil, fmt.Errorf("wecom bot_id and secret are required") + } + if cfg.WebSocketURL == "" { + cfg.WebSocketURL = wecomDefaultWebSocketURL + } + + base := channels.NewBaseChannel( + "wecom", + cfg, + messageBus, + bc.AllowFrom, + channels.WithReasoningChannelID(bc.ReasoningChannelID), + ) + + ch := &WeComChannel{ + BaseChannel: base, + config: cfg, + pending: make(map[string]chan wecomEnvelope), + turns: make(map[string][]wecomTurn), + recent: newRecentMessageSet(wecomRecentMessageMax), + routes: newReqIDStore(""), + mediaClient: &http.Client{Timeout: wecomMediaTimeout}, + } + ch.SetOwner(ch) + return ch, nil +} + +func (c *WeComChannel) Name() string { return "wecom" } + +func (c *WeComChannel) Start(ctx context.Context) error { + logger.InfoC("wecom", "Starting WeCom channel...") + c.ctx, c.cancel = context.WithCancel(ctx) + c.SetRunning(true) + go c.connectLoop() + return nil +} + +func (c *WeComChannel) Stop(_ context.Context) error { + logger.InfoC("wecom", "Stopping WeCom channel...") + if c.cancel != nil { + c.cancel() + } + c.connMu.Lock() + if c.conn != nil { + _ = c.conn.Close() + c.conn = nil + } + c.connMu.Unlock() + c.clearTurns() + c.SetRunning(false) + return nil +} + +func (c *WeComChannel) BeginStream(_ context.Context, chatID string) (channels.Streamer, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + + turn, ok := c.getTurn(chatID) + if !ok { + return nil, fmt.Errorf("wecom streaming unavailable: no active turn") + } + if time.Since(turn.CreatedAt) > wecomStreamMaxDuration { + c.consumeTurn(chatID, turn) + return nil, fmt.Errorf("wecom streaming unavailable: turn expired") + } + + return &wecomStreamer{ + channel: c, + chatID: chatID, + turn: turn, + }, nil +} + +func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + content := strings.TrimSpace(msg.Content) + if content == "" { + return nil, nil + } + + if turn, ok := c.getTurn(msg.ChatID); ok { + if time.Since(turn.CreatedAt) <= wecomStreamMaxDuration { + if err := c.sendStreamReply(turn, content); err == nil { + c.consumeTurn(msg.ChatID, turn) + return nil, nil + } + } + c.consumeTurn(msg.ChatID, turn) + } + + if route, ok := c.routes.Get(msg.ChatID); ok { + if err := c.sendActivePush(route.ChatID, route.ChatType, content); err != nil { + return nil, err + } + return nil, nil + } + + if err := c.sendActivePush(msg.ChatID, 0, content); err != nil { + return nil, err + } + return nil, nil +} + +func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + + route, chatType, hasTurn := c.resolveMediaRoute(msg.ChatID) + chatID := route.ChatID + if chatID == "" { + chatID = msg.ChatID + } + + for _, part := range msg.Parts { + if strings.TrimSpace(part.Ref) == "" { + if caption := strings.TrimSpace(part.Caption); caption != "" { + if err := c.sendActivePush(chatID, chatType, caption); err != nil { + return nil, err + } + } + continue + } + + localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part) + if err != nil { + return nil, fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed) + } + + func() { + if cleanup != nil { + defer cleanup() + } + + uploaded, uploadErr := c.uploadOutboundMedia(ctx, localPath, filename, contentType, part) + if uploadErr != nil { + logger.WarnCF("wecom", "Falling back to placeholder after media upload failure", map[string]any{ + "chat_id": chatID, + "ref": part.Ref, + "filename": filename, + "content_type": contentType, + "error": uploadErr.Error(), + }) + if hasTurn { + if finishErr := c.sendStreamChunk(route, true, ""); finishErr != nil { + err = finishErr + return + } + c.deleteTurn(msg.ChatID) + hasTurn = false + } + err = c.sendActivePush(chatID, chatType, fallbackWeComMediaText(part, "", filename)) + return + } + + if hasTurn { + err = c.sendTurnMedia(route, uploaded) + c.deleteTurn(msg.ChatID) + hasTurn = false + } else { + err = c.sendActiveMedia(chatID, chatType, uploaded) + } + if err != nil { + return + } + if caption := strings.TrimSpace(part.Caption); caption != "" { + err = c.sendActivePush(chatID, chatType, caption) + } + }() + if err != nil { + return nil, err + } + } + + return nil, nil +} + +func (c *WeComChannel) connectLoop() { + backoff := time.Second + for { + select { + case <-c.ctx.Done(): + return + default: + } + + if err := c.runConnection(); err != nil { + logger.WarnCF("wecom", "WeCom connection lost", map[string]any{ + "error": err.Error(), + "backoff": backoff.String(), + }) + select { + case <-time.After(backoff): + case <-c.ctx.Done(): + return + } + if backoff < time.Minute { + backoff *= 2 + if backoff > time.Minute { + backoff = time.Minute + } + } + continue + } + return + } +} + +func (c *WeComChannel) runConnection() error { + dialCtx, cancel := context.WithTimeout(c.ctx, wecomConnectTimeout) + defer cancel() + + conn, resp, err := websocket.DefaultDialer.DialContext(dialCtx, c.config.WebSocketURL, nil) + if resp != nil { + _ = resp.Body.Close() + } + if err != nil { + return fmt.Errorf("%w: %v", channels.ErrTemporary, err) + } + + c.connMu.Lock() + c.conn = conn + c.connMu.Unlock() + defer func() { + c.connMu.Lock() + if c.conn == conn { + c.conn = nil + } + c.connMu.Unlock() + _ = conn.Close() + c.clearTurns() + }() + + readErrCh := make(chan error, 1) + go func() { + readErrCh <- c.readLoop(conn) + }() + + if writeErr := c.writeAndWait(conn, wecomCommand{ + Cmd: wecomCmdSubscribe, + Headers: wecomHeaders{ReqID: randomID(10)}, + Body: map[string]string{ + "bot_id": c.config.BotID, + "secret": c.config.Secret.String(), + }, + }, wecomCommandTimeout); writeErr != nil { + return writeErr + } + + heartbeatDone := make(chan struct{}) + go func() { + defer close(heartbeatDone) + c.heartbeatLoop(conn) + }() + + err = <-readErrCh + _ = conn.Close() + <-heartbeatDone + return err +} + +func (c *WeComChannel) heartbeatLoop(conn *websocket.Conn) { + ticker := time.NewTicker(wecomHeartbeatInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := c.writeAndWait(conn, wecomCommand{ + Cmd: wecomCmdPing, + Headers: wecomHeaders{ReqID: randomID(10)}, + }, wecomCommandTimeout); err != nil { + logger.WarnCF("wecom", "Heartbeat failed", map[string]any{"error": err.Error()}) + _ = conn.Close() + return + } + case <-c.ctx.Done(): + return + } + } +} + +func (c *WeComChannel) readLoop(conn *websocket.Conn) error { + for { + _, raw, err := conn.ReadMessage() + if err != nil { + select { + case <-c.ctx.Done(): + return nil + default: + return fmt.Errorf("%w: %v", channels.ErrTemporary, err) + } + } + + var env wecomEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + logger.WarnCF("wecom", "Failed to parse WebSocket message", map[string]any{"error": err.Error()}) + continue + } + + if env.Cmd == "" && env.Headers.ReqID != "" { + c.pendingMu.Lock() + ch, ok := c.pending[env.Headers.ReqID] + if ok { + delete(c.pending, env.Headers.ReqID) + } + c.pendingMu.Unlock() + if ok { + ch <- env + } + continue + } + + go c.handleEnvelope(env) + } +} + +func (c *WeComChannel) handleEnvelope(env wecomEnvelope) { + switch env.Cmd { + case wecomCmdMsgCallback: + c.handleMessageCallback(env) + case wecomCmdEventCallback: + c.handleEventCallback(env) + default: + logger.DebugCF("wecom", "Ignoring unsupported WeCom command", map[string]any{"cmd": env.Cmd}) + } +} + +func (c *WeComChannel) handleEventCallback(env wecomEnvelope) { + var msg wecomIncomingMessage + if err := json.Unmarshal(env.Body, &msg); err != nil { + logger.WarnCF("wecom", "Failed to parse WeCom event callback", map[string]any{"error": err.Error()}) + } +} + +func (c *WeComChannel) handleMessageCallback(env wecomEnvelope) { + var msg wecomIncomingMessage + if err := json.Unmarshal(env.Body, &msg); err != nil { + logger.WarnCF("wecom", "Failed to parse WeCom message callback", map[string]any{"error": err.Error()}) + return + } + if !c.recent.Mark(msg.MsgID) { + return + } + + reqID := env.Headers.ReqID + if reqID == "" { + logger.WarnC("wecom", "WeCom message callback missing req_id") + return + } + if msg.Event != nil && msg.Event.EventType != "" { + return + } + + if err := c.dispatchIncoming(reqID, msg); err != nil { + logger.WarnCF("wecom", "Failed to dispatch WeCom message", map[string]any{ + "req_id": reqID, + "error": err.Error(), + }) + _ = c.respondImmediate(reqID, "The WeCom message could not be processed.") + } +} + +func (c *WeComChannel) dispatchIncoming(reqID string, msg wecomIncomingMessage) error { + senderID := msg.From.UserID + if senderID == "" { + senderID = "unknown" + } + actualChatID := incomingChatID(msg) + chatType := incomingChatTypeCode(msg.ChatType) + peerKind := "direct" + if msg.ChatType == "group" { + peerKind = "group" + } + + sender := bus.SenderInfo{ + Platform: "wecom", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("wecom", senderID), + DisplayName: senderID, + } + + var ( + content string + quoteText string + mediaRefs []string + err error + ) + scope := channels.BuildMediaScope("wecom", actualChatID, msg.MsgID) + switch msg.MsgType { + case "text": + if msg.Text != nil { + content = strings.TrimSpace(msg.Text.Content) + } + case "voice": + if msg.Voice != nil { + content = strings.TrimSpace(msg.Voice.Content) + } + case "image": + content = "[image]" + mediaRefs, err = c.collectSingleMedia(c.ctx, scope, msg.MsgID, &mediaPayload{ + url: msg.Image.URL, + aesKey: msg.Image.AESKey, + }, "image", ".jpg") + case "file": + content = "[file]" + mediaRefs, err = c.collectSingleMedia(c.ctx, scope, msg.MsgID, &mediaPayload{ + url: msg.File.URL, + aesKey: msg.File.AESKey, + }, "file", ".bin") + case "video": + content = "[video]" + mediaRefs, err = c.collectSingleMedia(c.ctx, scope, msg.MsgID, &mediaPayload{ + url: msg.Video.URL, + aesKey: msg.Video.AESKey, + }, "video", ".mp4") + case "mixed": + content, mediaRefs, err = c.collectMixedMedia(c.ctx, scope, msg) + default: + return c.respondImmediate(reqID, "Unsupported WeCom message type: "+msg.MsgType) + } + if err != nil { + return err + } + if msg.Quote != nil && msg.Quote.Text != nil { + quoteText = strings.TrimSpace(msg.Quote.Text.Content) + if content == "" { + content = quoteText + } + } + if content == "" && len(mediaRefs) == 0 { + return c.respondImmediate(reqID, "The WeCom message did not contain usable content.") + } + + turn := wecomTurn{ + ReqID: reqID, + ChatID: actualChatID, + ChatType: chatType, + StreamID: randomID(10), + CreatedAt: time.Now(), + } + c.queueTurn(actualChatID, turn) + if err := c.routes.Put(actualChatID, reqID, chatType, wecomRouteTTL); err != nil { + logger.WarnCF("wecom", "Failed to persist req_id route", map[string]any{ + "chat_id": actualChatID, + "req_id": reqID, + "error": err.Error(), + }) + } + + opening := "" + if c.config.SendThinkingMessage { + opening = "Processing..." + } + if err := c.sendStreamChunk(turn, false, opening); err != nil { + return err + } + + metadata := map[string]string{ + "channel": "wecom", + "req_id": reqID, + "chat_id": actualChatID, + "chat_type": msg.ChatType, + "msg_id": msg.MsgID, + "msg_type": msg.MsgType, + } + if quoteText != "" { + metadata["quote_text"] = quoteText + } + + inboundCtx := bus.InboundContext{ + Channel: c.Name(), + Account: strings.TrimSpace(msg.AIBotID), + ChatID: actualChatID, + ChatType: peerKind, + SenderID: senderID, + MessageID: msg.MsgID, + ReplyHandles: map[string]string{ + "req_id": reqID, + }, + Raw: metadata, + } + + c.HandleInboundContext(c.ctx, actualChatID, content, mediaRefs, inboundCtx, sender) + return nil +} + +func (c *WeComChannel) collectSingleMedia( + ctx context.Context, + scope, msgID string, + payload interface { + GetURL() string + GetAESKey() string + }, + label, fallbackExt string, +) ([]string, error) { + if payload == nil || payload.GetURL() == "" { + return nil, fmt.Errorf("%s payload is empty", label) + } + ref, err := c.storeRemoteMedia(ctx, scope, msgID, payload.GetURL(), payload.GetAESKey(), fallbackExt) + if err != nil { + return nil, err + } + return []string{ref}, nil +} + +type mediaPayload struct { + url string + aesKey string +} + +func (p *mediaPayload) GetURL() string { return p.url } +func (p *mediaPayload) GetAESKey() string { return p.aesKey } + +func (c *WeComChannel) collectMixedMedia( + ctx context.Context, + scope string, + msg wecomIncomingMessage, +) (string, []string, error) { + if msg.Mixed == nil { + return "", nil, fmt.Errorf("mixed message is empty") + } + + var textParts []string + var refs []string + for idx, item := range msg.Mixed.MsgItem { + switch item.MsgType { + case "text": + if item.Text != nil && strings.TrimSpace(item.Text.Content) != "" { + textParts = append(textParts, strings.TrimSpace(item.Text.Content)) + } + case "image": + if item.Image != nil && item.Image.URL != "" { + ref, err := c.storeRemoteMedia( + ctx, + scope, + fmt.Sprintf("%s-%d", msg.MsgID, idx), + item.Image.URL, + item.Image.AESKey, + ".jpg", + ) + if err != nil { + return "", nil, err + } + refs = append(refs, ref) + } + case "file": + if item.File != nil && item.File.URL != "" { + ref, err := c.storeRemoteMedia( + ctx, + scope, + fmt.Sprintf("%s-%d", msg.MsgID, idx), + item.File.URL, + item.File.AESKey, + ".bin", + ) + if err != nil { + return "", nil, err + } + refs = append(refs, ref) + } + } + } + + content := strings.Join(textParts, "\n") + if content == "" && len(refs) > 0 { + content = "[media]" + } + return content, refs, nil +} + +func (c *WeComChannel) respondImmediate(reqID, content string) error { + turn := wecomTurn{ + ReqID: reqID, + StreamID: randomID(10), + CreatedAt: time.Now(), + } + return c.sendStreamChunk(turn, true, content) +} + +func (c *WeComChannel) sendStreamReply(turn wecomTurn, content string) error { + return c.sendStreamChunk(turn, true, content) +} + +func (c *WeComChannel) sendStreamChunk(turn wecomTurn, finish bool, content string) error { + return c.sendCommand(wecomCommand{ + Cmd: wecomCmdRespondMsg, + Headers: wecomHeaders{ReqID: turn.ReqID}, + Body: wecomRespondMsgBody{ + MsgType: "stream", + Stream: &wecomStreamContent{ + ID: turn.StreamID, + Finish: finish, + Content: content, + }, + }, + }, wecomCommandTimeout) +} + +func (c *WeComChannel) sendTurnMedia(turn wecomTurn, uploaded *wecomOutboundMedia) error { + if uploaded == nil { + return fmt.Errorf("wecom outbound media is nil: %w", channels.ErrSendFailed) + } + if err := c.sendCommand(wecomCommand{ + Cmd: wecomCmdRespondMsg, + Headers: wecomHeaders{ReqID: turn.ReqID}, + Body: uploaded.respondBody(), + }, wecomCommandTimeout); err != nil { + return err + } + return c.sendStreamChunk(turn, true, "") +} + +func (c *WeComChannel) sendActivePush(chatID string, chatType uint32, content string) error { + if strings.TrimSpace(chatID) == "" { + return fmt.Errorf("empty chat ID: %w", channels.ErrSendFailed) + } + return c.sendCommand(wecomCommand{ + Cmd: wecomCmdSendMsg, + Headers: wecomHeaders{ReqID: randomID(10)}, + Body: wecomSendMsgBody{ + ChatID: chatID, + ChatType: chatType, + MsgType: "markdown", + Markdown: &wecomMarkdownContent{Content: content}, + }, + }, wecomCommandTimeout) +} + +func (c *WeComChannel) sendActiveMedia(chatID string, chatType uint32, uploaded *wecomOutboundMedia) error { + if strings.TrimSpace(chatID) == "" { + return fmt.Errorf("empty chat ID: %w", channels.ErrSendFailed) + } + if uploaded == nil { + return fmt.Errorf("wecom outbound media is nil: %w", channels.ErrSendFailed) + } + return c.sendCommand(wecomCommand{ + Cmd: wecomCmdSendMsg, + Headers: wecomHeaders{ReqID: randomID(10)}, + Body: uploaded.sendBody(chatID, chatType), + }, wecomCommandTimeout) +} + +func (c *WeComChannel) sendCommand(cmd wecomCommand, timeout time.Duration) error { + _, err := c.sendCommandAck(cmd, timeout) + return err +} + +func (c *WeComChannel) sendCommandAck(cmd wecomCommand, timeout time.Duration) (wecomEnvelope, error) { + if c.commandSend != nil { + return c.commandSend(cmd, timeout) + } + return c.writeCurrentAck(cmd, timeout) +} + +func (c *WeComChannel) writeCurrentAck(cmd wecomCommand, timeout time.Duration) (wecomEnvelope, error) { + c.connMu.Lock() + conn := c.conn + c.connMu.Unlock() + if conn == nil { + return wecomEnvelope{}, fmt.Errorf("wecom websocket not connected: %w", channels.ErrTemporary) + } + return c.writeAndWaitAck(conn, cmd, timeout) +} + +func (c *WeComChannel) writeAndWait(conn *websocket.Conn, cmd wecomCommand, timeout time.Duration) error { + _, err := c.writeAndWaitAck(conn, cmd, timeout) + return err +} + +func (c *WeComChannel) writeAndWaitAck( + conn *websocket.Conn, + cmd wecomCommand, + timeout time.Duration, +) (wecomEnvelope, error) { + if cmd.Headers.ReqID == "" { + cmd.Headers.ReqID = randomID(10) + } + waitCh := make(chan wecomEnvelope, 1) + c.pendingMu.Lock() + c.pending[cmd.Headers.ReqID] = waitCh + c.pendingMu.Unlock() + defer func() { + c.pendingMu.Lock() + delete(c.pending, cmd.Headers.ReqID) + c.pendingMu.Unlock() + }() + + data, err := json.Marshal(cmd) + if err != nil { + return wecomEnvelope{}, fmt.Errorf("%w: %v", channels.ErrSendFailed, err) + } + c.connMu.Lock() + err = conn.WriteMessage(websocket.TextMessage, data) + c.connMu.Unlock() + if err != nil { + return wecomEnvelope{}, fmt.Errorf("%w: %v", channels.ErrTemporary, err) + } + + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case env := <-waitCh: + if env.ErrCode != 0 { + return wecomEnvelope{}, fmt.Errorf( + "%w: wecom errcode=%d errmsg=%s", + channels.ErrTemporary, + env.ErrCode, + env.ErrMsg, + ) + } + return env, nil + case <-timer.C: + return wecomEnvelope{}, fmt.Errorf("%w: timeout waiting for WeCom ack", channels.ErrTemporary) + case <-c.ctx.Done(): + return wecomEnvelope{}, c.ctx.Err() + } +} + +func (c *WeComChannel) getTurn(chatID string) (wecomTurn, bool) { + c.turnsMu.Lock() + defer c.turnsMu.Unlock() + queue := c.turns[chatID] + if len(queue) == 0 { + return wecomTurn{}, false + } + return queue[0], true +} + +func (c *WeComChannel) deleteTurn(chatID string) { + c.turnsMu.Lock() + defer c.turnsMu.Unlock() + queue := c.turns[chatID] + if len(queue) <= 1 { + delete(c.turns, chatID) + return + } + c.turns[chatID] = queue[1:] +} + +func (c *WeComChannel) queueTurn(chatID string, turn wecomTurn) { + c.turnsMu.Lock() + defer c.turnsMu.Unlock() + c.turns[chatID] = append(c.turns[chatID], turn) +} + +func (c *WeComChannel) consumeTurn(chatID string, turn wecomTurn) bool { + c.turnsMu.Lock() + defer c.turnsMu.Unlock() + + queue := c.turns[chatID] + if len(queue) == 0 { + return false + } + current := queue[0] + if current.ReqID != turn.ReqID || current.StreamID != turn.StreamID { + return false + } + if len(queue) == 1 { + delete(c.turns, chatID) + return true + } + c.turns[chatID] = queue[1:] + return true +} + +func (c *WeComChannel) clearTurns() { + c.turnsMu.Lock() + c.turns = make(map[string][]wecomTurn) + c.turnsMu.Unlock() +} + +func randomID(n int) string { + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + if n <= 0 { + n = 10 + } + buf := make([]byte, n) + for i := range buf { + v, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) + buf[i] = alphabet[v.Int64()] + } + return string(buf) +} + +func (s *wecomStreamer) Update(ctx context.Context, content string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return nil + } + if err := s.validateActiveTurn(); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + + if !s.lastSentAt.IsZero() { + wait := time.Until(s.lastSentAt.Add(wecomStreamMinInterval)) + if wait > 0 { + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + } + } + } + + if err := s.channel.sendStreamChunk(s.turn, false, content); err != nil { + return err + } + s.content = content + s.lastSentAt = time.Now() + return nil +} + +func (s *wecomStreamer) Finalize(ctx context.Context, content string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return nil + } + if err := s.validateActiveTurn(); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if err := s.channel.sendStreamChunk(s.turn, true, content); err != nil { + return err + } + + s.content = content + s.closed = true + s.channel.consumeTurn(s.chatID, s.turn) + return nil +} + +func (s *wecomStreamer) Cancel(_ context.Context) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return + } + if s.validateActiveTurn() == nil { + _ = s.channel.sendStreamChunk(s.turn, true, s.content) + s.channel.consumeTurn(s.chatID, s.turn) + } + s.closed = true +} + +func (s *wecomStreamer) validateActiveTurn() error { + if time.Since(s.turn.CreatedAt) > wecomStreamMaxDuration { + s.channel.consumeTurn(s.chatID, s.turn) + return fmt.Errorf("wecom streaming unavailable: turn expired") + } + current, ok := s.channel.getTurn(s.chatID) + if !ok || current.ReqID != s.turn.ReqID || current.StreamID != s.turn.StreamID { + return fmt.Errorf("wecom streaming unavailable: turn no longer active") + } + return nil +} diff --git a/pkg/channels/wecom/wecom_test.go b/pkg/channels/wecom/wecom_test.go new file mode 100644 index 000000000..85a2f6ef7 --- /dev/null +++ b/pkg/channels/wecom/wecom_test.go @@ -0,0 +1,661 @@ +package wecom + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" +) + +func TestDispatchIncoming_UsesActualChatIDAndStoresReqIDRoute(t *testing.T) { + t.Parallel() + + messageBus := bus.NewMessageBus() + ch := newTestWeComChannel(t, messageBus) + + var commands []wecomCommand + ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) { + commands = append(commands, cmd) + return wecomTestAck(nil), nil + } + + msg := wecomIncomingMessage{ + MsgID: "msg-1", + ChatID: "chat-1", + ChatType: "direct", + MsgType: "text", + Text: &struct { + Content string `json:"content"` + }{Content: "hello"}, + } + msg.From.UserID = "user-1" + + if err := ch.dispatchIncoming("req-1", msg); err != nil { + t.Fatalf("dispatchIncoming() error = %v", err) + } + + select { + case inbound := <-messageBus.InboundChan(): + if inbound.ChatID != "chat-1" { + t.Fatalf("inbound ChatID = %q, want chat-1", inbound.ChatID) + } + if inbound.MessageID != "msg-1" { + t.Fatalf("inbound MessageID = %q, want msg-1", inbound.MessageID) + } + if inbound.Context.ChatType != "direct" { + t.Fatalf("inbound Context.ChatType = %q, want direct", inbound.Context.ChatType) + } + if inbound.Context.ReplyHandles["req_id"] != "req-1" { + t.Fatalf("inbound req_id = %q, want req-1", inbound.Context.ReplyHandles["req_id"]) + } + default: + t.Fatal("expected inbound message to be published") + } + + turn, ok := ch.getTurn("chat-1") + if !ok { + t.Fatal("expected queued turn for chat-1") + } + if turn.ReqID != "req-1" { + t.Fatalf("turn.ReqID = %q, want req-1", turn.ReqID) + } + + route, ok := ch.routes.Get("chat-1") + if !ok { + t.Fatal("expected persisted route for chat-1") + } + if route.ReqID != "req-1" || route.ChatType != 1 { + t.Fatalf("route = %+v", route) + } + + if len(commands) != 1 { + t.Fatalf("expected 1 opening command, got %d", len(commands)) + } + if commands[0].Cmd != wecomCmdRespondMsg { + t.Fatalf("opening command = %q, want %q", commands[0].Cmd, wecomCmdRespondMsg) + } + if commands[0].Headers.ReqID != "req-1" { + t.Fatalf("opening req_id = %q, want req-1", commands[0].Headers.ReqID) + } +} + +func TestNewChannel_DoesNotRegisterMessageSplitLimit(t *testing.T) { + t.Parallel() + + ch := newTestWeComChannel(t, bus.NewMessageBus()) + if got := ch.MaxMessageLength(); got != 0 { + t.Fatalf("MaxMessageLength() = %d, want 0", got) + } +} + +func TestBeginStream_UpdateAndFinalize(t *testing.T) { + t.Parallel() + + ch := newTestWeComChannel(t, bus.NewMessageBus()) + ch.SetRunning(true) + ch.queueTurn("chat-1", wecomTurn{ + ReqID: "req-1", + ChatID: "chat-1", + ChatType: 1, + StreamID: "stream-1", + CreatedAt: time.Now(), + }) + + var commands []wecomCommand + ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) { + commands = append(commands, cmd) + return wecomTestAck(nil), nil + } + + streamer, err := ch.BeginStream(context.Background(), "chat-1") + if err != nil { + t.Fatalf("BeginStream() error = %v", err) + } + if err := streamer.Update(context.Background(), "draft"); err != nil { + t.Fatalf("Update() error = %v", err) + } + if err := streamer.Finalize(context.Background(), "final"); err != nil { + t.Fatalf("Finalize() error = %v", err) + } + + if len(commands) != 2 { + t.Fatalf("expected 2 commands, got %d", len(commands)) + } + for i, wantFinish := range []bool{false, true} { + if commands[i].Cmd != wecomCmdRespondMsg { + t.Fatalf("command[%d].Cmd = %q, want %q", i, commands[i].Cmd, wecomCmdRespondMsg) + } + body, ok := commands[i].Body.(wecomRespondMsgBody) + if !ok { + t.Fatalf("command[%d] body type = %T", i, commands[i].Body) + } + if body.Stream == nil { + t.Fatalf("command[%d] missing stream body", i) + } + if body.Stream.ID != "stream-1" { + t.Fatalf("command[%d] stream id = %q, want stream-1", i, body.Stream.ID) + } + if body.Stream.Finish != wantFinish { + t.Fatalf("command[%d] finish = %v, want %v", i, body.Stream.Finish, wantFinish) + } + } + if body := commands[0].Body.(wecomRespondMsgBody); body.Stream.Content != "draft" { + t.Fatalf("update content = %q, want draft", body.Stream.Content) + } + if body := commands[1].Body.(wecomRespondMsgBody); body.Stream.Content != "final" { + t.Fatalf("final content = %q, want final", body.Stream.Content) + } + if _, ok := ch.getTurn("chat-1"); ok { + t.Fatal("expected turn to be consumed after Finalize") + } +} + +func TestSend_StreamFailureFallsBackToActualChatID(t *testing.T) { + t.Parallel() + + ch := newTestWeComChannel(t, bus.NewMessageBus()) + ch.SetRunning(true) + ch.queueTurn("chat-1", wecomTurn{ + ReqID: "req-1", + ChatID: "chat-1", + ChatType: 1, + StreamID: "stream-1", + CreatedAt: time.Now(), + }) + ch.queueTurn("chat-1", wecomTurn{ + ReqID: "req-2", + ChatID: "chat-1", + ChatType: 1, + StreamID: "stream-2", + CreatedAt: time.Now(), + }) + if err := ch.routes.Put("chat-1", "req-2", 1, time.Hour); err != nil { + t.Fatalf("Put() error = %v", err) + } + + var commands []wecomCommand + ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) { + commands = append(commands, cmd) + if len(commands) == 1 && cmd.Cmd == wecomCmdRespondMsg { + return wecomEnvelope{}, errors.New("stream send failed") + } + return wecomTestAck(nil), nil + } + + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ + Channel: "wecom", + ChatID: "chat-1", + Content: "hello", + }); err != nil { + t.Fatalf("Send() error = %v", err) + } + + if len(commands) != 2 { + t.Fatalf("expected 2 commands, got %d", len(commands)) + } + if commands[0].Cmd != wecomCmdRespondMsg || commands[0].Headers.ReqID != "req-1" { + t.Fatalf("first command = %+v", commands[0]) + } + if commands[1].Cmd != wecomCmdSendMsg { + t.Fatalf("second command = %q, want %q", commands[1].Cmd, wecomCmdSendMsg) + } + body, ok := commands[1].Body.(wecomSendMsgBody) + if !ok { + t.Fatalf("unexpected send body type %T", commands[1].Body) + } + if body.ChatID != "chat-1" { + t.Fatalf("send chatid = %q, want chat-1", body.ChatID) + } + if body.ChatType != 1 { + t.Fatalf("send chat_type = %d, want 1", body.ChatType) + } + + nextTurn, ok := ch.getTurn("chat-1") + if !ok { + t.Fatal("expected second turn to remain queued") + } + if nextTurn.ReqID != "req-2" { + t.Fatalf("next queued req_id = %q, want req-2", nextTurn.ReqID) + } +} + +func TestSend_DoesNotSplitStreamReply(t *testing.T) { + t.Parallel() + + ch := newTestWeComChannel(t, bus.NewMessageBus()) + ch.SetRunning(true) + ch.queueTurn("chat-1", wecomTurn{ + ReqID: "req-1", + ChatID: "chat-1", + ChatType: 1, + StreamID: "stream-1", + CreatedAt: time.Now(), + }) + + var commands []wecomCommand + ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) { + commands = append(commands, cmd) + return wecomTestAck(nil), nil + } + + content := strings.Repeat("\u4e2d", 30000) + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ + Channel: "wecom", + ChatID: "chat-1", + Content: content, + }); err != nil { + t.Fatalf("Send() error = %v", err) + } + + if len(commands) != 1 { + t.Fatalf("expected 1 stream command, got %d", len(commands)) + } + body, ok := commands[0].Body.(wecomRespondMsgBody) + if !ok { + t.Fatalf("unexpected body type %T", commands[0].Body) + } + if body.Stream == nil || !body.Stream.Finish { + t.Fatalf("stream body = %+v", body.Stream) + } + if body.Stream.Content != content { + t.Fatalf("stream content length = %d, want %d", len(body.Stream.Content), len(content)) + } +} + +func TestSend_DoesNotSplitActivePush(t *testing.T) { + t.Parallel() + + ch := newTestWeComChannel(t, bus.NewMessageBus()) + ch.SetRunning(true) + + var commands []wecomCommand + ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) { + commands = append(commands, cmd) + return wecomTestAck(nil), nil + } + + content := strings.Repeat("a", 30000) + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ + Channel: "wecom", + ChatID: "chat-1", + Content: content, + }); err != nil { + t.Fatalf("Send() error = %v", err) + } + + if len(commands) != 1 { + t.Fatalf("expected 1 send command, got %d", len(commands)) + } + if commands[0].Cmd != wecomCmdSendMsg { + t.Fatalf("command = %q, want %q", commands[0].Cmd, wecomCmdSendMsg) + } + body, ok := commands[0].Body.(wecomSendMsgBody) + if !ok { + t.Fatalf("unexpected body type %T", commands[0].Body) + } + if body.Markdown == nil || body.Markdown.Content != content { + t.Fatalf("markdown content length = %d, want %d", len(body.Markdown.Content), len(content)) + } +} + +func TestSendMedia_SendsActiveImage(t *testing.T) { + t.Parallel() + + ch := newTestWeComChannel(t, bus.NewMessageBus()) + ch.SetRunning(true) + + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + imageData := wecomTestJPEGData(t) + imagePath := filepath.Join(t.TempDir(), "photo.jpg") + if err := os.WriteFile(imagePath, imageData, 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + ref, err := store.Store(imagePath, media.MediaMeta{ + Filename: "photo.jpg", + ContentType: "image/jpeg", + Source: "test", + CleanupPolicy: media.CleanupPolicyForgetOnly, + }, "scope-1") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + var commands []wecomCommand + ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) { + commands = append(commands, cmd) + switch cmd.Cmd { + case wecomCmdUploadMediaInit: + return wecomTestAck(wecomUploadMediaInitResponse{UploadID: "upload-1"}), nil + case wecomCmdUploadMediaEnd: + return wecomTestAck(wecomUploadMediaFinishResponse{ + Type: "image", + MediaID: "media-1", + }), nil + default: + return wecomTestAck(nil), nil + } + } + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + Channel: "wecom", + ChatID: "chat-1", + Parts: []bus.MediaPart{{ + Ref: ref, + Type: "image", + Filename: "photo.jpg", + ContentType: "image/jpeg", + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(commands) != 4 { + t.Fatalf("expected 4 commands, got %d", len(commands)) + } + if commands[0].Cmd != wecomCmdUploadMediaInit { + t.Fatalf("first command = %q, want %q", commands[0].Cmd, wecomCmdUploadMediaInit) + } + initBody, ok := commands[0].Body.(wecomUploadMediaInitBody) + if !ok { + t.Fatalf("unexpected init body type %T", commands[0].Body) + } + if initBody.Type != "image" || initBody.Filename != "photo.jpg" || initBody.TotalChunks != 1 { + t.Fatalf("init body = %+v", initBody) + } + if commands[1].Cmd != wecomCmdUploadMediaChunk { + t.Fatalf("second command = %q, want %q", commands[1].Cmd, wecomCmdUploadMediaChunk) + } + chunkBody, ok := commands[1].Body.(wecomUploadMediaChunkBody) + if !ok { + t.Fatalf("unexpected chunk body type %T", commands[1].Body) + } + if chunkBody.UploadID != "upload-1" || chunkBody.ChunkIndex != 0 || chunkBody.Base64Data == "" { + t.Fatalf("chunk body = %+v", chunkBody) + } + if commands[2].Cmd != wecomCmdUploadMediaEnd { + t.Fatalf("third command = %q, want %q", commands[2].Cmd, wecomCmdUploadMediaEnd) + } + if commands[3].Cmd != wecomCmdSendMsg { + t.Fatalf("fourth command = %q, want %q", commands[3].Cmd, wecomCmdSendMsg) + } + + body, ok := commands[3].Body.(wecomSendMsgBody) + if !ok { + t.Fatalf("unexpected send body type %T", commands[3].Body) + } + if body.MsgType != "image" || body.Image == nil { + t.Fatalf("send body = %+v", body) + } + if body.ChatID != "chat-1" { + t.Fatalf("send chatid = %q, want chat-1", body.ChatID) + } + if body.Image.MediaID != "media-1" { + t.Fatalf("image media_id = %q, want media-1", body.Image.MediaID) + } +} + +func TestSendMedia_UsesTurnImageAndFinishesStream(t *testing.T) { + t.Parallel() + + ch := newTestWeComChannel(t, bus.NewMessageBus()) + ch.SetRunning(true) + + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + imageData := wecomTestJPEGData(t) + imagePath := filepath.Join(t.TempDir(), "reply.jpg") + if err := os.WriteFile(imagePath, imageData, 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + ref, err := store.Store(imagePath, media.MediaMeta{ + Filename: "reply.jpg", + ContentType: "image/jpeg", + Source: "test", + CleanupPolicy: media.CleanupPolicyForgetOnly, + }, "scope-2") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + ch.queueTurn("chat-1", wecomTurn{ + ReqID: "req-1", + ChatID: "chat-1", + ChatType: 1, + StreamID: "stream-1", + CreatedAt: time.Now(), + }) + putErr := ch.routes.Put("chat-1", "req-1", 1, time.Hour) + if putErr != nil { + t.Fatalf("Put() error = %v", putErr) + } + + var commands []wecomCommand + ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) { + commands = append(commands, cmd) + switch cmd.Cmd { + case wecomCmdUploadMediaInit: + return wecomTestAck(wecomUploadMediaInitResponse{UploadID: "upload-2"}), nil + case wecomCmdUploadMediaEnd: + return wecomTestAck(wecomUploadMediaFinishResponse{ + Type: "image", + MediaID: "media-2", + }), nil + default: + return wecomTestAck(nil), nil + } + } + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + Channel: "wecom", + ChatID: "chat-1", + Parts: []bus.MediaPart{{ + Ref: ref, + Type: "image", + Filename: "reply.jpg", + ContentType: "image/jpeg", + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(commands) != 5 { + t.Fatalf("expected 5 commands, got %d", len(commands)) + } + if commands[0].Cmd != wecomCmdUploadMediaInit { + t.Fatalf("first command = %+v", commands[0]) + } + if commands[1].Cmd != wecomCmdUploadMediaChunk { + t.Fatalf("second command = %+v", commands[1]) + } + if commands[2].Cmd != wecomCmdUploadMediaEnd { + t.Fatalf("third command = %+v", commands[2]) + } + if commands[3].Cmd != wecomCmdRespondMsg || commands[3].Headers.ReqID != "req-1" { + t.Fatalf("fourth command = %+v", commands[3]) + } + if commands[4].Cmd != wecomCmdRespondMsg || commands[4].Headers.ReqID != "req-1" { + t.Fatalf("fifth command = %+v", commands[4]) + } + + imageBody, ok := commands[3].Body.(wecomRespondMsgBody) + if !ok { + t.Fatalf("unexpected image body type %T", commands[3].Body) + } + if imageBody.MsgType != "image" || imageBody.Image == nil { + t.Fatalf("image body = %+v", imageBody) + } + if imageBody.Image.MediaID != "media-2" { + t.Fatalf("image media_id = %q, want media-2", imageBody.Image.MediaID) + } + + streamBody, ok := commands[4].Body.(wecomRespondMsgBody) + if !ok { + t.Fatalf("unexpected finish body type %T", commands[4].Body) + } + if streamBody.MsgType != "stream" || streamBody.Stream == nil || !streamBody.Stream.Finish { + t.Fatalf("finish body = %+v", streamBody) + } + + if _, ok := ch.getTurn("chat-1"); ok { + t.Fatal("expected turn to be removed after media send") + } +} + +func TestSendMedia_SendsActiveFile(t *testing.T) { + t.Parallel() + + ch := newTestWeComChannel(t, bus.NewMessageBus()) + ch.SetRunning(true) + + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + filePath := filepath.Join(t.TempDir(), "report.pdf") + if err := os.WriteFile(filePath, []byte("%PDF-1.4"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + ref, err := store.Store(filePath, media.MediaMeta{ + Filename: "report.pdf", + ContentType: "application/pdf", + Source: "test", + CleanupPolicy: media.CleanupPolicyForgetOnly, + }, "scope-3") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + var commands []wecomCommand + ch.commandSend = func(cmd wecomCommand, _ time.Duration) (wecomEnvelope, error) { + commands = append(commands, cmd) + switch cmd.Cmd { + case wecomCmdUploadMediaInit: + return wecomTestAck(wecomUploadMediaInitResponse{UploadID: "upload-3"}), nil + case wecomCmdUploadMediaEnd: + return wecomTestAck(wecomUploadMediaFinishResponse{ + Type: "file", + MediaID: "media-3", + }), nil + default: + return wecomTestAck(nil), nil + } + } + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + Channel: "wecom", + ChatID: "chat-2", + Parts: []bus.MediaPart{{ + Ref: ref, + Type: "file", + Filename: "report.pdf", + ContentType: "application/pdf", + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(commands) != 4 { + t.Fatalf("expected 4 commands, got %d", len(commands)) + } + if commands[0].Cmd != wecomCmdUploadMediaInit { + t.Fatalf("first command = %q, want %q", commands[0].Cmd, wecomCmdUploadMediaInit) + } + initBody, ok := commands[0].Body.(wecomUploadMediaInitBody) + if !ok { + t.Fatalf("unexpected init body type %T", commands[0].Body) + } + if initBody.Type != "file" || initBody.Filename != "report.pdf" { + t.Fatalf("init body = %+v", initBody) + } + if commands[1].Cmd != wecomCmdUploadMediaChunk { + t.Fatalf("second command = %q, want %q", commands[1].Cmd, wecomCmdUploadMediaChunk) + } + if commands[2].Cmd != wecomCmdUploadMediaEnd { + t.Fatalf("third command = %q, want %q", commands[2].Cmd, wecomCmdUploadMediaEnd) + } + if commands[3].Cmd != wecomCmdSendMsg { + t.Fatalf("fourth command = %q, want %q", commands[3].Cmd, wecomCmdSendMsg) + } + + body, ok := commands[3].Body.(wecomSendMsgBody) + if !ok { + t.Fatalf("unexpected body type %T", commands[3].Body) + } + if body.MsgType != "file" || body.File == nil { + t.Fatalf("body = %+v", body) + } + if body.File.MediaID != "media-3" { + t.Fatalf("file media_id = %q, want media-3", body.File.MediaID) + } +} + +func newTestWeComChannel(t *testing.T, messageBus *bus.MessageBus) *WeComChannel { + t.Helper() + + cfg := &config.WeComSettings{BotID: "bot-1"} + cfg.SetSecret("secret-1") + bc := &config.Channel{Type: config.ChannelWeCom, Enabled: true} + ch, err := NewChannel(bc, cfg, messageBus) + if err != nil { + t.Fatalf("NewChannel() error = %v", err) + } + ch.ctx = context.Background() + ch.routes = newReqIDStore(filepath.Join(t.TempDir(), "reqids.json")) + return ch +} + +func wecomTestJPEGData(t *testing.T) []byte { + t.Helper() + + const jpegBase64 = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////" + + "//////////////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////" + + "//////////////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAVEQEBAAAAAAAAAAAAAAAAAAAABf/aAAwDAQACEAMQAAAB6A//xAAVEAEBAAAAAAAAAAAAAAAAAAAAEf/aAAgBAQABBQJf/8QAFBEBAAAAAAAAAAAAAAAAAAAAEP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAAAAAAEP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAEP/aAAgBAQAGPwJf/8QAFBABAAAAAAAAAAAAAAAAAAAAEP/aAAgBAQABPyFf/9k=" + + return decodeTestBase64(t, jpegBase64) +} + +func TestDecodeWeComUploadFinish_AcceptsNumericCreatedAt(t *testing.T) { + t.Parallel() + + resp, err := decodeWeComEnvelopeBody[wecomUploadMediaFinishResponse](wecomEnvelope{ + Body: json.RawMessage(`{"type":"file","media_id":"media-1","created_at":1380000000}`), + }) + if err != nil { + t.Fatalf("decodeWeComEnvelopeBody() error = %v", err) + } + if resp.Type != "file" || resp.MediaID != "media-1" { + t.Fatalf("response = %+v", resp) + } + if string(resp.CreatedAt) != "1380000000" { + t.Fatalf("created_at = %s, want 1380000000", string(resp.CreatedAt)) + } +} + +func wecomTestAck(body any) wecomEnvelope { + var raw []byte + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + panic(err) + } + raw = encoded + } + return wecomEnvelope{ + ErrCode: 0, + ErrMsg: "ok", + Body: raw, + } +} diff --git a/pkg/channels/weixin/api.go b/pkg/channels/weixin/api.go new file mode 100644 index 000000000..6dc52790e --- /dev/null +++ b/pkg/channels/weixin/api.go @@ -0,0 +1,231 @@ +package weixin + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strconv" +) + +const ( + weixinChannelVersion = "2.1.1" + weixinIlinkAppID = "bot" + // 2.1.1 encoded as 0x00MMNNPP => 0x00020101 => 131329 + weixinClientVersion = 131329 +) + +type ApiClient struct { + BaseURL string + Token string + HttpClient *http.Client +} + +func NewApiClient(baseURL, token string, proxy string) (*ApiClient, error) { + if baseURL == "" { + baseURL = "https://ilinkai.weixin.qq.com/" + } + + client := &http.Client{ + // Default timeout; will be overridden per context + } + + if proxy != "" { + proxyURL, err := url.Parse(proxy) + if err != nil { + return nil, fmt.Errorf("invalid proxy URL %q: %w", proxy, err) + } + + // Clone the default transport so we preserve all default settings (TLS, HTTP/2, timeouts, keep-alives) + if defaultTransport, ok := http.DefaultTransport.(*http.Transport); ok { + transport := defaultTransport.Clone() + transport.Proxy = http.ProxyURL(proxyURL) + client.Transport = transport + } else { + // Fallback: preserve previous behavior if DefaultTransport is not the expected type + client.Transport = &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + } + } + } + + return &ApiClient{ + BaseURL: baseURL, + Token: token, + HttpClient: client, + }, nil +} + +func randomWechatUIN() string { + var b [4]byte + _, _ = rand.Read(b[:]) + uint32Val := binary.BigEndian.Uint32(b[:]) + return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", uint32Val))) +} + +func (c *ApiClient) post(ctx context.Context, endpoint string, body any, responseObj any) error { + u, err := url.Parse(c.BaseURL) + if err != nil { + return err + } + u.Path = path.Join(u.Path, endpoint) + + jsonData, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", u.String(), bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header["iLink-App-Id"] = []string{weixinIlinkAppID} + req.Header["iLink-App-ClientVersion"] = []string{strconv.Itoa(weixinClientVersion)} + if endpoint != "ilink/bot/get_bot_qrcode" && endpoint != "ilink/bot/get_qrcode_status" { + req.Header["AuthorizationType"] = []string{"ilink_bot_token"} + req.Header["X-WECHAT-UIN"] = []string{randomWechatUIN()} + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + return fmt.Errorf("http POST %s failed: %w", endpoint, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("http %d %s: %s", resp.StatusCode, resp.Status, string(respBody)) + } + + if responseObj != nil { + if err := json.Unmarshal(respBody, responseObj); err != nil { + return fmt.Errorf("failed to unmarshal response: %w, body: %s", err, string(respBody)) + } + } + + return nil +} + +func (c *ApiClient) GetUpdates(ctx context.Context, req GetUpdatesReq) (*GetUpdatesResp, error) { + req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion} + var resp GetUpdatesResp + err := c.post(ctx, "ilink/bot/getupdates", req, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (c *ApiClient) SendMessage(ctx context.Context, req SendMessageReq) (*SendMessageResp, error) { + req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion} + var resp SendMessageResp + if err := c.post(ctx, "ilink/bot/sendmessage", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*GetUploadUrlResp, error) { + req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion} + var resp GetUploadUrlResp + err := c.post(ctx, "ilink/bot/getuploadurl", req, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (c *ApiClient) GetConfig(ctx context.Context, req GetConfigReq) (*GetConfigResp, error) { + req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion} + var resp GetConfigResp + if err := c.post(ctx, "ilink/bot/getconfig", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) (*SendTypingResp, error) { + req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion} + var resp SendTypingResp + if err := c.post(ctx, "ilink/bot/sendtyping", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (c *ApiClient) getQR(ctx context.Context, endpoint string, query map[string]string, respObj any) error { + u, err := url.Parse(c.BaseURL) + if err != nil { + return err + } + u.Path = path.Join(u.Path, endpoint) + q := u.Query() + for key, value := range query { + q.Set(key, value) + } + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) + if err != nil { + return err + } + req.Header["iLink-App-Id"] = []string{weixinIlinkAppID} + req.Header["iLink-App-ClientVersion"] = []string{strconv.Itoa(weixinClientVersion)} + + resp, err := c.HttpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s failed: %d %s", endpoint, resp.StatusCode, string(respBody)) + } + if err := json.Unmarshal(respBody, respObj); err != nil { + return err + } + + return nil +} + +func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeResponse, error) { + // get_bot_qrcode is GET, not POST + var qrcodeResp QRCodeResponse + if err := c.getQR(ctx, "ilink/bot/get_bot_qrcode", map[string]string{ + "bot_type": botType, + }, &qrcodeResp); err != nil { + return nil, err + } + return &qrcodeResp, nil +} + +func (c *ApiClient) GetQRCodeStatus(ctx context.Context, qrcode string) (*StatusResponse, error) { + // get_qrcode_status is GET + var statusResp StatusResponse + if err := c.getQR(ctx, "ilink/bot/get_qrcode_status", map[string]string{ + "qrcode": qrcode, + }, &statusResp); err != nil { + return nil, err + } + return &statusResp, nil +} diff --git a/pkg/channels/weixin/auth.go b/pkg/channels/weixin/auth.go new file mode 100644 index 000000000..0a0e597c1 --- /dev/null +++ b/pkg/channels/weixin/auth.go @@ -0,0 +1,133 @@ +package weixin + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/mdp/qrterminal/v3" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// AuthFlowOpts configures the interactive QR login flow. +type AuthFlowOpts struct { + BaseURL string + BotType string + Timeout time.Duration + Proxy string +} + +// PerformLoginInteractive starts the Weixin QR login flow and blocks until login is successful or times out. +// It prints a QR code to the terminal for the user to scan. +// Returns the BotToken, UserID, AccountID, and BaseUrl on success. +func PerformLoginInteractive( + ctx context.Context, + opts AuthFlowOpts, +) (botToken, userID, accountID, baseUrl string, err error) { + if opts.BaseURL == "" { + opts.BaseURL = "https://ilinkai.weixin.qq.com/" + } + if opts.BotType == "" { + opts.BotType = "3" // Default iLink Bot Type + } + if opts.Timeout == 0 { + opts.Timeout = 5 * time.Minute + } + + api, err := NewApiClient(opts.BaseURL, "", opts.Proxy) + if err != nil { + return "", "", "", "", fmt.Errorf("failed to create api client: %w", err) + } + pollAPI := api + + logger.InfoC("weixin", "Requesting Weixin QR code...") + qrResp, err := api.GetQRCode(ctx, opts.BotType) + if err != nil { + return "", "", "", "", fmt.Errorf("failed to get qrcode: %w", err) + } + + fmt.Println("\n=======================================================") + fmt.Println("Please scan the following QR code with WeChat to login:") + fmt.Println("=======================================================") + fmt.Println() + + // Create Small QR + qrconfig := qrterminal.Config{ + Level: qrterminal.L, + Writer: os.Stdout, + HalfBlocks: true, + } + qrterminal.GenerateWithConfig(qrResp.QrcodeImgContent, qrconfig) + + fmt.Printf("\nQR Code Link: %s\n\n", qrResp.QrcodeImgContent) + fmt.Println("Waiting for scan...") + + timeoutCtx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + pollTicker := time.NewTicker(2 * time.Second) + defer pollTicker.Stop() + + scannedPrinted := false + + for { + select { + case <-timeoutCtx.Done(): + return "", "", "", "", fmt.Errorf("login timeout") + case <-pollTicker.C: + statusResp, err := pollAPI.GetQRCodeStatus(timeoutCtx, qrResp.Qrcode) + if err != nil { + // Long poll timeout or temporary error + continue + } + + switch statusResp.Status { + case "wait": + // still waiting + case "scaned": + if !scannedPrinted { + fmt.Println("👀 QR Code scanned! Please confirm login on your WeChat app...") + scannedPrinted = true + } + case "confirmed": + if statusResp.BotToken == "" || statusResp.IlinkBotID == "" { + return "", "", "", "", fmt.Errorf("login confirmed but missing bot_token or ilink_bot_id") + } + logger.InfoCF("weixin", "Login successful", map[string]any{ + "account_id": statusResp.IlinkBotID, + }) + + return statusResp.BotToken, statusResp.IlinkUserID, statusResp.IlinkBotID, statusResp.Baseurl, nil + case "scaned_but_redirect": + if statusResp.RedirectHost == "" { + logger.WarnC( + "weixin", + "scaned_but_redirect received without redirect_host; continuing on current host", + ) + continue + } + nextBaseURL := "https://" + statusResp.RedirectHost + "/" + nextAPI, nextErr := NewApiClient(nextBaseURL, "", opts.Proxy) + if nextErr != nil { + logger.WarnCF("weixin", "Failed to switch QR polling host", map[string]any{ + "redirect_host": statusResp.RedirectHost, + "error": nextErr.Error(), + }) + continue + } + pollAPI = nextAPI + logger.InfoCF("weixin", "Switched QR polling host", map[string]any{ + "redirect_host": statusResp.RedirectHost, + }) + case "expired": + return "", "", "", "", fmt.Errorf("qrcode expired, please try again") + default: + logger.WarnCF("weixin", "Unknown QR code status", map[string]any{ + "status": statusResp.Status, + }) + } + } + } +} diff --git a/pkg/channels/weixin/media.go b/pkg/channels/weixin/media.go new file mode 100644 index 000000000..cf1b45612 --- /dev/null +++ b/pkg/channels/weixin/media.go @@ -0,0 +1,1157 @@ +package weixin + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/md5" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/h2non/filetype" + + "github.com/sipeed/picoclaw/pkg/bus" + basechannels "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" +) + +const ( + weixinMediaMaxBytes = 100 << 20 + weixinTypingKeepAlive = 5 * time.Second + weixinUploadRetryMax = 3 + weixinDownloadRetryMax = 2 + weixinDownloadRetryDelay = 300 * time.Millisecond + weixinVoiceTranscodeTimeout = 15 * time.Second +) + +type uploadedFileInfo struct { + downloadParam string + aesKeyHex string + fileSize int64 + cipherSize int64 + filename string +} + +func pkcs7Pad(src []byte, blockSize int) []byte { + padding := blockSize - len(src)%blockSize + if padding == 0 { + padding = blockSize + } + out := make([]byte, len(src)+padding) + copy(out, src) + for i := len(src); i < len(out); i++ { + out[i] = byte(padding) + } + return out +} + +func pkcs7Unpad(src []byte, blockSize int) ([]byte, error) { + if len(src) == 0 || len(src)%blockSize != 0 { + return nil, fmt.Errorf("invalid padded data size %d", len(src)) + } + padding := int(src[len(src)-1]) + if padding <= 0 || padding > blockSize || padding > len(src) { + return nil, fmt.Errorf("invalid padding size %d", padding) + } + for i := len(src) - padding; i < len(src); i++ { + if src[i] != byte(padding) { + return nil, fmt.Errorf("invalid padding content") + } + } + return src[:len(src)-padding], nil +} + +func encryptAESECB(plaintext, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + padded := pkcs7Pad(plaintext, block.BlockSize()) + out := make([]byte, len(padded)) + for i := 0; i < len(padded); i += block.BlockSize() { + block.Encrypt(out[i:i+block.BlockSize()], padded[i:i+block.BlockSize()]) + } + return out, nil +} + +func decryptAESECB(ciphertext, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + if len(ciphertext)%block.BlockSize() != 0 { + return nil, fmt.Errorf("invalid ciphertext size %d", len(ciphertext)) + } + out := make([]byte, len(ciphertext)) + for i := 0; i < len(ciphertext); i += block.BlockSize() { + block.Decrypt(out[i:i+block.BlockSize()], ciphertext[i:i+block.BlockSize()]) + } + return pkcs7Unpad(out, block.BlockSize()) +} + +func parseWeixinMediaAESKey(aesKeyBase64 string) ([]byte, error) { + decoded, err := base64.StdEncoding.DecodeString(aesKeyBase64) + if err != nil { + return nil, err + } + if len(decoded) == 16 { + return decoded, nil + } + if len(decoded) == 32 { + if raw, err := hex.DecodeString(string(decoded)); err == nil && len(raw) == 16 { + return raw, nil + } + } + return nil, fmt.Errorf("unsupported aes_key length %d", len(decoded)) +} + +func imageAESKey(img *ImageItem) ([]byte, bool, error) { + if img == nil { + return nil, false, nil + } + if img.Aeskey != "" { + raw, err := hex.DecodeString(img.Aeskey) + if err != nil { + return nil, false, err + } + return raw, true, nil + } + if img.Media != nil && img.Media.AesKey != "" { + raw, err := parseWeixinMediaAESKey(img.Media.AesKey) + if err != nil { + return nil, false, err + } + return raw, true, nil + } + return nil, false, nil +} + +func genericMediaAESKey(mediaRef *CDNMedia) ([]byte, error) { + if mediaRef == nil || mediaRef.AesKey == "" { + return nil, fmt.Errorf("missing aes_key") + } + return parseWeixinMediaAESKey(mediaRef.AesKey) +} + +func aesEcbPaddedSize(size int64) int64 { + return (size/16 + 1) * 16 +} + +func randomHex(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func buildCDNDownloadURL(base, encryptedQueryParam string) string { + return strings.TrimRight(base, "/") + + "/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam) +} + +func shouldRetryCDNDownload(statusCode int) bool { + // statusCode=0 represents transport/build errors from the HTTP client. + return statusCode == 0 || statusCode >= 500 || statusCode == http.StatusTooManyRequests +} + +func buildCDNUploadURL(base, uploadParam, filekey string) string { + return strings.TrimRight(base, "/") + + "/upload?encrypted_query_param=" + url.QueryEscape(uploadParam) + + "&filekey=" + url.QueryEscape(filekey) +} + +func uniqCDNURLs(urls []string) []string { + seen := make(map[string]struct{}, len(urls)) + out := make([]string, 0, len(urls)) + for _, raw := range urls { + u := strings.TrimSpace(raw) + if u == "" { + continue + } + if _, ok := seen[u]; ok { + continue + } + seen[u] = struct{}{} + out = append(out, u) + } + return out +} + +func (c *WeixinChannel) downloadCDNBufferOnce(ctx context.Context, downloadURL string) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return nil, 0, err + } + resp, err := c.api.HttpClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, resp.StatusCode, fmt.Errorf("cdn download HTTP %d: %s", resp.StatusCode, string(body)) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, weixinMediaMaxBytes+1)) + if err != nil { + return nil, resp.StatusCode, err + } + if len(data) > weixinMediaMaxBytes { + return nil, resp.StatusCode, fmt.Errorf("cdn media too large: %d bytes", len(data)) + } + return data, resp.StatusCode, nil +} + +func (c *WeixinChannel) downloadCDNBuffer( + ctx context.Context, + encryptedQueryParam, + fullURL string, +) ([]byte, error) { + candidates := uniqCDNURLs([]string{ + strings.TrimSpace(fullURL), + func() string { + if strings.TrimSpace(encryptedQueryParam) == "" { + return "" + } + return buildCDNDownloadURL(c.cdnBaseURL(), encryptedQueryParam) + }(), + }) + if len(candidates) == 0 { + return nil, fmt.Errorf("missing CDN download URL") + } + + var lastErr error + for _, downloadURL := range candidates { + for attempt := 1; attempt <= weixinDownloadRetryMax; attempt++ { + data, statusCode, err := c.downloadCDNBufferOnce(ctx, downloadURL) + if err == nil { + return data, nil + } + lastErr = fmt.Errorf("%w (attempt=%d url=%s)", err, attempt, downloadURL) + if !shouldRetryCDNDownload(statusCode) { + break + } + if attempt < weixinDownloadRetryMax { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(weixinDownloadRetryDelay): + } + } + } + } + return nil, lastErr +} + +func (c *WeixinChannel) downloadAndDecryptCDNBuffer( + ctx context.Context, + encryptedQueryParam string, + fullURL string, + key []byte, +) ([]byte, error) { + data, err := c.downloadCDNBuffer(ctx, encryptedQueryParam, fullURL) + if err != nil { + return nil, err + } + if len(key) == 0 { + return data, nil + } + return decryptAESECB(data, key) +} + +func (c *WeixinChannel) downloadImageBuffer( + ctx context.Context, + img *ImageItem, + key []byte, +) ([]byte, error) { + if img == nil { + return nil, fmt.Errorf("image item is nil") + } + if img.Media != nil { + data, err := c.downloadAndDecryptCDNBuffer(ctx, img.Media.EncryptQueryParam, img.Media.FullURL, key) + if err == nil { + return data, nil + } + if img.ThumbMedia == nil { + return nil, fmt.Errorf("image download failed: %w", err) + } + } + if img.ThumbMedia != nil { + data, err := c.downloadAndDecryptCDNBuffer(ctx, img.ThumbMedia.EncryptQueryParam, img.ThumbMedia.FullURL, key) + if err == nil { + return data, nil + } + return nil, fmt.Errorf("image download failed: %w", err) + } + return nil, fmt.Errorf("image media is nil") +} + +func detectMediaMetadata(data []byte, fallbackName, fallbackContentType string) (string, string) { + contentType := strings.TrimSpace(fallbackContentType) + ext := filepath.Ext(fallbackName) + if kind, err := filetype.Match(data); err == nil && kind != filetype.Unknown { + contentType = kind.MIME.Value + if kind.Extension != "" { + ext = "." + kind.Extension + } + } + if contentType == "" && ext != "" { + contentType = mime.TypeByExtension(strings.ToLower(ext)) + } + if contentType == "" { + contentType = http.DetectContentType(data) + } + if ext == "" && contentType != "" { + if exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 { + ext = exts[0] + } + } + + filename := sanitizeFilename(fallbackName) + if filename == "" { + filename = "media" + } + if filepath.Ext(filename) == "" && ext != "" { + filename += ext + } + return filename, contentType +} + +func sanitizeFilename(name string) string { + name = filepath.Base(strings.TrimSpace(name)) + if name == "." || name == "/" || name == "" { + return "" + } + return name +} + +func writeManagedTempFile(prefix, filename string, data []byte) (string, error) { + if err := os.MkdirAll(media.TempDir(), 0o700); err != nil { + return "", err + } + pattern := prefix + "-*" + if ext := filepath.Ext(filename); ext != "" { + pattern += ext + } + f, err := os.CreateTemp(media.TempDir(), pattern) + if err != nil { + return "", err + } + defer f.Close() + if _, err := f.Write(data); err != nil { + os.Remove(f.Name()) + return "", err + } + return f.Name(), nil +} + +func (c *WeixinChannel) storeInboundBytes( + chatID, + messageID, + filename, + contentType string, + data []byte, +) (string, error) { + store := c.GetMediaStore() + if store == nil { + return "", fmt.Errorf("no media store available") + } + filename, contentType = detectMediaMetadata(data, filename, contentType) + tmpPath, err := writeManagedTempFile("weixin-inbound", filename, data) + if err != nil { + return "", err + } + ref, err := store.Store(tmpPath, media.MediaMeta{ + Filename: filename, + ContentType: contentType, + Source: "weixin", + CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, + }, basechannels.BuildMediaScope("weixin", chatID, messageID)) + if err != nil { + os.Remove(tmpPath) + return "", err + } + return ref, nil +} + +func isDownloadableMediaItem(item *MessageItem) bool { + if item == nil { + return false + } + + switch item.Type { + case MessageItemTypeImage: + return item.ImageItem != nil && item.ImageItem.Media != nil && + (item.ImageItem.Media.EncryptQueryParam != "" || item.ImageItem.Media.FullURL != "") + case MessageItemTypeVideo: + return item.VideoItem != nil && item.VideoItem.Media != nil && + (item.VideoItem.Media.EncryptQueryParam != "" || item.VideoItem.Media.FullURL != "") + case MessageItemTypeFile: + return item.FileItem != nil && item.FileItem.Media != nil && + (item.FileItem.Media.EncryptQueryParam != "" || item.FileItem.Media.FullURL != "") + case MessageItemTypeVoice: + return item.VoiceItem != nil && + item.VoiceItem.Media != nil && + (item.VoiceItem.Media.EncryptQueryParam != "" || item.VoiceItem.Media.FullURL != "") && + strings.TrimSpace(item.VoiceItem.Text) == "" + default: + return false + } +} + +func selectInboundMediaItem(msg WeixinMessage) *MessageItem { + priorities := []int{ + MessageItemTypeImage, + MessageItemTypeVideo, + MessageItemTypeFile, + MessageItemTypeVoice, + } + + for _, want := range priorities { + for i := range msg.ItemList { + item := &msg.ItemList[i] + if item.Type == want && isDownloadableMediaItem(item) { + return item + } + } + } + + for i := range msg.ItemList { + item := &msg.ItemList[i] + if item.Type != MessageItemTypeText || item.RefMsg == nil || item.RefMsg.MessageItem == nil { + continue + } + if isDownloadableMediaItem(item.RefMsg.MessageItem) { + return item.RefMsg.MessageItem + } + } + + return nil +} + +func tryTranscodeSilkToWAV(ctx context.Context, silk []byte) ([]byte, error) { + decoders := []struct { + name string + args func(inputPath, outputPath string) []string + }{ + { + name: "silk_v3_decoder", + args: func(inputPath, outputPath string) []string { return []string{inputPath, outputPath, "24000"} }, + }, + { + name: "silk_decoder", + args: func(inputPath, outputPath string) []string { return []string{inputPath, outputPath, "24000"} }, + }, + { + name: "ffmpeg", + args: func(inputPath, outputPath string) []string { + return []string{"-y", "-i", inputPath, outputPath} + }, + }, + } + + for _, decoder := range decoders { + bin, err := exec.LookPath(decoder.name) + if err != nil { + continue + } + + tmpIn, err := writeManagedTempFile("weixin-voice", "voice.silk", silk) + if err != nil { + return nil, err + } + tmpOut := filepath.Join(media.TempDir(), "weixin-voice-"+uuid.New().String()+".wav") + wav, ok := func() ([]byte, bool) { + defer os.Remove(tmpIn) + defer os.Remove(tmpOut) + + runCtx, cancel := context.WithTimeout(ctx, weixinVoiceTranscodeTimeout) + cmd := exec.CommandContext(runCtx, bin, decoder.args(tmpIn, tmpOut)...) + out, runErr := cmd.CombinedOutput() + cancel() + if runErr != nil { + logger.DebugCF("weixin", "SILK transcode command failed", map[string]any{ + "decoder": decoder.name, + "error": runErr.Error(), + "output": strings.TrimSpace(string(out)), + }) + return nil, false + } + + wav, readErr := os.ReadFile(tmpOut) + if readErr != nil { + logger.DebugCF("weixin", "Failed to read transcoded WAV", map[string]any{ + "decoder": decoder.name, + "error": readErr.Error(), + }) + return nil, false + } + return wav, len(wav) > 0 + }() + if ok { + return wav, nil + } + } + + return nil, fmt.Errorf("no SILK decoder available") +} + +func (c *WeixinChannel) downloadMediaFromItem( + ctx context.Context, + chatID, + messageID string, + item *MessageItem, +) (string, error) { + if item == nil { + return "", nil + } + + switch item.Type { + case MessageItemTypeImage: + if item.ImageItem == nil { + return "", fmt.Errorf("image media is nil") + } + key, ok, err := imageAESKey(item.ImageItem) + if err != nil { + return "", err + } + decryptKey := func() []byte { + if ok { + return key + } + return nil + }() + data, err := c.downloadImageBuffer(ctx, item.ImageItem, decryptKey) + if err != nil { + return "", err + } + return c.storeInboundBytes(chatID, messageID, "image", "", data) + + case MessageItemTypeVoice: + key, err := genericMediaAESKey(item.VoiceItem.Media) + if err != nil { + return "", err + } + silk, err := c.downloadAndDecryptCDNBuffer( + ctx, + item.VoiceItem.Media.EncryptQueryParam, + item.VoiceItem.Media.FullURL, + key, + ) + if err != nil { + return "", err + } + if wav, err := tryTranscodeSilkToWAV(ctx, silk); err == nil && len(wav) > 0 { + return c.storeInboundBytes(chatID, messageID, "voice.wav", "audio/wav", wav) + } + return c.storeInboundBytes(chatID, messageID, "voice.silk", "audio/silk", silk) + + case MessageItemTypeFile: + key, err := genericMediaAESKey(item.FileItem.Media) + if err != nil { + return "", err + } + data, err := c.downloadAndDecryptCDNBuffer( + ctx, + item.FileItem.Media.EncryptQueryParam, + item.FileItem.Media.FullURL, + key, + ) + if err != nil { + return "", err + } + filename := item.FileItem.FileName + if filename == "" { + filename = "file.bin" + } + contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename))) + return c.storeInboundBytes(chatID, messageID, filename, contentType, data) + + case MessageItemTypeVideo: + key, err := genericMediaAESKey(item.VideoItem.Media) + if err != nil { + return "", err + } + data, err := c.downloadAndDecryptCDNBuffer( + ctx, + item.VideoItem.Media.EncryptQueryParam, + item.VideoItem.Media.FullURL, + key, + ) + if err != nil { + return "", err + } + return c.storeInboundBytes(chatID, messageID, "video.mp4", "video/mp4", data) + } + + return "", nil +} + +func outboundMediaKind(partType, filename, contentType string) int { + switch strings.ToLower(strings.TrimSpace(partType)) { + case "image": + return UploadMediaTypeImage + case "video": + return UploadMediaTypeVideo + } + + ct := strings.ToLower(contentType) + switch { + case strings.HasPrefix(ct, "image/"): + return UploadMediaTypeImage + case strings.HasPrefix(ct, "video/"): + return UploadMediaTypeVideo + default: + return UploadMediaTypeFile + } +} + +func detectLocalContentType(localPath, hintContentType string) string { + if strings.TrimSpace(hintContentType) != "" { + return hintContentType + } + if kind, err := filetype.MatchFile(localPath); err == nil && kind != filetype.Unknown { + return kind.MIME.Value + } + if ext := filepath.Ext(localPath); ext != "" { + if ct := mime.TypeByExtension(strings.ToLower(ext)); ct != "" { + return ct + } + } + return "application/octet-stream" +} + +func downloadFilenameFromURL(rawURL, fallback string) string { + if fallback = sanitizeFilename(fallback); fallback != "" { + return fallback + } + parsed, err := url.Parse(rawURL) + if err == nil { + if base := sanitizeFilename(path.Base(parsed.Path)); base != "" { + return base + } + } + return "remote-media" +} + +func (c *WeixinChannel) downloadRemoteMediaToTemp( + ctx context.Context, + rawURL, + fallbackName string, +) (string, string, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return "", "", "", err + } + resp, err := c.api.HttpClient.Do(req) + if err != nil { + return "", "", "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return "", "", "", fmt.Errorf("remote media HTTP %d: %s", resp.StatusCode, string(body)) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, weixinMediaMaxBytes+1)) + if err != nil { + return "", "", "", err + } + if len(data) > weixinMediaMaxBytes { + return "", "", "", fmt.Errorf("remote media too large: %d bytes", len(data)) + } + + filename, contentType := detectMediaMetadata( + data, + downloadFilenameFromURL(rawURL, fallbackName), + resp.Header.Get("Content-Type"), + ) + tmpPath, err := writeManagedTempFile("weixin-remote", filename, data) + if err != nil { + return "", "", "", err + } + return tmpPath, filename, contentType, nil +} + +func (c *WeixinChannel) resolveOutboundPart( + ctx context.Context, + part bus.MediaPart, +) (string, string, string, func(), error) { + cleanup := func() {} + filename := sanitizeFilename(part.Filename) + contentType := strings.TrimSpace(part.ContentType) + + switch { + case strings.HasPrefix(part.Ref, "http://") || strings.HasPrefix(part.Ref, "https://"): + localPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, part.Ref, filename) + if err != nil { + return "", "", "", cleanup, err + } + return localPath, name, ct, func() { os.Remove(localPath) }, nil + + case strings.HasPrefix(part.Ref, "media://"): + store := c.GetMediaStore() + if store == nil { + return "", "", "", cleanup, fmt.Errorf("no media store available") + } + localPath, meta, err := store.ResolveWithMeta(part.Ref) + if err != nil { + return "", "", "", cleanup, err + } + if filename == "" { + filename = sanitizeFilename(meta.Filename) + } + if contentType == "" { + contentType = meta.ContentType + } + if strings.HasPrefix(localPath, "http://") || strings.HasPrefix(localPath, "https://") { + tmpPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, localPath, filename) + if err != nil { + return "", "", "", cleanup, err + } + return tmpPath, name, ct, func() { os.Remove(tmpPath) }, nil + } + if filename == "" { + filename = sanitizeFilename(filepath.Base(localPath)) + } + if contentType == "" { + contentType = detectLocalContentType(localPath, "") + } + return localPath, filename, contentType, cleanup, nil + + case strings.HasPrefix(part.Ref, "file://"): + u, err := url.Parse(part.Ref) + if err != nil { + return "", "", "", cleanup, err + } + localPath := u.Path + if filename == "" { + filename = sanitizeFilename(filepath.Base(localPath)) + } + if contentType == "" { + contentType = detectLocalContentType(localPath, "") + } + return localPath, filename, contentType, cleanup, nil + + default: + localPath := part.Ref + if filename == "" { + filename = sanitizeFilename(filepath.Base(localPath)) + } + if contentType == "" { + contentType = detectLocalContentType(localPath, "") + } + return localPath, filename, contentType, cleanup, nil + } +} + +func (c *WeixinChannel) uploadLocalFile( + ctx context.Context, + localPath, + filename, + toUserID string, + mediaType int, +) (*uploadedFileInfo, error) { + data, err := os.ReadFile(localPath) + if err != nil { + return nil, err + } + if len(data) > weixinMediaMaxBytes { + return nil, fmt.Errorf("media too large: %d bytes", len(data)) + } + + filekey, err := randomHex(16) + if err != nil { + return nil, err + } + aesKey := make([]byte, 16) + if _, readErr := rand.Read(aesKey); readErr != nil { + return nil, readErr + } + aesKeyHex := hex.EncodeToString(aesKey) + rawMD5 := md5.Sum(data) + + resp, err := c.api.GetUploadUrl(ctx, GetUploadUrlReq{ + Filekey: filekey, + MediaType: mediaType, + ToUserID: toUserID, + Rawsize: int64(len(data)), + RawfileMD5: hex.EncodeToString(rawMD5[:]), + Filesize: aesEcbPaddedSize(int64(len(data))), + NoNeedThumb: true, + Aeskey: aesKeyHex, + }) + if err != nil { + return nil, err + } + if resp == nil { + return nil, fmt.Errorf("getuploadurl returned nil response") + } + if resp.Ret != 0 || resp.Errcode != 0 { + if isSessionExpiredStatus(resp.Ret, resp.Errcode) { + c.pauseSession("getuploadurl", resp.Ret, resp.Errcode, resp.Errmsg) + } + return nil, fmt.Errorf("getuploadurl failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg) + } + uploadParam := strings.TrimSpace(resp.UploadParam) + uploadFullURL := strings.TrimSpace(resp.UploadFullURL) + if uploadParam == "" && uploadFullURL == "" { + return nil, fmt.Errorf("getuploadurl returned no upload URL") + } + + downloadParam, err := c.uploadBufferToCDN(ctx, data, uploadParam, uploadFullURL, filekey, aesKey) + if err != nil { + return nil, err + } + + return &uploadedFileInfo{ + downloadParam: downloadParam, + aesKeyHex: aesKeyHex, + fileSize: int64(len(data)), + cipherSize: aesEcbPaddedSize(int64(len(data))), + filename: filename, + }, nil +} + +func (c *WeixinChannel) uploadBufferToCDN( + ctx context.Context, + plaintext []byte, + uploadParam, + uploadFullURL, + filekey string, + aesKey []byte, +) (string, error) { + ciphertext, err := encryptAESECB(plaintext, aesKey) + if err != nil { + return "", err + } + + uploadURL := strings.TrimSpace(uploadFullURL) + if uploadURL == "" { + if strings.TrimSpace(uploadParam) == "" { + return "", fmt.Errorf("missing CDN upload URL") + } + uploadURL = buildCDNUploadURL(c.cdnBaseURL(), uploadParam, filekey) + } + var lastErr error + + for attempt := 1; attempt <= weixinUploadRetryMax; attempt++ { + req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(ciphertext)) + if reqErr != nil { + return "", reqErr + } + req.Header.Set("Content-Type", "application/octet-stream") + + resp, doErr := c.api.HttpClient.Do(req) + if doErr != nil { + lastErr = doErr + } else { + func() { + defer resp.Body.Close() + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + lastErr = fmt.Errorf( + "cdn upload client error %d: %s", + resp.StatusCode, + strings.TrimSpace(string(body)), + ) + return + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + lastErr = fmt.Errorf( + "cdn upload server error %d: %s", + resp.StatusCode, + strings.TrimSpace(string(body)), + ) + return + } + if encrypted := strings.TrimSpace(resp.Header.Get("X-Encrypted-Param")); encrypted != "" { + lastErr = nil + uploadParam = encrypted + return + } + lastErr = fmt.Errorf("cdn upload missing x-encrypted-param header") + }() + } + + if lastErr == nil { + return uploadParam, nil + } + if strings.Contains(lastErr.Error(), "client error") || attempt == weixinUploadRetryMax { + break + } + } + + return "", lastErr +} + +func (c *WeixinChannel) sendMessageItem( + ctx context.Context, + toUserID, + contextToken string, + item MessageItem, +) error { + resp, err := c.api.SendMessage(ctx, SendMessageReq{ + Msg: WeixinMessage{ + ToUserID: toUserID, + ClientID: "picoclaw-" + uuid.New().String(), + MessageType: MessageTypeBot, + MessageState: MessageStateFinish, + ItemList: []MessageItem{item}, + ContextToken: contextToken, + }, + }) + if err != nil { + return err + } + if resp == nil { + return fmt.Errorf("sendmessage returned nil response") + } + if resp.Ret != 0 || resp.Errcode != 0 { + if isSessionExpiredStatus(resp.Ret, resp.Errcode) { + c.pauseSession("sendmessage", resp.Ret, resp.Errcode, resp.Errmsg) + } + return fmt.Errorf("sendmessage failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg) + } + return nil +} + +func (c *WeixinChannel) sendTextMessage( + ctx context.Context, + toUserID, + contextToken, + text string, +) error { + if strings.TrimSpace(text) == "" { + return nil + } + return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{ + Type: MessageItemTypeText, + TextItem: &TextItem{ + Text: text, + }, + }) +} + +func encodeWeixinOutboundAESKey(aesKeyHex string) string { + return base64.StdEncoding.EncodeToString([]byte(aesKeyHex)) +} + +func (c *WeixinChannel) sendUploadedMedia( + ctx context.Context, + toUserID, + contextToken, + caption string, + mediaType int, + uploaded *uploadedFileInfo, +) error { + if err := c.sendTextMessage(ctx, toUserID, contextToken, caption); err != nil { + return err + } + + mediaRef := &CDNMedia{ + EncryptQueryParam: uploaded.downloadParam, + AesKey: encodeWeixinOutboundAESKey(uploaded.aesKeyHex), + EncryptType: 1, + } + + switch mediaType { + case UploadMediaTypeImage: + return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{ + Type: MessageItemTypeImage, + ImageItem: &ImageItem{ + Media: mediaRef, + MidSize: uploaded.cipherSize, + }, + }) + + case UploadMediaTypeVideo: + return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{ + Type: MessageItemTypeVideo, + VideoItem: &VideoItem{ + Media: mediaRef, + VideoSize: uploaded.cipherSize, + }, + }) + + default: + return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{ + Type: MessageItemTypeFile, + FileItem: &FileItem{ + Media: mediaRef, + FileName: uploaded.filename, + Len: fmt.Sprintf("%d", uploaded.fileSize), + }, + }) + } +} + +func (c *WeixinChannel) sendTypingStatus( + ctx context.Context, + chatID, + typingTicket string, + status int, +) error { + resp, err := c.api.SendTyping(ctx, SendTypingReq{ + IlinkUserID: chatID, + TypingTicket: typingTicket, + Status: status, + }) + if err != nil { + return err + } + if resp == nil { + return fmt.Errorf("sendtyping returned nil response") + } + if resp.Ret != 0 || resp.Errcode != 0 { + if isSessionExpiredStatus(resp.Ret, resp.Errcode) { + c.pauseSession("sendtyping", resp.Ret, resp.Errcode, resp.Errmsg) + } + return fmt.Errorf("sendtyping failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg) + } + return nil +} + +// StartTyping implements channels.TypingCapable. +func (c *WeixinChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + if strings.TrimSpace(chatID) == "" { + return func() {}, nil + } + if c.remainingPause() > 0 { + return func() {}, nil + } + + ticket, err := c.getTypingTicket(ctx, chatID) + if err != nil { + if ticket == "" { + return func() {}, err + } + logger.DebugCF("weixin", "GetConfig refresh failed; using cached typing ticket", map[string]any{ + "chat_id": chatID, + "error": err.Error(), + }) + } + if ticket == "" { + return func() {}, nil + } + + typingCtx, cancel := context.WithCancel(ctx) + var once sync.Once + stop := func() { + once.Do(func() { + cancel() + stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer stopCancel() + if err := c.sendTypingStatus(stopCtx, chatID, ticket, TypingStatusCancel); err != nil { + logger.DebugCF("weixin", "Failed to cancel typing indicator", map[string]any{ + "chat_id": chatID, + "error": err.Error(), + }) + } + }) + } + + if err := c.sendTypingStatus(typingCtx, chatID, ticket, TypingStatusTyping); err != nil { + stop() + return func() {}, err + } + + ticker := time.NewTicker(weixinTypingKeepAlive) + go func() { + defer ticker.Stop() + for { + select { + case <-typingCtx.Done(): + return + case <-ticker.C: + if err := c.sendTypingStatus(typingCtx, chatID, ticket, TypingStatusTyping); err != nil { + logger.DebugCF("weixin", "Failed to refresh typing indicator", map[string]any{ + "chat_id": chatID, + "error": err.Error(), + }) + } + } + } + }() + + return stop, nil +} + +// SendMedia implements channels.MediaSender. +func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { + if !c.IsRunning() { + return nil, basechannels.ErrNotRunning + } + if err := c.ensureSessionActive(); err != nil { + return nil, err + } + + contextToken := "" + if v, ok := c.contextTokens.Load(msg.ChatID); ok { + contextToken, _ = v.(string) + } + if contextToken == "" { + return nil, fmt.Errorf( + "weixin send media: missing context token for chat %s: %w", + msg.ChatID, + basechannels.ErrSendFailed, + ) + } + + for _, part := range msg.Parts { + localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part) + if err != nil { + logger.ErrorCF("weixin", "Failed to resolve outbound media", map[string]any{ + "chat_id": msg.ChatID, + "ref": part.Ref, + "error": err.Error(), + }) + return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) + } + func() { + if cleanup != nil { + defer cleanup() + } + + kind := outboundMediaKind(part.Type, filename, contentType) + uploaded, uploadErr := c.uploadLocalFile(ctx, localPath, filename, msg.ChatID, kind) + if uploadErr != nil { + err = uploadErr + return + } + err = c.sendUploadedMedia(ctx, msg.ChatID, contextToken, part.Caption, kind, uploaded) + }() + if err != nil { + logger.ErrorCF("weixin", "Failed to send outbound media", map[string]any{ + "chat_id": msg.ChatID, + "ref": part.Ref, + "error": err.Error(), + }) + if c.remainingPause() > 0 { + return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) + } + return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary) + } + } + + return nil, nil +} diff --git a/pkg/channels/weixin/state.go b/pkg/channels/weixin/state.go new file mode 100644 index 000000000..0f8257895 --- /dev/null +++ b/pkg/channels/weixin/state.go @@ -0,0 +1,256 @@ +package weixin + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + basechannels "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const ( + weixinDefaultCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c" + weixinConfigCacheTTL = 24 * time.Hour + weixinConfigRetryInitial = 2 * time.Second + weixinConfigRetryMax = time.Hour + weixinSessionPauseDuration = time.Hour + weixinSessionExpiredCode = -14 +) + +type typingTicketCacheEntry struct { + ticket string + nextFetchAt time.Time + retryDelay time.Duration +} + +type syncCursorFile struct { + GetUpdatesBuf string `json:"get_updates_buf"` +} + +type contextTokensFile struct { + Tokens map[string]string `json:"tokens"` +} + +func picoclawHomeDir() string { + return config.GetHome() +} + +func genWeixinAccountKey(cfg *config.WeixinSettings) string { + token := strings.TrimSpace(cfg.Token.String()) + if token == "" { + return "default" + } + sum := sha256.Sum256([]byte(strings.TrimSpace(cfg.BaseURL) + "|" + token)) + return hex.EncodeToString(sum[:8]) +} + +func buildWeixinSyncBufPath(cfg *config.WeixinSettings) string { + return filepath.Join(picoclawHomeDir(), "channels", "weixin", "sync", genWeixinAccountKey(cfg)+".json") +} + +func buildWeixinContextTokensPath(cfg *config.WeixinSettings) string { + return filepath.Join(picoclawHomeDir(), "channels", "weixin", "context-tokens", genWeixinAccountKey(cfg)+".json") +} + +func loadGetUpdatesBuf(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + + var decoded syncCursorFile + if err := json.Unmarshal(data, &decoded); err != nil { + return "", err + } + + return decoded.GetUpdatesBuf, nil +} + +func saveGetUpdatesBuf(path, cursor string) error { + data, err := json.Marshal(syncCursorFile{GetUpdatesBuf: cursor}) + if err != nil { + return err + } + return fileutil.WriteFileAtomic(path, data, 0o600) +} + +func loadContextTokens(path string) (map[string]string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var decoded contextTokensFile + if err := json.Unmarshal(data, &decoded); err != nil { + return nil, err + } + return decoded.Tokens, nil +} + +func saveContextTokens(path string, tokens map[string]string) error { + data, err := json.Marshal(contextTokensFile{Tokens: tokens}) + if err != nil { + return err + } + return fileutil.WriteFileAtomic(path, data, 0o600) +} + +func (c *WeixinChannel) cdnBaseURL() string { + if base := strings.TrimSpace(c.config.CDNBaseURL); base != "" { + return strings.TrimRight(base, "/") + } + return weixinDefaultCDNBaseURL +} + +func isSessionExpiredStatus(ret, errcode int) bool { + return ret == weixinSessionExpiredCode || errcode == weixinSessionExpiredCode +} + +func (c *WeixinChannel) pauseSession(operation string, ret, errcode int, errmsg string) time.Duration { + c.pauseMu.Lock() + defer c.pauseMu.Unlock() + + until := time.Now().Add(weixinSessionPauseDuration) + if until.After(c.pauseUntil) { + c.pauseUntil = until + } + + remaining := time.Until(c.pauseUntil) + logger.ErrorCF("weixin", "Session expired; pausing Weixin channel", map[string]any{ + "operation": operation, + "ret": ret, + "errcode": errcode, + "errmsg": errmsg, + "until": c.pauseUntil.Format(time.RFC3339), + "minutes": int((remaining + time.Minute - 1) / time.Minute), + }) + return remaining +} + +func (c *WeixinChannel) remainingPause() time.Duration { + c.pauseMu.Lock() + defer c.pauseMu.Unlock() + + if c.pauseUntil.IsZero() { + return 0 + } + remaining := time.Until(c.pauseUntil) + if remaining <= 0 { + c.pauseUntil = time.Time{} + return 0 + } + return remaining +} + +func (c *WeixinChannel) waitWhileSessionPaused(ctx context.Context) error { + remaining := c.remainingPause() + if remaining <= 0 { + return nil + } + + timer := time.NewTimer(remaining) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func (c *WeixinChannel) ensureSessionActive() error { + remaining := c.remainingPause() + if remaining <= 0 { + return nil + } + return fmt.Errorf( + "weixin session paused (%d min remaining): %w", + int((remaining+time.Minute-1)/time.Minute), + basechannels.ErrSendFailed, + ) +} + +func (c *WeixinChannel) getTypingTicket(ctx context.Context, userID string) (string, error) { + now := time.Now() + + c.typingMu.Lock() + entry, ok := c.typingCache[userID] + if ok && now.Before(entry.nextFetchAt) { + ticket := entry.ticket + c.typingMu.Unlock() + return ticket, nil + } + cachedTicket := entry.ticket + retryDelay := entry.retryDelay + c.typingMu.Unlock() + + contextToken := "" + if v, ok := c.contextTokens.Load(userID); ok { + contextToken, _ = v.(string) + } + + resp, err := c.api.GetConfig(ctx, GetConfigReq{ + IlinkUserID: userID, + ContextToken: contextToken, + }) + if err == nil && resp != nil && resp.Ret == 0 && resp.Errcode == 0 { + ticket := strings.TrimSpace(resp.TypingTicket) + c.typingMu.Lock() + c.typingCache[userID] = typingTicketCacheEntry{ + ticket: ticket, + nextFetchAt: now.Add(weixinConfigCacheTTL), + retryDelay: weixinConfigRetryInitial, + } + c.typingMu.Unlock() + return ticket, nil + } + + if resp != nil && isSessionExpiredStatus(resp.Ret, resp.Errcode) { + c.pauseSession("getconfig", resp.Ret, resp.Errcode, resp.Errmsg) + } + + if retryDelay <= 0 { + retryDelay = weixinConfigRetryInitial + } else { + retryDelay *= 2 + if retryDelay > weixinConfigRetryMax { + retryDelay = weixinConfigRetryMax + } + } + + c.typingMu.Lock() + c.typingCache[userID] = typingTicketCacheEntry{ + ticket: cachedTicket, + nextFetchAt: now.Add(retryDelay), + retryDelay: retryDelay, + } + c.typingMu.Unlock() + + if err != nil { + return cachedTicket, err + } + if resp == nil { + return cachedTicket, fmt.Errorf("getconfig returned nil response") + } + return cachedTicket, fmt.Errorf( + "getconfig failed: ret=%d errcode=%d errmsg=%s", + resp.Ret, + resp.Errcode, + resp.Errmsg, + ) +} diff --git a/pkg/channels/weixin/types.go b/pkg/channels/weixin/types.go new file mode 100644 index 000000000..f2c03894f --- /dev/null +++ b/pkg/channels/weixin/types.go @@ -0,0 +1,213 @@ +package weixin + +// BaseInfo is attached to every outgoing CGI request +type BaseInfo struct { + ChannelVersion string `json:"channel_version,omitempty"` +} + +type APIStatus struct { + Ret int `json:"ret,omitempty"` + Errcode int `json:"errcode,omitempty"` + Errmsg string `json:"errmsg,omitempty"` +} + +// UploadMediaType constants +const ( + UploadMediaTypeImage = 1 + UploadMediaTypeVideo = 2 + UploadMediaTypeFile = 3 + UploadMediaTypeVoice = 4 +) + +type GetUploadUrlReq struct { + Filekey string `json:"filekey,omitempty"` + MediaType int `json:"media_type,omitempty"` + ToUserID string `json:"to_user_id,omitempty"` + Rawsize int64 `json:"rawsize,omitempty"` + RawfileMD5 string `json:"rawfilemd5,omitempty"` + Filesize int64 `json:"filesize,omitempty"` + ThumbRawsize int64 `json:"thumb_rawsize,omitempty"` + ThumbRawfileMD5 string `json:"thumb_rawfilemd5,omitempty"` + ThumbFilesize int64 `json:"thumb_filesize,omitempty"` + NoNeedThumb bool `json:"no_need_thumb,omitempty"` + Aeskey string `json:"aeskey,omitempty"` // hex-encoded 16-byte AES key + BaseInfo BaseInfo `json:"base_info,omitempty"` +} + +type GetUploadUrlResp struct { + APIStatus + UploadParam string `json:"upload_param,omitempty"` + ThumbUploadParam string `json:"thumb_upload_param,omitempty"` + UploadFullURL string `json:"upload_full_url,omitempty"` +} + +const ( + MessageTypeNone = 0 + MessageTypeUser = 1 + MessageTypeBot = 2 +) + +const ( + MessageItemTypeNone = 0 + MessageItemTypeText = 1 + MessageItemTypeImage = 2 + MessageItemTypeVoice = 3 + MessageItemTypeFile = 4 + MessageItemTypeVideo = 5 +) + +const ( + MessageStateNew = 0 + MessageStateGenerating = 1 + MessageStateFinish = 2 +) + +type TextItem struct { + Text string `json:"text,omitempty"` +} + +type CDNMedia struct { + EncryptQueryParam string `json:"encrypt_query_param,omitempty"` + AesKey string `json:"aes_key,omitempty"` // base64 encoded + EncryptType int `json:"encrypt_type,omitempty"` + FullURL string `json:"full_url,omitempty"` +} + +type ImageItem struct { + Media *CDNMedia `json:"media,omitempty"` + ThumbMedia *CDNMedia `json:"thumb_media,omitempty"` + Aeskey string `json:"aeskey,omitempty"` + Url string `json:"url,omitempty"` + MidSize int64 `json:"mid_size,omitempty"` + ThumbSize int64 `json:"thumb_size,omitempty"` + ThumbHeight int `json:"thumb_height,omitempty"` + ThumbWidth int `json:"thumb_width,omitempty"` + HDSize int64 `json:"hd_size,omitempty"` +} + +type VoiceItem struct { + Media *CDNMedia `json:"media,omitempty"` + EncodeType int `json:"encode_type,omitempty"` + BitsPerSample int `json:"bits_per_sample,omitempty"` + SampleRate int `json:"sample_rate,omitempty"` + Playtime int `json:"playtime,omitempty"` + Text string `json:"text,omitempty"` +} + +type FileItem struct { + Media *CDNMedia `json:"media,omitempty"` + FileName string `json:"file_name,omitempty"` + MD5 string `json:"md5,omitempty"` + Len string `json:"len,omitempty"` +} + +type VideoItem struct { + Media *CDNMedia `json:"media,omitempty"` + VideoSize int64 `json:"video_size,omitempty"` + PlayLength int `json:"play_length,omitempty"` + VideoMD5 string `json:"video_md5,omitempty"` + ThumbMedia *CDNMedia `json:"thumb_media,omitempty"` + ThumbSize int64 `json:"thumb_size,omitempty"` + ThumbHeight int `json:"thumb_height,omitempty"` + ThumbWidth int `json:"thumb_width,omitempty"` +} + +type RefMessage struct { + MessageItem *MessageItem `json:"message_item,omitempty"` + Title string `json:"title,omitempty"` +} + +type MessageItem struct { + Type int `json:"type,omitempty"` + CreateTimeMs int64 `json:"create_time_ms,omitempty"` + UpdateTimeMs int64 `json:"update_time_ms,omitempty"` + IsCompleted bool `json:"is_completed,omitempty"` + MsgID string `json:"msg_id,omitempty"` + RefMsg *RefMessage `json:"ref_msg,omitempty"` + TextItem *TextItem `json:"text_item,omitempty"` + ImageItem *ImageItem `json:"image_item,omitempty"` + VoiceItem *VoiceItem `json:"voice_item,omitempty"` + FileItem *FileItem `json:"file_item,omitempty"` + VideoItem *VideoItem `json:"video_item,omitempty"` +} + +type WeixinMessage struct { + Seq int `json:"seq,omitempty"` + MessageID int64 `json:"message_id,omitempty"` + FromUserID string `json:"from_user_id,omitempty"` + ToUserID string `json:"to_user_id,omitempty"` + ClientID string `json:"client_id,omitempty"` + CreateTimeMs int64 `json:"create_time_ms,omitempty"` + UpdateTimeMs int64 `json:"update_time_ms,omitempty"` + DeleteTimeMs int64 `json:"delete_time_ms,omitempty"` + SessionID string `json:"session_id,omitempty"` + GroupID string `json:"group_id,omitempty"` + MessageType int `json:"message_type,omitempty"` + MessageState int `json:"message_state,omitempty"` + ItemList []MessageItem `json:"item_list,omitempty"` + ContextToken string `json:"context_token,omitempty"` +} + +type GetUpdatesReq struct { + SyncBuf string `json:"sync_buf,omitempty"` + GetUpdatesBuf string `json:"get_updates_buf,omitempty"` + BaseInfo BaseInfo `json:"base_info,omitempty"` +} + +type GetUpdatesResp struct { + APIStatus + Msgs []WeixinMessage `json:"msgs,omitempty"` + SyncBuf string `json:"sync_buf,omitempty"` + GetUpdatesBuf string `json:"get_updates_buf,omitempty"` + LongpollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"` +} + +type SendMessageReq struct { + Msg WeixinMessage `json:"msg,omitempty"` + BaseInfo BaseInfo `json:"base_info,omitempty"` +} + +type SendMessageResp struct { + APIStatus +} + +type GetConfigReq struct { + IlinkUserID string `json:"ilink_user_id,omitempty"` + ContextToken string `json:"context_token,omitempty"` + BaseInfo BaseInfo `json:"base_info,omitempty"` +} + +type GetConfigResp struct { + APIStatus + TypingTicket string `json:"typing_ticket,omitempty"` +} + +const ( + TypingStatusTyping = 1 + TypingStatusCancel = 2 +) + +type SendTypingReq struct { + IlinkUserID string `json:"ilink_user_id,omitempty"` + TypingTicket string `json:"typing_ticket,omitempty"` + Status int `json:"status,omitempty"` // 1=typing, 2=cancel + BaseInfo BaseInfo `json:"base_info,omitempty"` +} + +type SendTypingResp struct { + APIStatus +} + +type QRCodeResponse struct { + Qrcode string `json:"qrcode"` + QrcodeImgContent string `json:"qrcode_img_content"` +} + +type StatusResponse struct { + Status string `json:"status"` // "wait", "scaned", "confirmed", "expired", "scaned_but_redirect" + BotToken string `json:"bot_token,omitempty"` + IlinkBotID string `json:"ilink_bot_id,omitempty"` + Baseurl string `json:"baseurl,omitempty"` + IlinkUserID string `json:"ilink_user_id,omitempty"` + RedirectHost string `json:"redirect_host,omitempty"` +} diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go new file mode 100644 index 000000000..2897d2422 --- /dev/null +++ b/pkg/channels/weixin/weixin.go @@ -0,0 +1,444 @@ +package weixin + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// WeixinChannel is the Weixin channel implementation over Tencent iLink REST API. +type WeixinChannel struct { + *channels.BaseChannel + api *ApiClient + config *config.WeixinSettings + ctx context.Context + cancel context.CancelFunc + bus *bus.MessageBus + // contextTokens stores the last context_token per user (from_user_id → context_token). + // This is required by the iLink API to associate replies with the right chat session. + contextTokens sync.Map + typingMu sync.Mutex + typingCache map[string]typingTicketCacheEntry + pauseMu sync.Mutex + pauseUntil time.Time + syncBufPath string + contextTokensPath string +} + +func init() { + channels.RegisterFactory( + config.ChannelWeixin, + func(channelName, channelType string, cfg *config.Config, bus *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + weixinCfg, ok := decoded.(*config.WeixinSettings) + if !ok { + return nil, channels.ErrSendFailed + } + ch, err := NewWeixinChannel(bc, weixinCfg, bus) + if err != nil { + return nil, err + } + if channelName != config.ChannelWeixin { + ch.SetName(channelName) + } + return ch, nil + }, + ) +} + +// NewWeixinChannel creates a new WeixinChannel from config. +func NewWeixinChannel( + bc *config.Channel, + cfg *config.WeixinSettings, + messageBus *bus.MessageBus, +) (*WeixinChannel, error) { + api, err := NewApiClient(cfg.BaseURL, cfg.Token.String(), cfg.Proxy) + if err != nil { + return nil, fmt.Errorf("weixin: failed to create API client: %w", err) + } + + base := channels.NewBaseChannel( + bc.Name(), + cfg, + messageBus, + bc.AllowFrom, + channels.WithMaxMessageLength(4000), + channels.WithReasoningChannelID(bc.ReasoningChannelID), + ) + + return &WeixinChannel{ + BaseChannel: base, + api: api, + config: cfg, + bus: messageBus, + typingCache: make(map[string]typingTicketCacheEntry), + syncBufPath: buildWeixinSyncBufPath(cfg), + contextTokensPath: buildWeixinContextTokensPath(cfg), + }, nil +} + +func (c *WeixinChannel) Start(ctx context.Context) error { + logger.InfoC("weixin", "Starting Weixin channel") + c.ctx, c.cancel = context.WithCancel(ctx) + c.SetRunning(true) + c.restoreContextTokens() + go c.pollLoop(c.ctx) + logger.InfoC("weixin", "Weixin channel started") + return nil +} + +// restoreContextTokens loads persisted context tokens from disk into memory. +func (c *WeixinChannel) restoreContextTokens() { + tokens, err := loadContextTokens(c.contextTokensPath) + if err != nil { + logger.WarnCF("weixin", "Failed to load persisted context tokens", map[string]any{ + "path": c.contextTokensPath, + "error": err.Error(), + }) + return + } + if len(tokens) == 0 { + return + } + for userID, token := range tokens { + c.contextTokens.Store(userID, token) + } + logger.InfoCF("weixin", "Restored context tokens from disk", map[string]any{ + "path": c.contextTokensPath, + "count": len(tokens), + }) +} + +// persistContextTokens saves all in-memory context tokens to disk. +func (c *WeixinChannel) persistContextTokens() { + tokens := make(map[string]string) + c.contextTokens.Range(func(k, v any) bool { + if userID, ok := k.(string); ok { + if token, ok := v.(string); ok { + tokens[userID] = token + } + } + return true + }) + if err := saveContextTokens(c.contextTokensPath, tokens); err != nil { + logger.WarnCF("weixin", "Failed to persist context tokens", map[string]any{ + "path": c.contextTokensPath, + "error": err.Error(), + }) + } +} + +func (c *WeixinChannel) Stop(ctx context.Context) error { + logger.InfoC("weixin", "Stopping Weixin channel") + c.SetRunning(false) + if c.cancel != nil { + c.cancel() + } + return nil +} + +// pollLoop is the long-poll receive loop. It runs until ctx is canceled. +func (c *WeixinChannel) pollLoop(ctx context.Context) { + const ( + defaultPollTimeoutMs = 35_000 + retryDelay = 2 * time.Second + backoffDelay = 30 * time.Second + maxConsecutiveFails = 3 + ) + + consecutiveFails := 0 + getUpdatesBuf, err := loadGetUpdatesBuf(c.syncBufPath) + if err != nil { + logger.WarnCF("weixin", "Failed to load persisted get_updates_buf", map[string]any{ + "path": c.syncBufPath, + "error": err.Error(), + }) + getUpdatesBuf = "" + } else if getUpdatesBuf != "" { + logger.InfoCF("weixin", "Resuming persisted get_updates_buf", map[string]any{ + "path": c.syncBufPath, + "bytes": len(getUpdatesBuf), + "source": "disk", + }) + } + nextTimeoutMs := defaultPollTimeoutMs + + for { + select { + case <-ctx.Done(): + logger.InfoC("weixin", "Weixin poll loop stopped") + return + default: + } + + if err := c.waitWhileSessionPaused(ctx); err != nil { + if ctx.Err() != nil { + return + } + continue + } + + // Build a context with timeout slightly longer than the long-poll + pollCtx, pollCancel := context.WithTimeout(ctx, time.Duration(nextTimeoutMs+5000)*time.Millisecond) + + resp, err := c.api.GetUpdates(pollCtx, GetUpdatesReq{ + GetUpdatesBuf: getUpdatesBuf, + }) + pollCancel() + + if err != nil { + // Check if we're shutting down + if ctx.Err() != nil { + return + } + + consecutiveFails++ + logger.WarnCF("weixin", "getUpdates failed", map[string]any{ + "error": err.Error(), + "attempt": consecutiveFails, + }) + + if consecutiveFails >= maxConsecutiveFails { + logger.ErrorCF("weixin", "Too many consecutive failures, backing off", map[string]any{ + "duration": backoffDelay, + }) + consecutiveFails = 0 + select { + case <-ctx.Done(): + return + case <-time.After(backoffDelay): + } + } else { + select { + case <-ctx.Done(): + return + case <-time.After(retryDelay): + } + } + continue + } + + if isSessionExpiredStatus(resp.Ret, resp.Errcode) { + remaining := c.pauseSession("getupdates", resp.Ret, resp.Errcode, resp.Errmsg) + select { + case <-ctx.Done(): + return + case <-time.After(remaining): + } + continue + } + + if resp.Errcode != 0 || resp.Ret != 0 { + consecutiveFails++ + logger.ErrorCF("weixin", "getUpdates API error", map[string]any{ + "ret": resp.Ret, + "errcode": resp.Errcode, + "errmsg": resp.Errmsg, + }) + select { + case <-ctx.Done(): + return + case <-time.After(retryDelay): + } + continue + } + + consecutiveFails = 0 + + // Update the long-poll timeout from server hint + if resp.LongpollingTimeoutMs > 0 { + nextTimeoutMs = resp.LongpollingTimeoutMs + } + + // Advance cursor + if resp.GetUpdatesBuf != "" { + getUpdatesBuf = resp.GetUpdatesBuf + if err := saveGetUpdatesBuf(c.syncBufPath, getUpdatesBuf); err != nil { + logger.WarnCF("weixin", "Failed to persist get_updates_buf", map[string]any{ + "path": c.syncBufPath, + "error": err.Error(), + }) + } + } + + // Dispatch messages + for _, msg := range resp.Msgs { + c.handleInboundMessage(ctx, msg) + } + } +} + +// handleInboundMessage converts a WeixinMessage to a bus.InboundMessage. +func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMessage) { + fromUserID := msg.FromUserID + if fromUserID == "" { + return + } + + messageID := msg.ClientID + if messageID == "" { + messageID = uuid.New().String() + } + + // Build text content from item_list + var parts []string + for _, item := range msg.ItemList { + switch item.Type { + case MessageItemTypeText: + if item.TextItem != nil && item.TextItem.Text != "" { + parts = append(parts, item.TextItem.Text) + } + case MessageItemTypeVoice: + if item.VoiceItem != nil && item.VoiceItem.Text != "" { + // Use voice → text transcription from server + parts = append(parts, item.VoiceItem.Text) + } else { + parts = append(parts, "[audio]") + } + case MessageItemTypeImage: + parts = append(parts, "[image]") + case MessageItemTypeFile: + if item.FileItem != nil && item.FileItem.FileName != "" { + parts = append(parts, fmt.Sprintf("[file: %s]", item.FileItem.FileName)) + } else { + parts = append(parts, "[file]") + } + case MessageItemTypeVideo: + parts = append(parts, "[video]") + } + } + + var mediaRefs []string + if mediaItem := selectInboundMediaItem(msg); mediaItem != nil { + ref, err := c.downloadMediaFromItem(ctx, fromUserID, messageID, mediaItem) + if err != nil { + logger.ErrorCF("weixin", "Failed to download inbound media", map[string]any{ + "from_user_id": fromUserID, + "message_id": messageID, + "type": mediaItem.Type, + "error": err.Error(), + }) + } else if ref != "" { + mediaRefs = append(mediaRefs, ref) + } + } + + content := strings.Join(parts, "\n") + if content == "" && len(mediaRefs) == 0 { + return + } + + sender := bus.SenderInfo{ + Platform: "weixin", + PlatformID: fromUserID, + CanonicalID: identity.BuildCanonicalID("weixin", fromUserID), + Username: fromUserID, + DisplayName: fromUserID, + } + + if !c.IsAllowedSender(sender) { + logger.DebugCF("weixin", "Message rejected by allowlist", map[string]any{ + "from_user_id": fromUserID, + }) + return + } + + metadata := map[string]string{ + "from_user_id": fromUserID, + "context_token": msg.ContextToken, + "session_id": msg.SessionID, + } + + logger.DebugCF("weixin", "Received message", map[string]any{ + "from_user_id": fromUserID, + "content_len": len(content), + "media_count": len(mediaRefs), + }) + + // Store context_token for outbound reply association + if msg.ContextToken != "" { + c.contextTokens.Store(fromUserID, msg.ContextToken) + c.persistContextTokens() + } + + inboundCtx := bus.InboundContext{ + Channel: "weixin", + ChatID: fromUserID, + ChatType: "direct", + SenderID: fromUserID, + MessageID: messageID, + Raw: metadata, + } + if msg.ContextToken != "" { + inboundCtx.ReplyHandles = map[string]string{ + "context_token": msg.ContextToken, + } + } + + c.HandleInboundContext(ctx, fromUserID, content, mediaRefs, inboundCtx, sender) +} + +// Send implements channels.Channel by sending a text message to the WeChat user. +func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + if err := c.ensureSessionActive(); err != nil { + return nil, err + } + + if msg.Content == "" { + return nil, nil + } + + // We need a context_token to send a reply. It should be stored in the conversation metadata. + // The chat_id is the weixin user_id (from_user_id). + toUserID := msg.ChatID + + // Retrieve context_token from our per-user map (stored on last inbound) + contextToken := "" + if ct, ok := c.contextTokens.Load(toUserID); ok { + contextToken, _ = ct.(string) + } + + // If we don't have a context token for this user, we cannot send a valid reply. + // Treat this as a non-temporary error so the manager doesn't keep retrying. + if contextToken == "" { + logger.ErrorCF("weixin", "Missing context token, cannot send message", map[string]any{ + "to_user_id": toUserID, + }) + return nil, fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID) + } + + if err := c.sendTextMessage(ctx, toUserID, contextToken, msg.Content); err != nil { + logger.ErrorCF("weixin", "Failed to send message", map[string]any{ + "to_user_id": toUserID, + "error": err.Error(), + }) + if c.remainingPause() > 0 { + return nil, fmt.Errorf("weixin send: %w", channels.ErrSendFailed) + } + return nil, fmt.Errorf("weixin send: %w", channels.ErrTemporary) + } + + return nil, nil +} + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *WeixinChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/weixin/weixin_test.go b/pkg/channels/weixin/weixin_test.go new file mode 100644 index 000000000..aea2cbb0c --- /dev/null +++ b/pkg/channels/weixin/weixin_test.go @@ -0,0 +1,321 @@ +package weixin + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "io" + "net/http" + "path/filepath" + "testing" + "time" + + basechannels "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestParseWeixinMediaAESKey(t *testing.T) { + raw := []byte("1234567890abcdef") + + got, err := parseWeixinMediaAESKey(base64.StdEncoding.EncodeToString(raw)) + if err != nil { + t.Fatalf("parseWeixinMediaAESKey(raw) error = %v", err) + } + if !bytes.Equal(got, raw) { + t.Fatalf("parseWeixinMediaAESKey(raw) = %x, want %x", got, raw) + } + + hexEncoded := base64.StdEncoding.EncodeToString([]byte("31323334353637383930616263646566")) + got, err = parseWeixinMediaAESKey(hexEncoded) + if err != nil { + t.Fatalf("parseWeixinMediaAESKey(hex-string) error = %v", err) + } + if !bytes.Equal(got, raw) { + t.Fatalf("parseWeixinMediaAESKey(hex-string) = %x, want %x", got, raw) + } +} + +func TestDownloadAndDecryptCDNBuffer(t *testing.T) { + key := []byte("1234567890abcdef") + plaintext := []byte("hello weixin") + ciphertext, err := encryptAESECB(plaintext, key) + if err != nil { + t.Fatalf("encryptAESECB() error = %v", err) + } + + ch := &WeixinChannel{ + api: &ApiClient{ + HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/download" { + t.Fatalf("download path = %q, want /download", r.URL.Path) + } + if r.URL.Query().Get("encrypted_query_param") != "token" { + t.Fatalf("encrypted_query_param = %q, want token", r.URL.Query().Get("encrypted_query_param")) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(ciphertext)), + Header: make(http.Header), + }, nil + })}, + }, + config: &config.WeixinSettings{ + CDNBaseURL: "https://cdn.example.com", + }, + typingCache: make(map[string]typingTicketCacheEntry), + } + + got, err := ch.downloadAndDecryptCDNBuffer(context.Background(), "token", "", key) + if err != nil { + t.Fatalf("downloadAndDecryptCDNBuffer() error = %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("downloadAndDecryptCDNBuffer() = %q, want %q", got, plaintext) + } +} + +func TestDownloadAndDecryptCDNBufferUsesFullURLWhenProvided(t *testing.T) { + key := []byte("1234567890abcdef") + plaintext := []byte("hello weixin") + ciphertext, err := encryptAESECB(plaintext, key) + if err != nil { + t.Fatalf("encryptAESECB() error = %v", err) + } + + fullURLAttempts := 0 + ch := &WeixinChannel{ + api: &ApiClient{ + HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.String() == "https://full.example.com/download" { + fullURLAttempts++ + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(ciphertext)), + Header: make(http.Header), + }, nil + } + t.Fatalf("unexpected fallback request: %s", r.URL.String()) + return nil, nil + })}, + }, + config: &config.WeixinSettings{ + CDNBaseURL: "https://cdn.example.com", + }, + typingCache: make(map[string]typingTicketCacheEntry), + } + + got, err := ch.downloadAndDecryptCDNBuffer(context.Background(), "token", "https://full.example.com/download", key) + if err != nil { + t.Fatalf("downloadAndDecryptCDNBuffer() error = %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("downloadAndDecryptCDNBuffer() = %q, want %q", got, plaintext) + } + if fullURLAttempts == 0 { + t.Fatalf("fullURLAttempts = %d, want > 0", fullURLAttempts) + } +} + +func TestDownloadAndDecryptCDNBufferFallsBackToConstructedURLWhenFullURLFails(t *testing.T) { + key := []byte("1234567890abcdef") + plaintext := []byte("hello weixin") + ciphertext, err := encryptAESECB(plaintext, key) + if err != nil { + t.Fatalf("encryptAESECB() error = %v", err) + } + + fullURLAttempts := 0 + constructedAttempts := 0 + ch := &WeixinChannel{ + api: &ApiClient{ + HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.String() == "https://full.example.com/download?encrypted_query_param=token&taskid=123" { + fullURLAttempts++ + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewReader(nil)), + Header: make(http.Header), + }, nil + } + if r.URL.String() != "https://cdn.example.com/download?encrypted_query_param=token" { + t.Fatalf("unexpected fallback request: %s", r.URL.String()) + } + constructedAttempts++ + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(ciphertext)), + Header: make(http.Header), + }, nil + })}, + }, + config: &config.WeixinSettings{ + CDNBaseURL: "https://cdn.example.com", + }, + typingCache: make(map[string]typingTicketCacheEntry), + } + + got, err := ch.downloadAndDecryptCDNBuffer( + context.Background(), + "token", + "https://full.example.com/download?encrypted_query_param=token&taskid=123", + key, + ) + if err != nil { + t.Fatalf("downloadAndDecryptCDNBuffer() error = %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("downloadAndDecryptCDNBuffer() = %q, want %q", got, plaintext) + } + if fullURLAttempts == 0 { + t.Fatalf("fullURLAttempts = %d, want > 0", fullURLAttempts) + } + if constructedAttempts == 0 { + t.Fatalf("constructedAttempts = %d, want > 0", constructedAttempts) + } +} + +func TestBuildCDNDownloadURLEscapesOpaqueToken(t *testing.T) { + token := "MFcCAQAESzBJAgEAAgSieMV9AgM9CcwCBEoKPqICBGnHZB0EJDk4OWY5YWU0LTc4OGItNGQ5Ni1iMjZhLWU4YjhlMmEwOWVkZgIEIR0IAgIBAAQFAExUPQA%3D" + + got := buildCDNDownloadURL("https://cdn.example.com", token) + + if got != "https://cdn.example.com/download?encrypted_query_param=MFcCAQAESzBJAgEAAgSieMV9AgM9CcwCBEoKPqICBGnHZB0EJDk4OWY5YWU0LTc4OGItNGQ5Ni1iMjZhLWU4YjhlMmEwOWVkZgIEIR0IAgIBAAQFAExUPQA%253D" { + t.Fatalf("buildCDNDownloadURL() = %q", got) + } +} + +func TestUploadBufferToCDN(t *testing.T) { + key := []byte("1234567890abcdef") + plaintext := []byte("upload me") + wantCipher, err := encryptAESECB(plaintext, key) + if err != nil { + t.Fatalf("encryptAESECB() error = %v", err) + } + + ch := &WeixinChannel{ + api: &ApiClient{ + HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/upload" { + t.Fatalf("upload path = %q, want /upload", r.URL.Path) + } + if got := r.URL.Query().Get("encrypted_query_param"); got != "upload-param" { + t.Fatalf("encrypted_query_param = %q, want upload-param", got) + } + if got := r.URL.Query().Get("filekey"); got != "file-key" { + t.Fatalf("filekey = %q, want file-key", got) + } + body, _ := io.ReadAll(r.Body) + if !bytes.Equal(body, wantCipher) { + t.Fatalf("upload body = %x, want %x", body, wantCipher) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(nil)), + Header: http.Header{ + "X-Encrypted-Param": []string{"download-param"}, + }, + }, nil + })}, + }, + config: &config.WeixinSettings{ + CDNBaseURL: "https://cdn.example.com", + }, + typingCache: make(map[string]typingTicketCacheEntry), + } + + got, err := ch.uploadBufferToCDN(context.Background(), plaintext, "upload-param", "", "file-key", key) + if err != nil { + t.Fatalf("uploadBufferToCDN() error = %v", err) + } + if got != "download-param" { + t.Fatalf("uploadBufferToCDN() = %q, want download-param", got) + } +} + +func TestLoadSaveGetUpdatesBuf(t *testing.T) { + path := filepath.Join(t.TempDir(), "sync.json") + + if err := saveGetUpdatesBuf(path, "cursor-123"); err != nil { + t.Fatalf("saveGetUpdatesBuf() error = %v", err) + } + + got, err := loadGetUpdatesBuf(path) + if err != nil { + t.Fatalf("loadGetUpdatesBuf() error = %v", err) + } + if got != "cursor-123" { + t.Fatalf("loadGetUpdatesBuf() = %q, want cursor-123", got) + } +} + +func TestBuildWeixinSyncBufPathUsesPicoclawHome(t *testing.T) { + home := t.TempDir() + t.Setenv(config.EnvHome, home) + + wxCfg := &config.WeixinSettings{ + BaseURL: "https://ilinkai.weixin.qq.com/", + } + wxCfg.SetToken("token-123") + got := buildWeixinSyncBufPath(wxCfg) + if filepath.Dir(got) != filepath.Join(home, "channels", "weixin", "sync") { + t.Fatalf("sync path dir = %q", filepath.Dir(got)) + } +} + +func TestSessionPauseGuard(t *testing.T) { + ch := &WeixinChannel{ + typingCache: make(map[string]typingTicketCacheEntry), + } + + ch.pauseSession("getupdates", 0, weixinSessionExpiredCode, "expired") + + if err := ch.ensureSessionActive(); !errors.Is(err, basechannels.ErrSendFailed) { + t.Fatalf("ensureSessionActive() error = %v, want ErrSendFailed", err) + } + + ch.pauseMu.Lock() + ch.pauseUntil = time.Now().Add(-time.Second) + ch.pauseMu.Unlock() + + if err := ch.ensureSessionActive(); err != nil { + t.Fatalf("ensureSessionActive() after expiry error = %v, want nil", err) + } +} + +func TestSelectInboundMediaItemFallsBackToRefMessage(t *testing.T) { + msg := WeixinMessage{ + ItemList: []MessageItem{ + { + Type: MessageItemTypeText, + TextItem: &TextItem{ + Text: "look", + }, + RefMsg: &RefMessage{ + MessageItem: &MessageItem{ + Type: MessageItemTypeImage, + ImageItem: &ImageItem{ + Media: &CDNMedia{ + EncryptQueryParam: "abc", + }, + }, + }, + }, + }, + }, + } + + item := selectInboundMediaItem(msg) + if item == nil { + t.Fatal("selectInboundMediaItem() = nil, want ref media item") + } + if item.Type != MessageItemTypeImage { + t.Fatalf("selectInboundMediaItem().Type = %d, want %d", item.Type, MessageItemTypeImage) + } +} diff --git a/pkg/channels/whatsapp/init.go b/pkg/channels/whatsapp/init.go index d9c2669c3..a9558d185 100644 --- a/pkg/channels/whatsapp/init.go +++ b/pkg/channels/whatsapp/init.go @@ -7,7 +7,19 @@ import ( ) func init() { - channels.RegisterFactory("whatsapp", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewWhatsAppChannel(cfg.Channels.WhatsApp, b) - }) + channels.RegisterFactory( + config.ChannelWhatsApp, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.WhatsAppSettings) + if !ok { + return nil, channels.ErrSendFailed + } + return NewWhatsAppChannel(bc, c, b) + }, + ) } diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go index 70b3e02bf..4c338b5f4 100644 --- a/pkg/channels/whatsapp/whatsapp.go +++ b/pkg/channels/whatsapp/whatsapp.go @@ -20,7 +20,7 @@ import ( type WhatsAppChannel struct { *channels.BaseChannel conn *websocket.Conn - config config.WhatsAppConfig + config *config.WhatsAppSettings url string ctx context.Context cancel context.CancelFunc @@ -28,14 +28,18 @@ type WhatsAppChannel struct { connected bool } -func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) { +func NewWhatsAppChannel( + bc *config.Channel, + cfg *config.WhatsAppSettings, + bus *bus.MessageBus, +) (*WhatsAppChannel, error) { base := channels.NewBaseChannel( "whatsapp", cfg, bus, - cfg.AllowFrom, + bc.AllowFrom, channels.WithMaxMessageLength(65536), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &WhatsAppChannel{ @@ -104,15 +108,15 @@ func (c *WhatsAppChannel) Stop(ctx context.Context) error { return nil } -func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Check ctx before acquiring lock select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -120,7 +124,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err defer c.mu.Unlock() if c.conn == nil { - return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } payload := map[string]any{ @@ -131,17 +135,17 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err data, err := json.Marshal(payload) if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) + return nil, fmt.Errorf("failed to marshal message: %w", err) } _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { _ = c.conn.SetWriteDeadline(time.Time{}) - return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) } _ = c.conn.SetWriteDeadline(time.Time{}) - return nil + return nil, nil } func (c *WhatsAppChannel) listen() { @@ -223,13 +227,6 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) { metadata["user_name"] = userName } - var peer bus.Peer - if chatID == senderID { - peer = bus.Peer{Kind: "direct", ID: senderID} - } else { - peer = bus.Peer{Kind: "group", ID: chatID} - } - logger.InfoCF("whatsapp", "WhatsApp message received", map[string]any{ "sender": senderID, "preview": utils.Truncate(content, 50), @@ -248,5 +245,18 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) { return } - c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) + inboundCtx := bus.InboundContext{ + Channel: "whatsapp", + ChatID: chatID, + SenderID: senderID, + MessageID: messageID, + Raw: metadata, + } + if chatID == senderID { + inboundCtx.ChatType = "direct" + } else { + inboundCtx.ChatType = "group" + } + + c.HandleInboundContext(c.ctx, chatID, content, mediaPaths, inboundCtx, sender) } diff --git a/pkg/channels/whatsapp/whatsapp_command_test.go b/pkg/channels/whatsapp/whatsapp_command_test.go index ee8aa4a52..17ba0d2f9 100644 --- a/pkg/channels/whatsapp/whatsapp_command_test.go +++ b/pkg/channels/whatsapp/whatsapp_command_test.go @@ -3,7 +3,6 @@ package whatsapp import ( "context" "testing" - "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" @@ -13,7 +12,7 @@ import ( func TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) { messageBus := bus.NewMessageBus() ch := &WhatsAppChannel{ - BaseChannel: channels.NewBaseChannel("whatsapp", config.WhatsAppConfig{}, messageBus, nil), + BaseChannel: channels.NewBaseChannel("whatsapp", config.WhatsAppSettings{}, messageBus, nil), ctx: context.Background(), } @@ -25,10 +24,7 @@ func TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T "content": "/help", }) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - inbound, ok := messageBus.ConsumeInbound(ctx) + inbound, ok := <-messageBus.InboundChan() if !ok { t.Fatal("expected inbound message to be forwarded") } diff --git a/pkg/channels/whatsapp_native/init.go b/pkg/channels/whatsapp_native/init.go index df13e8539..f1be82ec9 100644 --- a/pkg/channels/whatsapp_native/init.go +++ b/pkg/channels/whatsapp_native/init.go @@ -9,12 +9,27 @@ import ( ) func init() { - channels.RegisterFactory("whatsapp_native", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - waCfg := cfg.Channels.WhatsApp - storePath := waCfg.SessionStorePath - if storePath == "" { - storePath = filepath.Join(cfg.WorkspacePath(), "whatsapp") - } - return NewWhatsAppNativeChannel(waCfg, b, storePath) - }) + channels.RegisterFactory( + config.ChannelWhatsAppNative, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.WhatsAppSettings) + if !ok { + return nil, channels.ErrSendFailed + } + storePath := c.SessionStorePath + if storePath == "" { + storePath = filepath.Join(cfg.WorkspacePath(), "whatsapp") + } + ch, err := NewWhatsAppNativeChannel(bc, channelName, c, b, storePath) + if err != nil { + return nil, err + } + return ch, nil + }, + ) } diff --git a/pkg/channels/whatsapp_native/whatsapp_command_test.go b/pkg/channels/whatsapp_native/whatsapp_command_test.go index cc2dcb619..4d269af66 100644 --- a/pkg/channels/whatsapp_native/whatsapp_command_test.go +++ b/pkg/channels/whatsapp_native/whatsapp_command_test.go @@ -20,7 +20,7 @@ import ( func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) { messageBus := bus.NewMessageBus() ch := &WhatsAppNativeChannel{ - BaseChannel: channels.NewBaseChannel("whatsapp_native", config.WhatsAppConfig{}, messageBus, nil), + BaseChannel: channels.NewBaseChannel("whatsapp_native", config.WhatsAppSettings{}, messageBus, nil), runCtx: context.Background(), } @@ -43,14 +43,19 @@ func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - inbound, ok := messageBus.ConsumeInbound(ctx) - if !ok { - t.Fatal("expected inbound message to be forwarded") - } - if inbound.Channel != "whatsapp_native" { - t.Fatalf("channel=%q", inbound.Channel) - } - if inbound.Content != "/new" { - t.Fatalf("content=%q", inbound.Content) + select { + case <-ctx.Done(): + t.Fatal("timeout waiting for message to be forwarded") + return + case inbound, ok := <-messageBus.InboundChan(): + if !ok { + t.Fatal("expected inbound message to be forwarded") + } + if inbound.Channel != "whatsapp_native" { + t.Fatalf("channel=%q", inbound.Channel) + } + if inbound.Content != "/new" { + t.Fatalf("content=%q", inbound.Content) + } } } diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index 188a7c8fa..de4ecfd44 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -48,7 +48,7 @@ const ( // WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge). type WhatsAppNativeChannel struct { *channels.BaseChannel - config config.WhatsAppConfig + config *config.WhatsAppSettings storePath string client *whatsmeow.Client container *sqlstore.Container @@ -64,11 +64,13 @@ type WhatsAppNativeChannel struct { // NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. // storePath is the directory for the SQLite session store (e.g. workspace/whatsapp). func NewWhatsAppNativeChannel( - cfg config.WhatsAppConfig, + bc *config.Channel, + name string, + cfg *config.WhatsAppSettings, bus *bus.MessageBus, storePath string, ) (channels.Channel, error) { - base := channels.NewBaseChannel("whatsapp_native", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536)) + base := channels.NewBaseChannel(name, cfg, bus, bc.AllowFrom, channels.WithMaxMessageLength(65536)) if storePath == "" { storePath = "whatsapp" } @@ -375,7 +377,6 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { if evt.Info.Chat.Server == types.GroupServer { peerKind = "group" } - peer := bus.Peer{Kind: peerKind, ID: chatID} messageID := evt.Info.ID sender := bus.SenderInfo{ Platform: "whatsapp", @@ -393,16 +394,26 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}, ) - c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) + + inboundCtx := bus.InboundContext{ + Channel: "whatsapp", + ChatID: chatID, + SenderID: senderID, + MessageID: messageID, + ChatType: peerKind, + Raw: metadata, + } + + c.HandleInboundContext(c.runCtx, chatID, content, mediaPaths, inboundCtx, sender) } -func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -411,18 +422,18 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag c.mu.Unlock() if client == nil || !client.IsConnected() { - return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } // Detect unpaired state: the client is connected (to WhatsApp servers) // but has not completed QR-login yet, so sending would fail. if client.Store.ID == nil { - return fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary) } to, err := parseJID(msg.ChatID) if err != nil { - return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err) + return nil, fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err) } waMsg := &waE2E.Message{ @@ -430,9 +441,9 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag } if _, err = client.SendMessage(ctx, to, waMsg); err != nil { - return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) } - return nil + return nil, nil } // parseJID converts a chat ID (phone number or JID string) to types.JID. diff --git a/pkg/channels/whatsapp_native/whatsapp_native_stub.go b/pkg/channels/whatsapp_native/whatsapp_native_stub.go index 984af23e7..d058d8bba 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native_stub.go +++ b/pkg/channels/whatsapp_native/whatsapp_native_stub.go @@ -13,9 +13,16 @@ import ( // NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native. // Build with: go build -tags whatsapp_native ./cmd/... func NewWhatsAppNativeChannel( - cfg config.WhatsAppConfig, + bc *config.Channel, + name string, + cfg *config.WhatsAppSettings, bus *bus.MessageBus, storePath string, ) (channels.Channel, error) { + _ = bc + _ = name + _ = cfg + _ = bus + _ = storePath return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native") } diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index a36dd3eba..e268812a0 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -8,9 +8,16 @@ func BuiltinDefinitions() []Definition { return []Definition{ startCommand(), helpCommand(), + stopCommand(), showCommand(), listCommand(), + useCommand(), + btwCommand(), switchCommand(), checkCommand(), + clearCommand(), + contextCommand(), + subagentsCommand(), + reloadCommand(), } } diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go index 66a84825e..bb9abe360 100644 --- a/pkg/commands/builtin_test.go +++ b/pkg/commands/builtin_test.go @@ -36,12 +36,73 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { t.Fatalf("/help handler error: %v", err) } // Now uses auto-generated EffectiveUsage which includes agents - if !strings.Contains(reply, "/show [model|channel|agents]") { + if !strings.Contains(reply, "/show [model|channel|agents|mcp ]") { t.Fatalf("/help reply missing /show usage, got %q", reply) } - if !strings.Contains(reply, "/list [models|channels|agents]") { + if !strings.Contains(reply, "/list [models|channels|agents|skills|mcp]") { t.Fatalf("/help reply missing /list usage, got %q", reply) } + if !strings.Contains(reply, "/stop") { + t.Fatalf("/help reply missing /stop usage, got %q", reply) + } + if !strings.Contains(reply, "/use ") { + if !strings.Contains(reply, "/use [message]") { + t.Fatalf("/help reply missing /use usage, got %q", reply) + } + } +} + +func TestBuiltinStop_UsesRuntimeStopper(t *testing.T) { + rt := &Runtime{ + StopActiveTurn: func() (StopResult, error) { + return StopResult{ + Stopped: true, + TaskName: "sync the long running job", + }, nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/stop", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/stop: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Task stopped. \"sync the long running job\" was canceled." { + t.Fatalf("/stop reply=%q", reply) + } +} + +func TestBuiltinStop_NoActiveTask(t *testing.T) { + rt := &Runtime{ + StopActiveTurn: func() (StopResult, error) { + return StopResult{}, nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/stop", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/stop: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "No active task to stop." { + t.Fatalf("/stop reply=%q, want no-active message", reply) + } } func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) { @@ -143,3 +204,205 @@ func TestBuiltinListAgents_RestoresOldBehavior(t *testing.T) { t.Fatalf("/list agents reply=%q, want agent IDs", reply) } } + +func TestBuiltinListSkills_UsesRuntimeSkillNames(t *testing.T) { + rt := &Runtime{ + ListSkillNames: func() []string { + return []string{"shell", "git"} + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/list skills", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/list skills: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "shell") || !strings.Contains(reply, "git") { + t.Fatalf("/list skills reply=%q, want installed skill names", reply) + } +} + +func TestBuiltinListMCP_UsesRuntimeServerStatus(t *testing.T) { + rt := &Runtime{ + ListMCPServers: func(context.Context) []MCPServerInfo { + return []MCPServerInfo{ + {Name: "filesystem", Enabled: true, Deferred: true, Connected: false}, + {Name: "github", Enabled: true, Deferred: false, Connected: true, ToolCount: 3}, + } + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/list mcp", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/list mcp: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "- `filesystem`\n Enabled: yes\n Deferred: yes\n "+ + "Connected: no\n Active tools: unavailable") { + t.Fatalf("/list mcp reply=%q, want formatted filesystem block", reply) + } + if !strings.Contains(reply, "- `github`\n Enabled: yes\n Deferred: no\n "+ + "Connected: yes\n Active tools: 3") { + t.Fatalf("/list mcp reply=%q, want formatted github block", reply) + } +} + +func TestBuiltinShowMCP_UsesRuntimeToolNames(t *testing.T) { + rt := &Runtime{ + ListMCPTools: func(_ context.Context, serverName string) ([]MCPToolInfo, error) { + if serverName != "github" { + t.Fatalf("serverName=%q, want github", serverName) + } + return []MCPToolInfo{ + { + Name: "create_issue", + Description: "Create a GitHub issue", + Parameters: []MCPToolParameterInfo{ + {Name: "body", Type: "string", Description: "Issue body"}, + {Name: "title", Type: "string", Description: "Issue title", Required: true}, + }, + }, + { + Name: "list_prs", + Description: "List open pull requests", + }, + }, nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/show mcp github", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/show mcp: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "Active MCP tools for `github`:\n- `create_issue`") { + t.Fatalf("/show mcp reply=%q, want tool header", reply) + } + if !strings.Contains(reply, "Description: Create a GitHub issue") { + t.Fatalf("/show mcp reply=%q, want description", reply) + } + if !strings.Contains(reply, " - `title` (string, required): Issue title") { + t.Fatalf("/show mcp reply=%q, want required parameter", reply) + } + if !strings.Contains(reply, " - `body` (string): Issue body") { + t.Fatalf("/show mcp reply=%q, want optional parameter", reply) + } + if !strings.Contains(reply, "- `list_prs`\n Description: List open pull requests\n Parameters: none") { + t.Fatalf("/show mcp reply=%q, want empty parameter block", reply) + } +} + +func TestBuiltinUseCommand_PassthroughsToAgentLogic(t *testing.T) { + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), nil) + + res := ex.Execute(context.Background(), Request{ + Text: "/use shell run ls", + }) + if res.Outcome != OutcomePassthrough { + t.Fatalf("/use outcome=%v, want=%v", res.Outcome, OutcomePassthrough) + } + if res.Command != "use" { + t.Fatalf("/use command=%q, want=%q", res.Command, "use") + } +} + +func TestBuiltinBtwCommand_UsesSideQuestionRuntime(t *testing.T) { + rt := &Runtime{ + AskSideQuestion: func(ctx context.Context, question string) (string, error) { + if question != "what is 2+2?" { + t.Fatalf("question=%q, want %q", question, "what is 2+2?") + } + return "4", nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/btw what is 2+2?", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/btw outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "4" { + t.Fatalf("/btw reply=%q, want=%q", reply, "4") + } +} + +func TestBuiltinBtwCommand_MissingQuestion(t *testing.T) { + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), &Runtime{ + AskSideQuestion: func(context.Context, string) (string, error) { + return "", nil + }, + }) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/btw", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/btw outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Usage: /btw " { + t.Fatalf("/btw reply=%q, want usage message", reply) + } +} + +func TestBuiltinBtwCommand_PreservesQuestionWhitespace(t *testing.T) { + const want = "explain:\n fmt.Println(\"hi\")" + rt := &Runtime{ + AskSideQuestion: func(ctx context.Context, question string) (string, error) { + if question != want { + t.Fatalf("question=%q, want %q", question, want) + } + return "ok", nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + res := ex.Execute(context.Background(), Request{ + Text: "/btw " + want, + Reply: func(text string) error { + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/btw outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } +} diff --git a/pkg/commands/cmd_btw.go b/pkg/commands/cmd_btw.go new file mode 100644 index 000000000..509f2a80c --- /dev/null +++ b/pkg/commands/cmd_btw.go @@ -0,0 +1,51 @@ +package commands + +import ( + "context" + "strings" +) + +func btwCommand() Definition { + return Definition{ + Name: "btw", + Description: "Ask a side question without changing session history", + Usage: "/btw ", + Handler: func(ctx context.Context, req Request, rt *Runtime) error { + const emptyAnswerMsg = "The model returned an empty response. This may indicate a provider error or token limit." + + if rt == nil || rt.AskSideQuestion == nil { + return req.Reply(unavailableMsg) + } + + question := sideQuestionText(req.Text) + if question == "" { + return req.Reply("Usage: /btw ") + } + + answer, err := rt.AskSideQuestion(ctx, question) + if err != nil { + return req.Reply(err.Error()) + } + if strings.TrimSpace(answer) == "" { + return req.Reply(emptyAnswerMsg) + } + + return req.Reply(answer) + }, + } +} + +func sideQuestionText(input string) string { + input = strings.TrimSpace(input) + if input == "" { + return "" + } + parts := strings.Fields(input) + if len(parts) < 2 { + return "" + } + if !strings.HasPrefix(input, parts[0]) { + return "" + } + return strings.TrimSpace(input[len(parts[0]):]) +} diff --git a/pkg/commands/cmd_clear.go b/pkg/commands/cmd_clear.go new file mode 100644 index 000000000..f0951eb3b --- /dev/null +++ b/pkg/commands/cmd_clear.go @@ -0,0 +1,20 @@ +package commands + +import "context" + +func clearCommand() Definition { + return Definition{ + Name: "clear", + Description: "Clear the chat history", + Usage: "/clear", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ClearHistory == nil { + return req.Reply(unavailableMsg) + } + if err := rt.ClearHistory(); err != nil { + return req.Reply("Failed to clear chat history: " + err.Error()) + } + return req.Reply("Chat history cleared!") + }, + } +} diff --git a/pkg/commands/cmd_context.go b/pkg/commands/cmd_context.go new file mode 100644 index 000000000..55481662c --- /dev/null +++ b/pkg/commands/cmd_context.go @@ -0,0 +1,42 @@ +package commands + +import ( + "context" + "fmt" +) + +func contextCommand() Definition { + return Definition{ + Name: "context", + Description: "Show current session context and token usage", + Usage: "/context", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.GetContextStats == nil { + return req.Reply(unavailableMsg) + } + stats := rt.GetContextStats() + if stats == nil { + return req.Reply("No active session context.") + } + return req.Reply(formatContextStats(stats)) + }, + } +} + +func formatContextStats(s *ContextStats) string { + remaining := s.CompressAtTokens - s.UsedTokens + if remaining < 0 { + remaining = 0 + } + usedWindowPercent := s.UsedTokens * 100 / max(s.TotalTokens, 1) + return fmt.Sprintf( + "Context usage \nMessages: %d \nUsed: ~%d / %d tokens (%d%%) \nCompress at: %d tokens \nCompression progress: %d%% \nRemaining: ~%d tokens", + s.MessageCount, + s.UsedTokens, + s.TotalTokens, + usedWindowPercent, + s.CompressAtTokens, + s.UsedPercent, + remaining, + ) +} diff --git a/pkg/commands/cmd_list.go b/pkg/commands/cmd_list.go index bf47b6e9c..c0021e55c 100644 --- a/pkg/commands/cmd_list.go +++ b/pkg/commands/cmd_list.go @@ -47,6 +47,28 @@ func listCommand() Definition { Description: "Registered agents", Handler: agentsHandler(), }, + { + Name: "skills", + Description: "Installed skills", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListSkillNames == nil { + return req.Reply(unavailableMsg) + } + names := rt.ListSkillNames() + if len(names) == 0 { + return req.Reply("No installed skills") + } + return req.Reply(fmt.Sprintf( + "Installed Skills:\n- %s\n\nUse /use to force one for a single request, or /use to apply it to your next message.", + strings.Join(names, "\n- "), + )) + }, + }, + { + Name: "mcp", + Description: "Configured MCP servers", + Handler: listMCPServersHandler(), + }, }, } } diff --git a/pkg/commands/cmd_reload.go b/pkg/commands/cmd_reload.go new file mode 100644 index 000000000..07ab44016 --- /dev/null +++ b/pkg/commands/cmd_reload.go @@ -0,0 +1,20 @@ +package commands + +import "context" + +func reloadCommand() Definition { + return Definition{ + Name: "reload", + Description: "Reload the configuration file", + Usage: "/reload", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ReloadConfig == nil { + return req.Reply(unavailableMsg) + } + if err := rt.ReloadConfig(); err != nil { + return req.Reply("Failed to reload configuration: " + err.Error()) + } + return req.Reply("Config reload triggered!") + }, + } +} diff --git a/pkg/commands/cmd_show.go b/pkg/commands/cmd_show.go index c655e6880..cda7aaea7 100644 --- a/pkg/commands/cmd_show.go +++ b/pkg/commands/cmd_show.go @@ -33,6 +33,12 @@ func showCommand() Definition { Description: "Registered agents", Handler: agentsHandler(), }, + { + Name: "mcp", + Description: "Active tools for an MCP server", + ArgsUsage: "", + Handler: showMCPToolsHandler(), + }, }, } } diff --git a/pkg/commands/cmd_stop.go b/pkg/commands/cmd_stop.go new file mode 100644 index 000000000..147688bdc --- /dev/null +++ b/pkg/commands/cmd_stop.go @@ -0,0 +1,52 @@ +package commands + +import ( + "context" + "fmt" + "strings" +) + +func stopCommand() Definition { + return Definition{ + Name: "stop", + Description: "Stop the current task", + Usage: "/stop", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.StopActiveTurn == nil { + return req.Reply(unavailableMsg) + } + + result, err := rt.StopActiveTurn() + if err != nil { + return req.Reply("Failed to stop task: " + err.Error()) + } + + return req.Reply(FormatStopReply(result)) + }, + } +} + +// FormatStopReply renders a user-facing reply for a stop request. +func FormatStopReply(result StopResult) string { + if !result.Stopped { + return "No active task to stop." + } + + taskName := compactStopTaskName(result.TaskName) + if taskName == "" { + return "Task stopped. Current task was canceled." + } + + return fmt.Sprintf("Task stopped. %q was canceled.", taskName) +} + +func compactStopTaskName(taskName string) string { + taskName = strings.Join(strings.Fields(strings.TrimSpace(taskName)), " ") + if taskName == "" { + return "" + } + if len(taskName) > 80 { + return taskName[:77] + "..." + } + return taskName +} diff --git a/pkg/commands/cmd_subagents.go b/pkg/commands/cmd_subagents.go new file mode 100644 index 000000000..29321823c --- /dev/null +++ b/pkg/commands/cmd_subagents.go @@ -0,0 +1,42 @@ +package commands + +import ( + "context" + "fmt" +) + +// TurnInfo is a mirrored struct from agent.TurnInfo to avoid circular dependencies. +type TurnInfo struct { + TurnID string + ParentTurnID string + Depth int + ChildTurnIDs []string + IsFinished bool +} + +func subagentsCommand() Definition { + return Definition{ + Name: "subagents", + Description: "Show running subagents and task tree", + Handler: func(ctx context.Context, req Request, rt *Runtime) error { + getTurnFn := rt.GetActiveTurn + if getTurnFn == nil { + return req.Reply("Runtime does not support querying active turns.") + } + + turnRaw := getTurnFn() + if turnRaw == nil { + return req.Reply("No active tasks running in this session.") + } + + if treeStr, ok := turnRaw.(string); ok { + if treeStr == "" { + return req.Reply("No active tasks running in this session.") + } + return req.Reply(fmt.Sprintf("🤖 **Active Subagents Tree**\n```text\n%s\n```", treeStr)) + } + + return req.Reply(fmt.Sprintf("🤖 **Active Subagents List**\n```text\n%+v\n```", turnRaw)) + }, + } +} diff --git a/pkg/commands/cmd_use.go b/pkg/commands/cmd_use.go new file mode 100644 index 000000000..4698f5f5e --- /dev/null +++ b/pkg/commands/cmd_use.go @@ -0,0 +1,9 @@ +package commands + +func useCommand() Definition { + return Definition{ + Name: "use", + Description: "Force a specific installed skill for one request", + Usage: "/use [message]", + } +} diff --git a/pkg/commands/handler_mcp.go b/pkg/commands/handler_mcp.go new file mode 100644 index 000000000..c3dcc1147 --- /dev/null +++ b/pkg/commands/handler_mcp.go @@ -0,0 +1,106 @@ +package commands + +import ( + "context" + "fmt" + "strings" +) + +func listMCPServersHandler() Handler { + return func(ctx context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListMCPServers == nil { + return req.Reply(unavailableMsg) + } + + servers := rt.ListMCPServers(ctx) + if len(servers) == 0 { + return req.Reply("No MCP servers configured") + } + + header := "Configured MCP Servers:" + if rt.Config != nil && !rt.Config.Tools.IsToolEnabled("mcp") { + header = "Configured MCP Servers (integration disabled):" + } + + lines := make([]string, 0, len(servers)*5+1) + lines = append(lines, header) + for idx, server := range servers { + if idx > 0 { + lines = append(lines, "") + } + lines = append(lines, fmt.Sprintf("- `%s`", server.Name)) + lines = append(lines, fmt.Sprintf(" Enabled: %s", yesNo(server.Enabled))) + lines = append(lines, fmt.Sprintf(" Deferred: %s", yesNo(server.Deferred))) + lines = append(lines, fmt.Sprintf(" Connected: %s", yesNo(server.Connected))) + if server.Connected { + lines = append(lines, fmt.Sprintf(" Active tools: %d", server.ToolCount)) + continue + } + lines = append(lines, " Active tools: unavailable") + } + + return req.Reply(strings.Join(lines, "\n")) + } +} + +func showMCPToolsHandler() Handler { + return func(ctx context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListMCPTools == nil { + return req.Reply(unavailableMsg) + } + + serverName := nthToken(req.Text, 2) + if serverName == "" { + return req.Reply("Usage: /show mcp ") + } + + tools, err := rt.ListMCPTools(ctx, serverName) + if err != nil { + return req.Reply(err.Error()) + } + if len(tools) == 0 { + return req.Reply(fmt.Sprintf("MCP server '%s' has no active tools", serverName)) + } + + lines := make([]string, 0, len(tools)*6+1) + lines = append(lines, fmt.Sprintf("Active MCP tools for `%s`:", serverName)) + for idx, tool := range tools { + if idx > 0 { + lines = append(lines, "") + } + lines = append(lines, fmt.Sprintf("- `%s`", tool.Name)) + lines = append(lines, fmt.Sprintf(" Description: %s", tool.Description)) + if len(tool.Parameters) == 0 { + lines = append(lines, " Parameters: none") + continue + } + + lines = append(lines, " Parameters:") + for _, param := range tool.Parameters { + line := fmt.Sprintf(" - `%s`", param.Name) + if param.Type != "" { + line += fmt.Sprintf(" (%s", param.Type) + if param.Required { + line += ", required" + } + line += ")" + } else if param.Required { + line += " (required)" + } + if param.Description != "" { + line += ": " + param.Description + } + lines = append(lines, line) + } + } + + return req.Reply(strings.Join(lines, "\n")) + } +} + +func yesNo(v bool) string { + if v { + return "yes" + } + return "no" +} diff --git a/pkg/commands/request.go b/pkg/commands/request.go index 62ee600f2..233b3ef9c 100644 --- a/pkg/commands/request.go +++ b/pkg/commands/request.go @@ -41,6 +41,11 @@ func parseCommandName(input string) (string, bool) { return name, true } +// CommandName returns the normalized command name for an input if present. +func CommandName(input string) (string, bool) { + return parseCommandName(input) +} + func trimCommandPrefix(token string) (string, bool) { for _, prefix := range commandPrefixes { if strings.HasPrefix(token, prefix) { diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 227d495f4..b0327c863 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -1,6 +1,46 @@ package commands -import "github.com/sipeed/picoclaw/pkg/config" +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type MCPServerInfo struct { + Name string + Enabled bool + Deferred bool + Connected bool + ToolCount int +} + +type MCPToolParameterInfo struct { + Name string + Type string + Description string + Required bool +} + +type MCPToolInfo struct { + Name string + Description string + Parameters []MCPToolParameterInfo +} + +// ContextStats describes current session context window usage. +type ContextStats struct { + UsedTokens int + TotalTokens int // model context window + CompressAtTokens int // compression threshold + UsedPercent int // 0-100 + MessageCount int +} + +// StopResult describes the outcome of a stop request for the current session. +type StopResult struct { + Stopped bool + TaskName string +} // Runtime provides runtime dependencies to command handlers. It is constructed // per-request by the agent loop so that per-request state (like session scope) @@ -8,9 +48,18 @@ import "github.com/sipeed/picoclaw/pkg/config" type Runtime struct { Config *config.Config GetModelInfo func() (name, provider string) + AskSideQuestion func(ctx context.Context, question string) (string, error) ListAgentIDs func() []string ListDefinitions func() []Definition + ListSkillNames func() []string + ListMCPServers func(ctx context.Context) []MCPServerInfo + ListMCPTools func(ctx context.Context, serverName string) ([]MCPToolInfo, error) GetEnabledChannels func() []string + GetActiveTurn func() any // Returning any to avoid circular dependency with agent package + GetContextStats func() *ContextStats SwitchModel func(value string) (oldModel string, err error) SwitchChannel func(value string) error + ClearHistory func() error + ReloadConfig func() error + StopActiveTurn func() (StopResult, error) } diff --git a/pkg/commands/show_list_handlers_test.go b/pkg/commands/show_list_handlers_test.go index 047708f0f..28d481b67 100644 --- a/pkg/commands/show_list_handlers_test.go +++ b/pkg/commands/show_list_handlers_test.go @@ -61,6 +61,9 @@ func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) { GetEnabledChannels: func() []string { return []string{"telegram"} }, + ListSkillNames: func() []string { + return []string{"shell"} + }, } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) @@ -82,4 +85,20 @@ func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) { if !strings.Contains(reply, "telegram") { t.Fatalf("whatsapp /list reply=%q, expected enabled channels content", reply) } + + reply = "" + res = ex.Execute(context.Background(), Request{ + Channel: "whatsapp", + Text: "/list skills", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("whatsapp /list skills outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "shell") { + t.Fatalf("whatsapp /list skills reply=%q, expected installed skills content", reply) + } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 334245be5..6396e792d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,94 +2,150 @@ package config import ( "encoding/json" + "errors" "fmt" + "math/rand" "os" "path/filepath" + "strconv" + "strings" "sync/atomic" + "time" "github.com/caarlos0/env/v11" + "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/pathutil" + providercommon "github.com/sipeed/picoclaw/pkg/providers/common" ) // rrCounter is a global counter for round-robin load balancing across models. var rrCounter atomic.Uint64 -// FlexibleStringSlice is a []string that also accepts JSON numbers, -// so allow_from can contain both "123" and 123. -type FlexibleStringSlice []string +// CurrentVersion is the latest config schema version +const CurrentVersion = 3 -func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { - // Try []string first - var ss []string - if err := json.Unmarshal(data, &ss); err == nil { - *f = ss - return nil - } - - // Try []interface{} to handle mixed types - var raw []any - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - result := make([]string, 0, len(raw)) - for _, v := range raw { - switch val := v.(type) { - case string: - result = append(result, val) - case float64: - result = append(result, fmt.Sprintf("%.0f", val)) - default: - result = append(result, fmt.Sprintf("%v", val)) - } - } - *f = result - return nil +func init() { + initChannel() } +// Config is the current config structure with version support. type Config struct { - Agents AgentsConfig `json:"agents"` - Bindings []AgentBinding `json:"bindings,omitempty"` - Session SessionConfig `json:"session,omitempty"` - Channels ChannelsConfig `json:"channels"` - Providers ProvidersConfig `json:"providers,omitempty"` - ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration - Gateway GatewayConfig `json:"gateway"` - Tools ToolsConfig `json:"tools"` - Heartbeat HeartbeatConfig `json:"heartbeat"` - Devices DevicesConfig `json:"devices"` + // Config schema version for migration. + Version int `json:"version" yaml:"-"` + Isolation IsolationConfig `json:"isolation,omitempty" yaml:"-"` + Agents AgentsConfig `json:"agents" yaml:"-"` + Session SessionConfig `json:"session,omitempty" yaml:"-"` + Channels ChannelsConfig `json:"channel_list" yaml:"channel_list"` + ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration + Gateway GatewayConfig `json:"gateway" yaml:"-"` + Events EventsConfig `json:"events,omitempty" yaml:"-"` + Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"` + Tools ToolsConfig `json:"tools" yaml:",inline"` + Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"` + Devices DevicesConfig `json:"devices" yaml:"-"` + Voice VoiceConfig `json:"voice" yaml:"-"` + // BuildInfo contains build-time version information + BuildInfo BuildInfo `json:"build_info,omitempty" yaml:"-"` + + // cache for sensitive values and compiled regex (computed once) + sensitiveCache *SensitiveDataCache +} + +// IsolationConfig controls subprocess isolation for commands started by PicoClaw. +// It is applied by the isolation package rather than by sandboxing the main process. +type IsolationConfig struct { + Enabled bool `json:"enabled,omitempty"` + ExposePaths []ExposePath `json:"expose_paths,omitempty"` +} + +// ExposePath describes a host path that should remain visible inside the isolated +// child-process environment. This is currently implemented on Linux only. +type ExposePath struct { + Source string `json:"source"` + Target string `json:"target,omitempty"` + Mode string `json:"mode"` +} + +// FilterSensitiveData filters sensitive values from content before sending to LLM. +// This prevents the LLM from seeing its own credentials. +// Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig). +// Short content (below FilterMinLength) is returned unchanged for performance. +func (c *Config) FilterSensitiveData(content string) string { + // Check if filtering is enabled (default: true) + if !c.Tools.IsFilterSensitiveDataEnabled() { + return content + } + // Fast path: skip filtering for short content + if len(content) < c.Tools.GetFilterMinLength() { + return content + } + return c.SensitiveDataReplacer().Replace(content) +} + +type HooksConfig struct { + Enabled bool `json:"enabled"` + Defaults HookDefaultsConfig `json:"defaults,omitempty"` + Builtins map[string]BuiltinHookConfig `json:"builtins,omitempty"` + Processes map[string]ProcessHookConfig `json:"processes,omitempty"` +} + +type HookDefaultsConfig struct { + ObserverTimeoutMS int `json:"observer_timeout_ms,omitempty"` + InterceptorTimeoutMS int `json:"interceptor_timeout_ms,omitempty"` + ApprovalTimeoutMS int `json:"approval_timeout_ms,omitempty"` +} + +type BuiltinHookConfig struct { + Enabled bool `json:"enabled"` + Priority int `json:"priority,omitempty"` + Config json.RawMessage `json:"config,omitempty"` +} + +type ProcessHookConfig struct { + Enabled bool `json:"enabled"` + Priority int `json:"priority,omitempty"` + Transport string `json:"transport,omitempty"` + Command []string `json:"command,omitempty"` + Dir string `json:"dir,omitempty"` + Env map[string]string `json:"env,omitempty"` + Observe []string `json:"observe,omitempty"` + Intercept []string `json:"intercept,omitempty"` +} + +// BuildInfo contains build-time version information +type BuildInfo struct { + Version string `json:"version"` + GitCommit string `json:"git_commit"` + BuildTime string `json:"build_time"` + GoVersion string `json:"go_version"` } // MarshalJSON implements custom JSON marshaling for Config -// to omit providers section when empty and session when empty -func (c Config) MarshalJSON() ([]byte, error) { +// to omit providers section when empty and session when empty. +func (c *Config) MarshalJSON() ([]byte, error) { type Alias Config aux := &struct { - Providers *ProvidersConfig `json:"providers,omitempty"` - Session *SessionConfig `json:"session,omitempty"` + Session *SessionConfig `json:"session,omitempty"` *Alias }{ - Alias: (*Alias)(&c), + Alias: (*Alias)(c), } - // Only include providers if not empty - if !c.Providers.IsEmpty() { - aux.Providers = &c.Providers - } - - // Only include session if not empty - if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 { - aux.Session = &c.Session + if len(c.Session.Dimensions) > 0 || len(c.Session.IdentityLinks) > 0 { + sessionCfg := c.Session + aux.Session = &sessionCfg } return json.Marshal(aux) } type AgentsConfig struct { - Defaults AgentDefaults `json:"defaults"` - List []AgentConfig `json:"list,omitempty"` + Defaults AgentDefaults `json:"defaults"` + List []AgentConfig `json:"list,omitempty"` + Dispatch *DispatchConfig `json:"dispatch,omitempty"` } // AgentModelConfig supports both string and structured model config. @@ -146,26 +202,29 @@ type SubagentsConfig struct { Model *AgentModelConfig `json:"model,omitempty"` } -type PeerMatch struct { - Kind string `json:"kind"` - ID string `json:"id"` +type DispatchConfig struct { + Rules []DispatchRule `json:"rules,omitempty"` } -type BindingMatch struct { - Channel string `json:"channel"` - AccountID string `json:"account_id,omitempty"` - Peer *PeerMatch `json:"peer,omitempty"` - GuildID string `json:"guild_id,omitempty"` - TeamID string `json:"team_id,omitempty"` +type DispatchRule struct { + Name string `json:"name,omitempty"` + Agent string `json:"agent"` + When DispatchSelector `json:"when"` + SessionDimensions []string `json:"session_dimensions,omitempty"` } -type AgentBinding struct { - AgentID string `json:"agent_id"` - Match BindingMatch `json:"match"` +type DispatchSelector struct { + Channel string `json:"channel,omitempty"` + Account string `json:"account,omitempty"` + Space string `json:"space,omitempty"` + Chat string `json:"chat,omitempty"` + Topic string `json:"topic,omitempty"` + Sender string `json:"sender,omitempty"` + Mentioned *bool `json:"mentioned,omitempty"` } type SessionConfig struct { - DMScope string `json:"dm_scope,omitempty"` + Dimensions []string `json:"dimensions,omitempty"` IdentityLinks map[string][]string `json:"identity_links,omitempty"` } @@ -181,24 +240,48 @@ type RoutingConfig struct { Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model } +// SubTurnConfig configures the SubTurn execution system. +type SubTurnConfig struct { + MaxDepth int `json:"max_depth" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_MAX_DEPTH"` + MaxConcurrent int `json:"max_concurrent" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_MAX_CONCURRENT"` + DefaultTimeoutMinutes int `json:"default_timeout_minutes" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_DEFAULT_TIMEOUT_MINUTES"` + DefaultTokenBudget int `json:"default_token_budget" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_DEFAULT_TOKEN_BUDGET"` + ConcurrencyTimeoutSec int `json:"concurrency_timeout_sec" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_CONCURRENCY_TIMEOUT_SEC"` +} + +type ToolFeedbackConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"` + MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"` + SeparateMessages bool `json:"separate_messages" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES"` +} + type AgentDefaults struct { - WorkspaceRoot string `json:"workspace_root,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE_ROOT"` - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` - Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` - Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead - ModelFallbacks []string `json:"model_fallbacks,omitempty"` - ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` - ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` - MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` - Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` - SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` - SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` - MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` - Routing *RoutingConfig `json:"routing,omitempty"` + WorkspaceRoot string `json:"workspace_root,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE_ROOT"` + Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` + Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` + ModelFallbacks []string `json:"model_fallbacks,omitempty"` + ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` + ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` + MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` + ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"` + Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` + MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` + SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` + MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` + Routing *RoutingConfig `json:"routing,omitempty"` + SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` + MaxParallelTurns int `json:"max_parallel_turns,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS"` + SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` + ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` + SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` + ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"` + ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"` + MaxLLMRetries int `json:"max_llm_retries,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_LLM_RETRIES"` + LLMRetryBackoffSecs int `json:"llm_retry_backoff_secs,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_LLM_RETRY_BACKOFF_SECS"` } const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB @@ -210,33 +293,30 @@ func (d *AgentDefaults) GetMaxMediaSize() int { return DefaultMaxMediaSize } +// GetToolFeedbackMaxArgsLength returns the max visible text length for tool argument previews. +func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int { + if d.ToolFeedback.MaxArgsLength > 0 { + return d.ToolFeedback.MaxArgsLength + } + return 300 +} + +// IsToolFeedbackEnabled returns true when tool feedback messages should be sent to the chat. +func (d *AgentDefaults) IsToolFeedbackEnabled() bool { + return d.ToolFeedback.Enabled +} + +// IsToolFeedbackSeparateMessagesEnabled returns true when each tool feedback +// update should be sent as its own chat message instead of editing a single +// in-place progress message. +func (d *AgentDefaults) IsToolFeedbackSeparateMessagesEnabled() bool { + return d.ToolFeedback.SeparateMessages +} + // GetModelName returns the effective model name for the agent defaults. // It prefers the new "model_name" field but falls back to "model" for backward compatibility. func (d *AgentDefaults) GetModelName() string { - if d.ModelName != "" { - return d.ModelName - } - return d.Model -} - -type ChannelsConfig struct { - WhatsApp WhatsAppConfig `json:"whatsapp"` - Telegram TelegramConfig `json:"telegram"` - Feishu FeishuConfig `json:"feishu"` - Discord DiscordConfig `json:"discord"` - MaixCam MaixCamConfig `json:"maixcam"` - QQ QQConfig `json:"qq"` - DingTalk DingTalkConfig `json:"dingtalk"` - Slack SlackConfig `json:"slack"` - Matrix MatrixConfig `json:"matrix"` - LINE LINEConfig `json:"line"` - OneBot OneBotConfig `json:"onebot"` - WeCom WeComConfig `json:"wecom"` - WeComApp WeComAppConfig `json:"wecom_app"` - WeComAIBot WeComAIBotConfig `json:"wecom_aibot"` - Pico PicoConfig `json:"pico"` - MagicForm MagicFormConfig `json:"magicform"` - IRC IRCConfig `json:"irc"` + return d.ModelName } // GroupTriggerConfig controls when the bot responds in group chats. @@ -252,214 +332,205 @@ type TypingConfig struct { // PlaceholderConfig controls placeholder message behavior (Phase 10). type PlaceholderConfig struct { - Enabled bool `json:"enabled,omitempty"` - Text string `json:"text,omitempty"` + Enabled bool `json:"enabled"` + Text FlexibleStringSlice `json:"text,omitempty"` } -type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` - UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` - SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` +// GetRandomText returns a random placeholder text, or default if none set. +func (p *PlaceholderConfig) GetRandomText() string { + if len(p.Text) == 0 { + return "Thinking..." + } + if len(p.Text) == 1 { + return p.Text[0] + } + idx := rand.Intn(len(p.Text)) + return p.Text[idx] } -type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` +type StreamingConfig struct { + Enabled bool `json:"enabled,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED"` + ThrottleSeconds int `json:"throttle_seconds,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS"` + MinGrowthChars int `json:"min_growth_chars,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS"` } -type FeishuConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` - EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` - VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` - RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"` +type WhatsAppSettings struct { + BridgeURL string `json:"bridge_url" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + UseNative bool `json:"use_native" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` + SessionStorePath string `json:"session_store_path" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` } -type DiscordConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` - MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"` +type TelegramSettings struct { + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` + Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"` + UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` } -type MaixCamConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` - Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` - Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"` +type FeishuSettings struct { + AppID string `json:"app_id" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` + AppSecret SecureString `json:"app_secret,omitzero" yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` + EncryptKey SecureString `json:"encrypt_key,omitzero" yaml:"encrypt_key,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` + VerificationToken SecureString `json:"verification_token,omitzero" yaml:"verification_token,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` + RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"` + IsLark bool `json:"is_lark" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"` } -type QQConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` +type DiscordSettings struct { + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"` + MentionOnly bool `json:"mention_only" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` } -type DingTalkConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` - ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` - ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"` +type MaixCamSettings struct { + Host string `json:"host" yaml:"-" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` + Port int `json:"port" yaml:"-" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` } -type SlackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` - BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` - AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"` +type QQSettings struct { + AppID string `json:"app_id" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` + AppSecret SecureString `json:"app_secret,omitzero" yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` + MaxMessageLength int `json:"max_message_length" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"` + MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"` + SendMarkdown bool `json:"send_markdown" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` } -type MatrixConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` - Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` - UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` - AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` - DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"` - JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` +type DingTalkSettings struct { + ClientID string `json:"client_id" yaml:"-" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` + ClientSecret SecureString `json:"client_secret,omitzero" yaml:"client_secret,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` } -type LINEConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` - ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` - ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"` +type SlackSettings struct { + BotToken SecureString `json:"bot_token,omitzero" yaml:"bot_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` + AppToken SecureString `json:"app_token,omitzero" yaml:"app_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` } -type OneBotConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` - WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` - AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` - ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` - GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"` +type MatrixSettings struct { + Homeserver string `json:"homeserver" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` + UserID string `json:"user_id" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` + AccessToken SecureString `json:"access_token,omitzero" yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` + DeviceID string `json:"device_id,omitempty" yaml:"-"` + JoinOnInvite bool `json:"join_on_invite" yaml:"-"` + MessageFormat string `json:"message_format,omitempty" yaml:"-"` + CryptoDatabasePath string `json:"crypto_database_path,omitempty" yaml:"-"` + CryptoPassphrase string `json:"crypto_passphrase,omitempty" yaml:"-"` } -type WeComConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"` - WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"` +type LINESettings struct { + ChannelSecret SecureString `json:"channel_secret,omitzero" yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` + ChannelAccessToken SecureString `json:"channel_access_token,omitzero" yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` + WebhookHost string `json:"webhook_host" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` } -type WeComAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"` - CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"` - CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"` - AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"` +type OneBotSettings struct { + WSUrl string `json:"ws_url" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` + AccessToken SecureString `json:"access_token,omitzero" yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` + ReconnectInterval int `json:"reconnect_interval" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` + GroupTriggerPrefix []string `json:"group_trigger_prefix" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` } -type WeComAIBotConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"` - MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps - WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"` +type WeComGroupConfig struct { + AllowFrom FlexibleStringSlice `json:"allow_from,omitempty"` } -type PicoConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` - AllowTokenQuery bool `json:"allow_token_query,omitempty"` - AllowOrigins []string `json:"allow_origins,omitempty"` - PingInterval int `json:"ping_interval,omitempty"` - ReadTimeout int `json:"read_timeout,omitempty"` - WriteTimeout int `json:"write_timeout,omitempty"` - MaxConnections int `json:"max_connections,omitempty"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` +type WeComSettings struct { + BotID string `json:"bot_id" yaml:"-" env:"BOT_ID"` + Secret SecureString `json:"secret,omitzero" yaml:"secret,omitempty" env:"SECRET"` + WebSocketURL string `json:"websocket_url,omitempty" yaml:"-" env:"WEBSOCKET_URL"` + SendThinkingMessage bool `json:"send_thinking_message" yaml:"-" env:"SEND_THINKING_MESSAGE"` } -type MagicFormConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAGICFORM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_MAGICFORM_TOKEN"` - BackendURL string `json:"backend_url" env:"PICOCLAW_CHANNELS_MAGICFORM_BACKEND_URL"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_MAGICFORM_WEBHOOK_PATH"` - WorkspaceRoot string `json:"workspace_root" env:"PICOCLAW_CHANNELS_MAGICFORM_WORKSPACE_ROOT"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAGICFORM_ALLOW_FROM"` +// MagicFormSettings configures the MagicForm webhook channel for tenant-aware +// callback-based message dispatch. +type MagicFormSettings struct { + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_MAGICFORM_TOKEN"` + BackendURL string `json:"backend_url" yaml:"-" env:"PICOCLAW_CHANNELS_MAGICFORM_BACKEND_URL"` + WebhookPath string `json:"webhook_path" yaml:"-" env:"PICOCLAW_CHANNELS_MAGICFORM_WEBHOOK_PATH"` + WorkspaceRoot string `json:"workspace_root,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MAGICFORM_WORKSPACE_ROOT"` + AllowFrom FlexibleStringSlice `json:"allow_from,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MAGICFORM_ALLOW_FROM"` } -type IRCConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"` - Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"` - TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"` - Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"` - User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"` - RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"` - Password string `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"` - NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"` - SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"` - SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"` - Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"` - RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"` +// SetToken sets the MagicForm token and marks it as dirty for security saving +func (c *MagicFormSettings) SetToken(token string) { + c.Token = *NewSecureString(token) +} + +func (c *WeComSettings) SetSecret(secret string) { + c.Secret = *NewSecureString(secret) +} + +type WeixinSettings struct { + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"` + AccountID string `json:"account_id,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_ACCOUNT_ID"` + BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"` + CDNBaseURL string `json:"cdn_base_url" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"` + Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"` +} + +// SetToken sets the Weixin token and marks it as dirty for security saving +func (c *WeixinSettings) SetToken(token string) { + c.Token = *NewSecureString(token) +} + +type PicoSettings struct { + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` + AllowTokenQuery bool `json:"allow_token_query,omitempty" yaml:"-"` + AllowOrigins []string `json:"allow_origins,omitempty" yaml:"-"` + PingInterval int `json:"ping_interval,omitempty" yaml:"-"` + ReadTimeout int `json:"read_timeout,omitempty" yaml:"-"` + WriteTimeout int `json:"write_timeout,omitempty" yaml:"-"` + MaxConnections int `json:"max_connections,omitempty" yaml:"-"` +} + +// SetToken sets the Pico token and marks it as dirty for security saving +func (c *PicoSettings) SetToken(token string) { + c.Token = *NewSecureString(token) +} + +type PicoClientSettings struct { + URL string `json:"url" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_CLIENT_URL"` + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_CLIENT_TOKEN"` + SessionID string `json:"session_id,omitempty" yaml:"-"` + PingInterval int `json:"ping_interval,omitempty" yaml:"-"` + ReadTimeout int `json:"read_timeout,omitempty" yaml:"-"` +} + +type IRCSettings struct { + Server string `json:"server" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_SERVER"` + TLS bool `json:"tls" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_TLS"` + Nick string `json:"nick" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_NICK"` + User string `json:"user,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_USER"` + RealName string `json:"real_name,omitempty" yaml:"-"` + Password SecureString `json:"password,omitzero" yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"` + NickServPassword SecureString `json:"nickserv_password,omitzero" yaml:"nickserv_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"` + SASLUser string `json:"sasl_user" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"` + SASLPassword SecureString `json:"sasl_password,omitzero" yaml:"sasl_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"` + Channels FlexibleStringSlice `json:"channels" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"` + RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" yaml:"-"` +} + +type VKSettings struct { + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_VK_TOKEN"` + GroupID int `json:"group_id" yaml:"-" env:"PICOCLAW_CHANNELS_VK_GROUP_ID"` +} + +func (c *VKSettings) SetToken(token string) { + c.Token = *NewSecureString(token) +} + +// TeamsWebhookSettings configures the output-only Microsoft Teams webhook channel. +// Multiple webhook targets can be configured and selected via ChatID at send time. +type TeamsWebhookSettings struct { + Webhooks map[string]TeamsWebhookTarget `json:"webhooks" yaml:"webhooks,omitempty"` +} + +// TeamsWebhookTarget represents a single Teams webhook destination. +type TeamsWebhookTarget struct { + WebhookURL SecureString `json:"webhook_url,omitzero" yaml:"webhook_url,omitempty"` + Title string `json:"title,omitempty" yaml:"-"` } type HeartbeatConfig struct { @@ -472,94 +543,30 @@ type DevicesConfig struct { MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` } -type ProvidersConfig struct { - Anthropic ProviderConfig `json:"anthropic"` - OpenAI OpenAIProviderConfig `json:"openai"` - LiteLLM ProviderConfig `json:"litellm"` - OpenRouter ProviderConfig `json:"openrouter"` - Groq ProviderConfig `json:"groq"` - Zhipu ProviderConfig `json:"zhipu"` - VLLM ProviderConfig `json:"vllm"` - Gemini ProviderConfig `json:"gemini"` - Nvidia ProviderConfig `json:"nvidia"` - Ollama ProviderConfig `json:"ollama"` - Moonshot ProviderConfig `json:"moonshot"` - ShengSuanYun ProviderConfig `json:"shengsuanyun"` - DeepSeek ProviderConfig `json:"deepseek"` - Cerebras ProviderConfig `json:"cerebras"` - Vivgrid ProviderConfig `json:"vivgrid"` - VolcEngine ProviderConfig `json:"volcengine"` - GitHubCopilot ProviderConfig `json:"github_copilot"` - Antigravity ProviderConfig `json:"antigravity"` - Qwen ProviderConfig `json:"qwen"` - Mistral ProviderConfig `json:"mistral"` - Avian ProviderConfig `json:"avian"` -} - -// IsEmpty checks if all provider configs are empty (no API keys or API bases set) -// Note: WebSearch is an optimization option and doesn't count as "non-empty" -func (p ProvidersConfig) IsEmpty() bool { - return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" && - p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" && - p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" && - p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" && - p.Groq.APIKey == "" && p.Groq.APIBase == "" && - p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" && - p.VLLM.APIKey == "" && p.VLLM.APIBase == "" && - p.Gemini.APIKey == "" && p.Gemini.APIBase == "" && - p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" && - p.Ollama.APIKey == "" && p.Ollama.APIBase == "" && - p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" && - p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" && - p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" && - p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" && - p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" && - p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" && - p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && - p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" && - p.Qwen.APIKey == "" && p.Qwen.APIBase == "" && - p.Mistral.APIKey == "" && p.Mistral.APIBase == "" && - p.Avian.APIKey == "" && p.Avian.APIBase == "" -} - -// MarshalJSON implements custom JSON marshaling for ProvidersConfig -// to omit the entire section when empty -func (p ProvidersConfig) MarshalJSON() ([]byte, error) { - if p.IsEmpty() { - return []byte("null"), nil - } - type Alias ProvidersConfig - return json.Marshal((*Alias)(&p)) -} - -type ProviderConfig struct { - APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` - APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` - Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` - RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"` - AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` - ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc` -} - -type OpenAIProviderConfig struct { - ProviderConfig - WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` +type VoiceConfig struct { + ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"` + TTSModelName string `json:"tts_model_name,omitempty" env:"PICOCLAW_VOICE_TTS_MODEL_NAME"` + EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"` + ElevenLabsAPIKey string `json:"elevenlabs_api_key,omitempty" env:"PICOCLAW_VOICE_ELEVENLABS_API_KEY"` } // ModelConfig represents a model-centric provider configuration. // It allows adding new providers (especially OpenAI-compatible ones) via configuration only. -// The model field uses protocol prefix format: [protocol/]model-identifier -// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot -// Default protocol is "openai" if no prefix is specified. +// The Model field may be either a plain model identifier or a provider-prefixed +// identifier such as "openai/gpt-5.4" or "nvidia/z-ai/glm-5.1". +// Supported providers include openai, anthropic, antigravity, claude-cli, +// codex-cli, github-copilot, and named OpenAI-compatible protocols such as +// groq, deepseek, modelscope, and novita. type ModelConfig struct { // Required fields ModelName string `json:"model_name"` // User-facing alias for the model - Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6") + Provider string `json:"provider"` // Provider name for routing and selection. When empty, provider resolution infers it from Model. + Model string `json:"model"` // Model identifier, optionally provider-prefixed. // HTTP-based providers - APIBase string `json:"api_base,omitempty"` // API endpoint URL - APIKey string `json:"api_key"` // API authentication key - Proxy string `json:"proxy,omitempty"` // HTTP proxy URL + APIBase string `json:"api_base,omitempty"` // API endpoint URL + Proxy string `json:"proxy,omitempty"` // HTTP proxy URL + Fallbacks []string `json:"fallbacks,omitempty"` // Fallback model names for failover // Special providers (CLI-based, OAuth, etc.) AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token @@ -567,10 +574,39 @@ type ModelConfig struct { Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers // Optional optimizations - RPM int `json:"rpm,omitempty"` // Requests per minute limit - MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") - RequestTimeout int `json:"request_timeout,omitempty"` - ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive + RPM int `json:"rpm,omitempty"` // Requests per minute limit + MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") + RequestTimeout int `json:"request_timeout,omitempty"` + ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive + ToolSchemaTransform string `json:"tool_schema_transform,omitempty"` // Optional tool schema compatibility transform (e.g. "simple") + ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body + CustomHeaders map[string]string `json:"custom_headers,omitempty"` // Additional headers to inject into every HTTP request + + APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover) + + // Enabled indicates whether this model entry is active. When omitted in + // existing configs, the field is inferred during load: models with API keys + // or the reserved "local-model" name are auto-enabled. + Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + // UserAgent is the user agent string to use for HTTP requests. + UserAgent string `json:"user_agent,omitempty" yaml:"-"` + + // isVirtual marks this model as a virtual model generated from multi-key expansion. + // Virtual models should not be persisted to config files. + isVirtual bool +} + +// APIKey returns the first API key from apiKeys +func (c *ModelConfig) APIKey() string { + if len(c.APIKeys) > 0 { + return c.APIKeys[0].String() + } + return "" +} + +// IsVirtual returns true if this model was generated from multi-key expansion. +func (c *ModelConfig) IsVirtual() bool { + return c.isVirtual } // Validate checks if the ModelConfig has all required fields. @@ -581,29 +617,81 @@ func (c *ModelConfig) Validate() error { if c.Model == "" { return fmt.Errorf("model is required") } + if _, err := providercommon.NormalizeToolSchemaTransform(c.ToolSchemaTransform); err != nil { + return err + } return nil } -type GatewayConfig struct { - Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` - Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` +func (c *ModelConfig) SetAPIKey(value string) { + if len(c.APIKeys) > 0 { + c.APIKeys[0].Set(value) + } else { + c.APIKeys = append(c.APIKeys, NewSecureString(value)) + } +} + +type ToolDiscoveryConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"` + TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"` + MaxSearchResults int `json:"max_search_results" env:"PICOCLAW_MAX_SEARCH_RESULTS"` + UseBM25 bool `json:"use_bm25" env:"PICOCLAW_TOOLS_DISCOVERY_USE_BM25"` + UseRegex bool `json:"use_regex" env:"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX"` } type ToolConfig struct { - Enabled bool `json:"enabled" env:"ENABLED"` + Enabled bool `json:"enabled" yaml:"-" env:"ENABLED"` } type BraveConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` + APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"` + MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` +} + +// APIKey returns the Brave API key +func (c *BraveConfig) APIKey() string { + if len(c.APIKeys) == 0 { + return "" + } + return c.APIKeys[0].String() +} + +// SetAPIKey sets the Brave API key +func (c *BraveConfig) SetAPIKey(key string) { + c.APIKeys = SimpleSecureStrings(key) +} + +func (c *BraveConfig) SetAPIKeys(keys []string) { + c.APIKeys = SimpleSecureStrings(keys...) } type TavilyConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"` - BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` + APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"` + BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` + MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` +} + +// APIKey returns the Tavily API key +func (c *TavilyConfig) APIKey() string { + if len(c.APIKeys) == 0 { + return "" + } + return c.APIKeys[0].String() +} + +// SetAPIKey sets the Tavily API key +func (c *TavilyConfig) SetAPIKey(key string) { + c.APIKeys = SimpleSecureStrings(key) +} + +// SetAPIKeys sets the Tavily API keys +func (c *TavilyConfig) SetAPIKeys(keys []string) { + c.APIKeys = make(SecureStrings, len(keys)) + for i, k := range keys { + c.APIKeys[i] = NewSecureString(k) + } } type DuckDuckGoConfig struct { @@ -611,10 +699,28 @@ type DuckDuckGoConfig struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` } +type SogouConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SOGOU_ENABLED"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SOGOU_MAX_RESULTS"` +} + type PerplexityConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` + APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"` + MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` +} + +// APIKey returns the Perplexity API key +func (c *PerplexityConfig) APIKey() string { + if len(c.APIKeys) == 0 { + return "" + } + return c.APIKeys[0].String() +} + +// SetAPIKey sets the Perplexity API key +func (c *PerplexityConfig) SetAPIKey(key string) { + c.APIKeys = SimpleSecureStrings(key) } type SearXNGConfig struct { @@ -624,79 +730,147 @@ type SearXNGConfig struct { } type GLMSearchConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"` - BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"` + APIKey SecureString `json:"api_key,omitzero" yaml:"api_key,omitempty" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"` + BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` // SearchEngine specifies the search backend: "search_std" (default), // "search_pro", "search_pro_sogou", or "search_pro_quark". - SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"` + SearchEngine string `json:"search_engine" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` + MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"` +} + +type BaiduSearchConfig struct { + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BAIDU_ENABLED"` + APIKey SecureString `json:"api_key,omitzero" yaml:"api_key,omitempty" env:"PICOCLAW_TOOLS_WEB_BAIDU_API_KEY"` + BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BAIDU_BASE_URL"` + MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BAIDU_MAX_RESULTS"` } type WebToolsConfig struct { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"` - Brave BraveConfig ` json:"brave"` - Tavily TavilyConfig ` json:"tavily"` - DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"` - Perplexity PerplexityConfig ` json:"perplexity"` - SearXNG SearXNGConfig ` json:"searxng"` - GLMSearch GLMSearchConfig ` json:"glm_search"` + ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"` + Brave BraveConfig `yaml:"brave,omitempty" json:"brave"` + Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"` + Sogou SogouConfig `yaml:"-" json:"sogou"` + DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"` + Perplexity PerplexityConfig `yaml:"perplexity,omitempty" json:"perplexity"` + SearXNG SearXNGConfig `yaml:"-" json:"searxng"` + GLMSearch GLMSearchConfig `yaml:"glm_search,omitempty" json:"glm_search"` + BaiduSearch BaiduSearchConfig `yaml:"baidu_search,omitempty" json:"baidu_search"` + Provider string `yaml:"-" json:"provider,omitempty" env:"PICOCLAW_TOOLS_WEB_PROVIDER"` + // PreferNative controls whether to use provider-native web search when + // the active LLM supports it (e.g. OpenAI web_search_preview). When true, + // the client-side web_search tool is hidden to avoid duplicate search surfaces, + // and the provider's built-in search is used instead. Falls back to client-side + // search when the provider does not support native search. + PreferNative bool `yaml:"-" json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config. - Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` - FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` + Proxy string `yaml:"-" json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` + FetchLimitBytes int64 `yaml:"-" json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` + Format string `yaml:"-" json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"` + PrivateHostWhitelist FlexibleStringSlice `yaml:"-" json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` } type CronToolsConfig struct { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"` - ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"` + ExecTimeoutMinutes int ` json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout + AllowCommand bool ` json:"allow_command" env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND"` } type ExecConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"` - EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"` - CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"` - CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"` - TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) - FilterEnv bool ` env:"PICOCLAW_TOOLS_EXEC_FILTER_ENV" json:"filter_env"` + EnableDenyPatterns bool ` json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"` + AllowRemote bool ` json:"allow_remote" env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE"` + CustomDenyPatterns []string ` json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` + CustomAllowPatterns []string ` json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"` + TimeoutSeconds int ` json:"timeout_seconds" env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS"` // 0 means use default (60s) + FilterEnv bool ` json:"filter_env" env:"PICOCLAW_TOOLS_EXEC_FILTER_ENV"` } type SkillsToolsConfig struct { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"` - Registries SkillsRegistriesConfig ` json:"registries"` - MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"` - SearchCache SearchCacheConfig ` json:"search_cache"` + ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_SKILLS_"` + Registries SkillsRegistriesConfig `yaml:"registries,omitempty" json:"registries"` + // Deprecated: use registries.github instead. + Github SkillsGithubConfig `yaml:"github,omitempty" json:"github"` + MaxConcurrentSearches int `yaml:"-" json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"` + SearchCache SearchCacheConfig `yaml:"-" json:"search_cache"` } type MediaCleanupConfig struct { ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"` - MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"` - Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"` + MaxAge int ` json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"` + Interval int ` json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"` +} + +type ReadFileToolConfig struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + MaxReadFileSize int `json:"max_read_file_size"` +} + +const ( + ReadFileModeBytes = "bytes" + ReadFileModeLines = "lines" +) + +func (c ReadFileToolConfig) EffectiveMode() string { + switch strings.ToLower(strings.TrimSpace(c.Mode)) { + case ReadFileModeLines: + return ReadFileModeLines + case "", ReadFileModeBytes: + return ReadFileModeBytes + default: + return ReadFileModeBytes + } } type ToolsConfig struct { - AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` - AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` - Web WebToolsConfig `json:"web"` - Cron CronToolsConfig `json:"cron"` - Exec ExecConfig `json:"exec"` - Skills SkillsToolsConfig `json:"skills"` - MediaCleanup MediaCleanupConfig `json:"media_cleanup"` - MCP MCPConfig `json:"mcp"` - AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` - EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` - FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` - I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"` - InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` - ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` - Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` - ReadFile ToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` - SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` - Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` - SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` - Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` - WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` - WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` + AllowReadPaths []string `json:"allow_read_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` + AllowWritePaths []string `json:"allow_write_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` + // FilterSensitiveData controls whether to filter sensitive values (API keys, + // tokens, secrets) from tool results before sending to the LLM. + // Default: true (enabled) + FilterSensitiveData bool `json:"filter_sensitive_data" yaml:"-" env:"PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA"` + // FilterMinLength is the minimum content length required for filtering. + // Content shorter than this will be returned unchanged for performance. + // Default: 8 + FilterMinLength int `json:"filter_min_length" yaml:"-" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"` + Web WebToolsConfig `json:"web" yaml:"web,omitempty"` + Cron CronToolsConfig `json:"cron" yaml:"-"` + Exec ExecConfig `json:"exec" yaml:"-"` + Skills SkillsToolsConfig `json:"skills" yaml:"skills,omitempty"` + MediaCleanup MediaCleanupConfig `json:"media_cleanup" yaml:"-"` + MCP MCPConfig `json:"mcp" yaml:"-"` + AppendFile ToolConfig `json:"append_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` + EditFile ToolConfig `json:"edit_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` + FindSkills ToolConfig `json:"find_skills" yaml:"-" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` + I2C ToolConfig `json:"i2c" yaml:"-" envPrefix:"PICOCLAW_TOOLS_I2C_"` + InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` + ListDir ToolConfig `json:"list_dir" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` + Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` + ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` + Serial ToolConfig `json:"serial" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SERIAL_"` + SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` + SendTTS ToolConfig `json:"send_tts" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_TTS_"` + Spawn ToolConfig `json:"spawn" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` + SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` + SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"` + Subagent ToolConfig `json:"subagent" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` + WebFetch ToolConfig `json:"web_fetch" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` + WriteFile ToolConfig `json:"write_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` +} + +// IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled +func (c *ToolsConfig) IsFilterSensitiveDataEnabled() bool { + return c.FilterSensitiveData +} + +// GetFilterMinLength returns the minimum content length for filtering (default: 8) +func (c *ToolsConfig) GetFilterMinLength() int { + if c.FilterMinLength <= 0 { + return 8 + } + return c.FilterMinLength } type SearchCacheConfig struct { @@ -704,26 +878,96 @@ type SearchCacheConfig struct { TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"` } -type SkillsRegistriesConfig struct { - ClawHub ClawHubRegistryConfig `json:"clawhub"` +type SkillsRegistriesConfig []*SkillRegistryConfig + +func (c *SkillsRegistriesConfig) Get(name string) (SkillRegistryConfig, bool) { + if c == nil { + return SkillRegistryConfig{}, false + } + name = strings.TrimSpace(name) + if name == "" { + return SkillRegistryConfig{}, false + } + for _, registry := range *c { + if registry == nil || registry.Name != name { + continue + } + return *registry, true + } + return SkillRegistryConfig{}, false } -type ClawHubRegistryConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"` - BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"` - AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"` - SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"` - SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"` - DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"` - Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"` - MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"` - MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"` +func (c *SkillsRegistriesConfig) Set(name string, cfg SkillRegistryConfig) { + if c == nil { + return + } + name = strings.TrimSpace(name) + if name == "" { + return + } + cfg.Name = name + for i, registry := range *c { + if registry == nil || registry.Name != name { + continue + } + (*c)[i] = &cfg + return + } + *c = append(*c, &cfg) +} + +type SkillsGithubConfig struct { + BaseURL string `json:"base_url,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_BASE_URL"` + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_TOKEN"` + Proxy string `json:"proxy,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"` +} + +type SkillRegistryConfig struct { + Name string `json:"name,omitempty" yaml:"-" env:"-"` + Enabled bool `json:"enabled" yaml:"-" env:"-"` + BaseURL string `json:"base_url" yaml:"-" env:"-"` + AuthToken SecureString `json:"auth_token,omitzero" yaml:"auth_token,omitempty" env:"-"` + Param map[string]any `json:"-" yaml:"-" env:"-"` +} + +const ( + envSkillsClawHubEnabled = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED" + envSkillsClawHubBaseURL = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL" + envSkillsClawHubAuthToken = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN" + envSkillsClawHubSearchPath = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH" + envSkillsClawHubSkillsPath = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH" + envSkillsClawHubDownloadPath = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH" + envSkillsClawHubTimeout = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT" + envSkillsClawHubMaxZipSize = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE" + envSkillsClawHubMaxResponseSize = "PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE" + envSkillsGitHubEnabled = "PICOCLAW_SKILLS_REGISTRIES_GITHUB_ENABLED" + envSkillsGitHubBaseURL = "PICOCLAW_SKILLS_REGISTRIES_GITHUB_BASE_URL" + envSkillsGitHubAuthToken = "PICOCLAW_SKILLS_REGISTRIES_GITHUB_AUTH_TOKEN" + envSkillsGitHubProxy = "PICOCLAW_SKILLS_REGISTRIES_GITHUB_PROXY" +) + +func (c *SkillRegistryConfig) DecodeParam(target any) error { + if c == nil { + return nil + } + if len(c.Param) == 0 { + return nil + } + data, err := json.Marshal(c.Param) + if err != nil { + return err + } + return json.Unmarshal(data, target) } // MCPServerConfig defines configuration for a single MCP server type MCPServerConfig struct { // Enabled indicates whether this MCP server is active Enabled bool `json:"enabled"` + // Deferred controls whether this server's tools are registered as hidden (deferred/discovery mode). + // When nil, the global Discovery.Enabled setting applies. + // When explicitly set to true or false, it overrides the global setting for this server only. + Deferred *bool `json:"deferred,omitempty"` // Command is the executable to run (e.g., "npx", "python", "/path/to/server") Command string `json:"command"` // Args are the arguments to pass to the command @@ -742,80 +986,424 @@ type MCPServerConfig struct { // MCPConfig defines configuration for all MCP servers type MCPConfig struct { - ToolConfig `envPrefix:"PICOCLAW_TOOLS_MCP_"` + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` + Discovery ToolDiscoveryConfig ` json:"discovery"` + // MaxInlineTextChars controls how much MCP text stays inline before it is saved as an artifact. + MaxInlineTextChars int `json:"max_inline_text_chars,omitempty" env:"PICOCLAW_TOOLS_MCP_MAX_INLINE_TEXT_CHARS"` // Servers is a map of server name to server configuration Servers map[string]MCPServerConfig `json:"servers,omitempty"` } +const DefaultMCPMaxInlineTextChars = 16 * 1024 + +func (c *MCPConfig) GetMaxInlineTextChars() int { + if c.MaxInlineTextChars > 0 { + return c.MaxInlineTextChars + } + return DefaultMCPMaxInlineTextChars +} + func LoadConfig(path string) (*Config, error) { - cfg := DefaultConfig() + updateResolver(filepath.Dir(path)) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return cfg, nil + logger.WarnF( + "config file not found, using default config", + map[string]any{"path": path}, + ) + return DefaultConfig(), nil } return nil, err } - // Pre-scan the JSON to check how many model_list entries the user provided. - // Go's JSON decoder reuses existing slice backing-array elements rather than - // zero-initializing them, so fields absent from the user's JSON (e.g. api_base) - // would silently inherit values from the DefaultConfig template at the same - // index position. We only reset cfg.ModelList when the user actually provides - // entries; when count is 0 we keep DefaultConfig's built-in list as fallback. - var tmp Config - if err := json.Unmarshal(data, &tmp); err != nil { + // First, try to detect config version by reading the version field + var versionInfo struct { + Version int `json:"version"` + } + if e := json.Unmarshal(data, &versionInfo); e != nil { + e = wrapJSONError(data, e, "config.json") + logger.ErrorCF("config", formatDiagnosticLogMessage("Malformed config file", e), map[string]any{"path": path}) + return nil, e + } + if len(data) <= 10 { + logger.Warn(fmt.Sprintf("content is [%s]", string(data))) + return DefaultConfig(), nil + } + + // Load config based on detected version + var cfg *Config + switch versionInfo.Version { + case 0: + logger.InfoF( + "config migrate start", + map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, + ) + if err = validateLegacyConfigDiagnostics(data); err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) + return nil, err + } + + var m map[string]any + m, err = loadConfigMap(path) + if err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) + return nil, err + } + + migrateErr := migrateV0ToV1(m) + if migrateErr != nil { + return nil, fmt.Errorf("V0→V1 migration failed: %w", migrateErr) + } + migrateErr = migrateV1ToV2(m) + if migrateErr != nil { + return nil, fmt.Errorf("V1→V2 migration failed: %w", migrateErr) + } + migrateErr = migrateV2ToV3(m) + if migrateErr != nil { + return nil, fmt.Errorf("V2→V3 migration failed: %w", migrateErr) + } + + var migrated []byte + migrated, err = json.Marshal(m) + if err != nil { + return nil, err + } + + cfg, err = loadConfig(migrated) + if err != nil { + return nil, err + } + + err = makeBackup(path) + if err != nil { + return nil, err + } + + defer func(cfg *Config) { + _ = SaveConfig(path, cfg) + }(cfg) + case 1: + // V1→V3 migration: rename channels→channel_list, infer Enabled, migrate channel configs + logger.InfoF( + "config migrate start", + map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, + ) + if err = validateLegacyConfigDiagnostics(data); err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) + return nil, err + } + + var m map[string]any + m, err = loadConfigMap(path) + if err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) + return nil, err + } + + migrateErr := migrateV1ToV2(m) + if migrateErr != nil { + return nil, fmt.Errorf("V1→V2 migration failed: %w", migrateErr) + } + migrateErr = migrateV2ToV3(m) + if migrateErr != nil { + return nil, fmt.Errorf("V2→V3 migration failed: %w", migrateErr) + } + + var migrated []byte + migrated, err = json.Marshal(m) + if err != nil { + return nil, err + } + + cfg, err = loadConfig(migrated) + if err != nil { + return nil, err + } + + err = makeBackup(path) + if err != nil { + return nil, err + } + + defer func(cfg *Config) { + _ = SaveConfig(path, cfg) + }(cfg) + logger.InfoF( + "config migrate success", + map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, + ) + case 2: + // V2→V3 migration: rename channels→channel_list, convert flat→nested + logger.InfoF( + "config migrate start", + map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, + ) + if err = validateLegacyConfigDiagnostics(data); err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) + return nil, err + } + var m map[string]any + m, err = loadConfigMap(path) + if err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) + return nil, err + } + migrateErr := migrateV2ToV3(m) + if migrateErr != nil { + return nil, fmt.Errorf("V2→V3 migration failed: %w", migrateErr) + } + + var migrated []byte + migrated, err = json.Marshal(m) + if err != nil { + return nil, err + } + + cfg, err = loadConfig(migrated) + if err != nil { + return nil, err + } + + err = makeBackup(path) + if err != nil { + return nil, err + } + + defer func(cfg *Config) { + _ = SaveConfig(path, cfg) + }(cfg) + logger.InfoF( + "config migrate success", + map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, + ) + case CurrentVersion: + // Current version + cfg, err = loadConfig(data) + if err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) + return nil, err + } + // Load security configuration + secPath := securityPath(path) + err = loadSecurityConfig(cfg, secPath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("failed to load security config: %w", err) + } + + default: + return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version) + } + + applyLegacyBindingsMigration(data, cfg) + + gatewayHostBeforeEnv := cfg.Gateway.Host + + if err = env.Parse(cfg); err != nil { return nil, err } - if len(tmp.ModelList) > 0 { - cfg.ModelList = nil - } + applySkillsRegistryEnvCompat(cfg) - if err := json.Unmarshal(data, cfg); err != nil { + if err = InitChannelList(cfg.Channels); err != nil { return nil, err } - - if err := env.Parse(cfg); err != nil { - return nil, err + cfg.Gateway.Host, err = resolveGatewayHostFromEnv(gatewayHostBeforeEnv) + if err != nil { + return nil, fmt.Errorf("invalid gateway host: %w", err) } - // Migrate legacy channel config fields to new unified structures - cfg.migrateChannelConfigs() - - // Auto-migrate: if only legacy providers config exists, convert to model_list - if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() { - cfg.ModelList = ConvertProvidersToModelList(cfg) - } + // Expand multi-key configs into separate entries for key-level failover + cfg.ModelList = expandMultiKeyModels(cfg.ModelList) // Validate model_list for uniqueness and required fields - if err := cfg.ValidateModelList(); err != nil { + if err = cfg.ValidateModelList(); err != nil { return nil, err } + // Ensure Workspace has a default if not set + if cfg.Agents.Defaults.Workspace == "" { + homePath := GetHome() + cfg.Agents.Defaults.Workspace = filepath.Join(homePath, pkg.WorkspaceName) + } + return cfg, nil } -func (c *Config) migrateChannelConfigs() { - // Discord: mention_only -> group_trigger.mention_only - if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly { - c.Channels.Discord.GroupTrigger.MentionOnly = true +func applySkillsRegistryEnvCompat(cfg *Config) { + if cfg == nil { + return } - // OneBot: group_trigger_prefix -> group_trigger.prefixes - if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && - len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 { - c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix + registryCfg, foundClawHub := cfg.Tools.Skills.Registries.Get("clawhub") + if !foundClawHub { + registryCfg = SkillRegistryConfig{ + Name: "clawhub", + Param: map[string]any{}, + } } + if registryCfg.Param == nil { + registryCfg.Param = map[string]any{} + } + + if raw, envSet := os.LookupEnv(envSkillsClawHubEnabled); envSet { + if value, err := strconv.ParseBool(strings.TrimSpace(raw)); err == nil { + registryCfg.Enabled = value + } + } + if value, envSet := os.LookupEnv(envSkillsClawHubBaseURL); envSet { + registryCfg.BaseURL = value + } + if value, envSet := os.LookupEnv(envSkillsClawHubAuthToken); envSet { + registryCfg.AuthToken = *NewSecureString(value) + } + if value, envSet := os.LookupEnv(envSkillsClawHubSearchPath); envSet { + registryCfg.Param["search_path"] = value + } + if value, envSet := os.LookupEnv(envSkillsClawHubSkillsPath); envSet { + registryCfg.Param["skills_path"] = value + } + if value, envSet := os.LookupEnv(envSkillsClawHubDownloadPath); envSet { + registryCfg.Param["download_path"] = value + } + if raw, envSet := os.LookupEnv(envSkillsClawHubTimeout); envSet { + if value, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil { + registryCfg.Param["timeout"] = value + } + } + if raw, envSet := os.LookupEnv(envSkillsClawHubMaxZipSize); envSet { + if value, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil { + registryCfg.Param["max_zip_size"] = value + } + } + if raw, envSet := os.LookupEnv(envSkillsClawHubMaxResponseSize); envSet { + if value, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil { + registryCfg.Param["max_response_size"] = value + } + } + + cfg.Tools.Skills.Registries.Set("clawhub", registryCfg) + + githubCfg, foundGitHub := cfg.Tools.Skills.Registries.Get("github") + if !foundGitHub { + githubCfg = SkillRegistryConfig{ + Name: "github", + Param: map[string]any{}, + } + } + if githubCfg.Param == nil { + githubCfg.Param = map[string]any{} + } + + if raw, envSet := os.LookupEnv(envSkillsGitHubEnabled); envSet { + if value, err := strconv.ParseBool(strings.TrimSpace(raw)); err == nil { + githubCfg.Enabled = value + } + } + if value, envSet := os.LookupEnv(envSkillsGitHubBaseURL); envSet { + githubCfg.BaseURL = value + } + if value, envSet := os.LookupEnv(envSkillsGitHubAuthToken); envSet { + githubCfg.AuthToken = *NewSecureString(value) + } + if value, envSet := os.LookupEnv(envSkillsGitHubProxy); envSet { + githubCfg.Param["proxy"] = value + } + + cfg.Tools.Skills.Registries.Set("github", githubCfg) +} + +func makeBackup(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil + } + dateSuffix := time.Now().Format(".20060102.bak") + // Backup config file + bakPath := path + dateSuffix + if err := fileutil.CopyFile(path, bakPath, 0o600); err != nil { + logger.ErrorF("failed to create config backup", map[string]any{"error": err}) + return fmt.Errorf("failed to create config backup: %w", err) + } + // Backup security config file + secPath := securityPath(path) + if _, err := os.Stat(secPath); err == nil { + secBakPath := secPath + dateSuffix + if secErr := fileutil.CopyFile(secPath, secBakPath, 0o600); secErr != nil { + logger.ErrorF("failed to create security backup", map[string]any{"error": secErr}) + return fmt.Errorf("failed to create security backup: %w", secErr) + } + } + return nil +} + +func toNameIndex(list []*ModelConfig) []string { + nameList := make([]string, 0, len(list)) + countMap := make(map[string]int) + for _, model := range list { + name := model.ModelName + index := countMap[name] + nameList = append(nameList, fmt.Sprintf("%s:%d", name, index)) + countMap[name]++ + } + return nameList } func SaveConfig(path string, cfg *Config) error { + if cfg.Version < CurrentVersion { + cfg.Version = CurrentVersion + } + // Filter out virtual models before serializing to config file + nonVirtualModels := make([]*ModelConfig, 0, len(cfg.ModelList)) + for _, m := range cfg.ModelList { + if !m.isVirtual { + nonVirtualModels = append(nonVirtualModels, m) + } + } + // Temporarily replace ModelList with filtered version for serialization + originalModelList := cfg.ModelList + defer func() { + // Restore original ModelList after serialization + cfg.ModelList = originalModelList + }() + cfg.ModelList = nonVirtualModels + + if err := saveSecurityConfig(securityPath(path), cfg); err != nil { + logger.ErrorCF("config", "cannot save .security.yml", map[string]any{"error": err}) + return err + } + data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } - - // Use unified atomic write utility with explicit sync for flash storage reliability. return fileutil.WriteFileAtomic(path, data, 0o600) } @@ -823,53 +1411,6 @@ func (c *Config) WorkspacePath() string { return expandHome(c.Agents.Defaults.Workspace) } -func (c *Config) GetAPIKey() string { - if c.Providers.OpenRouter.APIKey != "" { - return c.Providers.OpenRouter.APIKey - } - if c.Providers.Anthropic.APIKey != "" { - return c.Providers.Anthropic.APIKey - } - if c.Providers.OpenAI.APIKey != "" { - return c.Providers.OpenAI.APIKey - } - if c.Providers.Gemini.APIKey != "" { - return c.Providers.Gemini.APIKey - } - if c.Providers.Zhipu.APIKey != "" { - return c.Providers.Zhipu.APIKey - } - if c.Providers.Groq.APIKey != "" { - return c.Providers.Groq.APIKey - } - if c.Providers.VLLM.APIKey != "" { - return c.Providers.VLLM.APIKey - } - if c.Providers.ShengSuanYun.APIKey != "" { - return c.Providers.ShengSuanYun.APIKey - } - if c.Providers.Cerebras.APIKey != "" { - return c.Providers.Cerebras.APIKey - } - return "" -} - -func (c *Config) GetAPIBase() string { - if c.Providers.OpenRouter.APIKey != "" { - if c.Providers.OpenRouter.APIBase != "" { - return c.Providers.OpenRouter.APIBase - } - return "https://openrouter.ai/api/v1" - } - if c.Providers.Zhipu.APIKey != "" { - return c.Providers.Zhipu.APIBase - } - if c.Providers.VLLM.APIKey != "" && c.Providers.VLLM.APIBase != "" { - return c.Providers.VLLM.APIBase - } - return "" -} - func expandHome(path string) string { if path == "" { return path @@ -893,17 +1434,17 @@ func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) { return nil, fmt.Errorf("model %q not found in model_list or providers", modelName) } if len(matches) == 1 { - return &matches[0], nil + return matches[0], nil } // Multiple configs - use round-robin for load balancing - idx := rrCounter.Add(1) % uint64(len(matches)) - return &matches[idx], nil + idx := (rrCounter.Add(1) - 1) % uint64(len(matches)) + return matches[idx], nil } // findMatches finds all ModelConfig entries with the given model_name. -func (c *Config) findMatches(modelName string) []ModelConfig { - var matches []ModelConfig +func (c *Config) findMatches(modelName string) []*ModelConfig { + var matches []*ModelConfig for i := range c.ModelList { if c.ModelList[i].ModelName == modelName { matches = append(matches, c.ModelList[i]) @@ -912,11 +1453,6 @@ func (c *Config) findMatches(modelName string) []ModelConfig { return matches } -// HasProvidersConfig checks if any provider in the old providers config has configuration. -func (c *Config) HasProvidersConfig() bool { - return !c.Providers.IsEmpty() -} - // ValidateModelList validates all ModelConfig entries in the model_list. // It checks that each model config is valid. // Note: Multiple entries with the same model_name are allowed for load balancing. @@ -929,6 +1465,90 @@ func (c *Config) ValidateModelList() error { return nil } +func (c *Config) SecurityCopyFrom(path string) error { + return loadSecurityConfig(c, securityPath(path)) +} + +func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { + var expanded []*ModelConfig + + for _, m := range models { + keys := m.APIKeys.Values() + + // Single key or no keys: keep as-is + if len(keys) <= 1 { + expanded = append(expanded, m) + continue + } + + // Multiple keys: expand + originalName := m.ModelName + + // Create entries for additional keys (key_1, key_2, ...) + var fallbackNames []string + for i := 1; i < len(keys); i++ { + suffix := fmt.Sprintf("__key_%d", i) + expandedName := originalName + suffix + + // Create a copy for the additional key + additionalEntry := &ModelConfig{ + ModelName: expandedName, + Provider: m.Provider, + Model: m.Model, + APIBase: m.APIBase, + APIKeys: SimpleSecureStrings(keys[i]), + Proxy: m.Proxy, + AuthMethod: m.AuthMethod, + ConnectMode: m.ConnectMode, + Workspace: m.Workspace, + RPM: m.RPM, + MaxTokensField: m.MaxTokensField, + RequestTimeout: m.RequestTimeout, + ThinkingLevel: m.ThinkingLevel, + ToolSchemaTransform: m.ToolSchemaTransform, + ExtraBody: m.ExtraBody, + CustomHeaders: m.CustomHeaders, + UserAgent: m.UserAgent, + isVirtual: true, + } + expanded = append(expanded, additionalEntry) + fallbackNames = append(fallbackNames, expandedName) + } + + // Create the primary entry with first key and fallbacks + primaryEntry := &ModelConfig{ + ModelName: originalName, + Provider: m.Provider, + Model: m.Model, + APIBase: m.APIBase, + Proxy: m.Proxy, + AuthMethod: m.AuthMethod, + ConnectMode: m.ConnectMode, + Workspace: m.Workspace, + RPM: m.RPM, + MaxTokensField: m.MaxTokensField, + RequestTimeout: m.RequestTimeout, + ThinkingLevel: m.ThinkingLevel, + ToolSchemaTransform: m.ToolSchemaTransform, + ExtraBody: m.ExtraBody, + CustomHeaders: m.CustomHeaders, + UserAgent: m.UserAgent, + APIKeys: SimpleSecureStrings(keys[0]), + } + + // Prepend new fallbacks to existing ones + if len(fallbackNames) > 0 { + primaryEntry.Fallbacks = append(fallbackNames, m.Fallbacks...) + } else if len(m.Fallbacks) > 0 { + primaryEntry.Fallbacks = m.Fallbacks + } + + expanded = append(expanded, primaryEntry) + } + + return expanded +} + func (t *ToolsConfig) IsToolEnabled(name string) bool { switch name { case "web": @@ -957,8 +1577,12 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.Message.Enabled case "read_file": return t.ReadFile.Enabled + case "serial": + return t.Serial.Enabled case "spawn": return t.Spawn.Enabled + case "spawn_status": + return t.SpawnStatus.Enabled case "spi": return t.SPI.Enabled case "subagent": @@ -967,6 +1591,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.WebFetch.Enabled case "send_file": return t.SendFile.Enabled + case "send_tts": + return t.SendTTS.Enabled case "write_file": return t.WriteFile.Enabled case "mcp": @@ -1050,12 +1676,7 @@ func (c *Config) MergeWorkspaceConfig(wc *WorkspaceConfig) error { // tools & channels: use raw JSON overlay so that only keys actually present // in the workspace file are applied (avoids clobbering bool fields with false). mergeRawJSONField(wc.rawJSON, "tools", &c.Tools) - mergeRawJSONField(wc.rawJSON, "channels", &c.Channels) - - // bindings: replace if workspace has entries - if len(src.Bindings) > 0 { - c.Bindings = src.Bindings - } + mergeRawJSONField(wc.rawJSON, "channel_list", &c.Channels) // session: merge non-zero fields (prevents cross-tenant identity leakage) mergeSessionConfig(&c.Session, &src.Session) @@ -1087,9 +1708,6 @@ func mergeAgentDefaults(dst, src *AgentDefaults) error { if src.ModelName != "" { dst.ModelName = src.ModelName } - if src.Model != "" { - dst.Model = src.Model - } if len(src.ModelFallbacks) > 0 { dst.ModelFallbacks = src.ModelFallbacks } @@ -1122,8 +1740,8 @@ func mergeAgentDefaults(dst, src *AgentDefaults) error { // mergeSessionConfig copies non-zero fields from src into dst. func mergeSessionConfig(dst, src *SessionConfig) { - if src.DMScope != "" { - dst.DMScope = src.DMScope + if len(src.Dimensions) > 0 { + dst.Dimensions = src.Dimensions } if len(src.IdentityLinks) > 0 { dst.IdentityLinks = src.IdentityLinks diff --git a/pkg/config/config_channel.go b/pkg/config/config_channel.go new file mode 100644 index 000000000..596853d1f --- /dev/null +++ b/pkg/config/config_channel.go @@ -0,0 +1,706 @@ +package config + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + + "github.com/caarlos0/env/v11" + "gopkg.in/yaml.v3" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// Channel type constants — single source of truth for all channel type names. +const ( + ChannelPico = "pico" + ChannelPicoClient = "pico_client" + ChannelTelegram = "telegram" + ChannelDiscord = "discord" + ChannelFeishu = "feishu" + ChannelWeixin = "weixin" + ChannelWeCom = "wecom" + ChannelDingTalk = "dingtalk" + ChannelSlack = "slack" + ChannelMatrix = "matrix" + ChannelLINE = "line" + ChannelOneBot = "onebot" + ChannelQQ = "qq" + ChannelIRC = "irc" + ChannelVK = "vk" + ChannelMaixCam = "maixcam" + ChannelWhatsApp = "whatsapp" + ChannelWhatsAppNative = "whatsapp_native" + ChannelTeamsWebHook = "teams_webhook" + ChannelMagicForm = "magicform" +) + +func initChannel() { + registerSingletonChannel(ChannelPico) + registerSingletonChannel(ChannelPicoClient) +} + +// singletonRegistry stores which channel types are singletons (only allow one instance). +// Each channel type should call registerSingletonChannel in its init() if it's a singleton. +var singletonRegistry = make(map[string]struct{}) + +// registerSingletonChannel marks a channel type as singleton (only one instance allowed). +// Should be called from the channel type's init() function. +func registerSingletonChannel(channelType string) { + singletonRegistry[channelType] = struct{}{} +} + +// IsSingletonChannel returns true if the channel type only allows one instance. +func IsSingletonChannel(channelType string) bool { + _, ok := singletonRegistry[channelType] + return ok +} + +// RawNode stores raw configuration data as JSON bytes, supporting both JSON and YAML. +// Internally uses json.RawMessage, so Decode always uses json.Unmarshal +// which correctly respects json struct tags. +type RawNode json.RawMessage + +// UnmarshalJSON implements json.Unmarshaler: stores raw JSON bytes. +// NOTE: yaml.Unmarshal may call this when unmarshaling into RawNode fields. +// We detect if the input looks like YAML (not JSON) and handle it. +func (r *RawNode) UnmarshalJSON(data []byte) error { + trimmed := strings.TrimSpace(string(data)) + if trimmed == "null" || trimmed == "{}" || trimmed == "[]" { + *r = nil + return nil + } + + // If it doesn't look like JSON (starts with {, [, ", digit, n, t, f), + // it's probably YAML data passed through yaml.Unmarshal. + // Try to parse as YAML and convert to JSON. + if len(trimmed) > 0 { + first := trimmed[0] + if first != '{' && first != '[' && first != '"' && first != '-' && + !(first >= '0' && first <= '9') && first != 'n' && first != 't' && first != 'f' { + // Looks like YAML, not JSON. Parse as YAML and convert to JSON. + var v any + if err := yaml.Unmarshal(data, &v); err != nil { + return err + } + jsonData, err := json.Marshal(v) + if err != nil { + return err + } + *r = jsonData + return nil + } + } + + *r = append((*r)[:0:0], data...) + return nil +} + +// MarshalJSON implements json.Marshaler: outputs stored JSON bytes. +func (r RawNode) MarshalJSON() ([]byte, error) { + if len(r) == 0 { + return []byte("null"), nil + } + return r, nil +} + +// UnmarshalYAML implements yaml.Unmarshaler: converts YAML node to JSON bytes. +// Merges the incoming YAML values with existing data, with YAML taking precedence. +func (r *RawNode) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == 0 { + //*r = nil + return nil + } + var v1, v2 map[string]any + if len(*r) > 0 { + if err := json.Unmarshal(*r, &v1); err != nil { + return err + } + } + if err := value.Decode(&v2); err != nil { + return err + } + v := mergeMap(v1, v2) + data, err := json.Marshal(v) + if err != nil { + return err + } + *r = data + return nil +} + +// mergeMap deeply merges two map[string]any. +// dst: base map +// src: override map (same keys overwrite dst, nested maps are merged recursively) +// Returns a new map without modifying the originals. +func mergeMap(dst, src map[string]any) map[string]any { + // logger.Infof("mergeMap: dst: %v, src: %v", dst, src) + // Create result map to avoid modifying originals + result := make(map[string]any) + + // Copy all content from base map + for k, v := range dst { + result[k] = v + } + + // Merge override map + for k, srcVal := range src { + dstVal, exists := result[k] + + if !exists { + // Key doesn't exist in base, add directly + result[k] = srcVal + continue + } + + // Both are maps → recursive merge + dstMap, dstIsMap := toMap(dstVal) + srcMap, srcIsMap := toMap(srcVal) + + if dstIsMap && srcIsMap { + result[k] = mergeMap(dstMap, srcMap) + } else { + // Not both maps → override + result[k] = srcVal + } + } + + return result +} + +// toMap safely converts any value to map[string]any. +func toMap(v any) (map[string]any, bool) { + m, ok := v.(map[string]any) + return m, ok +} + +// MarshalYAML implements yaml.ValueMarshaler: converts stored JSON back to a YAML-compatible value. +func (r RawNode) MarshalYAML() (any, error) { + if len(r) == 0 { + return nil, nil + } + var v any + if err := json.Unmarshal(r, &v); err != nil { + return nil, err + } + return v, nil +} + +// Decode unmarshals the stored data into the given target struct using json.Unmarshal. +func (r *RawNode) Decode(target any) error { + if len(*r) == 0 { + return nil + } + return json.Unmarshal(*r, target) +} + +// IsEmpty returns true if the node has not been populated. +func (r *RawNode) IsEmpty() bool { + return len(*r) == 0 +} + +// Channel defines the common fields shared by all channel types. +// Channel-specific settings go into Settings (nested format only). +// The settings struct should use SecureString/SecureStrings for sensitive fields. +// +// Decode stores the settings pointer internally; subsequent modifications to the +// decoded struct are automatically reflected in MarshalJSON/MarshalYAML. +// +// MarshalJSON outputs nested format (common fields at top level, settings as sub-key). +// MarshalYAML outputs only secure fields (for .security.yml). +// +// Standard Go JSON/YAML unmarshaling handles nested format correctly: +// - JSON: {"enabled": true, "type": "telegram", "settings": {"base_url": "..."}} +// - YAML: settings: {token: xxx} (for .security.yml) +// +//nolint:recvcheck +type Channel struct { + name string + Enabled bool `json:"enabled" yaml:"-"` + Type string `json:"type" yaml:"-"` + AllowFrom FlexibleStringSlice `json:"allow_from,omitempty" yaml:"-"` + ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` + Typing TypingConfig `json:"typing,omitempty" yaml:"-"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` + Settings RawNode `json:"settings,omitzero" yaml:"settings,omitempty"` + extend any +} + +// MarshalJSON implements json.Marshaler for Channel. +// Outputs nested format: common fields at top level, channel-specific in "settings". +// Secure fields (SecureString/SecureStrings) are removed from settings output. +func (b Channel) MarshalJSON() ([]byte, error) { + var settings RawNode + if b.extend != nil { + raw, err := json.Marshal(b.extend) + if err != nil { + return nil, err + } + settings = raw + } else { + settings = b.Settings + } + + out := b + out.Settings = settings + + // Use type alias to bypass our custom MarshalJSON (infinite recursion) + type Alias Channel + return json.Marshal((*Alias)(&out)) +} + +// MarshalYAML implements yaml.ValueMarshaler for Channel. +// Outputs only secure fields in the Settings YAML (for .security.yml). +// If Decode was called, it serializes from the stored extend (reflecting any +// modifications); otherwise falls back to decoding Settings via the channel Type +// to extract secure fields. +func (b Channel) MarshalYAML() (any, error) { + decoded, _ := b.GetDecoded() + return struct { + Settings any `json:"settings,omitzero" yaml:"settings,omitempty"` + }{ + Settings: decoded, + }, nil +} + +// Name returns the channel name. +func (b *Channel) Name() string { + return b.name +} + +// SetName sets the channel name. +func (b *Channel) SetName(name string) { + b.name = name +} + +// SetSecretField sets a secure field value by field name in the Settings JSON. +// NOTE: This only operates on raw Settings. If Decode() has been called, +// prefer modifying the typed struct directly — MarshalJSON serializes from extend. +func (b *Channel) SetSecretField(fieldName string, value SecureString) { + var m map[string]any + if err := json.Unmarshal(b.Settings, &m); err != nil { + return + } + m[fieldName] = value + data, err := json.Marshal(m) + if err != nil { + return + } + b.Settings = data +} + +// Decode decodes the Settings node into the given target struct and stores +// the pointer internally. Subsequent modifications to the target are +// automatically reflected in MarshalJSON/MarshalYAML (no explicit Encode needed). +func (b *Channel) Decode(target any) error { + if target == nil { + return fmt.Errorf("target is nil") + } + if err := b.Settings.Decode(target); err != nil { + return err + } + b.extend = target + return nil +} + +// GetDecoded returns the previously decoded settings struct. +// If Decode hasn't been called yet, it lazily decodes using the channel Type prototype. +// Returns an error if decoding fails; the decoded value (possibly nil) is still returned +// so callers can distinguish between "not decoded" and "decode failed". +func (b *Channel) GetDecoded() (any, error) { + if b.extend == nil { + // fallback to prototype-based creation + if target := newChannelSettings(b.Type); target != nil { + if err := b.Decode(target); err != nil { + return nil, fmt.Errorf("channel %q failed to decode settings: %w", b.name, err) + } + } + } + return b.extend, nil +} + +// UnmarshalYAML implements yaml.Unmarshaler for Channel. +// Merges the YAML node into the existing Channel. +// Supports both nested format (settings: {...}) and flat format (token: xxx). +func (b *Channel) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == 0 { + return nil + } + + type alias Channel + a := alias(*b) + err := value.Decode(&a) + if err != nil { + logger.Errorf("decode yaml error: %v", err) + return err + } + + *b = *(*Channel)(&a) + + if len(b.Settings) > 0 { + b.extend = nil + } + + return nil +} + +// SettingsIsEmpty returns true if Settings has not been populated. +func (b *Channel) SettingsIsEmpty() bool { + return b.Settings.IsEmpty() +} + +// CollectSensitiveValues returns all sensitive string values from this Channel's +// decoded settings (extend). Used by the security filter system. +func (b Channel) CollectSensitiveValues() []string { + if b.extend == nil { + return nil + } + var values []string + collectSensitive(reflect.ValueOf(b.extend), &values) + return values +} + +// ChannelsConfig maps channel name to its Channel configuration. +// Each Channel stores the full channel config in Settings and handles +// JSON/YAML serialization (removing/keeping secure fields automatically). +// +//nolint:recvcheck +type ChannelsConfig map[string]*Channel + +// UnmarshalYAML implements yaml.Unmarshaler for ChannelsConfig. +// This ensures that when loading security.yml, existing Channel instances +// are properly merged rather than replaced with new ones. +func (c *ChannelsConfig) UnmarshalYAML(value *yaml.Node) error { + // yaml.Node Content for a mapping contains alternating key-value nodes + // We need to iterate through them in pairs + if value.Kind != yaml.MappingNode { + return fmt.Errorf("expected mapping node, got %v", value.Kind) + } + + if *c == nil { + *c = make(ChannelsConfig) + } + + for i := 0; i < len(value.Content); i += 2 { + if i+1 >= len(value.Content) { + break + } + name := value.Content[i].Value + node := value.Content[i+1] + + existingBC := (*c)[name] + if existingBC != nil { + // Channel already exists - call UnmarshalYAML on it + // This merges security.yml settings into existing config + if err := existingBC.UnmarshalYAML(node); err != nil { + return err + } + // Ensure name is set (may have been empty before) + existingBC.SetName(name) + } else { + // New channel - create and unmarshal + newBC := &Channel{} + if err := node.Decode(newBC); err != nil { + return err + } + // Set the channel name from the map key + newBC.SetName(name) + (*c)[name] = newBC + } + } + + return nil +} + +// UnmarshalJSON implements json.Unmarshaler for ChannelsConfig. +// Sets the channel name from the map key after unmarshaling. +func (c *ChannelsConfig) UnmarshalJSON(data []byte) error { + // Use a type alias to avoid infinite recursion + type channelsConfigAlias map[string]*Channel + var raw channelsConfigAlias + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + if *c == nil { + *c = make(ChannelsConfig) + } + + for name, bc := range raw { + if bc != nil { + bc.SetName(name) + } + (*c)[name] = bc + } + + return nil +} + +// Get returns the Channel for the given channel name (map key), or nil if not found. +func (c ChannelsConfig) Get(name string) *Channel { + if c == nil { + return nil + } + return c[name] +} + +// GetByType returns the Channel for the given channel type, or nil if not found. +func (c ChannelsConfig) GetByType(t string) *Channel { + if c == nil { + return nil + } + for _, bc := range c { + if bc.Type == t { + return bc + } + } + return nil +} + +// SetEnabled sets the Enabled field on the Channel with the given name. +// Returns false if no channel with that name exists. +func (c ChannelsConfig) SetEnabled(name string, enabled bool) bool { + bc := c[name] + if bc == nil { + return false + } + bc.Enabled = enabled + return true +} + +// validateSingletonChannels checks that singleton channel types have at most +// one enabled instance. Returns an error if a singleton type has multiple enabled channels. +func validateSingletonChannels(channels ChannelsConfig) error { + typeCount := make(map[string]int) + typeNames := make(map[string][]string) + for name, bc := range channels { + if !bc.Enabled { + continue + } + t := bc.Type + if t == "" { + t = name + } + if IsSingletonChannel(t) { + typeCount[t]++ + typeNames[t] = append(typeNames[t], name) + } + } + for t, count := range typeCount { + if count > 1 { + return fmt.Errorf( + "channel type %q is singleton and does not support multiple instances, found %d enabled instances: %v", + t, + count, + typeNames[t], + ) + } + } + return nil +} + +// BaseFieldNames are JSON keys that belong to Channel, not to channel-specific settings. +var BaseFieldNames = map[string]struct{}{ + "enabled": {}, + "type": {}, + "allow_from": {}, + "reasoning_channel_id": {}, + "group_trigger": {}, + "typing": {}, + "placeholder": {}, +} + +// ─── Internal helpers ─── + +// extractSecureFieldNames uses reflection to find exported fields of type +// SecureString or SecureStrings and returns their JSON field names. +func extractSecureFieldNames(target any) map[string]struct{} { + v := reflect.ValueOf(target) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return nil + } + t := v.Type() + names := make(map[string]struct{}) + for i := range t.NumField() { + f := t.Field(i) + if !f.IsExported() { + continue + } + ft := f.Type + if ft == reflect.TypeOf(SecureString{}) || ft == reflect.TypeOf(&SecureString{}) || + ft == reflect.TypeOf(SecureStrings{}) || ft == reflect.TypeOf(&SecureStrings{}) { + jsonTag := f.Tag.Get("json") + name := strings.Split(jsonTag, ",")[0] + if name == "" || name == "-" { + name = f.Name + } + names[name] = struct{}{} + } + } + return names +} + +// mergeRawJSON merges two JSON objects (flat key-value) at the raw byte level. +// Overlay values override base values. +func mergeRawJSON(base, overlay RawNode) (RawNode, error) { + var baseMap, overlayMap map[string]any + if len(base) > 0 { + if err := json.Unmarshal(base, &baseMap); err != nil { + return base, err + } + } + if len(overlay) > 0 { + if err := json.Unmarshal(overlay, &overlayMap); err != nil { + return base, err + } + } + if baseMap == nil { + baseMap = make(map[string]any) + } + for k, v := range overlayMap { + baseMap[k] = v + } + data, err := json.Marshal(baseMap) + if err != nil { + return base, err + } + return RawNode(data), nil +} + +// removeSecureFields removes secure fields from the raw JSON. +// If secureFields is nil or empty, returns the raw node as-is. +func removeSecureFields(r RawNode, secureFields map[string]struct{}) RawNode { + if len(r) == 0 || len(secureFields) == 0 { + return r + } + var m map[string]any + if err := json.Unmarshal(r, &m); err != nil { + return r + } + for name := range secureFields { + delete(m, name) + } + data, err := json.Marshal(m) + if err != nil { + return r + } + return RawNode(data) +} + +// filterSecureFields keeps only secure fields in the raw JSON. +// If secureFields is nil or empty, returns nil (so omitzero/omitempty can omit it). +func filterSecureFields(r RawNode, secureFields map[string]struct{}) RawNode { + if len(r) == 0 || len(secureFields) == 0 { + return nil + } + var m map[string]any + if err := json.Unmarshal(r, &m); err != nil { + return nil + } + secureMap := make(map[string]any) + for name := range secureFields { + if val, ok := m[name]; ok { + secureMap[name] = val + } + } + if len(secureMap) == 0 { + return nil + } + data, err := json.Marshal(secureMap) + if err != nil { + return nil + } + return data +} + +// channelSettingsFactory maps channel type to a zero-value prototype of the +// corresponding Settings struct. InitChannelList uses reflect.New to create +// fresh instances, avoiding repeated closure boilerplate. +var channelSettingsFactory = map[string]any{ + ChannelPico: (PicoSettings{}), + ChannelPicoClient: (PicoClientSettings{}), + ChannelTelegram: (TelegramSettings{}), + ChannelDiscord: (DiscordSettings{}), + ChannelFeishu: (FeishuSettings{}), + ChannelWeixin: (WeixinSettings{}), + ChannelWeCom: (WeComSettings{}), + ChannelDingTalk: (DingTalkSettings{}), + ChannelSlack: (SlackSettings{}), + ChannelMatrix: (MatrixSettings{}), + ChannelLINE: (LINESettings{}), + ChannelOneBot: (OneBotSettings{}), + ChannelQQ: (QQSettings{}), + ChannelIRC: (IRCSettings{}), + ChannelVK: (VKSettings{}), + ChannelMaixCam: (MaixCamSettings{}), + ChannelWhatsApp: (WhatsAppSettings{}), + ChannelWhatsAppNative: (WhatsAppSettings{}), + ChannelTeamsWebHook: (TeamsWebhookSettings{}), + ChannelMagicForm: (MagicFormSettings{}), +} + +// newChannelSettings creates a fresh zero-value pointer for the given channel type. +// Returns nil if the type is not registered. +func newChannelSettings(channelType string) any { + proto, ok := channelSettingsFactory[channelType] + if !ok { + return nil + } + return reflect.New(reflect.TypeOf(proto)).Interface() +} + +// isValidChannelType returns true if the channel type is a known, registered type. +func isValidChannelType(channelType string) bool { + _, ok := channelSettingsFactory[channelType] + return ok +} + +// InitChannelList validates and initializes all channels in the ChannelsConfig. +// It performs three steps: +// 1. Validates that each channel has a non-empty Type +// 2. Validates singleton constraints +// 3. Decodes Settings into the correct typed struct based on Type, +// so that b.extend contains the actual settings (e.g., PicoSettings) +// +// After calling this method, callers can safely use b.extend via Decode() +// without re-parsing raw Settings. +func InitChannelList(channels ChannelsConfig) error { + // Step 1 & 3: validate type and decode into typed settings + for name, bc := range channels { + if bc == nil { + delete(channels, name) + continue + } + // Ensure channel name is set from the map key + bc.SetName(name) + // Infer Type from map key if not explicitly set + if bc.Type == "" { + bc.Type = name + } + if !isValidChannelType(bc.Type) { + return fmt.Errorf("channel %q has unknown type %q", name, bc.Type) + } + // Decode into the correct typed settings + if target := newChannelSettings(bc.Type); target != nil { + if err := bc.Decode(target); err != nil { + return fmt.Errorf("channel %q failed to decode settings: %w", name, err) + } + // Apply env overrides for channel-specific fields via struct tags + if err := env.Parse(target); err != nil { + // Non-fatal: some env vars may not apply + } + } + } + + // Step 2: validate singleton constraints + if err := validateSingletonChannels(channels); err != nil { + return err + } + + return nil +} diff --git a/pkg/config/config_channel_test.go b/pkg/config/config_channel_test.go new file mode 100644 index 000000000..fd3cd8246 --- /dev/null +++ b/pkg/config/config_channel_test.go @@ -0,0 +1,916 @@ +package config + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/sipeed/picoclaw/pkg/credential" +) + +// ─── Test extend structs (simplified, settings + secure in one struct) ─── + +type testTelegramConfig struct { + BaseURL string `json:"base_url" yaml:"-"` + Proxy string `json:"proxy" yaml:"-"` + UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-"` + Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"` + Token SecureString `json:"token,omitzero" yaml:"token,omitempty"` +} + +type testDiscordConfig struct { + MentionOnly bool `json:"mention_only" yaml:"-"` + Token SecureString `json:"token,omitzero" yaml:"token,omitempty"` + ApiKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty"` +} + +// ═══════════════════════════════════════════════════ +// RawNode JSON/YAML round-trip +// ═══════════════════════════════════════════════════ + +func TestRawNode_JSON_RoundTrip(t *testing.T) { + t.Run("unmarshal and decode", func(t *testing.T) { + var r RawNode + require.NoError(t, json.Unmarshal([]byte(`{"key":"value","num":42}`), &r)) + assert.False(t, r.IsEmpty()) + + var m map[string]any + require.NoError(t, r.Decode(&m)) + assert.Equal(t, "value", m["key"]) + assert.Equal(t, float64(42), m["num"]) + }) + + t.Run("marshal round-trip", func(t *testing.T) { + r := RawNode(`{"a":1}`) + data, err := json.Marshal(r) + require.NoError(t, err) + assert.JSONEq(t, `{"a":1}`, string(data)) + }) + + t.Run("null input", func(t *testing.T) { + var r RawNode + require.NoError(t, json.Unmarshal([]byte("null"), &r)) + assert.True(t, r.IsEmpty()) + + data, err := json.Marshal(r) + require.NoError(t, err) + assert.Equal(t, "null", string(data)) + }) + + t.Run("empty node decode", func(t *testing.T) { + var r RawNode + var m map[string]any + require.NoError(t, r.Decode(&m)) + assert.Nil(t, m) + }) +} + +func TestRawNode_YAML_RoundTrip(t *testing.T) { + t.Run("unmarshal and decode", func(t *testing.T) { + var r RawNode + require.NoError(t, yaml.Unmarshal([]byte("key: value\nnum: 42"), &r)) + assert.False(t, r.IsEmpty()) + + var m map[string]any + require.NoError(t, r.Decode(&m)) + assert.Equal(t, "value", m["key"]) + }) + + t.Run("marshal round-trip", func(t *testing.T) { + r := RawNode(`{"name":"test"}`) + data, err := yaml.Marshal(r) + require.NoError(t, err) + assert.Contains(t, string(data), "name: test") + }) + + t.Run("empty node marshal", func(t *testing.T) { + var r RawNode + v, err := yaml.Marshal(r) + require.NoError(t, err) + assert.Equal(t, "null\n", string(v)) + }) +} + +// ═══════════════════════════════════════════════════ +// JSON unmarshal: extend.json +// ═══════════════════════════════════════════════════ + +func TestChannel_JSON_Unmarshal(t *testing.T) { + jsonData := `{ + "enabled": true, + "type": "telegram", + "allow_from": ["user1", "user2"], + "reasoning_channel_id": "-100xxx", + "settings": { + "base_url": "https://custom-api.example.com", + "use_markdown_v2": true, + "streaming": {"enabled": true, "throttle_seconds": 2}, + "token": "[NOT_HERE]" + } + }` + + var ch Channel + require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) + + assert.True(t, ch.Enabled) + assert.Equal(t, "telegram", ch.Type) + assert.Equal(t, FlexibleStringSlice{"user1", "user2"}, ch.AllowFrom) + assert.Equal(t, "-100xxx", ch.ReasoningChannelID) + assert.False(t, ch.SettingsIsEmpty()) + + // Decode into combined struct + var cfg testTelegramConfig + require.NoError(t, ch.Decode(&cfg)) + assert.Equal(t, "https://custom-api.example.com", cfg.BaseURL) + assert.True(t, cfg.UseMarkdownV2) + assert.True(t, cfg.Streaming.Enabled) + assert.Equal(t, 2, cfg.Streaming.ThrottleSeconds) + // SecureString.UnmarshalJSON("[NOT_HERE]") → no-op → empty + assert.Equal(t, "", cfg.Token.String()) +} + +// ═══════════════════════════════════════════════════ +// JSON marshal: secure fields masked as [NOT_HERE] +// ═══════════════════════════════════════════════════ + +func TestChannel_JSON_Marshal_SecureMasked(t *testing.T) { + ch := Channel{ + Enabled: true, + Type: ChannelTelegram, + name: "my_telegram", + Settings: mustParseRawNode( + `{"base_url": "https://api.telegram.org", "proxy": "socks5://127.0.0.1:1080", "token": "123456:SECRET"}`, + ), + } + // Decode to register secure field names + var cfg testTelegramConfig + require.NoError(t, ch.Decode(&cfg)) + + data, err := json.MarshalIndent(ch, "", " ") + require.NoError(t, err) + t.Logf("JSON output:\n%s", string(data)) + + assert.NotContains(t, string(data), "token") + assert.NotContains(t, string(data), "123456:SECRET") + assert.NotContains(t, string(data), "SECRET") + assert.Contains(t, string(data), "base_url") + assert.Contains(t, string(data), "proxy") +} + +// ═══════════════════════════════════════════════════ +// YAML unmarshal: security.yml — only secure data +// ═══════════════════════════════════════════════════ + +func TestChannel_YAML_Unmarshal(t *testing.T) { + yamlData := ` +settings: + token: "789012:XYZ-TOKEN" +` + + var ch Channel + require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch)) + assert.False(t, ch.SettingsIsEmpty()) + + var cfg testTelegramConfig + require.NoError(t, ch.Decode(&cfg)) + assert.Equal(t, "789012:XYZ-TOKEN", cfg.Token.String()) + assert.Equal(t, "", cfg.BaseURL) +} + +// ═══════════════════════════════════════════════════ +// YAML marshal: only secure fields +// ═══════════════════════════════════════════════════ + +func TestChannel_YAML_Marshal_OnlySecureFields(t *testing.T) { + ch := Channel{ + Enabled: true, + Type: ChannelTelegram, + name: "my_telegram", + Settings: mustParseRawNode(`{"base_url": "https://api.telegram.org", "token": "123456:SECRET"}`), + } + var cfg testTelegramConfig + require.NoError(t, ch.Decode(&cfg)) + + data, err := yaml.Marshal(ch) + require.NoError(t, err) + t.Logf("YAML output:\n%s", string(data)) + + assert.NotContains(t, string(data), "NOT_HERE") + assert.Contains(t, string(data), "token") + assert.Contains(t, string(data), "123456:SECRET") + // Non-secure fields must NOT appear in YAML output + assert.NotContains(t, string(data), "base_url") + assert.NotContains(t, string(data), "proxy") +} + +// ═══════════════════════════════════════════════════ +// extractSecureFieldNames +// ═══════════════════════════════════════════════════ + +func TestExtractSecureFieldNames(t *testing.T) { + t.Run("telegram extend", func(t *testing.T) { + names := extractSecureFieldNames(&testTelegramConfig{}) + assert.Equal(t, map[string]struct{}{"token": {}}, names) + }) + + t.Run("discord extend", func(t *testing.T) { + names := extractSecureFieldNames(&testDiscordConfig{}) + assert.Equal(t, map[string]struct{}{"token": {}, "api_keys": {}}, names) + }) + + t.Run("non-struct target", func(t *testing.T) { + names := extractSecureFieldNames("not a struct") + assert.Nil(t, names) + }) + + t.Run("struct without secure fields", func(t *testing.T) { + type NoSecure struct { + Name string `json:"name"` + Count int `json:"count"` + } + names := extractSecureFieldNames(&NoSecure{}) + assert.Empty(t, names) + }) +} + +// ═══════════════════════════════════════════════════ +// mergeRawJSON +// ═══════════════════════════════════════════════════ + +func TestMergeRawJSON(t *testing.T) { + t.Run("overlay overrides base", func(t *testing.T) { + base := RawNode(`{"base_url": "old", "token": "[NOT_HERE]"}`) + overlay := RawNode(`{"token": "REAL_TOKEN"}`) + merged, err := mergeRawJSON(base, overlay) + require.NoError(t, err) + + var m map[string]any + json.Unmarshal(merged, &m) + assert.Equal(t, "old", m["base_url"]) + assert.Equal(t, "REAL_TOKEN", m["token"]) + }) + + t.Run("empty overlay", func(t *testing.T) { + base := RawNode(`{"base_url": "https://api.telegram.org"}`) + merged, err := mergeRawJSON(base, nil) + require.NoError(t, err) + // mergeRawJSON normalizes JSON through unmarshal→marshal, so compare parsed values + var orig, result map[string]any + json.Unmarshal(base, &orig) + json.Unmarshal(merged, &result) + assert.Equal(t, orig, result) + }) + + t.Run("empty base", func(t *testing.T) { + overlay := RawNode(`{"token": "NEW"}`) + merged, err := mergeRawJSON(nil, overlay) + require.NoError(t, err) + assert.Contains(t, string(merged), `"token":"NEW"`) + }) +} + +// ═══════════════════════════════════════════════════ +// Full flow: extend.json + security.yml merge +// ═══════════════════════════════════════════════════ + +func TestChannel_FullFlow_JSON_YAML_Merge(t *testing.T) { + // Step 1: Load from extend.json + jsonData := `{ + "enabled": true, + "type": "telegram", + "allow_from": ["admin"], + "settings": { + "base_url": "https://custom-api.example.com", + "use_markdown_v2": true, + "streaming": {"enabled": true}, + "token": "[NOT_HERE]" + } + }` + + var ch Channel + require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) + assert.True(t, ch.Enabled) + + // Step 2: Load secure from security.yml + yamlData := ` +settings: + token: "123456:REAL-TOKEN" +` + //var yamlOverlay struct { + // Settings RawNode `yaml:"settings"` + //} + require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch)) + + // Step 3: Merge + // require.NoError(t, ch.MergeSecure(yamlOverlay.Settings)) + + // Step 4: Decode merged result + var cfg testTelegramConfig + require.NoError(t, ch.Decode(&cfg)) + assert.Equal(t, "https://custom-api.example.com", cfg.BaseURL) + assert.True(t, cfg.UseMarkdownV2) + assert.Equal(t, "123456:REAL-TOKEN", cfg.Token.String()) + + // Step 5: Save extend.json → token masked as [NOT_HERE] + outJSON, err := json.MarshalIndent(ch, "", " ") + require.NoError(t, err) + t.Logf("Saved extend.json:\n%s", string(outJSON)) + assert.NotContains(t, string(outJSON), "token") + assert.NotContains(t, string(outJSON), "REAL-TOKEN") + assert.Contains(t, string(outJSON), "base_url") + + // Step 6: Save security.yml → only token + outYAML, err := yaml.Marshal(ch) + require.NoError(t, err) + t.Logf("Saved security.yml:\n%s", string(outYAML)) + assert.Contains(t, string(outYAML), "123456:REAL-TOKEN") + assert.NotContains(t, string(outYAML), "NOT_HERE") + assert.NotContains(t, string(outYAML), "base_url") +} + +// ═══════════════════════════════════════════════════ +// Multiple channels in a list +// ═══════════════════════════════════════════════════ + +func TestChannel_MultipleChannels(t *testing.T) { + type ChannelsWrapper struct { + Channels ChannelsConfig `json:"channels" yaml:"channels"` + } + + jsonData := `{ + "channels": { + "tg1": { + "enabled": true, + "type": "telegram", + "settings": {"base_url": "https://api.telegram.org", "token": "[NOT_HERE]"} + }, + "tg2": { + "enabled": true, + "type": "telegram", + "settings": {"base_url": "https://custom-api.example.com", "proxy": "socks5://proxy:1080", "token": "[NOT_HERE]"} + }, + "discord1": { + "enabled": true, + "type": "discord", + "settings": {"mention_only": true, "token": "[NOT_HERE]"} + } + } + }` + + var wrapper ChannelsWrapper + require.NoError(t, json.Unmarshal([]byte(jsonData), &wrapper)) + require.Len(t, wrapper.Channels, 3) + + // Decode each channel to register secure field names + for name, ch := range wrapper.Channels { + ch.SetName(name) // Set channel name + switch ch.Type { + case "telegram": + var tc testTelegramConfig + require.NoError(t, ch.Decode(&tc)) + case "discord": + var dc testDiscordConfig + require.NoError(t, ch.Decode(&dc)) + default: + t.Logf("Unknown channel type: %s for channel %s", ch.Type, name) + } + } + + // Load secrets from YAML + yamlData := ` +channels: + tg1: + settings: + token: "TOKEN_1" + tg2: + settings: + token: "TOKEN_2" + discord1: + settings: + token: "DISCORD_TOKEN" +` + require.NoError(t, yaml.Unmarshal([]byte(yamlData), &wrapper)) + + // Verify first telegram + var tg1 testTelegramConfig + require.NoError(t, wrapper.Channels["tg1"].Decode(&tg1)) + assert.Equal(t, "https://api.telegram.org", tg1.BaseURL) + assert.Equal(t, "TOKEN_1", tg1.Token.String()) + + // Verify second telegram + var tg2 testTelegramConfig + require.NoError(t, wrapper.Channels["tg2"].Decode(&tg2)) + assert.Equal(t, "https://custom-api.example.com", tg2.BaseURL) + assert.Equal(t, "socks5://proxy:1080", tg2.Proxy) + assert.Equal(t, "TOKEN_2", tg2.Token.String()) + + // Verify discord + var disc testDiscordConfig + require.NoError(t, wrapper.Channels["discord1"].Decode(&disc)) + assert.True(t, disc.MentionOnly) + assert.Equal(t, "DISCORD_TOKEN", disc.Token.String()) + + // Save JSON → all tokens removed + outJSON, err := json.MarshalIndent(wrapper, "", " ") + require.NoError(t, err) + t.Logf("Saved extend.json:\n%s", string(outJSON)) + assert.NotContains(t, string(outJSON), "token") + assert.NotContains(t, string(outJSON), "TOKEN_1") + assert.NotContains(t, string(outJSON), "DISCORD_TOKEN") + + // Save YAML → only tokens + outYAML, err := yaml.Marshal(wrapper) + require.NoError(t, err) + t.Logf("Saved security.yml:\n%s", string(outYAML)) + assert.Contains(t, string(outYAML), "TOKEN_1") + assert.Contains(t, string(outYAML), "DISCORD_TOKEN") + assert.NotContains(t, string(outYAML), "base_url") + assert.NotContains(t, string(outYAML), "NOT_HERE") +} + +// ═══════════════════════════════════════════════════ +// Empty/missing settings +// ═══════════════════════════════════════════════════ + +func TestChannel_EmptySettings(t *testing.T) { + // Flat format with only common fields: enabled and type are extracted to Channel, + // Settings should be empty (no channel-specific fields) + jsonData := `{ + "enabled": true, + "type": "telegram" + }` + + var ch Channel + require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) + // All fields are common fields — Settings should be empty + assert.True(t, ch.SettingsIsEmpty()) + + // Decode into typed config — common fields like enabled/type are extracted, + // channel-specific fields should be empty + var cfg testTelegramConfig + require.NoError(t, ch.Decode(&cfg)) + assert.Equal(t, "", cfg.BaseURL) + assert.Equal(t, "", cfg.Token.String()) +} + +func TestChannel_NestedEmptySettings(t *testing.T) { + // Nested format with empty settings + jsonData := `{ + "enabled": true, + "type": "telegram", + "settings": {} + }` + + var ch Channel + require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) + assert.True(t, ch.SettingsIsEmpty()) + + var cfg testTelegramConfig + require.NoError(t, ch.Decode(&cfg)) + assert.Equal(t, "", cfg.BaseURL) + assert.Equal(t, "", cfg.Token.String()) +} + +// ═══════════════════════════════════════════════════ +// YAML merge with fewer channels than JSON +// ═══════════════════════════════════════════════════ + +func TestChannel_MultipleChannels_PartialYAMLMerge(t *testing.T) { + type ChannelsWrapper struct { + Channels ChannelsConfig `json:"channels" yaml:"channels"` + } + + // JSON has 3 channels + jsonData := `{ + "channels": { + "tg1": {"enabled": true, "type": "telegram", "settings": {"base_url": "https://api.telegram.org", "token": "[NOT_HERE]"}}, + "tg2": {"enabled": true, "type": "telegram", "settings": {"base_url": "https://custom-api.example.com", "token": "[NOT_HERE]"}}, + "discord1": {"enabled": true, "type": "discord", "settings": {"mention_only": true, "token": "[NOT_HERE]"}} + } + }` + var wrapper ChannelsWrapper + require.NoError(t, json.Unmarshal([]byte(jsonData), &wrapper)) + require.Len(t, wrapper.Channels, 3) + t.Logf("wrapper: %v", wrapper) + + // YAML has only 2 secrets (missing tg2) + yamlData := ` +channels: + tg1: + settings: + token: "TOKEN_1" + discord1: + settings: + token: "DISCORD_TOKEN" +` + //var yamlWrapper struct { + // Channels map[string]struct { + // Settings RawNode `yaml:"settings"` + // } `yaml:"channels"` + //} + assert.True(t, wrapper.Channels["tg1"].Enabled) + assert.Equal(t, "telegram", wrapper.Channels["tg1"].Type) + + require.NoError(t, yaml.Unmarshal([]byte(yamlData), &wrapper)) + t.Logf("yamlWrapper: %v", wrapper) + require.Len(t, wrapper.Channels, 3) + + assert.True(t, wrapper.Channels["tg1"].Enabled) + + t.Logf("wrapper: %v", string(wrapper.Channels["tg1"].Settings)) + //// Merge by name; missing keys are simply absent from the YAML map (no-op) + //for name, ch := range wrapper.Channels { + // if overlay, ok := yamlWrapper.Channels[name]; ok { + // require.NoError(t, ch.MergeSecure(overlay.Settings)) + // } + //} + + // tg1: merged from YAML + var tg1 TelegramSettings + require.NoError(t, wrapper.Channels["tg1"].Decode(&tg1)) + assert.Equal(t, "TOKEN_1", tg1.Token.String()) + + // tg2: no YAML entry → MergeSecure not called → token stays [NOT_HERE] → empty + var tg2 TelegramSettings + require.NoError(t, wrapper.Channels["tg2"].Decode(&tg2)) + assert.Equal(t, "", tg2.Token.String()) + assert.Equal(t, "https://custom-api.example.com", tg2.BaseURL) + + // discord1: merged from YAML + var disc DiscordSettings + require.NoError(t, wrapper.Channels["discord1"].Decode(&disc)) + assert.Equal(t, "DISCORD_TOKEN", disc.Token.String()) + assert.True(t, disc.MentionOnly) +} + +// ═══════════════════════════════════════════════════ +// YAML list: channels with secure data +// ═══════════════════════════════════════════════════ + +func TestChannel_YAML_ListWithSecure(t *testing.T) { + yamlData := ` +channels: + tg_bot: + enabled: true + type: telegram + settings: + token: "TG_TOKEN_FROM_YAML" + discord_bot: + enabled: true + type: discord + settings: + token: "DISCORD_TOKEN_FROM_YAML" +` + + type ChannelsWrapper struct { + Channels map[string]*Channel `yaml:"channels"` + } + + var wrapper ChannelsWrapper + require.NoError(t, yaml.Unmarshal([]byte(yamlData), &wrapper)) + require.Len(t, wrapper.Channels, 2) + + var tg testTelegramConfig + require.NoError(t, wrapper.Channels["tg_bot"].Decode(&tg)) + assert.Equal(t, "TG_TOKEN_FROM_YAML", tg.Token.String()) + + var disc testDiscordConfig + require.NoError(t, wrapper.Channels["discord_bot"].Decode(&disc)) + assert.Equal(t, "DISCORD_TOKEN_FROM_YAML", disc.Token.String()) +} + +// ═══════════════════════════════════════════════════ +// removeSecureFields / filterSecureFields unit tests +// ═══════════════════════════════════════════════════ + +func TestRemoveSecureFields(t *testing.T) { + t.Run("removes known secure fields", func(t *testing.T) { + r := RawNode(`{"base_url": "https://api.telegram.org", "token": "SECRET"}`) + names := map[string]struct{}{"token": {}} + cleaned := removeSecureFields(r, names) + + var m map[string]any + json.Unmarshal(cleaned, &m) + assert.Equal(t, "https://api.telegram.org", m["base_url"]) + assert.NotContains(t, m, "token") + }) + + t.Run("nil secureFields returns as-is", func(t *testing.T) { + r := RawNode(`{"token": "SECRET"}`) + cleaned := removeSecureFields(r, nil) + assert.Equal(t, string(r), string(cleaned)) + }) + + t.Run("empty raw returns as-is", func(t *testing.T) { + cleaned := removeSecureFields(nil, map[string]struct{}{"token": {}}) + assert.Nil(t, cleaned) + }) +} + +func TestFilterSecureFields(t *testing.T) { + t.Run("keeps only secure fields", func(t *testing.T) { + r := RawNode(`{"base_url": "https://api.telegram.org", "token": "SECRET"}`) + names := map[string]struct{}{"token": {}} + filtered := filterSecureFields(r, names) + + var m map[string]any + json.Unmarshal(filtered, &m) + assert.NotContains(t, m, "base_url") + assert.Equal(t, "SECRET", m["token"]) + }) + + t.Run("nil secureFields returns nil", func(t *testing.T) { + r := RawNode(`{"token": "SECRET"}`) + filtered := filterSecureFields(r, nil) + assert.Nil(t, filtered) + }) + + t.Run("empty raw returns nil", func(t *testing.T) { + filtered := filterSecureFields(nil, map[string]struct{}{"token": {}}) + assert.Nil(t, filtered) + }) +} + +// ═══════════════════════════════════════════════════ +// SecureStrings (ApiKeys) full flow +// ═══════════════════════════════════════════════════ + +func TestChannel_SecureStrings_ApiKeys(t *testing.T) { + // Step 1: Load from extend.json + jsonData := `{ + "enabled": true, + "type": "discord", + "settings": { + "mention_only": true, + "token": "[NOT_HERE]", + "api_keys": ["[NOT_HERE]"] + } + }` + var ch Channel + require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) + + // Step 2: Merge secure from security.yml + yamlData := ` +settings: + token: "DISCORD_BOT_TOKEN" + api_keys: + - "KEY_1" + - "KEY_2" +` + require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch)) + + // Step 3: Decode — both SecureString and SecureStrings should be populated + var cfg testDiscordConfig + require.NoError(t, ch.Decode(&cfg)) + assert.True(t, cfg.MentionOnly) + assert.Equal(t, "DISCORD_BOT_TOKEN", cfg.Token.String()) + require.Len(t, cfg.ApiKeys, 2) + assert.Equal(t, "KEY_1", cfg.ApiKeys[0].String()) + assert.Equal(t, "KEY_2", cfg.ApiKeys[1].String()) + + // Step 4: Save extend.json — both secure fields removed + outJSON, err := json.MarshalIndent(ch, "", " ") + require.NoError(t, err) + t.Logf("Saved extend.json:\n%s", string(outJSON)) + assert.NotContains(t, string(outJSON), "token") + assert.NotContains(t, string(outJSON), "api_keys") + assert.NotContains(t, string(outJSON), "DISCORD_BOT_TOKEN") + assert.NotContains(t, string(outJSON), "KEY") + assert.Contains(t, string(outJSON), "mention_only") + + // Step 5: Save security.yml — only secure fields + outYAML, err := yaml.Marshal(ch) + require.NoError(t, err) + t.Logf("Saved security.yml:\n%s", string(outYAML)) + assert.Contains(t, string(outYAML), "DISCORD_BOT_TOKEN") + assert.Contains(t, string(outYAML), "KEY_1") + assert.Contains(t, string(outYAML), "KEY_2") + assert.NotContains(t, string(outYAML), "mention_only") + assert.NotContains(t, string(outYAML), "NOT_HERE") +} + +func TestChannel_SecureStrings_ApiKeys_EmptyInJSON(t *testing.T) { + // JSON has no api_keys field + jsonData := `{ + "enabled": true, + "type": "discord", + "settings": { + "mention_only": true, + "token": "[NOT_HERE]" + } + }` + var ch Channel + require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) + + // Merge with api_keys from YAML + yamlData := ` +settings: + token: "MY_TOKEN" + api_keys: + - "KEY_A" +` + require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch)) + + var cfg testDiscordConfig + require.NoError(t, ch.Decode(&cfg)) + assert.Equal(t, "MY_TOKEN", cfg.Token.String()) + require.Len(t, cfg.ApiKeys, 1) + assert.Equal(t, "KEY_A", cfg.ApiKeys[0].String()) +} + +func TestChannel_SecureStrings_ApiKeys_NoMerge(t *testing.T) { + // JSON only, no merge — SecureStrings should be empty + jsonData := `{ + "enabled": true, + "type": "discord", + "settings": { + "mention_only": true, + "token": "[NOT_HERE]", + "api_keys": ["[NOT_HERE]"] + } + }` + var ch Channel + require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) + + var cfg testDiscordConfig + require.NoError(t, ch.Decode(&cfg)) + assert.True(t, cfg.MentionOnly) + assert.Equal(t, "", cfg.Token.String()) + // ["[NOT_HERE]"] entries are filtered out → nil + assert.Nil(t, cfg.ApiKeys) +} + +// ═══════════════════════════════════════════════════ +// enc:// token: encrypt → store → merge → decrypt +// ═══════════════════════════════════════════════════ + +func TestChannel_EncryptedToken(t *testing.T) { + mustSetupSSHKey(t) + + const testPassphrase = "test-passphrase-123" + const plainToken = "123456:MY-SECRET-TOKEN" + + // Encrypt the token to get an enc:// string + encrypted, err := credential.Encrypt(testPassphrase, "", plainToken) + require.NoError(t, err) + require.True(t, strings.HasPrefix(encrypted, "enc://"), "expected enc:// prefix, got: %s", encrypted) + t.Logf("encrypted token: %s", encrypted) + + // Replace PassphraseProvider so SecureString.fromRaw can decrypt + orig := credential.PassphraseProvider + credential.PassphraseProvider = func() string { return testPassphrase } + t.Cleanup(func() { credential.PassphraseProvider = orig }) + + // Step 1: Load from extend.json (token is [NOT_HERE]) + jsonData := `{ + "enabled": true, + "type": "telegram", + "settings": { + "base_url": "https://api.telegram.org", + "use_markdown_v2": true, + "token": "[NOT_HERE]" + } + }` + var ch Channel + require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) + + // ── Scenario: security.yml stores enc:// token ── + yamlData := ` +settings: + token: ` + encrypted + ` +` + // Step 2: Merge enc:// token from security.yml + require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch)) + + // Step 3: Decode — SecureString.fromRaw resolves enc:// → plaintext + var cfg testTelegramConfig + require.NoError(t, ch.Decode(&cfg)) + assert.Equal(t, "https://api.telegram.org", cfg.BaseURL) + assert.True(t, cfg.UseMarkdownV2) + // The key assertion: enc:// is decrypted to the original plaintext + assert.Equal(t, plainToken, cfg.Token.String(), + "SecureString should resolve enc:// to the original plaintext token") + + // Step 4: Save extend.json → token masked as [NOT_HERE] + outJSON, err := json.MarshalIndent(ch, "", " ") + require.NoError(t, err) + assert.NotContains(t, string(outJSON), "token") + assert.NotContains(t, string(outJSON), plainToken) + assert.NotContains(t, string(outJSON), "enc://") + + // Step 5: Save security.yml → token preserved as enc:// + outYAML, err := yaml.Marshal(ch) + require.NoError(t, err) + t.Logf("Saved security.yml:\n%s", string(outYAML)) + assert.Contains(t, string(outYAML), encrypted) + assert.NotContains(t, string(outYAML), plainToken) + assert.NotContains(t, string(outYAML), "NOT_HERE") + assert.NotContains(t, string(outYAML), "base_url") +} + +// ═══════════════════════════════════════════════════ +// enc:// token directly in extend.json (edge case) +// ═══════════════════════════════════════════════════ + +func TestChannel_EncryptedTokenInJSON(t *testing.T) { + mustSetupSSHKey(t) + + const testPassphrase = "json-enc-passphrase" + const plainToken = "BOT-TOKEN-FROM-JSON" + const plainToken2 = "new token2" + + encrypted, err := credential.Encrypt(testPassphrase, "", plainToken) + require.NoError(t, err) + + orig := credential.PassphraseProvider + credential.PassphraseProvider = func() string { return testPassphrase } + t.Cleanup(func() { credential.PassphraseProvider = orig }) + + // extend.json with enc:// token directly (no merge needed) + jsonData := `{ + "enabled": true, + "type": "telegram", + "settings": { + "base_url": "https://api.telegram.org", + "token": ` + `"` + encrypted + `"` + ` + } + }` + t.Logf("JSON data:\n%s", jsonData) + var ch Channel + require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) + + var cfg testTelegramConfig + require.NoError(t, ch.Decode(&cfg)) + assert.Equal(t, plainToken, cfg.Token.String(), + "enc:// token in JSON should be decrypted correctly") + + cfg.Token.Set(plainToken2) + // No explicit Encode needed — Decode stored &cfg, so modifications are + // automatically reflected in MarshalJSON/MarshalYAML. + + // Save JSON → masked as [NOT_HERE] + outJSON, err := json.MarshalIndent(ch, "", " ") + require.NoError(t, err) + t.Logf("Saved extend.json:\n%s", string(outJSON)) + assert.NotContains(t, string(outJSON), "token") + assert.NotContains(t, string(outJSON), plainToken2) + assert.NotContains(t, string(outJSON), "enc://") + + // Save YAML → only token, re-encrypted + outYAML, err := yaml.Marshal(ch) + require.NoError(t, err) + t.Logf("Saved security.yml:\n%s", string(outYAML)) + // MarshalYAML re-encrypts with a new random salt/nonce, so verify via round-trip + assert.Contains(t, string(outYAML), "enc://") + + // Round-trip: unmarshal YAML output through Channel and verify decryption + var ch2 Channel + require.NoError(t, yaml.Unmarshal(outYAML, &ch2)) + var cfg2 testTelegramConfig + require.NoError(t, ch2.Decode(&cfg2)) + assert.Equal(t, plainToken2, cfg2.Token.String()) +} + +// ═══════════════════════════════════════════════════ +// enc:// token with missing passphrase → error +// ═══════════════════════════════════════════════════ + +func TestChannel_EncryptedToken_NoPassphrase(t *testing.T) { + mustSetupSSHKey(t) + + const testPassphrase = "will-be-removed" + encrypted, err := credential.Encrypt(testPassphrase, "", "secret-token") + require.NoError(t, err) + + // Ensure no passphrase is available + orig := credential.PassphraseProvider + credential.PassphraseProvider = func() string { return "" } + t.Cleanup(func() { credential.PassphraseProvider = orig }) + + jsonData := `{ + "enabled": true, + "type": "telegram", + "settings": { + "base_url": "https://api.telegram.org", + "token": ` + `"` + encrypted + `"` + ` + } + }` + var ch Channel + require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) + + var cfg testTelegramConfig + // Decode should fail because enc:// cannot be decrypted without passphrase + err = ch.Decode(&cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "passphrase required") +} + +// ─── helper ─── + +func mustParseRawNode(s string) RawNode { + return RawNode(s) +} diff --git a/pkg/config/config_old.go b/pkg/config/config_old.go new file mode 100644 index 000000000..c19620427 --- /dev/null +++ b/pkg/config/config_old.go @@ -0,0 +1,623 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package config + +import "strings" + +// isProvidersMapEmpty checks if a providers map has any non-empty provider configurations. +func isProvidersMapEmpty(providers map[string]any) bool { + for _, prov := range providers { + if provMap, ok := prov.(map[string]any); ok { + if apiKey, ok := provMap["api_key"]; ok && apiKey != "" { + return false + } + if apiBase, ok := provMap["api_base"]; ok && apiBase != "" { + return false + } + if connectMode, ok := provMap["connect_mode"]; ok && connectMode != "" { + return false + } + if authMethod, ok := provMap["auth_method"]; ok && authMethod != "" { + return false + } + } + } + return true +} + +// v0ProvidersMapToModelList converts a V0 providers map to a model_list slice. +func v0ProvidersMapToModelList(providers map[string]any, userProvider, userModel string) []any { + // providerMigration defines migration rules for a provider + type providerMigration struct { + jsonKeys []string + protocol string + defModel string + extractFn func(prov map[string]any) map[string]any + } + + migrations := []providerMigration{ + { + jsonKeys: []string{"openai", "gpt"}, + protocol: "openai", + defModel: "openai/gpt-5.4", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + if v, ok := prov["auth_method"]; ok && v != "" { + entry["auth_method"] = v + } + if v, ok := prov["web_search"]; ok && v != false { + entry["web_search"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"anthropic", "claude"}, + protocol: "anthropic", + defModel: "anthropic/claude-sonnet-4.6", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + if v, ok := prov["auth_method"]; ok && v != "" { + entry["auth_method"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"litellm"}, + protocol: "litellm", + defModel: "litellm/auto", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"openrouter"}, + protocol: "openrouter", + defModel: "openrouter/auto", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"groq"}, + protocol: "groq", + defModel: "groq/llama-3.1-70b-versatile", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"zhipu", "glm"}, + protocol: "zhipu", + defModel: "zhipu/glm-4", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"vllm"}, + protocol: "vllm", + defModel: "vllm/auto", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"gemini", "google"}, + protocol: "gemini", + defModel: "gemini/gemini-pro", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"nvidia"}, + protocol: "nvidia", + defModel: "nvidia/meta/llama-3.1-8b-instruct", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"ollama"}, + protocol: "ollama", + defModel: "ollama/llama3", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"moonshot", "kimi"}, + protocol: "moonshot", + defModel: "moonshot/kimi", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"shengsuanyun"}, + protocol: "shengsuanyun", + defModel: "shengsuanyun/auto", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"deepseek"}, + protocol: "deepseek", + defModel: "deepseek/deepseek-chat", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"cerebras"}, + protocol: "cerebras", + defModel: "cerebras/llama-3.3-70b", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"vivgrid"}, + protocol: "vivgrid", + defModel: "vivgrid/auto", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"volcengine", "doubao"}, + protocol: "volcengine", + defModel: "volcengine/doubao-pro", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"github_copilot", "copilot"}, + protocol: "github-copilot", + defModel: "github-copilot/gpt-5.4", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["connect_mode"]; ok && v != "" { + entry["connect_mode"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"antigravity"}, + protocol: "antigravity", + defModel: "antigravity/gemini-2.0-flash", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["auth_method"]; ok && v != "" { + entry["auth_method"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"qwen", "tongyi"}, + protocol: "qwen", + defModel: "qwen/qwen-max", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"mistral"}, + protocol: "mistral", + defModel: "mistral/mistral-small-latest", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"avian"}, + protocol: "avian", + defModel: "avian/deepseek/deepseek-v3.2", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"minimax"}, + protocol: "minimax", + defModel: "minimax/minimax", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"longcat"}, + protocol: "longcat", + defModel: "longcat/LongCat-Flash-Thinking", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"modelscope"}, + protocol: "modelscope", + defModel: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + { + jsonKeys: []string{"novita"}, + protocol: "novita", + defModel: "novita/auto", + extractFn: func(prov map[string]any) map[string]any { + entry := make(map[string]any) + if v, ok := prov["api_key"]; ok && v != "" { + entry["api_key"] = v + } + if v, ok := prov["api_base"]; ok && v != "" { + entry["api_base"] = v + } + if v, ok := prov["proxy"]; ok && v != "" { + entry["proxy"] = v + } + if v, ok := prov["request_timeout"]; ok && v != nil { + entry["request_timeout"] = v + } + return entry + }, + }, + } + + // We need access to agents.defaults for user provider/model, but we only have providers map + // This function is called with just the providers map, so we can't access agents.defaults + // The caller (migrateV0ToV1) would need to pass this information if needed + // For now, we skip the user provider/model matching + + var result []any + + for _, migration := range migrations { + // Find the provider in the providers map + var provData map[string]any + found := false + for _, key := range migration.jsonKeys { + if v, ok := providers[key]; ok { + if provMap, ok := v.(map[string]any); ok { + provData = provMap + found = true + break + } + } + } + if !found { + continue + } + + // Extract fields using the extraction function + entry := migration.extractFn(provData) + if len(entry) == 0 { + continue + } + + // Add model_name and model + entry["model_name"] = migration.jsonKeys[0] + + // Use the user's model if the provider matches, otherwise use the default + modelToUse := migration.defModel + if userProvider != "" && userModel != "" { + for _, key := range migration.jsonKeys { + if userProvider == key { + // Build the model string with protocol prefix if needed + if !strings.Contains(userModel, "/") { + modelToUse = migration.protocol + "/" + userModel + } else { + modelToUse = userModel + } + break + } + } + } + entry["model"] = modelToUse + + result = append(result, entry) + } + + return result +} diff --git a/pkg/config/config_struct.go b/pkg/config/config_struct.go new file mode 100644 index 000000000..65cfeb107 --- /dev/null +++ b/pkg/config/config_struct.go @@ -0,0 +1,733 @@ +package config + +import ( + "encoding/json" + "fmt" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + + "gopkg.in/yaml.v3" + + "github.com/sipeed/picoclaw/pkg/credential" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// FlexibleStringSlice is a []string that also accepts JSON numbers, +// so allow_from can contain both "123" and 123. +// It also supports parsing comma-separated strings from environment variables, +// including both English (,) and Chinese (,) commas. +type FlexibleStringSlice []string + +func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *f = nil + return nil + } + + // Accept a single JSON string for convenience, e.g.: + // "text": "Thinking..." + var singleString string + if err := json.Unmarshal(data, &singleString); err == nil { + *f = FlexibleStringSlice{singleString} + return nil + } + + // Accept a single JSON number too, to keep symmetry with mixed allow_from + // payloads that may contain numeric identifiers. + var singleNumber float64 + if err := json.Unmarshal(data, &singleNumber); err == nil { + *f = FlexibleStringSlice{fmt.Sprintf("%.0f", singleNumber)} + return nil + } + + // Try []string first + var ss []string + if err := json.Unmarshal(data, &ss); err == nil { + *f = ss + return nil + } + + // Try []interface{} to handle mixed types + var raw []any + if err := json.Unmarshal(data, &raw); err != nil { + var s string + // fail over to compatible to old format string + if err = json.Unmarshal(data, &s); err != nil { + return err + } + *f = []string{s} + return nil + } + + result := make([]string, 0, len(raw)) + for _, v := range raw { + switch val := v.(type) { + case string: + result = append(result, val) + case float64: + result = append(result, fmt.Sprintf("%.0f", val)) + default: + result = append(result, fmt.Sprintf("%v", val)) + } + } + *f = result + return nil +} + +// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing. +// It handles comma-separated values with both English (,) and Chinese (,) commas. +func (f *FlexibleStringSlice) UnmarshalText(text []byte) error { + if len(text) == 0 { + *f = nil + return nil + } + + s := string(text) + // Replace Chinese comma with English comma, then split + s = strings.ReplaceAll(s, ",", ",") + parts := strings.Split(s, ",") + + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + *f = result + return nil +} + +const ( + notHere = `"[NOT_HERE]"` +) + +// SecureStrings is a slice of SecureString +// +//nolint:recvcheck +type SecureStrings []*SecureString + +// IsZero returns true if the SecureStrings is nil or empty. +func (s SecureStrings) IsZero() bool { + if !callerFromYaml() { + return true + } + return len(s) == 0 +} + +// Values returns the decrypted/resolved values +func (s *SecureStrings) Values() []string { + if s == nil { + return nil + } + keys := make([]string, len(*s)) + for i, k := range *s { + keys[i] = k.String() + } + return unique(keys) +} + +func SimpleSecureStrings(val ...string) SecureStrings { + val = unique(val) + vv := make(SecureStrings, len(val)) + for i, s := range val { + vv[i] = NewSecureString(s) + } + return vv +} + +// unique returns a new slice with duplicate elements removed. +func unique[T comparable](input []T) []T { + m := make(map[T]struct{}) + var result []T + for _, v := range input { + if _, ok := m[v]; !ok { + m[v] = struct{}{} + result = append(result, v) + } + } + return result +} + +func (s SecureStrings) MarshalJSON() ([]byte, error) { + return []byte(notHere), nil +} + +func (s *SecureStrings) UnmarshalJSON(value []byte) error { + if string(value) == notHere { + return nil + } + var v []*SecureString + err := json.Unmarshal(value, &v) + if err != nil { + return err + } + // Filter out elements where SecureString.UnmarshalJSON was a no-op + // (e.g. "[NOT_HERE]" entries), keeping only actually populated values. + filtered := make(SecureStrings, 0, len(v)) + for _, ss := range v { + if ss == nil { + continue + } + if ss.resolved != "" || ss.raw != "" { + filtered = append(filtered, ss) + } + } + if len(filtered) == 0 { + *s = nil + } else { + *s = filtered + } + return nil +} + +// SecureString the string value that can be decrypted or resolved +// +//nolint:recvcheck +type SecureString struct { + resolved string // Decrypted/resolved value returned by String() + raw string // Persisted raw value (enc://, file://, or plaintext) +} + +func callerFromYaml() bool { + _, file, _, ok := runtime.Caller(2) + if ok { + d := filepath.Dir(file) + // check the caller is from yaml.v + if !strings.Contains(d, "yaml.v") { + return false + } + } + return true +} + +// IsZero returns true if the SecureString is empty +// if caller not yaml, just return true for prevent marshal this field +func (s SecureString) IsZero() bool { + if !callerFromYaml() { + return true + } + return s.resolved == "" +} + +func NewSecureString(value string) *SecureString { + s := &SecureString{} + if err := s.fromRaw(value); err != nil { + logger.Warn(fmt.Sprintf("NewSecureString.fromRaw error: %s", err)) + } + return s +} + +func (s *SecureString) String() string { + if s == nil { + return "" + } + return s.resolved +} + +func (s *SecureString) Set(value string) *SecureString { + s.resolved = value + s.raw = "" + return s +} + +func (s SecureString) MarshalJSON() ([]byte, error) { + return []byte(notHere), nil +} + +func (s *SecureString) UnmarshalJSON(value []byte) error { + if string(value) == notHere { + return nil + } + var v string + if err := json.Unmarshal(value, &v); err != nil { + return err + } + return s.fromRaw(v) +} + +func (s SecureString) MarshalYAML() (any, error) { + // Preserve raw value if it is already a reference (enc:// or file://) + if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) { + return s.raw, nil + } + // If resolved is a reference format (e.g. set via Set), copy back to raw + if strings.HasPrefix(s.resolved, credential.EncScheme) || strings.HasPrefix(s.resolved, credential.FileScheme) { + s.raw = s.resolved + return s.raw, nil + } + // Try to encrypt the resolved value + if passphrase := credential.PassphraseProvider(); passphrase != "" { + encrypted, err := credential.Encrypt(passphrase, "", s.resolved) + if err != nil { + logger.Errorf("Encrypt error: %v", err) + return nil, err + } + s.raw = encrypted + } else { + s.raw = s.resolved + } + return s.raw, nil +} + +func (s *SecureString) UnmarshalYAML(value *yaml.Node) error { + return s.fromRaw(value.Value) +} + +func (s *SecureString) fromRaw(v string) error { + s.raw = v + vv, err := resolveKey(v) + if err != nil { + return err + } + s.resolved = vv + return nil +} + +var ( + secResolverMu sync.RWMutex + secResolver *credential.Resolver +) + +func updateResolver(path string) { + secResolverMu.Lock() + defer secResolverMu.Unlock() + secResolver = credential.NewResolver(path) +} + +func resolveKey(v string) (string, error) { + secResolverMu.RLock() + resolver := secResolver + secResolverMu.RUnlock() + if resolver == nil { + resolver = credential.NewResolver("") + } + if strings.HasPrefix(v, "enc://") || strings.HasPrefix(v, "file://") { + decrypted, err := resolver.Resolve(v) + if err != nil { + logger.Errorf("Resolve error: %v", err) + return "", err + } + return decrypted, nil + } + return v, nil +} + +func (s *SecureString) UnmarshalText(text []byte) error { + v := string(text) + return s.fromRaw(v) +} + +type SecureModelList []*ModelConfig + +func (v *SecureModelList) UnmarshalYAML(value *yaml.Node) error { + mm := make(map[string]*ModelConfig) + if err := value.Decode(&mm); err != nil { + logger.Errorf("Decode error: %v", err) + return err + } + nameList := toNameIndex(*v) + for i, m := range *v { + sec := mm[nameList[i]] + if sec == nil { + sec = mm[m.ModelName] + } + if sec != nil { + m.APIKeys = sec.APIKeys + } + } + return nil +} + +func (v SecureModelList) MarshalYAML() (any, error) { + type onlySecureData struct { + APIKeys SecureStrings `yaml:"api_keys,omitempty"` + } + mm := make(map[string]onlySecureData) + nameList := toNameIndex(v) + for i, m := range v { + mm[nameList[i]] = onlySecureData{ + APIKeys: m.APIKeys, + } + } + + return mm, nil +} + +func (v *SkillsRegistriesConfig) UnmarshalJSON(data []byte) error { + var list []json.RawMessage + if err := json.Unmarshal(data, &list); err == nil { + decodedList := make([]*SkillRegistryConfig, 0, len(list)) + for _, item := range list { + var nameOnly struct { + Name string `json:"name"` + } + if err := json.Unmarshal(item, &nameOnly); err != nil { + return err + } + registry := cloneRegistryConfig(findRegistryConfigByName(*v, nameOnly.Name)) + if registry == nil { + registry = &SkillRegistryConfig{Name: nameOnly.Name} + } + if err := json.Unmarshal(item, registry); err != nil { + return err + } + decodedList = append(decodedList, registry) + } + if len(*v) > 0 { + for _, registry := range decodedList { + if registry == nil { + continue + } + v.Set(registry.Name, *registry) + } + return nil + } + *v = decodedList + return nil + } + + legacy := map[string]json.RawMessage{} + if err := json.Unmarshal(data, &legacy); err != nil { + return err + } + + if len(*v) == 0 { + keys := make([]string, 0, len(legacy)) + for name := range legacy { + keys = append(keys, name) + } + sort.Strings(keys) + decodedList := make([]*SkillRegistryConfig, 0, len(keys)) + for _, name := range keys { + var registry SkillRegistryConfig + if err := json.Unmarshal(legacy[name], ®istry); err != nil { + return err + } + registry.Name = name + decodedList = append(decodedList, ®istry) + } + *v = decodedList + return nil + } + + for _, name := range sortedRegistryNamesFromJSON(legacy) { + registry := cloneRegistryConfig(findRegistryConfigByName(*v, name)) + if registry == nil { + registry = &SkillRegistryConfig{Name: name} + } + if err := json.Unmarshal(legacy[name], registry); err != nil { + return err + } + registry.Name = name + v.Set(name, *registry) + } + return nil +} + +func (v SkillsRegistriesConfig) MarshalJSON() ([]byte, error) { + if v == nil { + return []byte("null"), nil + } + mm := make(map[string]SkillRegistryConfig, len(v)) + for _, registry := range v { + if registry == nil || registry.Name == "" { + continue + } + mm[registry.Name] = *registry + } + return json.Marshal(mm) +} + +func (c *SkillRegistryConfig) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + params := cloneRegistryParams(c.Param) + if params == nil { + params = map[string]any{} + } + if value, ok := raw["name"]; ok { + if err := json.Unmarshal(value, &c.Name); err != nil { + return err + } + } + if value, ok := raw["enabled"]; ok { + if err := json.Unmarshal(value, &c.Enabled); err != nil { + return err + } + } + if value, ok := raw["base_url"]; ok { + if err := json.Unmarshal(value, &c.BaseURL); err != nil { + return err + } + } + if value, ok := raw["auth_token"]; ok { + if err := json.Unmarshal(value, &c.AuthToken); err != nil { + return err + } + } + if value, ok := raw["param"]; ok { + var nested map[string]any + if err := json.Unmarshal(value, &nested); err != nil { + return err + } + for key, nestedValue := range nested { + params[key] = nestedValue + } + } + for key, value := range raw { + switch key { + case "name", "enabled", "base_url", "auth_token", "param": + continue + case "_auth_token": + // UI/API shadow secret fields should hydrate SecureString only and must + // never be persisted as arbitrary registry params. + continue + default: + var decoded any + if err := json.Unmarshal(value, &decoded); err != nil { + return err + } + params[key] = decoded + } + } + c.Param = params + return nil +} + +func (c SkillRegistryConfig) MarshalJSON() ([]byte, error) { + m := map[string]any{ + "enabled": c.Enabled, + "base_url": c.BaseURL, + } + if c.AuthToken.String() != "" { + m["auth_token"] = c.AuthToken + } + for key, value := range c.Param { + if key == "" || key == "param" || strings.HasPrefix(key, "_") { + continue + } + if _, exists := m[key]; exists { + continue + } + m[key] = value + } + return json.Marshal(m) +} + +func (c *SkillRegistryConfig) UnmarshalYAML(value *yaml.Node) error { + var raw map[string]any + if err := value.Decode(&raw); err != nil { + return err + } + params := cloneRegistryParams(c.Param) + if params == nil { + params = map[string]any{} + } + if nested, ok := raw["param"].(map[string]any); ok { + for k, v := range nested { + params[k] = v + } + } + for key, v := range raw { + switch key { + case "name": + if s, ok := v.(string); ok { + c.Name = s + } + case "enabled": + if b, ok := v.(bool); ok { + c.Enabled = b + } + case "base_url": + if s, ok := v.(string); ok { + c.BaseURL = s + } + case "auth_token": + data, err := yaml.Marshal(v) + if err != nil { + return err + } + if err := yaml.Unmarshal(data, &c.AuthToken); err != nil { + return err + } + case "_auth_token": + // UI/API shadow secret fields should hydrate SecureString only and must + // never be persisted as arbitrary registry params. + continue + case "param": + continue + default: + params[key] = v + } + } + c.Param = params + return nil +} + +func (c SkillRegistryConfig) MarshalYAML() (any, error) { + m := map[string]any{ + "enabled": c.Enabled, + "base_url": c.BaseURL, + } + if c.AuthToken.String() != "" { + m["auth_token"] = c.AuthToken + } + keys := make([]string, 0, len(c.Param)) + for key := range c.Param { + if key == "" || key == "param" || strings.HasPrefix(key, "_") { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if _, exists := m[key]; exists { + continue + } + m[key] = c.Param[key] + } + return m, nil +} + +func (v *SkillsRegistriesConfig) UnmarshalYAML(value *yaml.Node) error { + decoded, err := decodeRegistryNodesFromYAML(value, nil) + if err != nil { + logger.Errorf("Decode error: %v", err) + return err + } + if len(*v) == 0 { + keys := make([]string, 0, len(decoded)) + for name := range decoded { + keys = append(keys, name) + } + sort.Strings(keys) + list := make([]*SkillRegistryConfig, 0, len(keys)) + for _, name := range keys { + registry := decoded[name] + if registry == nil { + continue + } + list = append(list, registry) + } + *v = list + return nil + } + decoded, err = decodeRegistryNodesFromYAML(value, *v) + if err != nil { + logger.Errorf("Decode error: %v", err) + return err + } + for _, name := range sortedRegistryNames(decoded) { + registry := decoded[name] + if registry == nil { + continue + } + v.Set(name, *registry) + } + return nil +} + +func decodeRegistryNodesFromYAML( + value *yaml.Node, + existing SkillsRegistriesConfig, +) (map[string]*SkillRegistryConfig, error) { + decoded := make(map[string]*SkillRegistryConfig) + if value == nil { + return decoded, nil + } + for i := 0; i+1 < len(value.Content); i += 2 { + nameNode := value.Content[i] + registryNode := value.Content[i+1] + if nameNode == nil || registryNode == nil { + continue + } + name := strings.TrimSpace(nameNode.Value) + if name == "" { + continue + } + registry := cloneRegistryConfig(findRegistryConfigByName(existing, name)) + if registry == nil { + registry = &SkillRegistryConfig{Name: name} + } + if err := registryNode.Decode(registry); err != nil { + return nil, err + } + registry.Name = name + decoded[name] = registry + } + return decoded, nil +} + +func cloneRegistryParams(src map[string]any) map[string]any { + if src == nil { + return nil + } + cloned := make(map[string]any, len(src)) + for key, value := range src { + cloned[key] = value + } + return cloned +} + +func cloneRegistryConfig(src *SkillRegistryConfig) *SkillRegistryConfig { + if src == nil { + return nil + } + cloned := *src + cloned.Param = cloneRegistryParams(src.Param) + return &cloned +} + +func findRegistryConfigByName(registries SkillsRegistriesConfig, name string) *SkillRegistryConfig { + for _, registry := range registries { + if registry == nil || registry.Name != name { + continue + } + return registry + } + return nil +} + +func sortedRegistryNames(mm map[string]*SkillRegistryConfig) []string { + keys := make([]string, 0, len(mm)) + for name := range mm { + keys = append(keys, name) + } + sort.Strings(keys) + return keys +} + +func sortedRegistryNamesFromJSON(mm map[string]json.RawMessage) []string { + keys := make([]string, 0, len(mm)) + for name := range mm { + keys = append(keys, name) + } + sort.Strings(keys) + return keys +} + +func (v SkillsRegistriesConfig) MarshalYAML() (any, error) { + type onlySecureRegistryData struct { + AuthToken SecureString `yaml:"auth_token,omitempty"` + } + mm := make(map[string]onlySecureRegistryData) + for _, registry := range v { + if registry == nil || registry.Name == "" { + continue + } + if registry.AuthToken.String() == "" { + continue + } + mm[registry.Name] = onlySecureRegistryData{ + AuthToken: registry.AuthToken, + } + } + + return mm, nil +} diff --git a/pkg/config/config_struct_test.go b/pkg/config/config_struct_test.go new file mode 100644 index 000000000..dc35d14f3 --- /dev/null +++ b/pkg/config/config_struct_test.go @@ -0,0 +1,404 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/caarlos0/env/v11" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" + + "github.com/sipeed/picoclaw/pkg/credential" +) + +func TestLoadSecurityValue(t *testing.T) { + type valueStruct struct { + Url string `json:"url,omitempty" yaml:"-"` + Token *SecureString `json:"token,omitempty" yaml:"token,omitempty" env:"PICO_TOKEN"` + ApiKeys SecureStrings `json:"api_keys,omitempty" yaml:"api_keys,omitempty" env:"PICO_API_KEYS"` + } + + type testStruct struct { + Pico *valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"` + } + + v1 := &testStruct{ + Pico: &valueStruct{ + Url: "https://example.com", + Token: NewSecureString("token1"), + ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")}, + }, + } + bytes, err := yaml.Marshal(v1) + assert.NoError(t, err) + jsonBytes, err := json.Marshal(v1) + assert.NoError(t, err) + const want = `pico: + token: token1 + api_keys: + - api-key1 + - api-key2 +` + const jsonPost = `{"pico":{"url":"https://example.com","token":"token0"}}` + v0 := &testStruct{} + err = json.Unmarshal([]byte(jsonPost), v0) + assert.NoError(t, err) + assert.Equal(t, "https://example.com", v0.Pico.Url) + assert.Equal(t, "token0", v0.Pico.Token.String()) + + const jsonWant = `{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}` + assert.Equal(t, want, string(bytes)) + assert.Equal(t, jsonWant, string(jsonBytes)) + + v2 := &testStruct{} + err = json.Unmarshal(jsonBytes, v2) + assert.NoError(t, err) + err = yaml.Unmarshal(bytes, v2) + assert.NoError(t, err) + assert.Equal(t, "https://example.com", v2.Pico.Url) + if v2.Pico.Token != nil { + assert.Equal(t, "token1", v2.Pico.Token.String()) + assert.Equal(t, "token1", v2.Pico.Token.raw) + } + + v2.Pico.Token = NewSecureString("token1") + v2.Pico.Token.raw = "abc" + err = yaml.Unmarshal(bytes, v2) + assert.NoError(t, err) + assert.Equal(t, "token1", v2.Pico.Token.raw) + + os.Setenv("PICO_TOKEN", "token_env") + err = env.Parse(v2) + assert.NoError(t, err) + assert.NotNil(t, v2.Pico.Token) + assert.Equal(t, "token1", v2.Pico.Token.String()) + + v3 := &testStruct{Pico: &valueStruct{}} + err = env.Parse(v3) + assert.NoError(t, err) + if v3.Pico.Token != nil { + assert.Equal(t, "token_env", v3.Pico.Token.String()) + } + + type toolsStruct struct { + Pico valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"` + } + + type testStruct2 struct { + Tools toolsStruct `json:"tools,omitempty" yaml:",inline"` + } + + v4 := &testStruct2{ + Tools: toolsStruct{ + Pico: valueStruct{ + Url: "https://example.com", + Token: NewSecureString("token1"), + ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")}, + }, + }, + } + bytes, err = yaml.Marshal(v4) + assert.NoError(t, err) + assert.Equal(t, want, string(bytes)) + jsonBytes, err = json.Marshal(v4) + assert.NoError(t, err) + assert.Equal( + t, + `{"tools":{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}}`, + string(jsonBytes), + ) + + v5 := &testStruct2{} + err = json.Unmarshal(jsonBytes, v5) + assert.NoError(t, err) + assert.Equal(t, "https://example.com", v5.Tools.Pico.Url) + err = yaml.Unmarshal(bytes, v5) + assert.NoError(t, err) + assert.NotNil(t, v5.Tools.Pico.Token) + assert.Equal(t, "token1", v5.Tools.Pico.Token.raw) + + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err = os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + const passphrase = "test-passphrase-32bytes-long-ok!" + + t.Setenv(credential.SSHKeyPathEnvVar, sshKeyPath) + + t.Setenv(credential.PassphraseEnvVar, passphrase) + + v5.Tools.Pico.Token.Set("newtoken1") + v5.Tools.Pico.ApiKeys[0].Set("newapi-key1") + bytes, err = yaml.Marshal(v5) + assert.NoError(t, err) + t.Logf("yaml: %s", string(bytes)) + + v6 := &testStruct2{} + err = yaml.Unmarshal(bytes, v6) + assert.NoError(t, err) + assert.NotNil(t, v6.Tools.Pico.Token) + assert.Equal(t, "newtoken1", v6.Tools.Pico.Token.String()) +} + +func TestSkillRegistryConfigDecodeParam(t *testing.T) { + registry := SkillRegistryConfig{ + Name: "github", + Param: map[string]any{ + "proxy": "http://127.0.0.1:7890", + }, + } + + var private struct { + Proxy string `json:"proxy"` + } + err := registry.DecodeParam(&private) + assert.NoError(t, err) + assert.Equal(t, "http://127.0.0.1:7890", private.Proxy) +} + +func TestSkillRegistryConfigJSONFlattensParam(t *testing.T) { + registry := SkillRegistryConfig{ + Name: "github", + Enabled: true, + BaseURL: "https://github.com", + Param: map[string]any{ + "proxy": "http://127.0.0.1:7890", + }, + } + + data, err := json.Marshal(registry) + assert.NoError(t, err) + assert.Contains(t, string(data), `"proxy":"http://127.0.0.1:7890"`) + assert.NotContains(t, string(data), `"param"`) + + var loaded SkillRegistryConfig + err = json.Unmarshal(data, &loaded) + assert.NoError(t, err) + assert.Equal(t, "http://127.0.0.1:7890", loaded.Param["proxy"]) +} + +func TestSkillRegistryConfigJSONIgnoresShadowSecretFields(t *testing.T) { + var registry SkillRegistryConfig + err := json.Unmarshal([]byte(`{ + "enabled": true, + "base_url": "https://github.com", + "_auth_token": "shadow-secret", + "proxy": "http://127.0.0.1:7890" + }`), ®istry) + assert.NoError(t, err) + assert.Equal(t, "https://github.com", registry.BaseURL) + assert.Equal(t, "http://127.0.0.1:7890", registry.Param["proxy"]) + _, exists := registry.Param["_auth_token"] + assert.False(t, exists) + + registry.Param["_auth_token"] = "should-not-round-trip" + data, err := json.Marshal(registry) + assert.NoError(t, err) + assert.NotContains(t, string(data), "_auth_token") + assert.Contains(t, string(data), `"proxy":"http://127.0.0.1:7890"`) + + yamlData, err := yaml.Marshal(registry) + assert.NoError(t, err) + assert.NotContains(t, string(yamlData), "_auth_token") + assert.Contains(t, string(yamlData), "proxy: http://127.0.0.1:7890") +} + +func TestSkillRegistryConfigYAMLIgnoresShadowSecretFields(t *testing.T) { + var registry SkillRegistryConfig + err := yaml.Unmarshal([]byte(` +enabled: true +base_url: https://github.com +_auth_token: shadow-secret +proxy: http://127.0.0.1:7890 +`), ®istry) + assert.NoError(t, err) + assert.Equal(t, "https://github.com", registry.BaseURL) + assert.Equal(t, "http://127.0.0.1:7890", registry.Param["proxy"]) + _, exists := registry.Param["_auth_token"] + assert.False(t, exists) +} + +func TestSkillsRegistriesConfigMarshalYAMLIncludesRegistryToken(t *testing.T) { + registries := SkillsRegistriesConfig{ + &SkillRegistryConfig{ + Name: "github", + AuthToken: *NewSecureString("registry-auth-token"), + }, + } + + data, err := yaml.Marshal(registries) + assert.NoError(t, err) + assert.Contains(t, string(data), "github:") + assert.Contains(t, string(data), "auth_token: registry-auth-token") + + loaded := SkillsRegistriesConfig{ + &SkillRegistryConfig{Name: "github"}, + } + err = yaml.Unmarshal(data, &loaded) + assert.NoError(t, err) + github, ok := loaded.Get("github") + assert.True(t, ok) + assert.Equal(t, "registry-auth-token", github.AuthToken.String()) +} + +func TestSkillsRegistriesConfigUnmarshalYAMLBuildsEntriesFromEmptySlice(t *testing.T) { + var registries SkillsRegistriesConfig + err := yaml.Unmarshal([]byte(`github: + enabled: true + base_url: https://ghe.example.com/git + proxy: http://127.0.0.1:7890 +`), ®istries) + assert.NoError(t, err) + + github, ok := registries.Get("github") + assert.True(t, ok) + assert.True(t, github.Enabled) + assert.Equal(t, "https://ghe.example.com/git", github.BaseURL) + assert.Equal(t, "http://127.0.0.1:7890", github.Param["proxy"]) +} + +func TestSkillsRegistriesConfigMarshalJSONPreservesObjectShape(t *testing.T) { + registries := SkillsRegistriesConfig{ + &SkillRegistryConfig{ + Name: "github", + Enabled: true, + BaseURL: "https://ghe.example.com/git", + Param: map[string]any{ + "proxy": "http://127.0.0.1:7890", + }, + }, + &SkillRegistryConfig{ + Name: "clawhub", + Enabled: true, + BaseURL: "https://clawhub.ai", + }, + } + + data, err := json.Marshal(registries) + assert.NoError(t, err) + assert.Contains(t, string(data), `"github":{`) + assert.Contains(t, string(data), `"clawhub":{`) + assert.NotContains(t, string(data), `[{`) + assert.NotContains(t, string(data), `"name":"github"`) + assert.NotContains(t, string(data), `"name":"clawhub"`) + + var decoded map[string]json.RawMessage + err = json.Unmarshal(data, &decoded) + assert.NoError(t, err) + assert.Contains(t, decoded, "github") + assert.Contains(t, decoded, "clawhub") + + var roundTripped SkillsRegistriesConfig + err = json.Unmarshal(data, &roundTripped) + assert.NoError(t, err) + + github, ok := roundTripped.Get("github") + assert.True(t, ok) + assert.Equal(t, "https://ghe.example.com/git", github.BaseURL) + assert.Equal(t, "http://127.0.0.1:7890", github.Param["proxy"]) + + clawhub, ok := roundTripped.Get("clawhub") + assert.True(t, ok) + assert.Equal(t, "https://clawhub.ai", clawhub.BaseURL) +} + +func TestSkillsRegistriesConfigUnmarshalJSONPreservesDefaultRegistries(t *testing.T) { + registries := DefaultConfig().Tools.Skills.Registries + + err := json.Unmarshal([]byte(`{ + "clawhub": { + "base_url": "https://clawhub.example.com" + } + }`), ®istries) + assert.NoError(t, err) + + clawhub, ok := registries.Get("clawhub") + assert.True(t, ok) + assert.True(t, clawhub.Enabled) + assert.Equal(t, "https://clawhub.example.com", clawhub.BaseURL) + + github, ok := registries.Get("github") + assert.True(t, ok) + assert.True(t, github.Enabled) + assert.Equal(t, "https://github.com", github.BaseURL) + assert.Empty(t, github.Param) +} + +func TestSkillsRegistriesConfigUnmarshalJSONListPreservesDefaultRegistries(t *testing.T) { + registries := DefaultConfig().Tools.Skills.Registries + + err := json.Unmarshal([]byte(`[ + { + "name": "clawhub", + "base_url": "https://clawhub.example.com" + } + ]`), ®istries) + assert.NoError(t, err) + + clawhub, ok := registries.Get("clawhub") + assert.True(t, ok) + assert.True(t, clawhub.Enabled) + assert.Equal(t, "https://clawhub.example.com", clawhub.BaseURL) + + github, ok := registries.Get("github") + assert.True(t, ok) + assert.True(t, github.Enabled) + assert.Equal(t, "https://github.com", github.BaseURL) + assert.Empty(t, github.Param) +} + +func TestSkillsRegistriesConfigUnmarshalYAMLAppendsNewRegistryToExistingSlice(t *testing.T) { + registries := DefaultConfig().Tools.Skills.Registries + + err := yaml.Unmarshal([]byte(`custom: + base_url: https://skills.example.com + auth_token: custom-token +`), ®istries) + assert.NoError(t, err) + + custom, ok := registries.Get("custom") + assert.True(t, ok) + assert.Equal(t, "https://skills.example.com", custom.BaseURL) + assert.Equal(t, "custom-token", custom.AuthToken.String()) + + github, ok := registries.Get("github") + assert.True(t, ok) + assert.Equal(t, "https://github.com", github.BaseURL) +} + +func TestSkillsRegistriesConfigUnmarshalYAMLOverridesDefaultRegistryFields(t *testing.T) { + registries := DefaultConfig().Tools.Skills.Registries + + err := yaml.Unmarshal([]byte(`github: + enabled: false + base_url: https://ghe.example.com/git + proxy: http://127.0.0.1:7890 +`), ®istries) + assert.NoError(t, err) + + github, ok := registries.Get("github") + assert.True(t, ok) + assert.False(t, github.Enabled) + assert.Equal(t, "https://ghe.example.com/git", github.BaseURL) + assert.Equal(t, "http://127.0.0.1:7890", github.Param["proxy"]) +} + +func TestSkillsRegistriesConfigUnmarshalYAMLRetainsDefaultsForOmittedFields(t *testing.T) { + registries := DefaultConfig().Tools.Skills.Registries + + err := yaml.Unmarshal([]byte(`github: + auth_token: registry-token +`), ®istries) + assert.NoError(t, err) + + github, ok := registries.Get("github") + assert.True(t, ok) + assert.True(t, github.Enabled) + assert.Equal(t, "https://github.com", github.BaseURL) + assert.Equal(t, "registry-token", github.AuthToken.String()) + assert.Empty(t, github.Param) +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 47f79c6f0..4f1c5c5e8 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -7,8 +7,25 @@ import ( "runtime" "strings" "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" + + "github.com/sipeed/picoclaw/pkg/credential" ) +// mustSetupSSHKey generates a temporary Ed25519 SSH key in t.TempDir() and sets +// PICOCLAW_SSH_KEY_PATH to its path for the duration of the test. This is required +// whenever a test exercises encryption/decryption via credential.Encrypt or SaveConfig. +func mustSetupSSHKey(t *testing.T) { + t.Helper() + keyPath := filepath.Join(t.TempDir(), "picoclaw_ed25519.key") + if err := credential.GenerateSSHKey(keyPath); err != nil { + t.Fatalf("mustSetupSSHKey: %v", err) + } + t.Setenv("PICOCLAW_SSH_KEY_PATH", keyPath) +} + func TestAgentModelConfig_UnmarshalString(t *testing.T) { var m AgentModelConfig if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil { @@ -92,18 +109,8 @@ func TestAgentConfig_FullParse(t *testing.T) { } ] }, - "bindings": [ - { - "agent_id": "support", - "match": { - "channel": "telegram", - "account_id": "*", - "peer": {"kind": "direct", "id": "user123"} - } - } - ], "session": { - "dm_scope": "per-peer", + "dimensions": ["sender"], "identity_links": { "john": ["telegram:123", "discord:john#1234"] } @@ -141,19 +148,8 @@ func TestAgentConfig_FullParse(t *testing.T) { t.Errorf("support.Subagents = %+v", support.Subagents) } - if len(cfg.Bindings) != 1 { - t.Fatalf("bindings len = %d, want 1", len(cfg.Bindings)) - } - binding := cfg.Bindings[0] - if binding.AgentID != "support" || binding.Match.Channel != "telegram" { - t.Errorf("binding = %+v", binding) - } - if binding.Match.Peer == nil || binding.Match.Peer.Kind != "direct" || binding.Match.Peer.ID != "user123" { - t.Errorf("binding.Match.Peer = %+v", binding.Match.Peer) - } - - if cfg.Session.DMScope != "per-peer" { - t.Errorf("Session.DMScope = %q", cfg.Session.DMScope) + if len(cfg.Session.Dimensions) != 1 || cfg.Session.Dimensions[0] != "sender" { + t.Errorf("Session.Dimensions = %v", cfg.Session.Dimensions) } if len(cfg.Session.IdentityLinks) != 1 { t.Errorf("Session.IdentityLinks = %v", cfg.Session.IdentityLinks) @@ -164,6 +160,41 @@ func TestAgentConfig_FullParse(t *testing.T) { } } +func TestDefaultConfig_MCPMaxInlineTextChars(t *testing.T) { + cfg := DefaultConfig() + if cfg.Tools.MCP.GetMaxInlineTextChars() != DefaultMCPMaxInlineTextChars { + t.Fatalf( + "DefaultConfig().Tools.MCP.GetMaxInlineTextChars() = %d, want %d", + cfg.Tools.MCP.GetMaxInlineTextChars(), + DefaultMCPMaxInlineTextChars, + ) + } +} + +func TestLoadConfig_MCPMaxInlineTextChars(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + raw := `{ + "tools": { + "mcp": { + "enabled": true, + "max_inline_text_chars": 2048 + } + } + }` + if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil { + t.Fatalf("WriteFile(configPath): %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if got := cfg.Tools.MCP.GetMaxInlineTextChars(); got != 2048 { + t.Fatalf("cfg.Tools.MCP.GetMaxInlineTextChars() = %d, want 2048", got) + } +} + func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) { jsonData := `{ "agents": { @@ -184,8 +215,242 @@ func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) { if len(cfg.Agents.List) != 0 { t.Errorf("agents.list should be empty for backward compat, got %d", len(cfg.Agents.List)) } - if len(cfg.Bindings) != 0 { - t.Errorf("bindings should be empty, got %d", len(cfg.Bindings)) +} + +func TestAgentConfig_ParsesDispatchRules(t *testing.T) { + jsonData := `{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7" + }, + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "support-vip", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-100123", + "sender": "12345", + "mentioned": true + }, + "session_dimensions": ["chat", "sender"] + } + ] + } + } + }` + + cfg := DefaultConfig() + if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.Agents.Dispatch == nil { + t.Fatal("Agents.Dispatch should not be nil") + } + if len(cfg.Agents.Dispatch.Rules) != 1 { + t.Fatalf("Dispatch.Rules len = %d, want 1", len(cfg.Agents.Dispatch.Rules)) + } + rule := cfg.Agents.Dispatch.Rules[0] + if rule.Name != "support-vip" || rule.Agent != "support" { + t.Fatalf("rule = %+v", rule) + } + if rule.When.Channel != "telegram" || rule.When.Chat != "group:-100123" || rule.When.Sender != "12345" { + t.Fatalf("rule.When = %+v", rule.When) + } + if rule.When.Mentioned == nil || !*rule.When.Mentioned { + t.Fatalf("rule.When.Mentioned = %+v, want true", rule.When.Mentioned) + } + if got := rule.SessionDimensions; len(got) != 2 || got[0] != "chat" || got[1] != "sender" { + t.Fatalf("rule.SessionDimensions = %v, want [chat sender]", got) + } +} + +func TestLoadConfig_MigratesLegacyBindingsToDispatchRules(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + raw := `{ + "version": 2, + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7" + }, + "list": [ + { "id": "main", "default": true }, + { "id": "support" }, + { "id": "ops" }, + { "id": "slack" } + ] + }, + "bindings": [ + { + "agent_id": "support", + "match": { + "channel": "telegram", + "peer": { "kind": "group", "id": "-100123" } + } + }, + { + "agent_id": "ops", + "match": { + "channel": "discord", + "guild_id": "guild-1" + } + }, + { + "agent_id": "slack", + "match": { + "channel": "slack", + "account_id": "*" + } + } + ] + }` + if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil { + t.Fatalf("WriteFile(configPath): %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Agents.Dispatch == nil { + t.Fatal("Agents.Dispatch should not be nil") + } + if len(cfg.Agents.Dispatch.Rules) != 3 { + t.Fatalf("Dispatch.Rules len = %d, want 3", len(cfg.Agents.Dispatch.Rules)) + } + + first := cfg.Agents.Dispatch.Rules[0] + if first.Agent != "support" { + t.Fatalf("first.Agent = %q, want %q", first.Agent, "support") + } + if first.When.Channel != "telegram" || first.When.Chat != "group:-100123" { + t.Fatalf("first.When = %+v", first.When) + } + if first.When.Account != legacyDefaultAccountID { + t.Fatalf("first.When.Account = %q, want %q", first.When.Account, legacyDefaultAccountID) + } + + second := cfg.Agents.Dispatch.Rules[1] + if second.Agent != "ops" || second.When.Space != "guild:guild-1" { + t.Fatalf("second = %+v", second) + } + + third := cfg.Agents.Dispatch.Rules[2] + if third.Agent != "slack" { + t.Fatalf("third.Agent = %q, want %q", third.Agent, "slack") + } + if third.When.Channel != "slack" || third.When.Account != "" { + t.Fatalf("third.When = %+v", third.When) + } +} + +func TestLoadConfig_PrefersDispatchRulesOverLegacyBindings(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + raw := `{ + "version": 2, + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7" + }, + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "explicit", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-100123" + } + } + ] + } + }, + "bindings": [ + { + "agent_id": "main", + "match": { + "channel": "telegram", + "account_id": "*" + } + } + ] + }` + if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil { + t.Fatalf("WriteFile(configPath): %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Agents.Dispatch == nil { + t.Fatal("Agents.Dispatch should not be nil") + } + if len(cfg.Agents.Dispatch.Rules) != 1 { + t.Fatalf("Dispatch.Rules len = %d, want 1", len(cfg.Agents.Dispatch.Rules)) + } + if cfg.Agents.Dispatch.Rules[0].Name != "explicit" { + t.Fatalf("Dispatch.Rules[0].Name = %q, want %q", cfg.Agents.Dispatch.Rules[0].Name, "explicit") + } +} + +func TestLoadConfig_MigratesLegacyDirectBindingsWithIdentityLinks(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + raw := `{ + "version": 2, + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7" + }, + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ] + }, + "session": { + "identity_links": { + "john": ["telegram:123", "123"] + } + }, + "bindings": [ + { + "agent_id": "support", + "match": { + "channel": "telegram", + "peer": { "kind": "direct", "id": "123" } + } + } + ] + }` + if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil { + t.Fatalf("WriteFile(configPath): %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Agents.Dispatch == nil || len(cfg.Agents.Dispatch.Rules) != 1 { + t.Fatalf("Dispatch.Rules = %+v, want 1 migrated rule", cfg.Agents.Dispatch) + } + if got := cfg.Agents.Dispatch.Rules[0].When.Sender; got != "john" { + t.Fatalf("migrated sender selector = %q, want %q", got, "john") } } @@ -207,15 +472,6 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) { } } -// TestDefaultConfig_Model verifies model is set -func TestDefaultConfig_Model(t *testing.T) { - cfg := DefaultConfig() - - if cfg.Agents.Defaults.Model != "" { - t.Error("Model should be empty") - } -} - // TestDefaultConfig_MaxTokens verifies max tokens has default value func TestDefaultConfig_MaxTokens(t *testing.T) { cfg := DefaultConfig() @@ -247,26 +503,14 @@ func TestDefaultConfig_Temperature(t *testing.T) { func TestDefaultConfig_Gateway(t *testing.T) { cfg := DefaultConfig() - if cfg.Gateway.Host != "127.0.0.1" { + if cfg.Gateway.Host != "localhost" { t.Error("Gateway host should have default value") } if cfg.Gateway.Port == 0 { t.Error("Gateway port should have default value") } -} - -// TestDefaultConfig_Providers verifies provider structure -func TestDefaultConfig_Providers(t *testing.T) { - cfg := DefaultConfig() - - if cfg.Providers.Anthropic.APIKey != "" { - t.Error("Anthropic API key should be empty by default") - } - if cfg.Providers.OpenAI.APIKey != "" { - t.Error("OpenAI API key should be empty by default") - } - if cfg.Providers.OpenRouter.APIKey != "" { - t.Error("OpenRouter API key should be empty by default") + if cfg.Gateway.HotReload { + t.Error("Gateway hot reload should be disabled by default") } } @@ -274,17 +518,56 @@ func TestDefaultConfig_Providers(t *testing.T) { func TestDefaultConfig_Channels(t *testing.T) { cfg := DefaultConfig() - if cfg.Channels.Telegram.Enabled { - t.Error("Telegram should be disabled by default") + for name, bc := range cfg.Channels { + if bc.Enabled { + t.Errorf("Channel %q should be disabled by default", name) + } } - if cfg.Channels.Discord.Enabled { - t.Error("Discord should be disabled by default") +} + +func TestValidateSingletonChannels_RejectsMultipleInstances(t *testing.T) { + channels := ChannelsConfig{ + "pico1": &Channel{Enabled: true, Type: ChannelPico}, + "pico2": &Channel{Enabled: true, Type: ChannelPico}, } - if cfg.Channels.Slack.Enabled { - t.Error("Slack should be disabled by default") + err := validateSingletonChannels(channels) + if err == nil { + t.Fatal("expected error for multiple pico channels, got nil") } - if cfg.Channels.Matrix.Enabled { - t.Error("Matrix should be disabled by default") + if !strings.Contains(err.Error(), "singleton") { + t.Fatalf("expected singleton error, got: %v", err) + } +} + +func TestValidateSingletonChannels_AllowsSingleInstance(t *testing.T) { + channels := ChannelsConfig{ + "pico1": &Channel{Enabled: true, Type: ChannelPico}, + } + err := validateSingletonChannels(channels) + if err != nil { + t.Fatalf("expected no error for single pico channel, got: %v", err) + } +} + +func TestValidateSingletonChannels_IgnoresDisabledInstances(t *testing.T) { + channels := ChannelsConfig{ + "pico1": &Channel{Enabled: true, Type: ChannelPico}, + "pico2": &Channel{Enabled: false, Type: ChannelPico}, + } + err := validateSingletonChannels(channels) + if err != nil { + t.Fatalf("expected no error when only one pico channel is enabled, got: %v", err) + } +} + +func TestValidateSingletonChannels_AllowsMultiInstanceTypes(t *testing.T) { + channels := ChannelsConfig{ + "tg1": &Channel{Enabled: true, Type: ChannelTelegram}, + "tg2": &Channel{Enabled: true, Type: ChannelTelegram}, + } + err := validateSingletonChannels(channels) + if err != nil { + t.Fatalf("telegram should allow multiple instances, got error: %v", err) } } @@ -296,7 +579,7 @@ func TestDefaultConfig_WebTools(t *testing.T) { if cfg.Tools.Web.Brave.MaxResults != 5 { t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults) } - if cfg.Tools.Web.Brave.APIKey != "" { + if len(cfg.Tools.Web.Brave.APIKeys) != 0 { t.Error("Brave API key should be empty by default") } if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 { @@ -342,8 +625,101 @@ func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) { t.Fatalf("ReadFile failed: %v", err) } - if !strings.Contains(string(data), `"model": ""`) { - t.Fatalf("saved config should include empty legacy model field, got: %s", string(data)) + if !strings.Contains(string(data), `"model_name": ""`) { + t.Fatalf("saved config should include empty legacy model_name field, got: %s", string(data)) + } +} + +func TestSaveConfig_PreservesDisabledTelegramPlaceholder(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "config.json") + + cfg := DefaultConfig() + if bc := cfg.Channels.Get("telegram"); bc != nil { + bc.Placeholder.Enabled = false + } + + if err := SaveConfig(path, cfg); err != nil { + t.Fatalf("SaveConfig failed: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if !strings.Contains(string(data), `"placeholder": {`) { + t.Fatalf("saved config should include telegram placeholder config, got: %s", string(data)) + } + if !strings.Contains(string(data), `"enabled": false`) { + t.Fatalf("saved config should persist placeholder.enabled=false, got: %s", string(data)) + } + + loaded, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + bc := loaded.Channels.Get("telegram") + if bc != nil && bc.Placeholder.Enabled { + t.Fatal("telegram placeholder should remain disabled after SaveConfig/LoadConfig round-trip") + } +} + +// TestSaveConfig_FiltersVirtualModels verifies that SaveConfig does not write +// virtual models (generated by expandMultiKeyModels) to the config file. +func TestSaveConfig_FiltersVirtualModels(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "config.json") + + cfg := DefaultConfig() + + // Manually add a virtual model to ModelList (simulating what expandMultiKeyModels does) + primaryModel := &ModelConfig{ + ModelName: "gpt-4", + Model: "openai/gpt-4o", + APIKeys: SimpleSecureStrings("key1"), + } + virtualModel := &ModelConfig{ + ModelName: "gpt-4__key_1", + Model: "openai/gpt-4o", + APIKeys: SimpleSecureStrings("key2"), + isVirtual: true, + } + cfg.ModelList = []*ModelConfig{primaryModel, virtualModel} + + // SaveConfig should filter out virtual models + if err := SaveConfig(path, cfg); err != nil { + t.Fatalf("SaveConfig failed: %v", err) + } + + // Reload and verify + reloaded, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // Should only have the primary model, not the virtual one + if len(reloaded.ModelList) != 1 { + t.Fatalf("expected 1 model after reload, got %d", len(reloaded.ModelList)) + } + + if reloaded.ModelList[0].ModelName != "gpt-4" { + t.Errorf("expected model_name 'gpt-4', got %q", reloaded.ModelList[0].ModelName) + } + + // Verify virtual model was not persisted + for _, m := range reloaded.ModelList { + if m.ModelName == "gpt-4__key_1" { + t.Errorf("virtual model gpt-4__key_1 should not have been saved") + } + } + + // Verify the saved file does not contain the virtual model name + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if strings.Contains(string(data), "gpt-4__key_1") { + t.Errorf("saved config should not contain virtual model name 'gpt-4__key_1'") } } @@ -354,9 +730,6 @@ func TestConfig_Complete(t *testing.T) { if cfg.Agents.Defaults.Workspace == "" { t.Error("Workspace should not be empty") } - if cfg.Agents.Defaults.Model != "" { - t.Error("Model should be empty") - } if cfg.Agents.Defaults.Temperature != nil { t.Error("Temperature should be nil when not provided") } @@ -366,7 +739,7 @@ func TestConfig_Complete(t *testing.T) { if cfg.Agents.Defaults.MaxToolIterations == 0 { t.Error("MaxToolIterations should not be zero") } - if cfg.Gateway.Host != "127.0.0.1" { + if cfg.Gateway.Host != "localhost" { t.Error("Gateway host should have default value") } if cfg.Gateway.Port == 0 { @@ -375,19 +748,58 @@ func TestConfig_Complete(t *testing.T) { if !cfg.Heartbeat.Enabled { t.Error("Heartbeat should be enabled by default") } + if !cfg.Tools.Exec.AllowRemote { + t.Error("Exec.AllowRemote should be true by default") + } } -func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) { +func TestDefaultConfig_WebPreferNativeEnabled(t *testing.T) { cfg := DefaultConfig() - if !cfg.Providers.OpenAI.WebSearch { - t.Fatal("DefaultConfig().Providers.OpenAI.WebSearch should be true") + if !cfg.Tools.Web.PreferNative { + t.Fatal("DefaultConfig().Tools.Web.PreferNative should be true") } } -func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { +func TestDefaultConfig_WebProviderIsAuto(t *testing.T) { + cfg := DefaultConfig() + if cfg.Tools.Web.Provider != "auto" { + t.Fatalf("DefaultConfig().Tools.Web.Provider = %q, want auto", cfg.Tools.Web.Provider) + } +} + +func TestConfigExample_WebProviderIsAuto(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "config", "config.example.json")) + if err != nil { + t.Fatalf("ReadFile(config.example.json) error: %v", err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("Unmarshal(config.example.json) error: %v", err) + } + if cfg.Tools.Web.Provider != "auto" { + t.Fatalf("config.example.json tools.web.provider = %q, want auto", cfg.Tools.Web.Provider) + } +} + +func TestDefaultConfig_ToolFeedbackDisabled(t *testing.T) { + cfg := DefaultConfig() + if cfg.Agents.Defaults.ToolFeedback.Enabled { + t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.Enabled should be false") + } + if cfg.Agents.Defaults.ToolFeedback.SeparateMessages { + t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.SeparateMessages should be false") + } +} + +func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") - if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"api_base":""}}}`), 0o600); err != nil { + if err := os.WriteFile( + configPath, + []byte(`{"version":1,"agents":{"defaults":{"workspace":"./workspace"}}}`), + 0o600, + ); err != nil { t.Fatalf("WriteFile() error: %v", err) } @@ -395,15 +807,18 @@ func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error: %v", err) } - if !cfg.Providers.OpenAI.WebSearch { - t.Fatal("OpenAI codex web search should remain true when unset in config file") + if cfg.Agents.Defaults.ToolFeedback.Enabled { + t.Fatal("agents.defaults.tool_feedback.enabled should remain false when unset in config file") + } + if cfg.Agents.Defaults.ToolFeedback.SeparateMessages { + t.Fatal("agents.defaults.tool_feedback.separate_messages should remain false when unset in config file") } } -func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { +func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") - if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"web_search":false}}}`), 0o600); err != nil { + if err := os.WriteFile(configPath, []byte(`{"version":1,"tools":{"web":{"enabled":true}}}`), 0o600); err != nil { t.Fatalf("WriteFile() error: %v", err) } @@ -411,8 +826,198 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error: %v", err) } - if cfg.Providers.OpenAI.WebSearch { - t.Fatal("OpenAI codex web search should be false when disabled in config file") + if !cfg.Tools.Web.PreferNative { + t.Fatal("PreferNative should remain true when unset in config file") + } +} + +func TestLoadConfig_WebPreferNativeCanBeDisabled(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"prefer_native":false}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Tools.Web.PreferNative { + t.Fatal("PreferNative should be false when disabled in config file") + } +} + +func TestLoadConfig_SyntaxErrorReportsLineAndColumn(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + raw := "{\n \"version\": 2,\n \"tools\": {\n \"web\": {\n \"enabled\": true,,\n \"format\": \"markdown\"\n }\n }\n}\n" + if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("expected syntax error, got nil") + } + if !strings.Contains(err.Error(), "syntax error at line 5, column 23") { + t.Fatalf("expected line/column diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "\"enabled\": true,,") { + t.Fatalf("expected source snippet in diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "^") { + t.Fatalf("expected caret marker in diagnostic, got %q", err.Error()) + } +} + +func TestLoadConfig_TypeErrorReportsFieldPath(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + raw := "{\n \"version\": 2,\n \"tools\": {\n \"web\": {\n \"fetch_limit_bytes\": \"oops\"\n }\n }\n}\n" + if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("expected type error, got nil") + } + if !strings.Contains(err.Error(), "type error at line 5, column 33") { + t.Fatalf("expected line/column diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "fetch_limit_bytes") { + t.Fatalf("expected field name in diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "\"fetch_limit_bytes\": \"oops\"") { + t.Fatalf("expected source snippet in diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "^") { + t.Fatalf("expected caret marker in diagnostic, got %q", err.Error()) + } +} + +func TestLoadConfig_UnknownFieldsReportsExactPaths(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + raw := "{\n \"version\": 2,\n \"tools\": {\n \"weeb\": {\n \"enabled\": true\n },\n \"web\": {\n \"fatch_limit_bytes\": 123\n }\n }\n}\n" + if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("expected unknown field error, got nil") + } + if !strings.Contains(err.Error(), "tools.weeb") || !strings.Contains(err.Error(), "tools.web.fatch_limit_bytes") { + t.Fatalf("expected exact unknown field paths, got %q", err.Error()) + } +} + +func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("DefaultConfig().Tools.Exec.AllowRemote should be true") + } +} + +func TestDefaultConfig_FilterSensitiveDataEnabled(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Tools.FilterSensitiveData { + t.Fatal("DefaultConfig().Tools.FilterSensitiveData should be true") + } +} + +func TestDefaultConfig_FilterMinLength(t *testing.T) { + cfg := DefaultConfig() + if cfg.Tools.FilterMinLength != 8 { + t.Fatalf("DefaultConfig().Tools.FilterMinLength = %d, want 8", cfg.Tools.FilterMinLength) + } +} + +func TestToolsConfig_GetFilterMinLength(t *testing.T) { + tests := []struct { + name string + minLen int + expected int + }{ + {"zero returns default", 0, 8}, + {"negative returns default", -1, 8}, + {"positive returns value", 16, 16}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &ToolsConfig{FilterMinLength: tt.minLen} + if got := cfg.GetFilterMinLength(); got != tt.expected { + t.Errorf("GetFilterMinLength() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Tools.Cron.AllowCommand { + t.Fatal("DefaultConfig().Tools.Cron.AllowCommand should be true") + } +} + +func TestDefaultConfig_HooksDefaults(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Hooks.Enabled { + t.Fatal("DefaultConfig().Hooks.Enabled should be true") + } + if cfg.Hooks.Defaults.ObserverTimeoutMS != 500 { + t.Fatalf("ObserverTimeoutMS = %d, want 500", cfg.Hooks.Defaults.ObserverTimeoutMS) + } + if cfg.Hooks.Defaults.InterceptorTimeoutMS != 5000 { + t.Fatalf("InterceptorTimeoutMS = %d, want 5000", cfg.Hooks.Defaults.InterceptorTimeoutMS) + } + if cfg.Hooks.Defaults.ApprovalTimeoutMS != 60000 { + t.Fatalf("ApprovalTimeoutMS = %d, want 60000", cfg.Hooks.Defaults.ApprovalTimeoutMS) + } +} + +func TestDefaultConfig_LogLevel(t *testing.T) { + cfg := DefaultConfig() + if cfg.Gateway.LogLevel != "warn" { + t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Gateway.LogLevel) + } +} + +func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"version":1,"tools":{"exec":{"enable_deny_patterns":true}}}`), + 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("tools.exec.allow_remote should remain true when unset in config file") + } +} + +func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile( + configPath, + []byte(`{"version":1,"tools":{"cron":{"exec_timeout_minutes":5}}}`), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if !cfg.Tools.Cron.AllowCommand { + t.Fatal("tools.cron.allow_command should remain true when unset in config file") } } @@ -421,7 +1026,7 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) { configPath := filepath.Join(tmpDir, "config.json") configJSON := `{ "agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}}, - "model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}], + "model_list": [{"model_name":"gpt4","model":"openai/gpt-5.4","api_key":"x"}], "tools": {"web":{"proxy":"http://127.0.0.1:7890"}} }` if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { @@ -437,7 +1042,90 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) { } } -// TestDefaultConfig_DMScope verifies the default dm_scope value +func TestLoadConfig_HooksProcessConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + configJSON := `{ + "version": 1, + "hooks": { + "processes": { + "review-gate": { + "enabled": true, + "transport": "stdio", + "command": ["uvx", "picoclaw-hook-reviewer"], + "dir": "/tmp/hooks", + "env": { + "HOOK_MODE": "rewrite" + }, + "observe": ["turn_start", "turn_end"], + "intercept": ["before_tool", "approve_tool"] + } + }, + "builtins": { + "audit": { + "enabled": true, + "priority": 5, + "config": { + "label": "audit" + } + } + } + } +}` + if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { + t.Fatalf("os.WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + + processCfg, ok := cfg.Hooks.Processes["review-gate"] + if !ok { + t.Fatal("expected review-gate process hook") + } + if !processCfg.Enabled { + t.Fatal("expected review-gate process hook to be enabled") + } + if processCfg.Transport != "stdio" { + t.Fatalf("Transport = %q, want stdio", processCfg.Transport) + } + if len(processCfg.Command) != 2 || processCfg.Command[0] != "uvx" { + t.Fatalf("Command = %v", processCfg.Command) + } + if processCfg.Dir != "/tmp/hooks" { + t.Fatalf("Dir = %q, want /tmp/hooks", processCfg.Dir) + } + if processCfg.Env["HOOK_MODE"] != "rewrite" { + t.Fatalf("HOOK_MODE = %q, want rewrite", processCfg.Env["HOOK_MODE"]) + } + if len(processCfg.Observe) != 2 || processCfg.Observe[1] != "turn_end" { + t.Fatalf("Observe = %v", processCfg.Observe) + } + if len(processCfg.Intercept) != 2 || processCfg.Intercept[1] != "approve_tool" { + t.Fatalf("Intercept = %v", processCfg.Intercept) + } + + builtinCfg, ok := cfg.Hooks.Builtins["audit"] + if !ok { + t.Fatal("expected audit builtin hook") + } + if !builtinCfg.Enabled { + t.Fatal("expected audit builtin hook to be enabled") + } + if builtinCfg.Priority != 5 { + t.Fatalf("Priority = %d, want 5", builtinCfg.Priority) + } + if !strings.Contains(string(builtinCfg.Config), `"audit"`) { + t.Fatalf("Config = %s", string(builtinCfg.Config)) + } + if cfg.Hooks.Defaults.ApprovalTimeoutMS != 60000 { + t.Fatalf("ApprovalTimeoutMS = %d, want 60000", cfg.Hooks.Defaults.ApprovalTimeoutMS) + } +} + +// TestDefaultConfig_SessionDimensions verifies the default session dimensions // TestDefaultConfig_SummarizationThresholds verifies summarization defaults func TestDefaultConfig_SummarizationThresholds(t *testing.T) { cfg := DefaultConfig() @@ -450,22 +1138,28 @@ func TestDefaultConfig_SummarizationThresholds(t *testing.T) { } } -func TestDefaultConfig_DMScope(t *testing.T) { +func TestDefaultConfig_SessionDimensions(t *testing.T) { cfg := DefaultConfig() - if cfg.Session.DMScope != "per-channel-peer" { - t.Errorf("Session.DMScope = %q, want 'per-channel-peer'", cfg.Session.DMScope) + if len(cfg.Session.Dimensions) != 1 || cfg.Session.Dimensions[0] != "chat" { + t.Errorf("Session.Dimensions = %v, want [chat]", cfg.Session.Dimensions) } } func TestDefaultConfig_WorkspacePath_Default(t *testing.T) { - // Unset to ensure we test the default t.Setenv("PICOCLAW_HOME", "") - // Set a known home for consistent test results - t.Setenv("HOME", "/tmp/home") + + var fakeHome string + if runtime.GOOS == "windows" { + fakeHome = `C:\tmp\home` + t.Setenv("USERPROFILE", fakeHome) + } else { + fakeHome = "/tmp/home" + t.Setenv("HOME", fakeHome) + } cfg := DefaultConfig() - want := filepath.Join("/tmp/home", ".picoclaw", "workspace") + want := filepath.Join(fakeHome, ".picoclaw", "workspace") if cfg.Agents.Defaults.Workspace != want { t.Errorf("Default workspace path = %q, want %q", cfg.Agents.Defaults.Workspace, want) @@ -476,9 +1170,1265 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) { t.Setenv("PICOCLAW_HOME", "/custom/picoclaw/home") cfg := DefaultConfig() - want := "/custom/picoclaw/home/workspace" + want := filepath.Join("/custom/picoclaw/home", "workspace") if cfg.Agents.Defaults.Workspace != want { t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want) } } + +func TestDefaultConfig_IsolationEnabled(t *testing.T) { + cfg := DefaultConfig() + if cfg.Isolation.Enabled { + t.Fatal("DefaultConfig().Isolation.Enabled should be false") + } +} + +func TestConfig_UnmarshalIsolation(t *testing.T) { + cfg := DefaultConfig() + raw := []byte(`{ + "isolation": { + "enabled": false, + "expose_paths": [ + {"source":"/src","target":"/dst","mode":"ro"} + ] + } + }`) + if err := json.Unmarshal(raw, cfg); err != nil { + t.Fatalf("json.Unmarshal isolation config: %v", err) + } + if cfg.Isolation.Enabled { + t.Fatal("Isolation.Enabled should be false after unmarshal") + } + if len(cfg.Isolation.ExposePaths) != 1 { + t.Fatalf("ExposePaths len = %d, want 1", len(cfg.Isolation.ExposePaths)) + } + if got := cfg.Isolation.ExposePaths[0]; got.Source != "/src" || got.Target != "/dst" || got.Mode != "ro" { + t.Fatalf("ExposePaths[0] = %+v, want source=/src target=/dst mode=ro", got) + } +} + +// TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators +func TestFlexibleStringSlice_UnmarshalText(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "English commas only", + input: "123,456,789", + expected: []string{"123", "456", "789"}, + }, + { + name: "Chinese commas only", + input: "123,456,789", + expected: []string{"123", "456", "789"}, + }, + { + name: "Mixed English and Chinese commas", + input: "123,456,789", + expected: []string{"123", "456", "789"}, + }, + { + name: "Single value", + input: "123", + expected: []string{"123"}, + }, + { + name: "Values with whitespace", + input: " 123 , 456 , 789 ", + expected: []string{"123", "456", "789"}, + }, + { + name: "Empty string", + input: "", + expected: nil, + }, + { + name: "Only commas - English", + input: ",,", + expected: []string{}, + }, + { + name: "Only commas - Chinese", + input: ",,", + expected: []string{}, + }, + { + name: "Mixed commas with empty parts", + input: "123,,456,,789", + expected: []string{"123", "456", "789"}, + }, + { + name: "Complex mixed values", + input: "user1@example.com,user2@test.com, admin@domain.org", + expected: []string{"user1@example.com", "user2@test.com", "admin@domain.org"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var f FlexibleStringSlice + err := f.UnmarshalText([]byte(tt.input)) + if err != nil { + t.Fatalf("UnmarshalText(%q) error = %v", tt.input, err) + } + + if tt.expected == nil { + if f != nil { + t.Errorf("UnmarshalText(%q) = %v, want nil", tt.input, f) + } + return + } + + if len(f) != len(tt.expected) { + t.Errorf("UnmarshalText(%q) length = %d, want %d", tt.input, len(f), len(tt.expected)) + return + } + + for i, v := range tt.expected { + if f[i] != v { + t.Errorf("UnmarshalText(%q)[%d] = %q, want %q", tt.input, i, f[i], v) + } + } + }) + } +} + +// TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency tests nil vs empty slice behavior +func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) { + t.Run("Empty string returns nil", func(t *testing.T) { + var f FlexibleStringSlice + err := f.UnmarshalText([]byte("")) + if err != nil { + t.Fatalf("UnmarshalText error = %v", err) + } + if f != nil { + t.Errorf("Empty string should return nil, got %v", f) + } + }) + + t.Run("Commas only returns empty slice", func(t *testing.T) { + var f FlexibleStringSlice + err := f.UnmarshalText([]byte(",,,")) + if err != nil { + t.Fatalf("UnmarshalText error = %v", err) + } + if f == nil { + t.Error("Commas only should return empty slice, not nil") + } + if len(f) != 0 { + t.Errorf("Expected empty slice, got %v", f) + } + }) +} + +func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "null", + input: `null`, + expected: nil, + }, + { + name: "single string", + input: `"Thinking..."`, + expected: []string{"Thinking..."}, + }, + { + name: "single number", + input: `123`, + expected: []string{"123"}, + }, + { + name: "string array", + input: `["Thinking...", "Still working..."]`, + expected: []string{"Thinking...", "Still working..."}, + }, + { + name: "mixed array", + input: `["123", 456]`, + expected: []string{"123", "456"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var f FlexibleStringSlice + if err := json.Unmarshal([]byte(tt.input), &f); err != nil { + t.Fatalf("json.Unmarshal(%s) error = %v", tt.input, err) + } + if tt.expected == nil { + if f != nil { + t.Fatalf("json.Unmarshal(%s) = %#v, want nil slice", tt.input, f) + } + return + } + if len(f) != len(tt.expected) { + t.Fatalf("json.Unmarshal(%s) len = %d, want %d", tt.input, len(f), len(tt.expected)) + } + for i, want := range tt.expected { + if f[i] != want { + t.Fatalf("json.Unmarshal(%s)[%d] = %q, want %q", tt.input, i, f[i], want) + } + } + }) + } +} + +func TestLoadConfig_TelegramPlaceholderTextAcceptsSingleString(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{ + "version": 1, + "agents": { "defaults": { "workspace": "", "model": "", "max_tokens": 0, "max_tool_iterations": 0 } }, + "session": {}, + "channels": { + "telegram": { + "enabled": true, + "bot_token": "", + "allow_from": [], + "placeholder": { + "enabled": true, + "text": "Thinking..." + } + } + }, + "model_list": [], + "gateway": {}, + "tools": {}, + "heartbeat": {}, + "devices": {}, + "voice": {} + }` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + bc := cfg.Channels.Get("telegram") + if got := []string(bc.Placeholder.Text); len(got) != 1 || got[0] != "Thinking..." { + t.Fatalf("placeholder.text = %#v, want [\"Thinking...\"]", got) + } +} + +// TestLoadConfig_WarnsForPlaintextAPIKey verifies that LoadConfig resolves a plaintext +// api_keys entry into memory but does NOT rewrite the config file. File writes are the sole +// responsibility of SaveConfig. +func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + const original = `{"version":2,"model_list":[{"model_name":"test","model":"openai/gpt-4","api_keys":["sk-plaintext"]}]}` + if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + // In-memory value must be the resolved plaintext. + if cfg.ModelList[0].APIKey() != "sk-plaintext" { + t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey(), "sk-plaintext") + } + // The file on disk must remain unchanged — no need upgrade version + raw, _ := os.ReadFile(cfgPath) + if string(raw) != original { + t.Errorf("LoadConfig must not modify the config file; got:\n%s", string(raw)) + } +} + +// TestSaveConfig_EncryptsPlaintextAPIKey verifies that SaveConfig writes enc:// ciphertext +// to disk and that a subsequent LoadConfig decrypts it back to the original plaintext. +func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + mustSetupSSHKey(t) + + cfg := DefaultConfig() + cfg.ModelList = []*ModelConfig{ + {ModelName: "test", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("")}, + } + cfg.ModelList[0].APIKeys[0].Set("sk-plaintext") + + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + // Disk must contain enc://, not the raw key. + secPath := filepath.Join(dir, SecurityConfigFile) + raw, _ := os.ReadFile(secPath) + if !strings.Contains(string(raw), "enc://") { + t.Errorf("saved file should contain enc://, got:\n%s", string(raw)) + } + if strings.Contains(string(raw), "sk-plaintext") { + t.Errorf("saved file must not contain the plaintext key") + } + + // A fresh load must decrypt back to the original plaintext. + cfg2, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig after SaveConfig: %v", err) + } + if cfg2.ModelList[0].APIKey() != "sk-plaintext" { + t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey(), "sk-plaintext") + } +} + +// TestLoadConfig_NoSealWithoutPassphrase verifies that api_key values are left +// unchanged when PICOCLAW_KEY_PASSPHRASE is not set. +func TestLoadConfig_NoSealWithoutPassphrase(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + if _, err := LoadConfig(cfgPath); err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + raw, _ := os.ReadFile(cfgPath) + if strings.Contains(string(raw), "enc://") { + t.Error("config file must not be modified when no passphrase is set") + } +} + +// TestLoadConfig_FileRefNotSealed verifies that file:// api_key references are not +// converted to enc:// values (they are resolved at runtime by the Resolver). +func TestLoadConfig_FileRefNotSealed(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + keyFile := filepath.Join(dir, "openai.key") + if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + data := `{"version":1,"model_list":[{"model_name":"test","model":"openai/gpt-4"}]}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + secPath := filepath.Join(dir, SecurityConfigFile) + if err := saveSecurityConfig( + secPath, + &Config{ModelList: SecureModelList{ + &ModelConfig{ModelName: "test", APIKeys: SimpleSecureStrings("file://openai.key")}, + }}); err != nil { + t.Fatalf("saveSecurityConfig: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + if _, err := LoadConfig(cfgPath); err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + raw, _ := os.ReadFile(secPath) + if !strings.Contains(string(raw), "file://openai.key") { + t.Error("file:// reference should be preserved unchanged in the config file") + } + if strings.Contains(string(raw), "enc://") { + t.Error("file:// reference must not be converted to enc://") + } +} + +// TestSaveConfig_MixedKeys verifies that SaveConfig encrypts only plaintext api_keys +// and leaves already-encrypted (enc://) and file:// entries unchanged. +func TestSaveConfig_MixedKeys(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + mustSetupSSHKey(t) + + // Pre-encrypt one key so we have a genuine enc:// value to put in the config. + if err := SaveConfig(cfgPath, &Config{ + ModelList: []*ModelConfig{ + {ModelName: "pre", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-already-plain")}, + }, + }); err != nil { + t.Fatalf("setup SaveConfig: %v", err) + } + raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile)) + // Extract the enc:// value from the saved file. + var tmp struct { + ModelList map[string]struct { + APIKeys []string `yaml:"api_keys"` + } `yaml:"model_list"` + } + if err := yaml.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 { + t.Fatalf("setup: could not parse saved config: %v", err) + } + alreadyEncrypted := tmp.ModelList["pre:0"].APIKeys[0] + if !strings.HasPrefix(alreadyEncrypted, "enc://") { + t.Fatalf("setup: expected enc:// key, got %q", alreadyEncrypted) + } + + // Build a config with three models: + // 1. plaintext → must be encrypted by SaveConfig + // 2. enc:// → must be left unchanged (already encrypted) + // 3. file:// → must be left unchanged (file reference) + keyFile := filepath.Join(dir, "api.key") + if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + cfg := &Config{ + Version: CurrentVersion, + ModelList: []*ModelConfig{ + {ModelName: "plain", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-new-plaintext")}, + {ModelName: "enc", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings(alreadyEncrypted)}, + {ModelName: "file", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("file://api.key")}, + }, + } + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + t.Logf("alreadyEncrypted: %s", alreadyEncrypted) + raw, _ = os.ReadFile(filepath.Join(dir, SecurityConfigFile)) + s := string(raw) + + t.Logf("saved file:\n%s", s) + + // 1. Plaintext must be encrypted. + if strings.Contains(s, "sk-new-plaintext") { + t.Error("plaintext key must not appear in saved file") + } + // 2. The pre-existing enc:// value must still be present (byte-for-byte unchanged). + if !strings.Contains(s, alreadyEncrypted) { + t.Error("pre-existing enc:// entry must be preserved unchanged") + } + // 3. file:// must be preserved. + if !strings.Contains(s, "file://api.key") { + t.Error("file:// reference must be preserved unchanged") + } + + // Now load and verify all three decrypt/resolve correctly. + cfg2, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig after SaveConfig: %v", err) + } + byName := make(map[string]string) + for _, m := range cfg2.ModelList { + byName[m.ModelName] = m.APIKey() + } + if byName["plain"] != "sk-new-plaintext" { + t.Errorf("plain model api_key = %q, want %q", byName["plain"], "sk-new-plaintext") + } + if byName["enc"] != "sk-already-plain" { + t.Errorf("enc model api_key = %q, want %q", byName["enc"], "sk-already-plain") + } + if byName["file"] != "sk-from-file" { + t.Errorf("file model api_key = %q, want %q", byName["file"], "sk-from-file") + } +} + +// TestLoadConfig_MixedKeys_NoPassphrase verifies that when PICOCLAW_KEY_PASSPHRASE +// is not set, enc:// entries cause LoadConfig to return an error, while plaintext +// and file:// entries in the same config are not affected. +func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + // First encrypt a key so we have a real enc:// value. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + mustSetupSSHKey(t) + if err := SaveConfig(cfgPath, &Config{ + Version: CurrentVersion, + ModelList: []*ModelConfig{ + {ModelName: "m", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-secret")}, + }, + }); err != nil { + t.Fatalf("setup SaveConfig: %v", err) + } + raw, err := LoadConfig(cfgPath) + assert.NoError(t, err) + encValue := raw.ModelList[0].APIKeys[0].raw + assert.NotEmpty(t, encValue) + assert.Equal(t, "enc://", encValue[:6]) + + // Write a mixed config: enc:// + plaintext + file:// + keyFile := filepath.Join(dir, "api.key") + if err = os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + mixed, _ := json.Marshal(map[string]any{ + "model_list": []map[string]any{ + {"model_name": "enc", "model": "openai/gpt-4", "api_key": encValue}, + {"model_name": "plain", "model": "openai/gpt-4", "api_key": "sk-plain"}, + {"model_name": "file", "model": "openai/gpt-4", "api_key": "file://api.key"}, + }, + }) + if err = os.WriteFile(cfgPath, mixed, 0o600); err != nil { + t.Fatalf("setup write: %v", err) + } + secs, _ := yaml.Marshal(map[string]any{ + "model_list": map[string]map[string]any{ + "enc:0": {"api_keys": []string{encValue}}, + "plain:0": {"api_keys": []string{"sk-plain"}}, + "file:0": {"api_keys": []string{"file://api.key"}}, + }, + }) + if err = os.WriteFile(filepath.Join(dir, SecurityConfigFile), secs, 0o600); err != nil { + t.Fatalf("security write: %v", err) + } + + // Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + + cfg2, err := LoadConfig(cfgPath) + if err == nil { + t.Logf("LoadConfig: %#v", cfg2.ModelList) + t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set") + } + if !strings.Contains(err.Error(), "passphrase required") { + t.Errorf("error should mention passphrase required, got: %v", err) + } +} + +// TestSaveConfig_UsesPassphraseProvider verifies that SaveConfig encrypts plaintext +// api_keys using credential.PassphraseProvider() rather than os.Getenv directly. +// This matters for the launcher, which clears the environment variable and redirects +// PassphraseProvider to an in-memory SecureStore. +func TestSaveConfig_UsesPassphraseProvider(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + // Ensure the env var is empty — passphrase must come from PassphraseProvider only. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + mustSetupSSHKey(t) + + // Replace PassphraseProvider with an in-memory function (simulating SecureStore). + const testPassphrase = "provider-passphrase" + orig := credential.PassphraseProvider + credential.PassphraseProvider = func() string { return testPassphrase } + t.Cleanup(func() { credential.PassphraseProvider = orig }) + + cfg := DefaultConfig() + cfg.ModelList = []*ModelConfig{ + {ModelName: "test", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-plaintext")}, + } + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile)) + if !strings.Contains(string(raw), "enc://") { + t.Errorf("SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", raw) + } +} + +// TestLoadConfig_UsesPassphraseProvider verifies that LoadConfig decrypts enc:// keys +// using credential.PassphraseProvider() rather than os.Getenv directly. +func TestLoadConfig_UsesPassphraseProvider(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + // Ensure the env var is empty throughout. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + mustSetupSSHKey(t) + + const testPassphrase = "provider-passphrase" + const plainKey = "sk-secret" + + // First, encrypt the key using the same passphrase. + encrypted, err := credential.Encrypt(testPassphrase, "", plainKey) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + raw, _ := json.Marshal(map[string]any{ + "model_list": []map[string]any{ + {"model_name": "test", "model": "openai/gpt-4", "api_key": encrypted}, + }, + }) + if err = os.WriteFile(cfgPath, raw, 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + // Redirect PassphraseProvider — env var is empty, so without this the load would fail. + orig := credential.PassphraseProvider + credential.PassphraseProvider = func() string { return testPassphrase } + t.Cleanup(func() { credential.PassphraseProvider = orig }) + + t.Logf("cfgPath: %s", cfgPath) + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.ModelList[0].APIKey() != plainKey { + t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey(), plainKey) + } +} + +func TestConfigParsesLogLevel(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{"version":1,"gateway":{"log_level":"debug"}}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.Gateway.LogLevel != "debug" { + t.Errorf("LogLevel = %q, want \"debug\"", cfg.Gateway.LogLevel) + } +} + +func TestConfigLogLevelEmpty(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{"version":1}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + // When config omits log_level, the DefaultConfig value ("fatal") is preserved. + if cfg.Gateway.LogLevel != "warn" { + t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Gateway.LogLevel) + } +} + +func TestResolveGatewayLogLevel(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{"version":1,"gateway":{"log_level":"debug"}}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + if got := ResolveGatewayLogLevel(cfgPath); got != "debug" { + t.Fatalf("ResolveGatewayLogLevel() = %q, want %q", got, "debug") + } +} + +func TestResolveGatewayLogLevel_UsesEnvOverrideAndNormalizesInvalid(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{"version":1,"gateway":{"log_level":"debug"}}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv("PICOCLAW_LOG_LEVEL", "warning") + if got := ResolveGatewayLogLevel(cfgPath); got != "warn" { + t.Fatalf("ResolveGatewayLogLevel() with env override = %q, want %q", got, "warn") + } + + t.Setenv("PICOCLAW_LOG_LEVEL", "garbage") + if got := ResolveGatewayLogLevel(cfgPath); got != DefaultGatewayLogLevel { + t.Fatalf("ResolveGatewayLogLevel() with invalid env override = %q, want %q", got, DefaultGatewayLogLevel) + } +} + +func TestLoadConfig_AppliesLegacyClawHubRegistryEnvOverrides(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{"version":2,"tools":{"skills":{"registries":{"clawhub":{"enabled":true,"base_url":"https://clawhub.ai"}}}}}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv(envSkillsClawHubBaseURL, "https://clawhub.example.com") + t.Setenv(envSkillsClawHubAuthToken, "clawhub-token-from-env") + t.Setenv(envSkillsClawHubEnabled, "false") + t.Setenv(envSkillsClawHubSearchPath, "/custom/search") + t.Setenv(envSkillsClawHubDownloadPath, "/custom/download") + t.Setenv(envSkillsClawHubTimeout, "17") + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + clawhub, ok := cfg.Tools.Skills.Registries.Get("clawhub") + if !ok { + t.Fatal("clawhub registry missing") + } + if clawhub.BaseURL != "https://clawhub.example.com" { + t.Fatalf("BaseURL = %q, want %q", clawhub.BaseURL, "https://clawhub.example.com") + } + if clawhub.AuthToken.String() != "clawhub-token-from-env" { + t.Fatalf("AuthToken = %q, want %q", clawhub.AuthToken.String(), "clawhub-token-from-env") + } + if clawhub.Enabled { + t.Fatal("Enabled = true, want false") + } + if got := clawhub.Param["search_path"]; got != "/custom/search" { + t.Fatalf("search_path = %v, want %q", got, "/custom/search") + } + if got := clawhub.Param["download_path"]; got != "/custom/download" { + t.Fatalf("download_path = %v, want %q", got, "/custom/download") + } + if got := clawhub.Param["timeout"]; got != 17 { + t.Fatalf("timeout = %v, want %d", got, 17) + } +} + +func TestLoadConfig_AppliesGitHubRegistryEnvOverrides(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{"version":2,"tools":{"skills":{"registries":{"github":{"enabled":true,"base_url":"https://github.com"}}}}}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv(envSkillsGitHubBaseURL, "https://ghe.example.com/git") + t.Setenv(envSkillsGitHubAuthToken, "github-token-from-env") + t.Setenv(envSkillsGitHubEnabled, "false") + t.Setenv(envSkillsGitHubProxy, "http://127.0.0.1:7890") + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + github, ok := cfg.Tools.Skills.Registries.Get("github") + if !ok { + t.Fatal("github registry missing") + } + if github.BaseURL != "https://ghe.example.com/git" { + t.Fatalf("BaseURL = %q, want %q", github.BaseURL, "https://ghe.example.com/git") + } + if github.AuthToken.String() != "github-token-from-env" { + t.Fatalf("AuthToken = %q, want %q", github.AuthToken.String(), "github-token-from-env") + } + if github.Enabled { + t.Fatal("Enabled = true, want false") + } + if got := github.Param["proxy"]; got != "http://127.0.0.1:7890" { + t.Fatalf("proxy = %v, want %q", got, "http://127.0.0.1:7890") + } +} + +func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + cfg := &Config{ + Version: CurrentVersion, + ModelList: []*ModelConfig{ + { + ModelName: "test-model", + Model: "openai/test", + APIKeys: SimpleSecureStrings("sk-test"), + ExtraBody: map[string]any{"custom_field": "value", "num_field": 42}, + }, + }, + } + + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + + loaded, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + + if loaded.ModelList[0].ExtraBody == nil { + t.Fatal("ExtraBody should not be nil after round-trip") + } + if got := loaded.ModelList[0].ExtraBody["custom_field"]; got != "value" { + t.Errorf("ExtraBody[custom_field] = %v, want value", got) + } + if got := loaded.ModelList[0].ExtraBody["num_field"]; got != float64(42) { + t.Errorf("ExtraBody[num_field] = %v, want 42", got) + } +} + +func TestModelConfig_CustomHeadersRoundTrip(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + cfg := &Config{ + Version: CurrentVersion, + ModelList: []*ModelConfig{ + { + ModelName: "test-model", + Model: "openai/test", + APIKeys: SimpleSecureStrings("sk-test"), + CustomHeaders: map[string]string{"X-Source": "coding-plan", "X-Agent": "openclaw"}, + }, + }, + } + + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + + loaded, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + + if loaded.ModelList[0].CustomHeaders == nil { + t.Fatal("CustomHeaders should not be nil after round-trip") + } + if got := loaded.ModelList[0].CustomHeaders["X-Source"]; got != "coding-plan" { + t.Errorf("CustomHeaders[X-Source] = %q, want coding-plan", got) + } + if got := loaded.ModelList[0].CustomHeaders["X-Agent"]; got != "openclaw" { + t.Errorf("CustomHeaders[X-Agent] = %q, want openclaw", got) + } +} + +func TestModelConfig_ToolSchemaTransformRoundTrip(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + cfg := &Config{ + Version: CurrentVersion, + ModelList: []*ModelConfig{ + { + ModelName: "test-model", + Model: "openai/test", + APIKeys: SimpleSecureStrings("sk-test"), + ToolSchemaTransform: "simple", + }, + }, + } + + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + + loaded, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + + if got := loaded.ModelList[0].ToolSchemaTransform; got != "simple" { + t.Fatalf("ToolSchemaTransform = %q, want %q", got, "simple") + } +} + +func TestDefaultConfig_MinimaxExtraBody(t *testing.T) { + cfg := DefaultConfig() + + var minimaxCfg *ModelConfig + for i := range cfg.ModelList { + if cfg.ModelList[i].Provider == "minimax" && cfg.ModelList[i].Model == "MiniMax-M2.5" { + minimaxCfg = cfg.ModelList[i] + break + } + } + if minimaxCfg == nil { + t.Fatal("Minimax model not found in ModelList") + } + if minimaxCfg.ExtraBody == nil { + t.Fatal("Minimax ExtraBody should not be nil") + } + if got, ok := minimaxCfg.ExtraBody["reasoning_split"]; !ok || got != true { + t.Fatalf("Minimax ExtraBody[reasoning_split] = %v, want true", got) + } +} + +func TestFilterSensitiveData(t *testing.T) { + // Test with nil security config + cfg := &Config{} + if got := cfg.FilterSensitiveData("hello sk-key123 world"); got != "hello sk-key123 world" { + t.Errorf("nil security: got %q, want original", got) + } + + // Test with empty content + if got := cfg.FilterSensitiveData(""); got != "" { + t.Errorf("empty content: got %q, want empty", got) + } + + // Test short content (less than FilterMinLength=8, should skip filtering) + cfg.ModelList = SecureModelList{ + &ModelConfig{ + ModelName: "test", + APIKeys: SimpleSecureStrings("sk-long-key-12345"), + }, + } + m, err := cfg.GetModelConfig("test") + assert.NoError(t, err) + m.APIKeys = SimpleSecureStrings("sk-long-key-12345") + cfg.Tools.FilterSensitiveData = true + cfg.Tools.FilterMinLength = 8 + + // Debug: check if sensitive values are collected + values := cfg.collectSensitiveValues() + t.Logf("collected %d sensitive values: %v", len(values), values) + + if got := cfg.FilterSensitiveData("sk-key"); got != "sk-key" { + t.Errorf("short content should not be filtered: got %q", got) + } + + // Test filtering works + content := "Your API key is sk-long-key-12345 and token abc123" + // abc123 is not in sensitive values, only sk-long-key-12345 should be filtered + expected := "Your API key is [FILTERED] and token abc123" + if got := cfg.FilterSensitiveData(content); got != expected { + t.Errorf("filtering failed: got %q, want %q", got, expected) + } + + // Test disabled filtering + cfg.Tools.FilterSensitiveData = false + if got := cfg.FilterSensitiveData(content); got != content { + t.Errorf("disabled filtering: got %q, want original %q", got, content) + } +} + +func TestFilterSensitiveData_MultipleKeys(t *testing.T) { + cfg := &Config{ + Tools: ToolsConfig{ + FilterSensitiveData: true, + FilterMinLength: 8, + }, + ModelList: SecureModelList{ + &ModelConfig{ + ModelName: "model1", + Model: "openai/model1", + APIKeys: SecureStrings{NewSecureString("key-one"), NewSecureString("key-two")}, + }, + &ModelConfig{ + ModelName: "model2", + Model: "openai/model2", + APIKeys: SecureStrings{NewSecureString("key-three")}, + }, + }, + } + + content := "key-one and key-two and key-three should be filtered" + expected := "[FILTERED] and [FILTERED] and [FILTERED] should be filtered" + if got := cfg.FilterSensitiveData(content); got != expected { + t.Errorf("multiple keys: got %q, want %q", got, expected) + } +} + +func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { + cfg := &Config{ + // Model API keys + ModelList: SecureModelList{ + &ModelConfig{ + ModelName: "test-model", + APIKeys: SecureStrings{NewSecureString("sk-model-key-12345")}, + }, + }, + // Channel tokens + Channels: testChannelsConfigWithTokens(), + Tools: ToolsConfig{ + FilterSensitiveData: true, + FilterMinLength: 8, + // Web tool API keys + Web: WebToolsConfig{ + Brave: BraveConfig{APIKeys: SecureStrings{NewSecureString("brave-api-key")}}, + Tavily: TavilyConfig{APIKeys: SecureStrings{NewSecureString("tavily-api-key")}}, + Perplexity: PerplexityConfig{APIKeys: SecureStrings{NewSecureString("perplexity-api-key")}}, + GLMSearch: GLMSearchConfig{APIKey: *NewSecureString("glm-search-key")}, + BaiduSearch: BaiduSearchConfig{APIKey: *NewSecureString("baidu-search-key")}, + }, + // Skills tokens + Skills: SkillsToolsConfig{ + Github: SkillsGithubConfig{Token: *NewSecureString("github-token-xyz")}, + Registries: SkillsRegistriesConfig{ + &SkillRegistryConfig{Name: "clawhub", AuthToken: *NewSecureString("clawhub-auth-token")}, + }, + }, + }, + } + + tests := []struct { + name string + content string + want string + }{ + { + name: "model_api_key", + content: "Using model with key sk-model-key-12345", + want: "Using model with key [FILTERED]", + }, + { + name: "telegram_token", + content: "Telegram token: telegram-bot-token-abcdef", + want: "Telegram token: [FILTERED]", + }, + { + name: "discord_token", + content: "Discord token: discord-bot-token-xyz789", + want: "Discord token: [FILTERED]", + }, + { + name: "slack_tokens", + content: "Slack bot: xoxb-slack-bot-token, app: xapp-slack-app-token", + want: "Slack bot: [FILTERED], app: [FILTERED]", + }, + { + name: "matrix_token", + content: "Matrix access token: matrix-access-token-abc", + want: "Matrix access token: [FILTERED]", + }, + { + name: "brave_api_key", + content: "Brave key: brave-api-key", + want: "Brave key: [FILTERED]", + }, + { + name: "tavily_api_key", + content: "Tavily key: tavily-api-key", + want: "Tavily key: [FILTERED]", + }, + { + name: "github_token", + content: "GitHub token: github-token-xyz", + want: "GitHub token: [FILTERED]", + }, + { + name: "irc_passwords", + content: "IRC password: irc-password, nickserv: nickserv-pass", + want: "IRC password: [FILTERED], nickserv: [FILTERED]", + }, + { + name: "mixed_content", + content: "Model key sk-model-key-12345 and Telegram token telegram-bot-token-abcdef", + want: "Model key [FILTERED] and Telegram token [FILTERED]", + }, + { + name: "short_key_not_filtered", + content: "Key abc not filtered because length < 8", + want: "Key abc not filtered because length < 8", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := cfg.FilterSensitiveData(tt.content); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// makeBackup tests +// --------------------------------------------------------------------------- + +// TestMakeBackup_WithDateSuffix verifies backup files include a date suffix. +func TestMakeBackup_WithDateSuffix(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"version":2}`), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := makeBackup(configPath); err != nil { + t.Fatalf("makeBackup: %v", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + + var hasDatedBackup bool + for _, e := range entries { + if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched { + hasDatedBackup = true + // Verify backup content matches original + bakPath := filepath.Join(dir, e.Name()) + data, err := os.ReadFile(bakPath) + if err != nil { + t.Fatalf("ReadFile backup: %v", err) + } + if string(data) != `{"version":2}` { + t.Errorf("backup content = %q, want original content", string(data)) + } + break + } + } + if !hasDatedBackup { + t.Error("expected backup file with date suffix pattern config.json.20*.bak") + } +} + +// TestMakeBackup_AlsoBacksSecurityFile verifies that the security config file +// is also backed up with the same date suffix. +func TestMakeBackup_AlsoBacksSecurityFile(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + secPath := securityPath(configPath) + + os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) + os.WriteFile(secPath, []byte(`model_list:\n test:0:\n api_keys:\n - "sk-test"\n`), 0o600) + + if err := makeBackup(configPath); err != nil { + t.Fatalf("makeBackup: %v", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + + configBackups := 0 + secBackups := 0 + for _, e := range entries { + if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched { + configBackups++ + } + if matched, _ := filepath.Match(".security.yml.20*.bak", e.Name()); matched { + secBackups++ + } + } + if configBackups != 1 { + t.Errorf("expected 1 config backup, got %d", configBackups) + } + if secBackups != 1 { + t.Errorf("expected 1 security backup, got %d", secBackups) + } +} + +// TestMakeBackup_NonexistentFileSkipsBackup verifies that makeBackup returns nil +// when the config file does not exist (no error, no panic). +func TestMakeBackup_NonexistentFileSkipsBackup(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "nonexistent.json") + + if err := makeBackup(configPath); err != nil { + t.Fatalf("makeBackup on nonexistent file should return nil, got: %v", err) + } +} + +// TestMakeBackup_OnlyConfigNoSecurity verifies backup succeeds when only +// the config file exists and no security file. +func TestMakeBackup_OnlyConfigNoSecurity(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) + + if err := makeBackup(configPath); err != nil { + t.Fatalf("makeBackup: %v", err) + } + + entries, _ := os.ReadDir(dir) + configBackups := 0 + secBackups := 0 + for _, e := range entries { + if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched { + configBackups++ + } + if matched, _ := filepath.Match(".security.yml.20*.bak", e.Name()); matched { + secBackups++ + } + } + if configBackups != 1 { + t.Errorf("expected 1 config backup, got %d", configBackups) + } + if secBackups != 0 { + t.Errorf("expected 0 security backups when no security file exists, got %d", secBackups) + } +} + +// TestMakeBackup_SameDateSuffix verifies that config and security backups +// share the same date suffix (they are created in the same makeBackup call). +func TestMakeBackup_SameDateSuffix(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + secPath := securityPath(configPath) + + os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) + os.WriteFile(secPath, []byte(`key: value`), 0o600) + + if err := makeBackup(configPath); err != nil { + t.Fatalf("makeBackup: %v", err) + } + + entries, _ := os.ReadDir(dir) + var configDate, secDate string + for _, e := range entries { + name := e.Name() + // Extract date part: after the last . before .bak + // e.g. config.json.20260330.bak → 20260330 + if strings.HasPrefix(name, "config.json.") && strings.HasSuffix(name, ".bak") { + configDate = strings.TrimPrefix(name, "config.json.") + configDate = strings.TrimSuffix(configDate, ".bak") + } + if strings.HasPrefix(name, ".security.yml.") && strings.HasSuffix(name, ".bak") { + secDate = strings.TrimPrefix(name, ".security.yml.") + secDate = strings.TrimSuffix(secDate, ".bak") + } + } + if configDate == "" { + t.Fatal("config backup file not found") + } + if secDate == "" { + t.Fatal("security backup file not found") + } + if configDate != secDate { + t.Errorf("config backup date = %q, security backup date = %q, should match", configDate, secDate) + } +} + +func testChannelsConfigWithTokens() ChannelsConfig { + channels := make(ChannelsConfig) + type chDef struct { + name string + cfg any + } + defs := []chDef{ + {"telegram", TelegramSettings{Token: *NewSecureString("telegram-bot-token-abcdef")}}, + {"discord", DiscordSettings{Token: *NewSecureString("discord-bot-token-xyz789")}}, + { + "slack", + SlackSettings{ + BotToken: *NewSecureString("xoxb-slack-bot-token"), + AppToken: *NewSecureString("xapp-slack-app-token"), + }, + }, + {"matrix", MatrixSettings{AccessToken: *NewSecureString("matrix-access-token-abc")}}, + { + "feishu", + FeishuSettings{ + AppSecret: *NewSecureString("feishu-app-secret-123"), + EncryptKey: *NewSecureString("feishu-encrypt-key"), + }, + }, + {"dingtalk", DingTalkSettings{ClientSecret: *NewSecureString("dingtalk-client-secret")}}, + {"onebot", OneBotSettings{AccessToken: *NewSecureString("onebot-access-token")}}, + {"wecom", WeComSettings{Secret: *NewSecureString("wecom-secret")}}, + {"pico", PicoSettings{Token: *NewSecureString("pico-token-abc123")}}, + { + "irc", + IRCSettings{ + Password: *NewSecureString("irc-password"), + NickServPassword: *NewSecureString("nickserv-pass"), + SASLPassword: *NewSecureString("sasl-pass"), + }, + }, + } + for _, def := range defs { + // Create Channel directly with settings to preserve SecureString values + bc := &Channel{Type: def.name} + bc.Decode(def.cfg) + channels[def.name] = bc + } + return channels +} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 7fb3daa48..8e2494ae5 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -6,179 +6,57 @@ package config import ( - "os" + "encoding/json" "path/filepath" + + "github.com/sipeed/picoclaw/pkg" ) // DefaultConfig returns the default configuration for PicoClaw. func DefaultConfig() *Config { - // Determine the base path for the workspace. - // Priority: $PICOCLAW_HOME > ~/.picoclaw - var homePath string - if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" { - homePath = picoclawHome - } else { - userHome, _ := os.UserHomeDir() - homePath = filepath.Join(userHome, ".picoclaw") - } - workspacePath := filepath.Join(homePath, "workspace") + workspacePath := filepath.Join(GetHome(), pkg.WorkspaceName) return &Config{ + Version: CurrentVersion, + // Isolation is opt-in so existing installations keep their current behavior + // until the user explicitly enables subprocess sandboxing. + Isolation: IsolationConfig{ + Enabled: false, + }, Agents: AgentsConfig{ Defaults: AgentDefaults{ Workspace: workspacePath, RestrictToWorkspace: true, Provider: "", - Model: "", MaxTokens: 32768, Temperature: nil, // nil means use provider default MaxToolIterations: 50, SummarizeMessageThreshold: 20, SummarizeTokenPercent: 75, + SteeringMode: "one-at-a-time", + ToolFeedback: ToolFeedbackConfig{ + Enabled: false, + MaxArgsLength: 300, + SeparateMessages: false, + }, + SplitOnMarker: false, + MaxLLMRetries: 2, + LLMRetryBackoffSecs: 2, }, }, - Bindings: []AgentBinding{}, Session: SessionConfig{ - DMScope: "per-channel-peer", + Dimensions: []string{"chat"}, }, - Channels: ChannelsConfig{ - WhatsApp: WhatsAppConfig{ - Enabled: false, - BridgeURL: "ws://localhost:3001", - UseNative: false, - SessionStorePath: "", - AllowFrom: FlexibleStringSlice{}, - }, - Telegram: TelegramConfig{ - Enabled: false, - Token: "", - AllowFrom: FlexibleStringSlice{}, - Typing: TypingConfig{Enabled: true}, - Placeholder: PlaceholderConfig{ - Enabled: true, - Text: "Thinking... 💭", - }, - }, - Feishu: FeishuConfig{ - Enabled: false, - AppID: "", - AppSecret: "", - EncryptKey: "", - VerificationToken: "", - AllowFrom: FlexibleStringSlice{}, - }, - Discord: DiscordConfig{ - Enabled: false, - Token: "", - AllowFrom: FlexibleStringSlice{}, - MentionOnly: false, - }, - MaixCam: MaixCamConfig{ - Enabled: false, - Host: "0.0.0.0", - Port: 18790, - AllowFrom: FlexibleStringSlice{}, - }, - QQ: QQConfig{ - Enabled: false, - AppID: "", - AppSecret: "", - AllowFrom: FlexibleStringSlice{}, - }, - DingTalk: DingTalkConfig{ - Enabled: false, - ClientID: "", - ClientSecret: "", - AllowFrom: FlexibleStringSlice{}, - }, - Slack: SlackConfig{ - Enabled: false, - BotToken: "", - AppToken: "", - AllowFrom: FlexibleStringSlice{}, - }, - Matrix: MatrixConfig{ - Enabled: false, - Homeserver: "https://matrix.org", - UserID: "", - AccessToken: "", - DeviceID: "", - JoinOnInvite: true, - AllowFrom: FlexibleStringSlice{}, - GroupTrigger: GroupTriggerConfig{ - MentionOnly: true, - }, - Placeholder: PlaceholderConfig{ - Enabled: true, - Text: "Thinking... 💭", - }, - }, - LINE: LINEConfig{ - Enabled: false, - ChannelSecret: "", - ChannelAccessToken: "", - WebhookHost: "0.0.0.0", - WebhookPort: 18791, - WebhookPath: "/webhook/line", - AllowFrom: FlexibleStringSlice{}, - GroupTrigger: GroupTriggerConfig{MentionOnly: true}, - }, - OneBot: OneBotConfig{ - Enabled: false, - WSUrl: "ws://127.0.0.1:3001", - AccessToken: "", - ReconnectInterval: 5, - GroupTriggerPrefix: []string{}, - AllowFrom: FlexibleStringSlice{}, - }, - WeCom: WeComConfig{ - Enabled: false, - Token: "", - EncodingAESKey: "", - WebhookURL: "", - WebhookHost: "0.0.0.0", - WebhookPort: 18793, - WebhookPath: "/webhook/wecom", - AllowFrom: FlexibleStringSlice{}, - ReplyTimeout: 5, - }, - WeComApp: WeComAppConfig{ - Enabled: false, - CorpID: "", - CorpSecret: "", - AgentID: 0, - Token: "", - EncodingAESKey: "", - WebhookHost: "0.0.0.0", - WebhookPort: 18792, - WebhookPath: "/webhook/wecom-app", - AllowFrom: FlexibleStringSlice{}, - ReplyTimeout: 5, - }, - WeComAIBot: WeComAIBotConfig{ - Enabled: false, - Token: "", - EncodingAESKey: "", - WebhookPath: "/webhook/wecom-aibot", - AllowFrom: FlexibleStringSlice{}, - ReplyTimeout: 5, - MaxSteps: 10, - WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?", - }, - Pico: PicoConfig{ - Enabled: false, - Token: "", - PingInterval: 30, - ReadTimeout: 60, - WriteTimeout: 10, - MaxConnections: 100, - AllowFrom: FlexibleStringSlice{}, + Channels: defaultChannels(), + Hooks: HooksConfig{ + Enabled: true, + Defaults: HookDefaultsConfig{ + ObserverTimeoutMS: 500, + InterceptorTimeoutMS: 5000, + ApprovalTimeoutMS: 60000, }, }, - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{WebSearch: true}, - }, - ModelList: []ModelConfig{ + ModelList: []*ModelConfig{ // ============================================ // Add your API key to the model you want to use // ============================================ @@ -186,132 +64,148 @@ func DefaultConfig() *Config { // Zhipu AI (智谱) - https://open.bigmodel.cn/usercenter/apikeys { ModelName: "glm-4.7", - Model: "zhipu/glm-4.7", + Provider: "zhipu", + Model: "glm-4.7", APIBase: "https://open.bigmodel.cn/api/paas/v4", - APIKey: "", }, // OpenAI - https://platform.openai.com/api-keys { - ModelName: "gpt-5.2", - Model: "openai/gpt-5.2", + ModelName: "gpt-5.4", + Provider: "openai", + Model: "gpt-5.4", APIBase: "https://api.openai.com/v1", - APIKey: "", }, // Anthropic Claude - https://console.anthropic.com/settings/keys { ModelName: "claude-sonnet-4.6", - Model: "anthropic/claude-sonnet-4.6", + Provider: "anthropic", + Model: "claude-sonnet-4.6", APIBase: "https://api.anthropic.com/v1", - APIKey: "", }, // DeepSeek - https://platform.deepseek.com/ { ModelName: "deepseek-chat", - Model: "deepseek/deepseek-chat", + Provider: "deepseek", + Model: "deepseek-chat", APIBase: "https://api.deepseek.com/v1", - APIKey: "", + }, + + // Venice AI - https://venice.ai + { + ModelName: "venice-uncensored", + Provider: "venice", + Model: "venice-uncensored", + APIBase: "https://api.venice.ai/api/v1", }, // Google Gemini - https://ai.google.dev/ { ModelName: "gemini-2.0-flash", - Model: "gemini/gemini-2.0-flash-exp", + Provider: "gemini", + Model: "gemini-2.0-flash-exp", APIBase: "https://generativelanguage.googleapis.com/v1beta", - APIKey: "", }, // Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey { ModelName: "qwen-plus", - Model: "qwen/qwen-plus", + Provider: "qwen", + Model: "qwen-plus", APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1", - APIKey: "", }, // Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys { ModelName: "moonshot-v1-8k", - Model: "moonshot/moonshot-v1-8k", + Provider: "moonshot", + Model: "moonshot-v1-8k", APIBase: "https://api.moonshot.cn/v1", - APIKey: "", }, // Groq - https://console.groq.com/keys { ModelName: "llama-3.3-70b", - Model: "groq/llama-3.3-70b-versatile", + Provider: "groq", + Model: "llama-3.3-70b-versatile", APIBase: "https://api.groq.com/openai/v1", - APIKey: "", }, // OpenRouter (100+ models) - https://openrouter.ai/keys { ModelName: "openrouter-auto", - Model: "openrouter/auto", + Provider: "openrouter", + Model: "auto", APIBase: "https://openrouter.ai/api/v1", - APIKey: "", }, { - ModelName: "openrouter-gpt-5.2", - Model: "openrouter/openai/gpt-5.2", + ModelName: "openrouter-gpt-5.4", + Provider: "openrouter", + Model: "openai/gpt-5.4", APIBase: "https://openrouter.ai/api/v1", - APIKey: "", }, // NVIDIA - https://build.nvidia.com/ { ModelName: "nemotron-4-340b", - Model: "nvidia/nemotron-4-340b-instruct", + Provider: "nvidia", + Model: "nemotron-4-340b-instruct", APIBase: "https://integrate.api.nvidia.com/v1", - APIKey: "", }, // Cerebras - https://inference.cerebras.ai/ { ModelName: "cerebras-llama-3.3-70b", - Model: "cerebras/llama-3.3-70b", + Provider: "cerebras", + Model: "llama-3.3-70b", APIBase: "https://api.cerebras.ai/v1", - APIKey: "", }, // Vivgrid - https://vivgrid.com { ModelName: "vivgrid-auto", - Model: "vivgrid/auto", + Provider: "vivgrid", + Model: "auto", APIBase: "https://api.vivgrid.com/v1", - APIKey: "", }, // Volcengine (火山引擎) - https://console.volcengine.com/ark { - ModelName: "doubao-pro", - Model: "volcengine/doubao-pro-32k", + ModelName: "ark-code-latest", + Provider: "volcengine", + Model: "ark-code-latest", + APIBase: "https://ark.cn-beijing.volces.com/api/v3", + }, + { + ModelName: "doubao-pro", + Provider: "volcengine", + Model: "doubao-pro-32k", APIBase: "https://ark.cn-beijing.volces.com/api/v3", - APIKey: "", }, // ShengsuanYun (神算云) { ModelName: "deepseek-v3", - Model: "shengsuanyun/deepseek-v3", + Provider: "shengsuanyun", + Model: "deepseek-v3", APIBase: "https://api.shengsuanyun.com/v1", - APIKey: "", }, // Antigravity (Google Cloud Code Assist) - OAuth only { ModelName: "gemini-flash", - Model: "antigravity/gemini-3-flash", + Provider: "antigravity", + Model: "gemini-3-flash", AuthMethod: "oauth", }, // GitHub Copilot - https://github.com/settings/tokens { - ModelName: "copilot-gpt-5.2", - Model: "github-copilot/gpt-5.2", + ModelName: "copilot-gpt-5.4", + Provider: "github-copilot", + Model: "gpt-5.4", APIBase: "http://localhost:4321", AuthMethod: "oauth", }, @@ -319,46 +213,95 @@ func DefaultConfig() *Config { // Ollama (local) - https://ollama.com { ModelName: "llama3", - Model: "ollama/llama3", + Provider: "ollama", + Model: "llama3", APIBase: "http://localhost:11434/v1", - APIKey: "ollama", }, // Mistral AI - https://console.mistral.ai/api-keys { ModelName: "mistral-small", - Model: "mistral/mistral-small-latest", + Provider: "mistral", + Model: "mistral-small-latest", APIBase: "https://api.mistral.ai/v1", - APIKey: "", }, // Avian - https://avian.io { ModelName: "deepseek-v3.2", - Model: "avian/deepseek/deepseek-v3.2", + Provider: "avian", + Model: "deepseek/deepseek-v3.2", APIBase: "https://api.avian.io/v1", - APIKey: "", }, { ModelName: "kimi-k2.5", - Model: "avian/moonshotai/kimi-k2.5", + Provider: "avian", + Model: "moonshotai/kimi-k2.5", APIBase: "https://api.avian.io/v1", - APIKey: "", + }, + + // Minimax - https://api.minimaxi.com/ + { + ModelName: "MiniMax-M2.5", + Provider: "minimax", + Model: "MiniMax-M2.5", + APIBase: "https://api.minimaxi.com/v1", + ExtraBody: map[string]any{"reasoning_split": true}, + }, + + // LongCat - https://longcat.chat/platform + { + ModelName: "LongCat-Flash-Thinking", + Provider: "longcat", + Model: "LongCat-Flash-Thinking", + APIBase: "https://api.longcat.chat/openai", + }, + + // ModelScope (魔搭社区) - https://modelscope.cn/my/tokens + { + ModelName: "modelscope-qwen", + Provider: "modelscope", + Model: "Qwen/Qwen3-235B-A22B-Instruct-2507", + APIBase: "https://api-inference.modelscope.cn/v1", }, // VLLM (local) - http://localhost:8000 { ModelName: "local-model", - Model: "vllm/custom-model", + Provider: "vllm", + Model: "custom-model", APIBase: "http://localhost:8000/v1", - APIKey: "", + }, + + // LM Studio (local) - http://localhost:1234 + { + ModelName: "lmstudio-local", + Provider: "lmstudio", + Model: "openai/gpt-oss-20b", + APIBase: "http://localhost:1234/v1", + }, + + // Azure OpenAI - https://portal.azure.com + // model_name is a user-friendly alias; the model field's path after "azure/" is your deployment name + { + ModelName: "azure-gpt5", + Provider: "azure", + Model: "my-gpt5-deployment", + APIBase: "https://your-resource.openai.azure.com", }, }, Gateway: GatewayConfig{ - Host: "127.0.0.1", - Port: 18790, + Host: "localhost", + Port: 18790, + HotReload: false, + LogLevel: DefaultGatewayLogLevel, + }, + Events: EventsConfig{ + Logging: defaultEventLoggingConfig(), }, Tools: ToolsConfig{ + FilterSensitiveData: true, + FilterMinLength: 8, MediaCleanup: MediaCleanupConfig{ ToolConfig: ToolConfig{ Enabled: true, @@ -370,20 +313,29 @@ func DefaultConfig() *Config { ToolConfig: ToolConfig{ Enabled: true, }, + Provider: "auto", + PreferNative: true, Proxy: "", FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default + Format: "plaintext", Brave: BraveConfig{ Enabled: false, - APIKey: "", + MaxResults: 5, + }, + Tavily: TavilyConfig{ + Enabled: false, + MaxResults: 5, + }, + Sogou: SogouConfig{ + Enabled: true, MaxResults: 5, }, DuckDuckGo: DuckDuckGoConfig{ - Enabled: true, + Enabled: false, MaxResults: 5, }, Perplexity: PerplexityConfig{ Enabled: false, - APIKey: "", MaxResults: 5, }, SearXNG: SearXNGConfig{ @@ -393,23 +345,29 @@ func DefaultConfig() *Config { }, GLMSearch: GLMSearchConfig{ Enabled: false, - APIKey: "", BaseURL: "https://open.bigmodel.cn/api/paas/v4/web_search", SearchEngine: "search_std", MaxResults: 5, }, + BaiduSearch: BaiduSearchConfig{ + Enabled: false, + BaseURL: "https://qianfan.baidubce.com/v2/ai_search/web_search", + MaxResults: 10, + }, }, Cron: CronToolsConfig{ ToolConfig: ToolConfig{ Enabled: true, }, ExecTimeoutMinutes: 5, + AllowCommand: true, }, Exec: ExecConfig{ ToolConfig: ToolConfig{ Enabled: true, }, EnableDenyPatterns: true, + AllowRemote: true, TimeoutSeconds: 60, }, Skills: SkillsToolsConfig{ @@ -417,9 +375,17 @@ func DefaultConfig() *Config { Enabled: true, }, Registries: SkillsRegistriesConfig{ - ClawHub: ClawHubRegistryConfig{ + &SkillRegistryConfig{ + Name: "clawhub", Enabled: true, BaseURL: "https://clawhub.ai", + Param: map[string]any{}, + }, + &SkillRegistryConfig{ + Name: "github", + Enabled: true, + BaseURL: "https://github.com", + Param: map[string]any{}, }, }, MaxConcurrentSearches: 2, @@ -431,11 +397,22 @@ func DefaultConfig() *Config { SendFile: ToolConfig{ Enabled: true, }, + SendTTS: ToolConfig{ + Enabled: false, + }, MCP: MCPConfig{ ToolConfig: ToolConfig{ Enabled: false, }, - Servers: map[string]MCPServerConfig{}, + Discovery: ToolDiscoveryConfig{ + Enabled: false, + TTL: 5, + MaxSearchResults: 5, + UseBM25: true, + UseRegex: false, + }, + MaxInlineTextChars: DefaultMCPMaxInlineTextChars, + Servers: map[string]MCPServerConfig{}, }, AppendFile: ToolConfig{ Enabled: true, @@ -458,12 +435,20 @@ func DefaultConfig() *Config { Message: ToolConfig{ Enabled: true, }, - ReadFile: ToolConfig{ - Enabled: true, + ReadFile: ReadFileToolConfig{ + Enabled: true, + Mode: ReadFileModeBytes, + MaxReadFileSize: 64 * 1024, // 64KB + }, + Serial: ToolConfig{ + Enabled: false, // Hardware tool - requires host serial ports }, Spawn: ToolConfig{ Enabled: true, }, + SpawnStatus: ToolConfig{ + Enabled: false, + }, SPI: ToolConfig{ Enabled: false, // Hardware tool - Linux only }, @@ -485,5 +470,113 @@ func DefaultConfig() *Config { Enabled: false, MonitorUSB: true, }, + Voice: VoiceConfig{ + ModelName: "", + TTSModelName: "", + EchoTranscription: false, + ElevenLabsAPIKey: "", + }, + BuildInfo: BuildInfo{ + Version: Version, + GitCommit: GitCommit, + BuildTime: BuildTime, + GoVersion: GoVersion, + }, } } + +func defaultChannels() ChannelsConfig { + defs := map[string]any{ + "whatsapp": map[string]any{ + "settings": map[string]any{ + "bridge_url": "ws://localhost:3001", + }, + }, + "telegram": map[string]any{ + "typing": map[string]any{"enabled": true}, + "placeholder": map[string]any{"enabled": true, "text": []string{"Thinking... 💭"}}, + "settings": map[string]any{ + "streaming": map[string]any{"enabled": true, "throttle_seconds": 3, "min_growth_chars": 200}, + "use_markdown_v2": false, + }, + }, + "feishu": map[string]any{}, + "discord": map[string]any{}, + "maixcam": map[string]any{ + "settings": map[string]any{"host": "0.0.0.0", "port": 18790}, + }, + "qq": map[string]any{ + "settings": map[string]any{"max_message_length": 2000}, + }, + "dingtalk": map[string]any{}, + "slack": map[string]any{}, + "matrix": map[string]any{ + "group_trigger": map[string]any{"mention_only": true}, + "placeholder": map[string]any{"enabled": true, "text": []string{"Thinking... 💭"}}, + "settings": map[string]any{ + "homeserver": "https://matrix.org", + "join_on_invite": true, + }, + }, + "line": map[string]any{ + "group_trigger": map[string]any{"mention_only": true}, + "settings": map[string]any{ + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/line", + }, + }, + "onebot": map[string]any{ + "settings": map[string]any{ + "ws_url": "ws://127.0.0.1:3001", + "reconnect_interval": 5, + }, + }, + "wecom": map[string]any{ + "settings": map[string]any{ + "websocket_url": "wss://openws.work.weixin.qq.com", + "send_thinking_message": true, + }, + }, + "weixin": map[string]any{ + "settings": map[string]any{ + "base_url": "https://ilinkai.weixin.qq.com/", + "cdn_base_url": "https://novac2c.cdn.weixin.qq.com/c2c", + }, + }, + "pico": map[string]any{ + "settings": map[string]any{ + "ping_interval": 30, + "read_timeout": 60, + "write_timeout": 10, + "max_connections": 100, + }, + }, + "irc": map[string]any{ + "settings": map[string]any{ + "server": "", + "tls": true, + "nick": "picoclaw", + "channels": []string{}, + }, + }, + } + + channels := make(ChannelsConfig, len(defs)) + for name, def := range defs { + data, err := json.Marshal(def) + if err != nil { + continue + } + bc := &Channel{} + if err := json.Unmarshal(data, bc); err != nil { + continue + } + bc.SetName(name) + if bc.Type == "" { + bc.Type = name + } + channels[name] = bc + } + return channels +} diff --git a/pkg/config/diagnostics.go b/pkg/config/diagnostics.go new file mode 100644 index 000000000..bbc59c03b --- /dev/null +++ b/pkg/config/diagnostics.go @@ -0,0 +1,441 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "reflect" + "sort" + "strings" + "unicode/utf8" + + "golang.org/x/term" +) + +func decodeJSONWithDiagnostics(data []byte, target any, label string) error { + var raw any + if err := json.Unmarshal(data, &raw); err != nil { + return wrapJSONError(data, err, label) + } + + unknownFields := collectUnknownJSONFields(raw, reflect.TypeOf(target), "") + if len(unknownFields) > 0 { + sort.Strings(unknownFields) + return fmt.Errorf( + "%s contains unknown field(s): %s", + label, + strings.Join(unknownFields, ", "), + ) + } + + if err := json.Unmarshal(data, target); err != nil { + return wrapJSONError(data, err, label) + } + return nil +} + +func DiagnosticSummary(err error) string { + if err == nil { + return "" + } + summary, _ := splitDiagnosticError(err.Error()) + return stripANSISequences(summary) +} + +func formatDiagnosticLogMessage(prefix string, err error) string { + if err == nil { + return prefix + } + + summary, preview := splitDiagnosticError(err.Error()) + summary = stripANSISequences(summary) + if preview == "" { + if summary == "" { + return prefix + } + return prefix + ": " + summary + } + if summary == "" { + return prefix + "\n" + preview + } + return prefix + ": " + summary + "\n" + preview +} + +func wrapJSONError(data []byte, err error, label string) error { + switch e := err.(type) { + case *json.SyntaxError: + line, column := lineAndColumnForOffset(data, e.Offset) + preview := diagnosticPreviewForOffset(data, e.Offset) + if preview != "" { + return fmt.Errorf( + "%s syntax error at line %d, column %d: %w\n%s", + label, + line, + column, + err, + preview, + ) + } + return fmt.Errorf("%s syntax error at line %d, column %d: %w", label, line, column, err) + case *json.UnmarshalTypeError: + line, column := lineAndColumnForOffset(data, e.Offset) + preview := diagnosticPreviewForOffset(data, e.Offset) + field := strings.TrimSpace(e.Field) + if field != "" { + if preview != "" { + return fmt.Errorf( + "%s type error at line %d, column %d for field %q: expected %s but got %s\n%s", + label, + line, + column, + field, + e.Type.String(), + e.Value, + preview, + ) + } + return fmt.Errorf( + "%s type error at line %d, column %d for field %q: expected %s but got %s", + label, + line, + column, + field, + e.Type.String(), + e.Value, + ) + } + if preview != "" { + return fmt.Errorf( + "%s type error at line %d, column %d: expected %s but got %s\n%s", + label, + line, + column, + e.Type.String(), + e.Value, + preview, + ) + } + return fmt.Errorf( + "%s type error at line %d, column %d: expected %s but got %s", + label, + line, + column, + e.Type.String(), + e.Value, + ) + default: + return fmt.Errorf("failed to parse %s: %w", label, err) + } +} + +func splitDiagnosticError(message string) (string, string) { + if idx := strings.IndexByte(message, '\n'); idx >= 0 { + return message[:idx], message[idx+1:] + } + return message, "" +} + +func stripANSISequences(s string) string { + if s == "" { + return "" + } + + var b strings.Builder + b.Grow(len(s)) + + for i := 0; i < len(s); i++ { + if s[i] != 0x1b { + b.WriteByte(s[i]) + continue + } + if i+1 >= len(s) || s[i+1] != '[' { + continue + } + i += 2 + for i < len(s) { + c := s[i] + if c >= '@' && c <= '~' { + break + } + i++ + } + } + + return b.String() +} + +func diagnosticPreviewForOffset(data []byte, offset int64) string { + if len(data) == 0 { + return "" + } + + start, end := lineBoundsForOffset(data, offset) + if start >= end { + return "" + } + + lineNumber, column := lineAndColumnForOffset(data, offset) + line := strings.TrimRight(string(data[start:end]), "\r\n") + if strings.TrimSpace(line) == "" { + return "" + } + + trimmedLine, trimOffset := trimDiagnosticLine(line, column) + if trimmedLine == "" { + return "" + } + + prefix := fmt.Sprintf("%4d | ", lineNumber) + caretColumn := column - trimOffset + if caretColumn < 1 { + caretColumn = 1 + } + + if diagnosticsUseColor() { + linePrefix := "\x1b[2m" + prefix + "\x1b[0m" + caretPrefix := "\x1b[2m" + strings.Repeat(" ", len(fmt.Sprintf("%4d", lineNumber))) + " | " + "\x1b[0m" + highlighted := highlightDiagnosticColumn(trimmedLine, caretColumn) + caretPad := strings.Repeat(" ", maxRuneCount(trimmedLine, caretColumn-1)) + return fmt.Sprintf( + " %s%s\n %s%s\x1b[1;31m^\x1b[0m", + linePrefix, + highlighted, + caretPrefix, + caretPad, + ) + } + + caretPrefix := strings.Repeat(" ", len(prefix)) + caretPad := strings.Repeat(" ", maxRuneCount(trimmedLine, caretColumn-1)) + return fmt.Sprintf( + " %s%s\n %s%s^", + prefix, + trimmedLine, + caretPrefix, + caretPad, + ) +} + +func lineAndColumnForOffset(data []byte, offset int64) (int, int) { + if offset <= 0 { + return 1, 1 + } + if offset > int64(len(data)) { + offset = int64(len(data)) + } + + line := 1 + column := 1 + for i := int64(0); i < offset-1; i++ { + if data[i] == '\n' { + line++ + column = 1 + continue + } + column++ + } + return line, column +} + +func lineBoundsForOffset(data []byte, offset int64) (int, int) { + if len(data) == 0 { + return 0, 0 + } + + if offset <= 0 { + offset = 1 + } + if offset > int64(len(data)) { + offset = int64(len(data)) + } + + index := int(offset - 1) + if index < 0 { + index = 0 + } + if index >= len(data) { + index = len(data) - 1 + } + + start := index + for start > 0 && data[start-1] != '\n' { + start-- + } + + end := index + for end < len(data) && data[end] != '\n' { + end++ + } + + return start, end +} + +func trimDiagnosticLine(line string, column int) (string, int) { + runes := []rune(line) + if len(runes) == 0 { + return "", 0 + } + + if len(runes) <= 160 { + return line, 0 + } + + const contextBefore = 60 + const maxWidth = 160 + + start := column - 1 - contextBefore + if start < 0 { + start = 0 + } + if start > len(runes)-maxWidth { + start = len(runes) - maxWidth + } + if start < 0 { + start = 0 + } + + end := start + maxWidth + if end > len(runes) { + end = len(runes) + } + + trimmed := string(runes[start:end]) + trimOffset := start + + if start > 0 { + trimmed = "..." + trimmed + trimOffset -= 3 + } + if end < len(runes) { + trimmed += "..." + } + + return trimmed, trimOffset +} + +func diagnosticsUseColor() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +} + +func highlightDiagnosticColumn(line string, column int) string { + runes := []rune(line) + if column < 1 || column > len(runes) { + return line + } + + index := column - 1 + return string(runes[:index]) + "\x1b[31m" + string(runes[index]) + "\x1b[0m" + string(runes[index+1:]) +} + +func maxRuneCount(s string, count int) int { + if count <= 0 { + return 0 + } + runes := []rune(s) + if count > len(runes) { + count = len(runes) + } + return utf8.RuneCountInString(string(runes[:count])) +} + +func collectUnknownJSONFields(raw any, targetType reflect.Type, path string) []string { + targetType = derefType(targetType) + if targetType == nil { + return nil + } + + switch targetType.Kind() { + case reflect.Struct: + obj, ok := raw.(map[string]any) + if !ok { + return nil + } + fieldMap := jsonFieldTypeMap(targetType) + var issues []string + for key, value := range obj { + fieldType, exists := fieldMap[key] + fieldPath := appendJSONPath(path, key) + if !exists { + issues = append(issues, fieldPath) + continue + } + issues = append(issues, collectUnknownJSONFields(value, fieldType, fieldPath)...) + } + return issues + case reflect.Slice, reflect.Array: + items, ok := raw.([]any) + if !ok { + return nil + } + var issues []string + elemType := targetType.Elem() + for i, item := range items { + itemPath := fmt.Sprintf("%s[%d]", path, i) + issues = append(issues, collectUnknownJSONFields(item, elemType, itemPath)...) + } + return issues + case reflect.Map: + obj, ok := raw.(map[string]any) + if !ok { + return nil + } + var issues []string + elemType := targetType.Elem() + for key, value := range obj { + fieldPath := appendJSONPath(path, key) + issues = append(issues, collectUnknownJSONFields(value, elemType, fieldPath)...) + } + return issues + default: + return nil + } +} + +func jsonFieldTypeMap(t reflect.Type) map[string]reflect.Type { + result := make(map[string]reflect.Type) + populateJSONFieldTypeMap(result, derefType(t)) + return result +} + +func populateJSONFieldTypeMap(result map[string]reflect.Type, t reflect.Type) { + if t == nil || t.Kind() != reflect.Struct { + return + } + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + continue + } + + tag := field.Tag.Get("json") + name := strings.Split(tag, ",")[0] + if name == "-" { + continue + } + + if field.Anonymous && name == "" { + populateJSONFieldTypeMap(result, derefType(field.Type)) + continue + } + + if name == "" { + name = field.Name + } + result[name] = field.Type + } +} + +func derefType(t reflect.Type) reflect.Type { + for t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t +} + +func appendJSONPath(path, segment string) string { + if path == "" { + return segment + } + return path + "." + segment +} diff --git a/pkg/config/envkeys.go b/pkg/config/envkeys.go new file mode 100644 index 000000000..5a2590299 --- /dev/null +++ b/pkg/config/envkeys.go @@ -0,0 +1,57 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package config + +import ( + "os" + "path/filepath" + + "github.com/sipeed/picoclaw/pkg" +) + +// Runtime environment variable keys for the picoclaw process. +// These control the location of files and binaries at runtime and are read +// directly via os.Getenv / os.LookupEnv. All picoclaw-specific keys use the +// PICOCLAW_ prefix. Reference these constants instead of inline string +// literals to keep all supported knobs visible in one place and to prevent +// typos. +const ( + // EnvHome overrides the base directory for all picoclaw data + // (config, workspace, skills, auth store, …). + // Default: ~/.picoclaw + EnvHome = "PICOCLAW_HOME" + + // EnvConfig overrides the full path to the JSON config file. + // Default: $PICOCLAW_HOME/config.json + EnvConfig = "PICOCLAW_CONFIG" + + // EnvBuiltinSkills overrides the directory from which built-in + // skills are loaded. + // Default: /skills + EnvBuiltinSkills = "PICOCLAW_BUILTIN_SKILLS" + + // EnvBinary overrides the path to the picoclaw executable. + // Used by the web launcher when spawning the gateway subprocess. + // Default: resolved from the same directory as the current executable. + EnvBinary = "PICOCLAW_BINARY" + + // EnvGatewayHost overrides the host address for the gateway server. + // Default: "localhost" + EnvGatewayHost = "PICOCLAW_GATEWAY_HOST" +) + +func GetHome() string { + homePath, _ := os.UserHomeDir() + if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" { + homePath = picoclawHome + } else if homePath != "" { + homePath = filepath.Join(homePath, pkg.DefaultPicoClawHome) + } + if homePath == "" { + homePath = "." + } + return homePath +} diff --git a/pkg/config/events.go b/pkg/config/events.go new file mode 100644 index 000000000..54a2ce709 --- /dev/null +++ b/pkg/config/events.go @@ -0,0 +1,48 @@ +package config + +// EventsConfig groups runtime event configuration. +type EventsConfig struct { + Logging EventLoggingConfig `json:"logging,omitempty" envPrefix:"PICOCLAW_EVENTS_LOGGING_"` +} + +// EventLoggingConfig controls centralized runtime event logging. +type EventLoggingConfig struct { + // Enabled controls whether runtime events are printed by the built-in logger. + Enabled bool `json:"enabled" env:"ENABLED"` + // Include contains exact event kinds or glob patterns such as "agent.*" or "*". + Include []string `json:"include,omitempty" env:"INCLUDE"` + // Exclude contains exact event kinds or glob patterns to suppress after Include matches. + Exclude []string `json:"exclude,omitempty" env:"EXCLUDE"` + // MinSeverity filters out events below the configured severity: debug, info, warn, or error. + MinSeverity string `json:"min_severity,omitempty" env:"MIN_SEVERITY"` + // IncludePayload adds the raw payload to logs. Leave disabled unless detailed diagnostics are needed. + IncludePayload bool `json:"include_payload,omitempty" env:"INCLUDE_PAYLOAD"` +} + +// DefaultEventLoggingInclude keeps the pre-existing behavior where agent events +// are printed, while non-agent runtime events are published for subscribers only. +var DefaultEventLoggingInclude = []string{"agent.*"} + +// EffectiveEventLoggingConfig returns a logging config with stable defaults. +func EffectiveEventLoggingConfig(cfg *Config) EventLoggingConfig { + if cfg == nil { + return defaultEventLoggingConfig() + } + + out := cfg.Events.Logging + if out.MinSeverity == "" { + out.MinSeverity = "info" + } + if len(out.Include) == 0 { + out.Include = append([]string(nil), DefaultEventLoggingInclude...) + } + return out +} + +func defaultEventLoggingConfig() EventLoggingConfig { + return EventLoggingConfig{ + Enabled: true, + Include: append([]string(nil), DefaultEventLoggingInclude...), + MinSeverity: "info", + } +} diff --git a/pkg/config/events_test.go b/pkg/config/events_test.go new file mode 100644 index 000000000..6cd410492 --- /dev/null +++ b/pkg/config/events_test.go @@ -0,0 +1,103 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestDefaultEventLoggingConfig(t *testing.T) { + cfg := DefaultConfig() + logCfg := EffectiveEventLoggingConfig(cfg) + + if !logCfg.Enabled { + t.Fatal("default event logging should be enabled") + } + if !reflect.DeepEqual(logCfg.Include, []string{"agent.*"}) { + t.Fatalf("default include = %#v, want agent.*", logCfg.Include) + } + if logCfg.MinSeverity != "info" { + t.Fatalf("default min severity = %q, want info", logCfg.MinSeverity) + } + if logCfg.IncludePayload { + t.Fatal("default event logging should not include raw payloads") + } +} + +func TestLoadConfigEventLoggingOverrides(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + data := []byte(`{ + "version": 3, + "events": { + "logging": { + "enabled": false, + "include": ["gateway.*"], + "exclude": ["gateway.ready"], + "min_severity": "warn", + "include_payload": true + } + } + }`) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + logCfg := EffectiveEventLoggingConfig(cfg) + + if logCfg.Enabled { + t.Fatal("loaded event logging enabled = true, want false") + } + if !reflect.DeepEqual(logCfg.Include, []string{"gateway.*"}) { + t.Fatalf("loaded include = %#v, want gateway.*", logCfg.Include) + } + if !reflect.DeepEqual(logCfg.Exclude, []string{"gateway.ready"}) { + t.Fatalf("loaded exclude = %#v, want gateway.ready", logCfg.Exclude) + } + if logCfg.MinSeverity != "warn" { + t.Fatalf("loaded min severity = %q, want warn", logCfg.MinSeverity) + } + if !logCfg.IncludePayload { + t.Fatal("loaded include_payload = false, want true") + } +} + +func TestLoadConfigEventLoggingEnvOverrides(t *testing.T) { + t.Setenv("PICOCLAW_EVENTS_LOGGING_ENABLED", "false") + t.Setenv("PICOCLAW_EVENTS_LOGGING_INCLUDE", "gateway.*,channel.lifecycle.*") + t.Setenv("PICOCLAW_EVENTS_LOGGING_EXCLUDE", "gateway.ready") + t.Setenv("PICOCLAW_EVENTS_LOGGING_MIN_SEVERITY", "error") + t.Setenv("PICOCLAW_EVENTS_LOGGING_INCLUDE_PAYLOAD", "true") + + path := filepath.Join(t.TempDir(), "config.json") + data := []byte(`{"version": 3}`) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + logCfg := EffectiveEventLoggingConfig(cfg) + + if logCfg.Enabled { + t.Fatal("env enabled override = true, want false") + } + if !reflect.DeepEqual(logCfg.Include, []string{"gateway.*", "channel.lifecycle.*"}) { + t.Fatalf("env include = %#v, want gateway/channel lifecycle", logCfg.Include) + } + if !reflect.DeepEqual(logCfg.Exclude, []string{"gateway.ready"}) { + t.Fatalf("env exclude = %#v, want gateway.ready", logCfg.Exclude) + } + if logCfg.MinSeverity != "error" { + t.Fatalf("env min severity = %q, want error", logCfg.MinSeverity) + } + if !logCfg.IncludePayload { + t.Fatal("env include_payload = false, want true") + } +} diff --git a/pkg/config/example_security_usage.go b/pkg/config/example_security_usage.go new file mode 100644 index 000000000..42a1831b0 --- /dev/null +++ b/pkg/config/example_security_usage.go @@ -0,0 +1,586 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +// This file demonstrates how to use the security configuration feature +// It's not meant to be compiled, just for documentation purposes + +/* +Package config + +# Example: Using Security Configuration + +## Overview + +The security configuration feature allows you to separate sensitive data (API keys, +tokens, secrets, passwords) from your main configuration. The system automatically +loads values from `.security.yml` and applies them to the corresponding fields in +your config. + +**Key Points:** +- Values from `.security.yml` are automatically mapped to config fields +- No `ref:` syntax is needed - just omit sensitive fields from config.json +- If a field exists in both files, `.security.yml` value takes precedence +- You can mix direct values in config.json with security values + +## 1. Create .security.yml + +File: ~/.picoclaw/.security.yml + +```yaml +# Model API Keys +# All models MUST use 'api_keys' (plural) array format +# Even a single key must be provided as an array with one element +model_list: + + gpt-5.4: + api_keys: + - "sk-proj-your-actual-openai-key-1" + - "sk-proj-your-actual-openai-key-2" # Optional: Multiple keys for failover + claude-sonnet-4.6: + api_keys: + - "sk-ant-your-actual-anthropic-key" # Single key in array format + +# Channel Tokens +channels: + + telegram: + token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" + discord: + token: "your-discord-bot-token" + +# Web Tool Keys +# Brave, Tavily, Perplexity: Use 'api_keys' array +# GLMSearch, BaiduSearch: Use 'api_key' single string +web: + + brave: + api_keys: + - "BSAyour-brave-api-key-1" + - "BSAyour-brave-api-key-2" # Optional: Multiple keys for failover + tavily: + api_keys: + - "tvly-your-tavily-api-key" # Single key in array format + perplexity: + api_keys: + - "pplx-your-perplexity-api-key" # Single key in array format + glm_search: + api_key: "your-glm-search-api-key" # Single key (not array) + baidu_search: + api_key: "your-baidu-search-api-key" # Single key (not array) + +``` + +## 2. Simplify config.json + +File: ~/.picoclaw/config.json + +Note: Sensitive fields are omitted because they're loaded from .security.yml + +```json + + { + "version": 1, + "agents": { + "defaults": { + "workspace": "~/picoclaw-workspace", + "model_name": "gpt-5.4" + } + }, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api.openai.com/v1" + // api_key is automatically loaded from .security.yml + }, + { + "model_name": "claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4.6", + "api_base": "https://api.anthropic.com/v1" + // api_key is automatically loaded from .security.yml + } + ], + "channels": { + "telegram": { + "enabled": true + // token is automatically loaded from .security.yml + }, + "discord": { + "enabled": true + // token is automatically loaded from .security.yml + } + }, + "tools": { + "web": { + "brave": { + "enabled": true + // api_key is automatically loaded from .security.yml + }, + "tavily": { + "enabled": true + // api_key is automatically loaded from .security.yml + }, + "glm_search": { + "enabled": true + // api_key is automatically loaded from .security.yml + }, + "baidu_search": { + "enabled": true + // api_key is automatically loaded from .security.yml + } + } + } + } + +``` + +## 3. Set proper permissions + +```bash +chmod 600 ~/.picoclaw/.security.yml +``` + +## 4. Add to .gitignore + +```gitignore +# Security configuration +.security.yml +``` + +## 5. Verify it works + +```bash +picoclaw --version +``` + +# Supported Fields in .security.yml + +## Model API Keys + +All models MUST use the `api_keys` (plural) array format in .security.yml. + +```yaml +model_list: + + : + api_keys: + - "key-1" + - "key-2" # Optional: Multiple keys for failover + +``` + +Examples: +```yaml +model_list: + + gpt-5.4: + api_keys: + - "sk-proj-key-1" + - "sk-proj-key-2" + claude-sonnet-4.6: + api_keys: + - "sk-ant-key" + +``` + +**Important:** +- Always use `api_keys` (plural) for models +- Even a single key must be in an array format +- The model_name in .security.yml must match the model_name in config.json + +## Channel Tokens/Secrets + +```yaml +channels: + + telegram: + token: "value" + feishu: + app_secret: "value" + encrypt_key: "value" + verification_token: "value" + discord: + token: "value" + weixin: + token: "value" + qq: + app_secret: "value" + dingtalk: + client_secret: "value" + slack: + bot_token: "value" + app_token: "value" + matrix: + access_token: "value" + line: + channel_secret: "value" + channel_access_token: "value" + onebot: + access_token: "value" + wecom: + token: "value" + encoding_aes_key: "value" + wecom_app: + corp_secret: "value" + token: "value" + encoding_aes_key: "value" + wecom_aibot: + secret: "value" + token: "value" + encoding_aes_key: "value" + pico: + token: "value" + irc: + password: "value" + nickserv_password: "value" + sasl_password: "value" + +## Web Tool API Keys + +**Brave, Tavily, Perplexity:** +```yaml +web: + + brave: + api_keys: + - "BSA-key-1" + - "BSA-key-2" + tavily: + api_keys: + - "tvly-key" + perplexity: + api_keys: + - "pplx-key" + +``` +Use `api_keys` (plural) array format. + +**GLMSearch, BaiduSearch:** +```yaml +web: + + glm_search: + api_key: "your-glm-key" + baidu_search: + api_key: "your-baidu-key" + +``` +Use `api_key` (singular) single string format. + +## Skills Registry Tokens + +```yaml +skills: + + github: + token: "value" + clawhub: + auth_token: "value" + +``` + +# Backward Compatibility + +You can still use direct values in config.json if needed: + +```json + + { + "model_list": [ + { + "model_name": "local-model", + "model": "ollama/llama3", + "api_base": "http://localhost:11434/v1", + "api_key": "ollama" // Direct value (works fine) + } + ] + } + +``` + +You can also mix security values and direct values: + +```json + + { + "model_list": [ + { + "model_name": "cloud-model", + // api_key loaded from .security.yml + }, + { + "model_name": "local-model", + "model": "ollama/llama3", + "api_base": "http://localhost:11434/v1", + "api_key": "ollama" // Direct value + } + ] + } + +``` + +**Priority Order:** +1. Environment variables (highest priority) +2. .security.yml values +3. config.json direct values (lowest priority) + +# Migration from Old Config + +## Step 1: Backup your config +```bash +cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup +``` + +## Step 2: Create .security.yml +```bash +cp security.example.yml ~/.picoclaw/.security.yml +``` + +## Step 3: Fill in your API keys +Edit ~/.picoclaw/.security.yml and replace placeholders with your actual keys. + +## Step 4: Simplify config.json (Recommended) +Remove sensitive fields from ~/.picoclaw/config.json: +- `api_key` fields from model_list entries +- `token` fields from channels +- `api_key` fields from tools.web +- `token`/`auth_token` fields from tools.skills + +## Step 5: Set permissions +```bash +chmod 600 ~/.picoclaw/.security.yml +``` + +## Step 6: Test +```bash +picoclaw --version +``` + +If everything works, you can delete the backup: +```bash +rm ~/.picoclaw/config.json.backup +``` + +# Advanced Features + +## Multiple API Keys (Load Balancing & Failover) + +You can configure multiple API keys for models and web tools to enable: +- **Load balancing**: Requests are distributed across multiple keys +- **Failover**: If a key fails, the system automatically switches to another key +- **Rate limit management**: Distribute usage across multiple keys +- **High availability**: Reduce downtime during API provider issues + +### Example: Model with Multiple Keys + +**.security.yml:** +```yaml +model_list: + + gpt-5.4: + api_keys: + - "sk-proj-key-1" + - "sk-proj-key-2" + - "sk-proj-key-3" + +``` + +**config.json:** +```json + + { + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_base": "https://api.openai.com/v1" + } + ] + } + +``` + +### Example: Web Tool with Multiple Keys + +**.security.yml:** +```yaml +web: + + brave: + api_keys: + - "BSA-key-1" + - "BSA-key-2" + tavily: + api_keys: + - "tvly-your-key" # Single key in array format + glm_search: + api_key: "your-glm-key" # GLMSearch uses single key format + +``` + +**config.json:** +```json + + { + "tools": { + "web": { + "brave": { + "enabled": true + }, + "tavily": { + "enabled": true + }, + "glm_search": { + "enabled": true + } + } + } + } + +``` + +## Single Key Format + +**Models, Brave, Tavily, Perplexity:** +```yaml +model_list: + + gpt-5.4: + api_keys: + - "sk-proj-your-key" # Single key in array format + +``` + +**GLMSearch, BaiduSearch:** +```yaml +web: + + glm_search: + api_key: "your-glm-key" # Single key (not array) + +``` + +## Model Name Matching + +The system supports intelligent model name matching in .security.yml: + +### Example 1: Exact Match + +**config.json:** +```json + + { + "model_name": "gpt-5.4:0" + } + +``` + +**.security.yml (exact match with index):** +```yaml +model_list: + + gpt-5.4:0: + api_keys: ["key-1"] + +``` + +### Example 2: Base Name Match + +**config.json:** +```json + + { + "model_name": "gpt-5.4:0" + } + +``` + +**.security.yml (base name without index):** +```yaml +model_list: + + gpt-5.4: + api_keys: ["key-1", "key-2"] + +``` + +Both methods work. The base name match allows you to use simpler keys in .security.yml +even when your config uses indexed model names for load balancing. + +## Security File Permissions + +The security file should have restricted permissions: + +```bash +chmod 600 ~/.picoclaw/.security.yml +``` + +This ensures only the owner can read and write the file. + +# Security Best Practices + +1. Never commit .security.yml to version control +2. Add .security.yml to your .gitignore file +3. Set file permissions: chmod 600 ~/.picoclaw/.security.yml +4. Use different keys for different environments (dev, staging, production) +5. Rotate keys regularly and update .security.yml +6. Encrypt backups containing .security.yml +7. Review access regularly + +# Environment Variables + +You can override any security value using environment variables: + +```bash +# Channels +export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env" +export PICOCLAW_CHANNELS_DISCORD_TOKEN="discord-token-from-env" + +# Web Tools +export PICOCLAW_TOOLS_WEB_BRAVE_API_KEY="brave-key-from-env" +export PICOCLAW_TOOLS_WEB_BAIDU_API_KEY="baidu-key-from-env" + +# Skills +export PICOCLAW_TOOLS_SKILLS_GITHUB_TOKEN="github-token-from-env" +``` + +Environment variables have the highest priority and will override both config.json +and .security.yml values. + +# Troubleshooting + +## Error: "failed to load security config" +- Ensure .security.yml exists in the same directory as config.json +- Check YAML syntax is valid (use a YAML validator) +- Verify file permissions allow reading + +## Error: "model security entry not found" +- Check that the model name in config.json matches exactly in .security.yml +- Verify the model_list section exists in .security.yml +- For indexed names (e.g., "gpt-5.4:0"), check both exact match and base name match +- Ensure the YAML structure is correct (proper indentation) + +## Multiple API Keys Not Working +- Ensure you're using `api_keys` (plural) in .security.yml for models and web tools (except GLMSearch/BaiduSearch) +- Check that the array format is correct in YAML (proper indentation with dashes) +- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format) +- GLMSearch and BaiduSearch MUST use `api_key` (single string format) + +## Keys Not Being Applied +- Check that .security.yml is in the same directory as config.json +- Verify the file permissions allow reading (chmod 600 ~/.picoclaw/.security.yml) +- Ensure the YAML structure matches the expected format +- Check for typos in field names (case-sensitive) +- Verify the model/channel names match exactly (case-sensitive) + +## Load Balancing/Failover Issues +- Verify all API keys in the api_keys array are valid +- Check that all keys have the same rate limits and permissions +- Monitor logs to see which keys are being used and failing +- Ensure the api_keys array is properly formatted in YAML +*/ +package config + +// This file is documentation only diff --git a/pkg/config/gateway.go b/pkg/config/gateway.go new file mode 100644 index 000000000..392a4ca5e --- /dev/null +++ b/pkg/config/gateway.go @@ -0,0 +1,99 @@ +package config + +import ( + "encoding/json" + "os" + "strings" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/netbind" +) + +const DefaultGatewayLogLevel = "warn" + +type GatewayConfig struct { + Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` + Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` + HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"` + LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"` +} + +func canonicalGatewayLogLevel(level logger.LogLevel) string { + switch level { + case logger.DEBUG: + return "debug" + case logger.INFO: + return "info" + case logger.WARN: + return "warn" + case logger.ERROR: + return "error" + case logger.FATAL: + return "fatal" + default: + return DefaultGatewayLogLevel + } +} + +func normalizeGatewayLogLevel(logLevel string) string { + if level, ok := logger.ParseLevel(logLevel); ok { + return canonicalGatewayLogLevel(level) + } + return DefaultGatewayLogLevel +} + +// EffectiveGatewayLogLevel returns the normalized runtime log level from a loaded config. +// Invalid or empty values fall back to the package default. +func EffectiveGatewayLogLevel(cfg *Config) string { + if cfg == nil { + return DefaultGatewayLogLevel + } + return normalizeGatewayLogLevel(cfg.Gateway.LogLevel) +} + +func resolveGatewayHostFromEnv(baseHost string) (string, error) { + envHost, ok := os.LookupEnv(EnvGatewayHost) + if !ok { + return normalizeGatewayHostInput(baseHost) + } + + envHost = strings.TrimSpace(envHost) + if envHost == "" { + return normalizeGatewayHostInput(baseHost) + } + + return normalizeGatewayHostInput(envHost) +} + +func normalizeGatewayHostInput(host string) (string, error) { + host = strings.TrimSpace(host) + if host == "" { + host = strings.TrimSpace(DefaultConfig().Gateway.Host) + } + if host == "" { + host = "localhost" + } + return netbind.NormalizeHostInput(host) +} + +// ResolveGatewayLogLevel reads the configured gateway log level without triggering +// the full config loader, so startup code can apply logging before config load logs run. +// The PICOCLAW_LOG_LEVEL environment variable overrides the file value. +func ResolveGatewayLogLevel(path string) string { + cfg := struct { + Gateway GatewayConfig `json:"gateway"` + }{ + Gateway: GatewayConfig{LogLevel: DefaultGatewayLogLevel}, + } + + data, err := os.ReadFile(path) + if err == nil { + _ = json.Unmarshal(data, &cfg) + } + + if envLevel := os.Getenv("PICOCLAW_LOG_LEVEL"); envLevel != "" { + cfg.Gateway.LogLevel = envLevel + } + + return normalizeGatewayLogLevel(cfg.Gateway.LogLevel) +} diff --git a/pkg/config/gateway_host_env_test.go b/pkg/config/gateway_host_env_test.go new file mode 100644 index 000000000..40fabb1a3 --- /dev/null +++ b/pkg/config/gateway_host_env_test.go @@ -0,0 +1,98 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +func writeGatewayHostTestConfig(t *testing.T, host string) string { + t.Helper() + + configPath := filepath.Join(t.TempDir(), "config.json") + raw := fmt.Sprintf(`{"version":2,"gateway":{"host":%q,"port":18790}}`, host) + if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { + t.Fatalf("WriteFile(configPath): %v", err) + } + return configPath +} + +func TestLoadConfig_GatewayHostEnvTrimmed(t *testing.T) { + configPath := writeGatewayHostTestConfig(t, "127.0.0.1") + t.Setenv(EnvGatewayHost, " ::1 ") + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Gateway.Host != "::1" { + t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, "::1") + } +} + +func TestLoadConfig_GatewayHostBlankEnvFallsBackToConfigHost(t *testing.T) { + configPath := writeGatewayHostTestConfig(t, " localhost ") + t.Setenv(EnvGatewayHost, " ") + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + want, err := normalizeGatewayHostInput("localhost") + if err != nil { + t.Fatalf("normalizeGatewayHostInput() error: %v", err) + } + if cfg.Gateway.Host != want { + t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, want) + } +} + +func TestLoadConfig_GatewayHostBlankEnvAndConfigFallsBackToDefault(t *testing.T) { + configPath := writeGatewayHostTestConfig(t, " ") + t.Setenv(EnvGatewayHost, " ") + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + + defaultHost, err := normalizeGatewayHostInput(DefaultConfig().Gateway.Host) + if err != nil { + t.Fatalf("normalizeGatewayHostInput() error: %v", err) + } + if cfg.Gateway.Host != defaultHost { + t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, defaultHost) + } +} + +func TestLoadConfig_GatewayHostEnvPreservesExplicitWildcardHost(t *testing.T) { + configPath := writeGatewayHostTestConfig(t, "localhost") + t.Setenv(EnvGatewayHost, " 0.0.0.0 ") + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + + want, err := normalizeGatewayHostInput("0.0.0.0") + if err != nil { + t.Fatalf("normalizeGatewayHostInput() error: %v", err) + } + if cfg.Gateway.Host != want { + t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, want) + } +} + +func TestLoadConfig_GatewayHostEnvNormalizesMultiHostInput(t *testing.T) { + configPath := writeGatewayHostTestConfig(t, "localhost") + t.Setenv(EnvGatewayHost, " [::1] , 127.0.0.1 , ::1 ") + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Gateway.Host != "::1,127.0.0.1" { + t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, "::1,127.0.0.1") + } +} diff --git a/pkg/config/legacy_bindings.go b/pkg/config/legacy_bindings.go new file mode 100644 index 000000000..751a35de7 --- /dev/null +++ b/pkg/config/legacy_bindings.go @@ -0,0 +1,267 @@ +package config + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +const legacyDefaultAccountID = "default" + +type legacyBindingsEnvelope struct { + Bindings json.RawMessage `json:"bindings"` +} + +type legacyAgentBinding struct { + AgentID string `json:"agent_id"` + Match legacyBindingMatch `json:"match"` +} + +type legacyBindingMatch struct { + Channel string `json:"channel"` + AccountID string `json:"account_id,omitempty"` + Peer *legacyPeerMatch `json:"peer,omitempty"` + GuildID string `json:"guild_id,omitempty"` + TeamID string `json:"team_id,omitempty"` +} + +type legacyPeerMatch struct { + Kind string `json:"kind"` + ID string `json:"id"` +} + +func applyLegacyBindingsMigration(data []byte, cfg *Config) { + if cfg == nil { + return + } + + bindings, found, err := decodeLegacyBindings(data) + if err != nil { + logger.WarnF( + "legacy bindings config detected but could not be decoded", + map[string]any{"error": err}, + ) + return + } + if !found { + return + } + + if cfg.Agents.Dispatch != nil && len(cfg.Agents.Dispatch.Rules) > 0 { + logger.WarnF( + "legacy bindings config is deprecated and ignored because agents.dispatch.rules is configured", + map[string]any{"bindings": len(bindings), "dispatch_rules": len(cfg.Agents.Dispatch.Rules)}, + ) + return + } + + rules, dropped := migrateLegacyBindings(bindings, cfg.Session.IdentityLinks) + if len(rules) == 0 { + logger.WarnF( + "legacy bindings config is deprecated and could not be migrated", + map[string]any{"bindings": len(bindings), "dropped_bindings": dropped}, + ) + return + } + + if cfg.Agents.Dispatch == nil { + cfg.Agents.Dispatch = &DispatchConfig{} + } + cfg.Agents.Dispatch.Rules = rules + + fields := map[string]any{ + "bindings": len(bindings), + "dispatch_rules": len(rules), + } + if dropped > 0 { + fields["dropped_bindings"] = dropped + } + logger.WarnF("legacy bindings config is deprecated; migrated to agents.dispatch.rules in memory", fields) +} + +func decodeLegacyBindings(data []byte) ([]legacyAgentBinding, bool, error) { + var envelope legacyBindingsEnvelope + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, false, err + } + if len(envelope.Bindings) == 0 { + return nil, false, nil + } + + var bindings []legacyAgentBinding + if err := json.Unmarshal(envelope.Bindings, &bindings); err != nil { + return nil, true, err + } + return bindings, true, nil +} + +func migrateLegacyBindings(bindings []legacyAgentBinding, identityLinks map[string][]string) ([]DispatchRule, int) { + if len(bindings) == 0 { + return nil, 0 + } + + type prioritizedRule struct { + rule DispatchRule + index int + kind int + } + + prioritized := make([]prioritizedRule, 0, len(bindings)) + dropped := 0 + for i, binding := range bindings { + rule, kind, ok := migrateLegacyBinding(binding, i, identityLinks) + if !ok { + dropped++ + continue + } + prioritized = append(prioritized, prioritizedRule{rule: rule, index: i, kind: kind}) + } + if len(prioritized) == 0 { + return nil, dropped + } + + rules := make([]DispatchRule, 0, len(prioritized)) + for kind := 0; kind <= 4; kind++ { + for _, item := range prioritized { + if item.kind == kind { + rules = append(rules, item.rule) + } + } + } + return rules, dropped +} + +func migrateLegacyBinding( + binding legacyAgentBinding, + index int, + identityLinks map[string][]string, +) (DispatchRule, int, bool) { + channel := strings.ToLower(strings.TrimSpace(binding.Match.Channel)) + agentID := strings.TrimSpace(binding.AgentID) + if channel == "" || agentID == "" { + return DispatchRule{}, 0, false + } + + rule := DispatchRule{ + Name: fmt.Sprintf("legacy-binding-%d", index+1), + Agent: agentID, + When: DispatchSelector{ + Channel: channel, + }, + } + + switch normalizeLegacyAccountSelector(binding.Match.AccountID) { + case "": + case "*": + default: + rule.When.Account = normalizeLegacyAccountSelector(binding.Match.AccountID) + } + + if peer := binding.Match.Peer; peer != nil { + peerKind := strings.ToLower(strings.TrimSpace(peer.Kind)) + peerID := strings.TrimSpace(peer.ID) + if peerID == "" { + return DispatchRule{}, 0, false + } + switch peerKind { + case "direct": + rule.When.Sender = canonicalLegacyBindingSenderID(channel, peerID, identityLinks) + return rule, 0, true + case "group", "channel": + rule.When.Chat = peerKind + ":" + peerID + return rule, 0, true + case "topic": + rule.When.Topic = "topic:" + peerID + return rule, 0, true + default: + return DispatchRule{}, 0, false + } + } + + if guildID := strings.TrimSpace(binding.Match.GuildID); guildID != "" { + rule.When.Space = "guild:" + guildID + return rule, 1, true + } + + if teamID := strings.TrimSpace(binding.Match.TeamID); teamID != "" { + rule.When.Space = "team:" + teamID + return rule, 2, true + } + + accountSelector := normalizeLegacyAccountSelector(binding.Match.AccountID) + if accountSelector == "*" { + rule.When.Account = "" + return rule, 4, true + } + + rule.When.Account = accountSelector + return rule, 3, true +} + +func normalizeLegacyAccountSelector(accountID string) string { + accountID = strings.TrimSpace(accountID) + switch accountID { + case "": + return legacyDefaultAccountID + case "*": + return "*" + default: + return strings.ToLower(accountID) + } +} + +func canonicalLegacyBindingSenderID(channel, peerID string, identityLinks map[string][]string) string { + peerID = strings.TrimSpace(peerID) + if peerID == "" { + return "" + } + + if linked := resolveLegacyBindingLinkedID(identityLinks, channel, peerID); linked != "" { + return strings.ToLower(linked) + } + + return strings.ToLower(peerID) +} + +func resolveLegacyBindingLinkedID(identityLinks map[string][]string, channel, peerID string) string { + if len(identityLinks) == 0 { + return "" + } + peerID = strings.TrimSpace(peerID) + if peerID == "" { + return "" + } + + candidates := make(map[string]struct{}) + rawCandidate := strings.ToLower(peerID) + if rawCandidate != "" { + candidates[rawCandidate] = struct{}{} + } + channel = strings.ToLower(strings.TrimSpace(channel)) + if channel != "" { + candidates[channel+":"+rawCandidate] = struct{}{} + } + if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 { + candidates[rawCandidate[idx+1:]] = struct{}{} + } + + for canonical, ids := range identityLinks { + canonical = strings.TrimSpace(canonical) + if canonical == "" { + continue + } + for _, id := range ids { + normalized := strings.ToLower(strings.TrimSpace(id)) + if normalized == "" { + continue + } + if _, ok := candidates[normalized]; ok { + return canonical + } + } + } + + return "" +} diff --git a/pkg/config/migration.go b/pkg/config/migration.go index 51f21e4f4..96914819e 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -6,8 +6,14 @@ package config import ( - "slices" + "encoding/json" + "fmt" + "os" "strings" + + "gopkg.in/yaml.v3" + + "github.com/sipeed/picoclaw/pkg/logger" ) // buildModelWithProtocol constructs a model string with protocol prefix. @@ -21,416 +27,474 @@ func buildModelWithProtocol(protocol, model string) string { return protocol + "/" + model } -// providerMigrationConfig defines how to migrate a provider from old config to new format. -type providerMigrationConfig struct { - // providerNames are the possible names used in agents.defaults.provider - providerNames []string - // protocol is the protocol prefix for the model field - protocol string - // buildConfig creates the ModelConfig from ProviderConfig - buildConfig func(p ProvidersConfig) (ModelConfig, bool) +type legacyDiagnosticConfig struct { + Version int `json:"version"` + Isolation IsolationConfig `json:"isolation,omitempty"` + Agents legacyDiagnosticAgents `json:"agents,omitempty"` + Session SessionConfig `json:"session,omitempty"` + Channels map[string]any `json:"channels,omitempty"` + ChannelList ChannelsConfig `json:"channel_list,omitempty"` + ModelList []map[string]any `json:"model_list,omitempty"` + Gateway GatewayConfig `json:"gateway,omitempty"` + Hooks HooksConfig `json:"hooks,omitempty"` + Tools ToolsConfig `json:"tools,omitempty"` + Heartbeat HeartbeatConfig `json:"heartbeat,omitempty"` + Devices DevicesConfig `json:"devices,omitempty"` + Voice VoiceConfig `json:"voice,omitempty"` + Bindings json.RawMessage `json:"bindings,omitempty"` + Providers json.RawMessage `json:"providers,omitempty"` } -// ConvertProvidersToModelList converts the old ProvidersConfig to a slice of ModelConfig. -// This enables backward compatibility with existing configurations. -// It preserves the user's configured model from agents.defaults.model when possible. -func ConvertProvidersToModelList(cfg *Config) []ModelConfig { - if cfg == nil { - return nil +type legacyDiagnosticAgents struct { + Defaults legacyDiagnosticAgentDefaults `json:"defaults,omitempty"` + List []AgentConfig `json:"list,omitempty"` + Dispatch *DispatchConfig `json:"dispatch,omitempty"` +} + +type legacyDiagnosticAgentDefaults struct { + AgentDefaults + LegacyModel string `json:"model,omitempty"` +} + +func validateLegacyConfigDiagnostics(data []byte) error { + var cfg legacyDiagnosticConfig + return decodeJSONWithDiagnostics(data, &cfg, "config.json") +} + +func migrateLegacyAgentDefaultsModel(m map[string]any) { + agents, ok := m["agents"].(map[string]any) + if !ok { + return + } + defaults, ok := agents["defaults"].(map[string]any) + if !ok { + return + } + model, hasModel := defaults["model"] + if !hasModel { + return + } + if _, hasModelName := defaults["model_name"]; !hasModelName { + defaults["model_name"] = model + } + delete(defaults, "model") +} + +// loadConfigV1 loads a version 1 config (current schema) +func loadConfig(data []byte) (*Config, error) { + cfg := DefaultConfig() + + // Pre-scan the JSON to check how many model_list entries the user provided. + // Go's JSON decoder reuses existing slice backing-array elements rather than + // zero-initializing them, so fields absent from the user's JSON (e.g. api_base) + // would silently inherit values from the DefaultConfig template at the same + // index position. We only reset cfg.ModelList when the user actually provides + // entries; when count is 0 we keep DefaultConfig's built-in list as fallback. + var tmp Config + if err := decodeJSONWithDiagnostics(data, &tmp, "config.json"); err != nil { + return nil, err + } + if len(tmp.ModelList) > 0 { + cfg.ModelList = nil } - // Get user's configured provider and model - userProvider := strings.ToLower(cfg.Agents.Defaults.Provider) - userModel := cfg.Agents.Defaults.GetModelName() + if err := decodeJSONWithDiagnostics(data, cfg, "config.json"); err != nil { + return nil, err + } + return cfg, nil +} - p := cfg.Providers +func mergeAPIKeys(apiKey string, apiKeys []string) []string { + seen := make(map[string]struct{}) + var all []string - var result []ModelConfig - - // Track if we've applied the legacy model name fix (only for first provider) - legacyModelNameApplied := false - - // Define migration rules for each provider - migrations := []providerMigrationConfig{ - { - providerNames: []string{"openai", "gpt"}, - protocol: "openai", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "openai", - Model: "openai/gpt-5.2", - APIKey: p.OpenAI.APIKey, - APIBase: p.OpenAI.APIBase, - Proxy: p.OpenAI.Proxy, - RequestTimeout: p.OpenAI.RequestTimeout, - AuthMethod: p.OpenAI.AuthMethod, - }, true - }, - }, - { - providerNames: []string{"anthropic", "claude"}, - protocol: "anthropic", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "anthropic", - Model: "anthropic/claude-sonnet-4.6", - APIKey: p.Anthropic.APIKey, - APIBase: p.Anthropic.APIBase, - Proxy: p.Anthropic.Proxy, - RequestTimeout: p.Anthropic.RequestTimeout, - AuthMethod: p.Anthropic.AuthMethod, - }, true - }, - }, - { - providerNames: []string{"litellm"}, - protocol: "litellm", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "litellm", - Model: "litellm/auto", - APIKey: p.LiteLLM.APIKey, - APIBase: p.LiteLLM.APIBase, - Proxy: p.LiteLLM.Proxy, - RequestTimeout: p.LiteLLM.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"openrouter"}, - protocol: "openrouter", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "openrouter", - Model: "openrouter/auto", - APIKey: p.OpenRouter.APIKey, - APIBase: p.OpenRouter.APIBase, - Proxy: p.OpenRouter.Proxy, - RequestTimeout: p.OpenRouter.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"groq"}, - protocol: "groq", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.Groq.APIKey == "" && p.Groq.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "groq", - Model: "groq/llama-3.1-70b-versatile", - APIKey: p.Groq.APIKey, - APIBase: p.Groq.APIBase, - Proxy: p.Groq.Proxy, - RequestTimeout: p.Groq.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"zhipu", "glm"}, - protocol: "zhipu", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "zhipu", - Model: "zhipu/glm-4", - APIKey: p.Zhipu.APIKey, - APIBase: p.Zhipu.APIBase, - Proxy: p.Zhipu.Proxy, - RequestTimeout: p.Zhipu.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"vllm"}, - protocol: "vllm", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.VLLM.APIKey == "" && p.VLLM.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "vllm", - Model: "vllm/auto", - APIKey: p.VLLM.APIKey, - APIBase: p.VLLM.APIBase, - Proxy: p.VLLM.Proxy, - RequestTimeout: p.VLLM.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"gemini", "google"}, - protocol: "gemini", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.Gemini.APIKey == "" && p.Gemini.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "gemini", - Model: "gemini/gemini-pro", - APIKey: p.Gemini.APIKey, - APIBase: p.Gemini.APIBase, - Proxy: p.Gemini.Proxy, - RequestTimeout: p.Gemini.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"nvidia"}, - protocol: "nvidia", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "nvidia", - Model: "nvidia/meta/llama-3.1-8b-instruct", - APIKey: p.Nvidia.APIKey, - APIBase: p.Nvidia.APIBase, - Proxy: p.Nvidia.Proxy, - RequestTimeout: p.Nvidia.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"ollama"}, - protocol: "ollama", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.Ollama.APIKey == "" && p.Ollama.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "ollama", - Model: "ollama/llama3", - APIKey: p.Ollama.APIKey, - APIBase: p.Ollama.APIBase, - Proxy: p.Ollama.Proxy, - RequestTimeout: p.Ollama.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"moonshot", "kimi"}, - protocol: "moonshot", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "moonshot", - Model: "moonshot/kimi", - APIKey: p.Moonshot.APIKey, - APIBase: p.Moonshot.APIBase, - Proxy: p.Moonshot.Proxy, - RequestTimeout: p.Moonshot.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"shengsuanyun"}, - protocol: "shengsuanyun", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "shengsuanyun", - Model: "shengsuanyun/auto", - APIKey: p.ShengSuanYun.APIKey, - APIBase: p.ShengSuanYun.APIBase, - Proxy: p.ShengSuanYun.Proxy, - RequestTimeout: p.ShengSuanYun.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"deepseek"}, - protocol: "deepseek", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "deepseek", - Model: "deepseek/deepseek-chat", - APIKey: p.DeepSeek.APIKey, - APIBase: p.DeepSeek.APIBase, - Proxy: p.DeepSeek.Proxy, - RequestTimeout: p.DeepSeek.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"cerebras"}, - protocol: "cerebras", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "cerebras", - Model: "cerebras/llama-3.3-70b", - APIKey: p.Cerebras.APIKey, - APIBase: p.Cerebras.APIBase, - Proxy: p.Cerebras.Proxy, - RequestTimeout: p.Cerebras.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"vivgrid"}, - protocol: "vivgrid", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "vivgrid", - Model: "vivgrid/auto", - APIKey: p.Vivgrid.APIKey, - APIBase: p.Vivgrid.APIBase, - Proxy: p.Vivgrid.Proxy, - RequestTimeout: p.Vivgrid.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"volcengine", "doubao"}, - protocol: "volcengine", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "volcengine", - Model: "volcengine/doubao-pro", - APIKey: p.VolcEngine.APIKey, - APIBase: p.VolcEngine.APIBase, - Proxy: p.VolcEngine.Proxy, - RequestTimeout: p.VolcEngine.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"github_copilot", "copilot"}, - protocol: "github-copilot", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "github-copilot", - Model: "github-copilot/gpt-5.2", - APIBase: p.GitHubCopilot.APIBase, - ConnectMode: p.GitHubCopilot.ConnectMode, - }, true - }, - }, - { - providerNames: []string{"antigravity"}, - protocol: "antigravity", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.Antigravity.APIKey == "" && p.Antigravity.AuthMethod == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "antigravity", - Model: "antigravity/gemini-2.0-flash", - APIKey: p.Antigravity.APIKey, - AuthMethod: p.Antigravity.AuthMethod, - }, true - }, - }, - { - providerNames: []string{"qwen", "tongyi"}, - protocol: "qwen", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.Qwen.APIKey == "" && p.Qwen.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "qwen", - Model: "qwen/qwen-max", - APIKey: p.Qwen.APIKey, - APIBase: p.Qwen.APIBase, - Proxy: p.Qwen.Proxy, - RequestTimeout: p.Qwen.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"mistral"}, - protocol: "mistral", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "mistral", - Model: "mistral/mistral-small-latest", - APIKey: p.Mistral.APIKey, - APIBase: p.Mistral.APIBase, - Proxy: p.Mistral.Proxy, - RequestTimeout: p.Mistral.RequestTimeout, - }, true - }, - }, - { - providerNames: []string{"avian"}, - protocol: "avian", - buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { - if p.Avian.APIKey == "" && p.Avian.APIBase == "" { - return ModelConfig{}, false - } - return ModelConfig{ - ModelName: "avian", - Model: "avian/deepseek/deepseek-v3.2", - APIKey: p.Avian.APIKey, - APIBase: p.Avian.APIBase, - Proxy: p.Avian.Proxy, - RequestTimeout: p.Avian.RequestTimeout, - }, true - }, - }, + if k := strings.TrimSpace(apiKey); k != "" { + if _, exists := seen[k]; !exists { + seen[k] = struct{}{} + all = append(all, k) + } } - // Process each provider migration - for _, m := range migrations { - mc, ok := m.buildConfig(p) - if !ok { - continue + for _, k := range apiKeys { + if trimmed := strings.TrimSpace(k); trimmed != "" { + if _, exists := seen[trimmed]; !exists { + seen[trimmed] = struct{}{} + all = append(all, trimmed) + } } + } - // Check if this is the user's configured provider - if slices.Contains(m.providerNames, userProvider) && userModel != "" { - // Use the user's configured model instead of default - mc.Model = buildModelWithProtocol(m.protocol, userModel) - } else if userProvider == "" && userModel != "" && !legacyModelNameApplied { - // Legacy config: no explicit provider field but model is specified - // Use userModel as ModelName for the FIRST provider so GetModelConfig(model) can find it - // This maintains backward compatibility with old configs that relied on implicit provider selection - mc.ModelName = userModel - mc.Model = buildModelWithProtocol(m.protocol, userModel) - legacyModelNameApplied = true + return all +} + +func compareInt(v any, expected int) bool { + switch val := v.(type) { + case int: + return val == expected + case float64: + return val == float64(expected) + case nil: + return expected == 0 + default: + return false + } +} + +// migrateV0ToV1 converts a V0 (legacy, no version field) config JSON to V1 format: +// 1. Migrates legacy providers to model_list +// 2. Migrates agents.defaults.model → agents.defaults.model_name +// 3. Sets version to 1 +func migrateV0ToV1(m map[string]any) error { + if !compareInt(m["version"], 0) { + return fmt.Errorf("migrateV0ToV1: expected version 0, got %v", m["version"]) + } + + migrateLegacyAgentDefaultsModel(m) + + // Migrate legacy providers to model_list if no model_list exists + if _, hasModelList := m["model_list"]; !hasModelList { + if providers, hasProviders := m["providers"]; hasProviders { + if provMap, ok := providers.(map[string]any); ok && !isProvidersMapEmpty(provMap) { + // Extract user's provider and model from agents.defaults + userProvider := "" + userModel := "" + if agents, ok := m["agents"].(map[string]any); ok { + if defaults, ok := agents["defaults"].(map[string]any); ok { + if v, ok := defaults["provider"].(string); ok { + userProvider = v + } + // Check both model_name (new) and model (old) fields + if v, ok := defaults["model_name"].(string); ok && v != "" { + userModel = v + } else if v, ok := defaults["model"].(string); ok && v != "" { + userModel = v + } + } + } + + modelListRaw := v0ProvidersMapToModelList(provMap, userProvider, userModel) + if len(modelListRaw) > 0 { + m["model_list"] = modelListRaw + } + } } + } - result = append(result, mc) + // Convert model_list api_key → api_keys + if modelList, ok := m["model_list"].([]any); ok { + for _, model := range modelList { + if mVal, ok := model.(map[string]any); ok { + if ss := toUniqueStrings(mVal["api_key"], mVal["api_keys"]); len(ss) > 0 { + mVal["api_keys"] = ss + delete(mVal, "api_key") + } + } + } + } + + m["version"] = 1 + + return nil +} + +func toUniqueStrings(s any, ss any) []string { + set := make(map[string]struct{}) + + // process s + if str, ok := s.(string); ok && str != "" { + set[str] = struct{}{} + } + + // process ss as []any (JSON arrays) + if slice, ok := ss.([]any); ok { + for _, item := range slice { + if str, ok := item.(string); ok && str != "" { + set[str] = struct{}{} + } + } + } + + // process ss as []string + if slice, ok := ss.([]string); ok { + for _, item := range slice { + if item != "" { + set[item] = struct{}{} + } + } + } + + // map to slice + result := make([]string, 0, len(set)) + for k := range set { + result = append(result, k) } return result } + +// migrateV1ToV2 converts a V1 config JSON to V2 format: +// 1. Migrates legacy "mention_only" to "group_trigger.mention_only" +// 2. Infers "enabled" field for models +// 3. Sets version to 2 +func migrateV1ToV2(m map[string]any) error { + if !compareInt(m["version"], 1) { + return fmt.Errorf("migrateV1ToV2: expected version 1, got %#v", m["version"]) + } + + // Migrate channels: move "mention_only" to "group_trigger.mention_only" + if channels, ok := m["channels"]; ok { + if chMap, ok := channels.(map[string]any); ok { + for _, ch := range chMap { + if chVal, ok := ch.(map[string]any); ok { + if mentionOnly, hasMention := chVal["mention_only"]; hasMention { + delete(chVal, "mention_only") + if gt, hasGT := chVal["group_trigger"].(map[string]any); hasGT { + gt["mention_only"] = mentionOnly + } else { + chVal["group_trigger"] = map[string]any{"mention_only": mentionOnly} + } + } + } + } + } + } + + // Infer "enabled" field for models matching configV1.migrateModelEnabled behavior + if modelList, ok := m["model_list"].([]any); ok { + // Convert api_key → api_keys for each model + for _, model := range modelList { + if mVal, ok := model.(map[string]any); ok { + if ss := toUniqueStrings(mVal["api_key"], mVal["api_keys"]); len(ss) > 0 { + mVal["api_keys"] = ss + delete(mVal, "api_key") + } + } + } + + // Infer enabled status + for _, model := range modelList { + if mVal, ok := model.(map[string]any); ok { + // Skip if explicitly set + if _, hasEnabled := mVal["enabled"]; hasEnabled { + continue + } + // Models with API keys are considered enabled + if apiKeys, hasAPIKeys := mVal["api_keys"]; hasAPIKeys { + // Check for []any or []string + hasKeys := false + if keys, ok := apiKeys.([]any); ok { + hasKeys = len(keys) > 0 + } else if keys, ok := apiKeys.([]string); ok { + hasKeys = len(keys) > 0 + } + if hasKeys { + mVal["enabled"] = true + continue + } + } + // The reserved "local-model" entry is considered enabled + if mVal["model_name"] == "local-model" { + mVal["enabled"] = true + } + logger.Infof("model: %v", mVal) + } + } + } else { + logger.Warnf("model_list is not a slice: %#v", m["model_list"]) + } + + m["version"] = 2 + + return nil +} + +// migrateV2ToV3 converts a V2 config JSON to V3 format: +// 1. Renames "channels" key to "channel_list" +// 2. Converts flat-format channel entries to nested format (wrapping +// channel-specific fields in "settings") +// 3. Sets version to 3 +func migrateV2ToV3(m map[string]any) error { + if !compareInt(m["version"], 2) { + return fmt.Errorf("migrateV2ToV3: expected version 2, got %v", m["version"]) + } + + migrateLegacyAgentDefaultsModel(m) + delete(m, "bindings") + + // Rename channels → channel_list + if channels, ok := m["channels"]; ok { + delete(m, "channels") + + // Convert each channel from flat to nested format + if chMap, ok := channels.(map[string]any); ok { + for k, ch := range chMap { + if chVal, ok := ch.(map[string]any); ok { + chVal["type"] = k + // If already has "settings" key, leave as-is + if _, hasSettings := chVal["settings"]; hasSettings { + continue + } + + // Migrate Onebot "group_trigger_prefix" → "group_trigger.prefixes" + if gtp, hasGTP := chVal["group_trigger_prefix"]; hasGTP { + if gt, hasGT := chVal["group_trigger"].(map[string]any); hasGT { + if _, hasPrefixes := gt["prefixes"]; !hasPrefixes { + gt["prefixes"] = gtp + } + } else { + chVal["group_trigger"] = map[string]any{"prefixes": gtp} + } + delete(chVal, "group_trigger_prefix") + } + + // Separate channel-specific fields into "settings" + settings := make(map[string]any) + for fieldKey, v := range chVal { + if _, exists := BaseFieldNames[fieldKey]; !exists { + settings[fieldKey] = v + delete(chVal, fieldKey) + } + } + if len(settings) > 0 { + chVal["settings"] = settings + } + } + } + } + + m["channel_list"] = channels + } + + m["version"] = CurrentVersion + + return nil +} + +func loadConfigMap(path string) (map[string]any, error) { + var m1, m2 map[string]any + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return m1, nil + } + return nil, fmt.Errorf("failed to read config: %w", err) + } + if err = json.Unmarshal(data, &m1); err != nil { + return nil, wrapJSONError(data, err, "config.json") + } + secPath := securityPath(path) + data, err = os.ReadFile(secPath) + if err != nil { + if os.IsNotExist(err) { + return m1, nil + } + return nil, fmt.Errorf("failed to read security config: %w", err) + } + if err = yaml.Unmarshal(data, &m2); err != nil { + return nil, fmt.Errorf("failed to parse security config: %w", err) + } + if m2["web"] != nil || m2["skills"] != nil { + m3 := make(map[string]any) + if m2["web"] != nil { + m3["web"] = m2["web"] + delete(m2, "web") + } + if m2["skills"] != nil { + m3["skills"] = m2["skills"] + delete(m2, "skills") + if m, ok := m3["skills"].(map[string]any); ok { + if m["clawhub"] != nil { + m["registries"] = map[string]any{"clawhub": m["clawhub"]} + delete(m, "clawhub") + } + if gh, ok := m["github"].(map[string]any); ok { + registries, _ := m["registries"].(map[string]any) + if registries == nil { + registries = map[string]any{} + } + githubRegistry := map[string]any{} + for k, v := range gh { + githubRegistry[k] = v + } + if token, ok := githubRegistry["token"]; ok { + githubRegistry["auth_token"] = token + } + registries["github"] = githubRegistry + m["registries"] = registries + } + } + } + m2["tools"] = m3 + } + + // Handle model_list merging specially: m1 has array format, m2 has map format + if mainML, hasMainML := m1["model_list"]; hasMainML { + if secML, hasSecML := m2["model_list"]; hasSecML { + if secMap, ok := secML.(map[string]any); ok { + // JSON unmarshals arrays as []any, convert to []map[string]any + var mainArr []any + if rawArr, ok := mainML.([]any); ok { + mainArr = make([]any, 0, len(rawArr)) + for _, item := range rawArr { + if mVal, ok := item.(map[string]any); ok { + mainArr = append(mainArr, mVal) + } + } + } + if len(mainArr) > 0 { + // Merge array-style with map-style in-place + err = mergeModelListsWithMap(mainArr, secMap) + if err != nil { + logger.Errorf("mergeModelListsWithMap error: %v", err) + return nil, err + } + m1["model_list"] = mainArr + } + } + } + } + // Remove model_list from m2 so mergeMap doesn't override the array with map + delete(m2, "model_list") + + m := mergeMap(m1, m2) + return m, nil +} + +// mergeModelListsWithMap merges array-style model_list with map-style security model_list. +// It generates indexed keys from model_name (like toNameIndex) and uses them +// to look up security entries, falling back to ModelName if the indexed key doesn't exist. +func mergeModelListsWithMap(mainML []any, secML map[string]any) error { + // Build indexed keys like toNameIndex does + indexedKeys := make(map[string]int) + countMap := make(map[string]int) + for i, m := range mainML { + if mVal, ok := m.(map[string]any); ok { + if name, hasName := mVal["model_name"]; hasName { + nameStr := name.(string) + index := countMap[nameStr] + indexedKeys[fmt.Sprintf("%s:%d", nameStr, index)] = i + if _, ok := indexedKeys[nameStr]; !ok { + indexedKeys[nameStr] = i + } + countMap[nameStr]++ + } else { + return fmt.Errorf("model_name is required: %#v", mVal) + } + } + } + + for k, v := range secML { + if i, ok := indexedKeys[k]; ok { + if vv, ok := v.(map[string]any); ok { + if mVal, ok := mainML[i].(map[string]any); ok { + mVal["api_keys"] = vv["api_keys"] + } + } + } else { + logger.Warnf("model_name not found in main config: %s", k) + } + delete(secML, k) + } + + return nil +} diff --git a/pkg/config/migration_integration_test.go b/pkg/config/migration_integration_test.go new file mode 100644 index 000000000..49d341eb7 --- /dev/null +++ b/pkg/config/migration_integration_test.go @@ -0,0 +1,1110 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestMigration_Integration_LegacyConfigWithoutWorkspace tests the issue reported: +// User configured Model and Provider but no Workspace - settings should not be lost +func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) { + // Create a temporary directory for test config files + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + // Create a legacy config (version 0) with Model and Provider but NO Workspace + // This simulates the real-world scenario where user settings would be lost + legacyConfig := `{ + "agents": { + "defaults": { + "provider": "openai", + "model": "gpt-4o", + "max_tokens": 8192, + "temperature": 0.7 + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "test-token" + } + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "tools": { + "web": { + "enabled": true + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false + } + }` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + // Load the config - this should trigger migration + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // Verify version is updated + if cfg.Version != CurrentVersion { + t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion) + } + + // CRITICAL: Verify that user's settings are preserved + // This was the bug - these settings were lost when Workspace was empty + if cfg.Agents.Defaults.Provider != "openai" { + t.Errorf("Provider = %q, want %q (user's setting should be preserved)", cfg.Agents.Defaults.Provider, "openai") + } + + t.Logf("defaults: %v", cfg.Agents.Defaults) + // Old "model" field is migrated to "model_name" field + if cfg.Agents.Defaults.ModelName != "gpt-4o" { + t.Errorf( + "ModelName = %q, want %q (user's setting should be preserved)", + cfg.Agents.Defaults.ModelName, "gpt-4o", + ) + } + // GetModelName() should also return the migrated value + if cfg.Agents.Defaults.GetModelName() != "gpt-4o" { + t.Errorf("GetModelName() = %q, want %q", cfg.Agents.Defaults.GetModelName(), "gpt-4o") + } + if cfg.Agents.Defaults.MaxTokens != 8192 { + t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 8192) + } + if cfg.Agents.Defaults.Temperature == nil { + t.Error("Temperature should not be nil") + } else if *cfg.Agents.Defaults.Temperature != 0.7 { + t.Errorf("Temperature = %v, want %v", *cfg.Agents.Defaults.Temperature, 0.7) + } + + // Verify Workspace has a default value (should not be empty) + if cfg.Agents.Defaults.Workspace == "" { + t.Error("Workspace should have a default value, not be empty") + } + + // Verify other config sections are preserved + var tgCfg TelegramSettings + bc := cfg.Channels.Get("telegram") + if bc == nil || !bc.Enabled { + t.Error("Telegram.Enabled should be true") + } + bc.Decode(&tgCfg) + if tgCfg.Token.String() != "test-token" { + t.Errorf("Telegram.Token = %q, want %q", tgCfg.Token.String(), "test-token") + } + if cfg.Gateway.Port != 18790 { + t.Errorf("Gateway.Port = %d, want %d", cfg.Gateway.Port, 18790) + } +} + +// TestMigration_Integration_LegacyConfigWithWorkspace tests migration with Workspace set +func TestMigration_Integration_LegacyConfigWithWorkspace(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + legacyConfig := `{ + "agents": { + "defaults": { + "workspace": "/custom/workspace", + "provider": "deepseek", + "model": "deepseek-chat", + "max_tokens": 16384 + } + }, + "channels": { + "telegram": { + "enabled": false + } + }, + "gateway": { + "host": "0.0.0.0", + "port": 8080 + }, + "tools": { + "web": { + "enabled": false + } + }, + "heartbeat": { + "enabled": false + }, + "devices": { + "enabled": true + } + }` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // All user settings should be preserved + if cfg.Agents.Defaults.Workspace != "/custom/workspace" { + t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "/custom/workspace") + } + if cfg.Agents.Defaults.Provider != "deepseek" { + t.Errorf("Provider = %q, want %q", cfg.Agents.Defaults.Provider, "deepseek") + } + if cfg.Agents.Defaults.ModelName != "deepseek-chat" { + t.Errorf("ModelName = %q, want %q", cfg.Agents.Defaults.ModelName, "deepseek-chat") + } + if cfg.Agents.Defaults.MaxTokens != 16384 { + t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 16384) + } + + // Verify other settings + if cfg.Gateway.Port != 8080 { + t.Errorf("Gateway.Port = %d, want %d", cfg.Gateway.Port, 8080) + } + if !cfg.Devices.Enabled { + t.Error("Devices.Enabled should be true") + } +} + +// TestMigration_Integration_PreservesAllAgentsFields tests that ALL Agents fields are preserved +func TestMigration_Integration_PreservesAllAgentsFields(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + legacyConfig := `{ + "agents": { + "defaults": { + "workspace": "", + "restrict_to_workspace": false, + "allow_read_outside_workspace": true, + "provider": "anthropic", + "model": "claude-opus-4", + "model_fallbacks": ["claude-sonnet-4", "claude-haiku-4"], + "image_model": "claude-opus-4-vision", + "image_model_fallbacks": ["claude-sonnet-4-vision"], + "max_tokens": 4096, + "temperature": 0.5, + "max_tool_iterations": 100, + "summarize_message_threshold": 30, + "summarize_token_percent": 80, + "max_media_size": 10485760 + }, + "list": [ + { + "id": "special-agent", + "default": false, + "name": "Special Agent", + "workspace": "/special/workspace" + } + ] + }, + "channels": { + "telegram": {"enabled": false} + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "tools": { + "web": {"enabled": true} + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false + } + }` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // Verify ALL defaults fields are preserved + d := cfg.Agents.Defaults + + if d.RestrictToWorkspace != false { + t.Errorf("RestrictToWorkspace = %v, want false", d.RestrictToWorkspace) + } + if d.AllowReadOutsideWorkspace != true { + t.Errorf("AllowReadOutsideWorkspace = %v, want true", d.AllowReadOutsideWorkspace) + } + if d.Provider != "anthropic" { + t.Errorf("Provider = %q, want %q", d.Provider, "anthropic") + } + if d.ModelName != "claude-opus-4" { + t.Errorf("ModelName = %q, want %q", d.ModelName, "claude-opus-4") + } + if len(d.ModelFallbacks) != 2 { + t.Errorf("len(ModelFallbacks) = %d, want 2", len(d.ModelFallbacks)) + } else { + if d.ModelFallbacks[0] != "claude-sonnet-4" { + t.Errorf("ModelFallbacks[0] = %q, want %q", d.ModelFallbacks[0], "claude-sonnet-4") + } + if d.ModelFallbacks[1] != "claude-haiku-4" { + t.Errorf("ModelFallbacks[1] = %q, want %q", d.ModelFallbacks[1], "claude-haiku-4") + } + } + if d.ImageModel != "claude-opus-4-vision" { + t.Errorf("ImageModel = %q, want %q", d.ImageModel, "claude-opus-4-vision") + } + if len(d.ImageModelFallbacks) != 1 { + t.Errorf("len(ImageModelFallbacks) = %d, want 1", len(d.ImageModelFallbacks)) + } else if d.ImageModelFallbacks[0] != "claude-sonnet-4-vision" { + t.Errorf("ImageModelFallbacks[0] = %q, want %q", d.ImageModelFallbacks[0], "claude-sonnet-4-vision") + } + if d.MaxTokens != 4096 { + t.Errorf("MaxTokens = %d, want %d", d.MaxTokens, 4096) + } + if d.Temperature == nil || *d.Temperature != 0.5 { + t.Errorf("Temperature = %v, want 0.5", d.Temperature) + } + if d.MaxToolIterations != 100 { + t.Errorf("MaxToolIterations = %d, want %d", d.MaxToolIterations, 100) + } + if d.SummarizeMessageThreshold != 30 { + t.Errorf("SummarizeMessageThreshold = %d, want %d", d.SummarizeMessageThreshold, 30) + } + if d.SummarizeTokenPercent != 80 { + t.Errorf("SummarizeTokenPercent = %d, want %d", d.SummarizeTokenPercent, 80) + } + if d.MaxMediaSize != 10485760 { + t.Errorf("MaxMediaSize = %d, want %d", d.MaxMediaSize, 10485760) + } + + // Verify agent list is preserved + if len(cfg.Agents.List) != 1 { + t.Fatalf("len(Agents.List) = %d, want 1", len(cfg.Agents.List)) + } + if cfg.Agents.List[0].ID != "special-agent" { + t.Errorf("Agent.ID = %q, want %q", cfg.Agents.List[0].ID, "special-agent") + } + if cfg.Agents.List[0].Workspace != "/special/workspace" { + t.Errorf("Agent.Workspace = %q, want %q", cfg.Agents.List[0].Workspace, "/special/workspace") + } + + // Workspace should have default since it was empty in legacy config + if d.Workspace == "" { + t.Error("Workspace should have a default value, not be empty") + } +} + +// TestMigration_Integration_ChannelsConfigMigrated tests channel config migration +func TestMigration_Integration_ChannelsConfigMigrated(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + // Legacy config with old channel field formats + legacyConfig := `{ + "agents": { + "defaults": {} + }, + "channels": { + "discord": { + "enabled": true, + "token": "discord-token", + "mention_only": true + }, + "onebot": { + "enabled": true, + "ws_url": "ws://127.0.0.1:3001", + "group_trigger_prefix": ["/", "!"] + } + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "tools": { + "web": {"enabled": true} + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false + } + }` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // Discord: mention_only should be migrated to group_trigger.mention_only + discordBC := cfg.Channels.Get("discord") + if !discordBC.GroupTrigger.MentionOnly { + t.Error("Discord.GroupTrigger.MentionOnly should be true after migration") + } + + // OneBot: group_trigger_prefix should be migrated to group_trigger.prefixes + oneBotBC := cfg.Channels.Get("onebot") + if len(oneBotBC.GroupTrigger.Prefixes) != 2 { + t.Errorf("len(OneBot.GroupTrigger.Prefixes) = %d, want 2", len(oneBotBC.GroupTrigger.Prefixes)) + } else { + if oneBotBC.GroupTrigger.Prefixes[0] != "/" { + t.Errorf("Prefixes[0] = %q, want %q", oneBotBC.GroupTrigger.Prefixes[0], "/") + } + if oneBotBC.GroupTrigger.Prefixes[1] != "!" { + t.Errorf("Prefixes[1] = %q, want %q", oneBotBC.GroupTrigger.Prefixes[1], "!") + } + } +} + +// TestMigration_Integration_RoundTrip_SerializeAndLoad tests that migrated config can be saved and reloaded +func TestMigration_Integration_RoundTrip_SerializeAndLoad(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + legacyConfig := `{ + "agents": { + "defaults": { + "provider": "openai", + "model": "gpt-4o", + "max_tokens": 8192 + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "test-token" + } + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "tools": { + "web": {"enabled": true} + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false + } + }` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + // First load - triggers migration and saves + cfg1, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("First LoadConfig failed: %v", err) + } + + // Read the migrated config from disk + migratedData, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Failed to read migrated config: %v", err) + } + + // Verify it has the current version + var versionCheck struct { + Version int `json:"version"` + } + if err = json.Unmarshal(migratedData, &versionCheck); err != nil { + t.Fatalf("Failed to parse migrated config version: %v", err) + } + if versionCheck.Version != CurrentVersion { + t.Errorf("Migrated config version = %d, want %d", versionCheck.Version, CurrentVersion) + } + + // Second load - should load the migrated config without changes + cfg2, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("Second LoadConfig failed: %v", err) + } + + // Verify configs are identical + if cfg2.Agents.Defaults.Provider != cfg1.Agents.Defaults.Provider { + t.Errorf("Provider changed from %q to %q", cfg1.Agents.Defaults.Provider, cfg2.Agents.Defaults.Provider) + } + if cfg2.Agents.Defaults.ModelName != cfg1.Agents.Defaults.ModelName { + t.Errorf("ModelName changed from %q to %q", cfg1.Agents.Defaults.ModelName, cfg2.Agents.Defaults.ModelName) + } + if cfg2.Agents.Defaults.MaxTokens != cfg1.Agents.Defaults.MaxTokens { + t.Errorf("MaxTokens changed from %d to %d", cfg1.Agents.Defaults.MaxTokens, cfg2.Agents.Defaults.MaxTokens) + } +} + +// TestMigration_Integration_EmptyAgentsDefaults tests migration with completely empty agents config +func TestMigration_Integration_EmptyAgentsDefaults(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + // Legacy config with empty agents defaults + legacyConfig := `{ + "agents": { + "defaults": {} + }, + "channels": { + "telegram": {"enabled": false} + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "tools": { + "web": {"enabled": true} + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false + } + }` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // Workspace should have default value + if cfg.Agents.Defaults.Workspace == "" { + t.Error("Workspace should have a default value") + } + + // Note: When fields are explicitly set in config (even to zero values), + // they override defaults. This is correct JSON unmarshaling behavior. + // Users should set values they want; defaults are for unspecified fields. + if cfg.Agents.Defaults.MaxTokens == 0 { + // This is expected when users don't set max_tokens in their config + // The zero value (0) from the legacy config is preserved + } + if cfg.Agents.Defaults.MaxToolIterations == 0 { + // Same as above - zero value is preserved if it was in the config + } +} + +// TestMigration_Integration_ModelNameField tests migration using new model_name field +func TestMigration_Integration_ModelNameField(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + // Legacy config using the new model_name field + legacyConfig := `{ + "agents": { + "defaults": { + "provider": "deepseek", + "model_name": "deepseek-reasoner", + "model_fallbacks": ["deepseek-chat"] + } + }, + "channels": { + "telegram": {"enabled": false} + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "tools": { + "web": {"enabled": true} + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false + } + }` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // model_name field should be preserved + if cfg.Agents.Defaults.ModelName != "deepseek-reasoner" { + t.Errorf("ModelName = %q, want %q", cfg.Agents.Defaults.ModelName, "deepseek-reasoner") + } + + // GetModelName() should return model_name, not model (deprecated) + if cfg.Agents.Defaults.GetModelName() != "deepseek-reasoner" { + t.Errorf("GetModelName() = %q, want %q", cfg.Agents.Defaults.GetModelName(), "deepseek-reasoner") + } + + if len(cfg.Agents.Defaults.ModelFallbacks) != 1 { + t.Errorf("len(ModelFallbacks) = %d, want 1", len(cfg.Agents.Defaults.ModelFallbacks)) + } else if cfg.Agents.Defaults.ModelFallbacks[0] != "deepseek-chat" { + t.Errorf("ModelFallbacks[0] = %q, want %q", cfg.Agents.Defaults.ModelFallbacks[0], "deepseek-chat") + } +} + +// TestMigration_PreservesExistingSecurityConfig tests that when migrating from v0 to v1, +// existing .security.yml values (e.g., loaded from environment variables) are preserved +// and not overwritten by empty values from the legacy config. +func TestMigration_PreservesExistingSecurityConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + securityPath := filepath.Join(tmpDir, ".security.yml") + + // Create a legacy config (version 0) with model_list and channel config + // The model_list doesn't have api_keys, they should come from existing .security.yml + legacyConfig := `{ + "version": 1, + "agents": { + "defaults": { + "provider": "openai", + "model": "gpt-4" + } + }, + "model_list": [ + { + "model_name": "openai", + "model": "openai/gpt-4" + } + ], + "channels": { + "telegram": { + "enabled": true + } + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "tools": { + "web": {"enabled": true} + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false + } + }` + + // Create an existing .security.yml with values that might come from env vars + existingSecurity := `model_list: + openai:0: + api_keys: + - sk-existing-key-from-env +channels: + telegram: + token: existing-telegram-token-from-env + discord: + token: existing-discord-token-from-env +web: + brave: + api_keys: + - existing-brave-key +` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + if err := os.WriteFile(securityPath, []byte(existingSecurity), 0o600); err != nil { + t.Fatalf("Failed to write existing security config: %v", err) + } + + // Load the config - this should trigger migration + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + t.Logf("Migrated config: %#v", cfg.Channels["telegram"]) + t.Logf("Migrated config settings: %v", string(cfg.Channels["telegram"].Settings)) + + // Verify that the migrated config has the existing security values + // Telegram token should be preserved + var tgCfg1 *TelegramSettings + if bc := cfg.Channels.Get("telegram"); bc != nil { + t.Logf("telegram settings: %v", string(bc.Settings)) + if decoded, e := bc.GetDecoded(); e == nil && decoded != nil { + tgCfg1 = decoded.(*TelegramSettings) + } + } + require.NotNil(t, tgCfg1) + if tgCfg1.Token.String() != "existing-telegram-token-from-env" { + t.Errorf("Telegram token was overwritten: got %q, want %q", + tgCfg1.Token.String(), "existing-telegram-token-from-env") + } + + // Discord token should be preserved (even though legacy config didn't have it) + var dcCfg1 *DiscordSettings + if bc := cfg.Channels.Get("discord"); bc != nil { + if decoded, e := bc.GetDecoded(); e == nil && decoded != nil { + dcCfg1 = decoded.(*DiscordSettings) + } + } + if dcCfg1.Token.String() != "existing-discord-token-from-env" { + t.Errorf("Discord token was overwritten: got %q, want %q", + dcCfg1.Token.String(), "existing-discord-token-from-env") + } + + // Model API key should be preserved + t.Logf("model_list: %#v", cfg.ModelList[0]) + if cfg.ModelList[0].APIKey() != "sk-existing-key-from-env" { + t.Errorf("Model API key was overwritten: got %q, want %q", + cfg.ModelList[0].APIKey(), "sk-existing-key-from-env") + } + + // Brave API key should be preserved + if cfg.Tools.Web.Brave.APIKey() != "existing-brave-key" { + t.Errorf("Brave API key was overwritten: got %q, want %q", + cfg.Tools.Web.Brave.APIKey(), "existing-brave-key") + } + + // Reload the security config from disk to verify it wasn't corrupted + reloadedSec := cfg + t.Logf("reloadedSec started") + err = loadSecurityConfig(cfg, securityPath) + if err != nil { + t.Fatalf("Failed to reload security config: %v", err) + } + + var tgCfgSec *TelegramSettings + if bc := reloadedSec.Channels.Get("telegram"); bc != nil { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + tgCfgSec = decoded.(*TelegramSettings) + } + } + if tgCfgSec.Token.String() != "existing-telegram-token-from-env" { + t.Errorf("Telegram settings: %v", tgCfgSec) + t.Error("Telegram token not preserved in .security.yml file") + } + + var dcCfgSec *DiscordSettings + if bc := reloadedSec.Channels.Get("discord"); bc != nil { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + dcCfgSec = decoded.(*DiscordSettings) + } + } + if dcCfgSec.Token.String() != "existing-discord-token-from-env" { + t.Error("Discord token not preserved in .security.yml file") + } +} + +// --------------------------------------------------------------------------- +// V1 → V2 migration tests +// --------------------------------------------------------------------------- + +//// TestMigrateModelEnabled_APIKeysInferredEnabled verifies that models with API keys +//// are marked as enabled during V1→V2 migration. +//func TestMigrateModelEnabled_APIKeysInferredEnabled(t *testing.T) { +// v1 := &configV1{Config: Config{ +// ModelList: []*ModelConfig{ +// {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")}, +// {ModelName: "claude", Model: "anthropic/claude", APIKeys: SimpleSecureStrings("sk-ant")}, +// }, +// }} +// v1.migrateModelEnabled() +// for _, m := range v1.ModelList { +// if !m.Enabled { +// t.Errorf("model %q with API key should be enabled", m.ModelName) +// } +// } +//} +// +//// TestMigrateModelEnabled_LocalModelInferredEnabled verifies that the reserved +//// "local-model" entry is enabled even without API keys. +//func TestMigrateModelEnabled_LocalModelInferredEnabled(t *testing.T) { +// v1 := &configV1{ +// ModelList: []*ModelConfig{ +// {ModelName: "local-model", Model: "vllm/custom-model", APIBase: "http://localhost:8000/v1"}, +// }, +// } +// v1.migrateModelEnabled() +// if !v1.ModelList[0].Enabled { +// t.Error("local-model should be enabled") +// } +//} +// +//// TestMigrateModelEnabled_NoKeyStaysDisabled verifies that models without API keys +//// and not named "local-model" remain disabled. +//func TestMigrateModelEnabled_NoKeyStaysDisabled(t *testing.T) { +// v1 := &configV1{ +// ModelList: []*ModelConfig{ +// {ModelName: "gpt-4", Model: "openai/gpt-4"}, +// {ModelName: "claude", Model: "anthropic/claude"}, +// }, +// } +// v1.migrateModelEnabled() +// for _, m := range v1.ModelList { +// if m.Enabled { +// t.Errorf("model %q without API key should stay disabled", m.ModelName) +// } +// } +//} +// +//// TestMigrateModelEnabled_ExplicitEnabledPreserved verifies that a model with +//// explicitly enabled=true is NOT overridden by the migration. +//func TestMigrateModelEnabled_ExplicitEnabledPreserved(t *testing.T) { +// v1 := &configV1{Config: Config{ +// ModelList: []*ModelConfig{ +// {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test"), Enabled: true}, +// }, +// }} +// v1.migrateModelEnabled() +// if !v1.ModelList[0].Enabled { +// t.Error("explicitly enabled model should remain enabled") +// } +//} +// +//// TestMigrateModelEnabled_ExplicitDisabledNotOverridden verifies that a model with +//// explicitly enabled=false and API keys gets enabled during migration. +//// Note: since Go's zero value for bool is false and JSON omitempty omits false, +//// migration cannot distinguish "explicitly false" from "field absent". Both cases +//// get the same inference treatment. +//func TestMigrateModelEnabled_ExplicitDisabledNotOverridden(t *testing.T) { +// v1 := &configV1{Config: Config{ +// ModelList: []*ModelConfig{ +// {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test"), Enabled: false}, +// }, +// }} +// v1.migrateModelEnabled() +// // Even though Enabled was set to false, migration infers it as true because +// // the migration cannot distinguish from a missing field (both are zero value). +// if !v1.ModelList[0].Enabled { +// t.Error("model with API key should be enabled by migration inference") +// } +//} +// +//// TestMigrateModelEnabled_Mixed verifies a mix of models. +//func TestMigrateModelEnabled_Mixed(t *testing.T) { +// v1 := &configV1{Config: Config{ +// ModelList: []*ModelConfig{ +// {ModelName: "with-key", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")}, +// {ModelName: "no-key", Model: "openai/gpt-4"}, +// {ModelName: "local-model", Model: "vllm/custom"}, +// { +// ModelName: "disabled-explicit", +// Model: "openai/gpt-4", +// APIKeys: SimpleSecureStrings("sk-test"), +// Enabled: false, +// }, +// }, +// }} +// v1.migrateModelEnabled() +// +// assertEnabled := func(name string, want bool) { +// for _, m := range v1.ModelList { +// if m.ModelName == name { +// if m.Enabled != want { +// t.Errorf("model %q: Enabled=%v, want %v", name, m.Enabled, want) +// } +// return +// } +// } +// t.Errorf("model %q not found", name) +// } +// +// assertEnabled("with-key", true) +// assertEnabled("no-key", false) +// assertEnabled("local-model", true) +// assertEnabled("disabled-explicit", true) // false is zero value, migration infers from API key +//} +// +//// TestMigrateChannelConfigs_DiscordMentionOnly verifies Discord mention_only migration. +//func TestMigrateChannelConfigs_DiscordMentionOnly(t *testing.T) { +// channels := ChannelsConfig{"discord": makeBaseChannelFromConfig(DiscordSettings{MentionOnly: true})} +// v1 := &configV1{Config: Config{Channels: channels}} +// v1.migrateChannelConfigs() +// bc := v1.Channels.Get("discord") +// if !bc.GroupTrigger.MentionOnly { +// t.Error("Discord GroupTrigger.MentionOnly should be set to true") +// } +//} +// +//// TestMigrateChannelConfigs_DiscordAlreadyMigrated is a no-op test. +//func TestMigrateChannelConfigs_DiscordAlreadyMigrated(t *testing.T) { +// channels := ChannelsConfig{"discord": makeBaseChannelFromConfig(map[string]any{ +// "group_trigger": map[string]any{"mention_only": true}, +// })} +// v1 := &configV1{Config: Config{Channels: channels}} +// v1.migrateChannelConfigs() +//} +// +//// TestMigrateChannelConfigs_OneBotPrefix verifies OneBot prefix migration. +//func TestMigrateChannelConfigs_OneBotPrefix(t *testing.T) { +// channels := ChannelsConfig{"onebot": makeBaseChannelFromConfig(OneBotSettings{GroupTriggerPrefix: []string{"/"}})} +// v1 := &configV1{Config: Config{Channels: channels}} +// v1.migrateChannelConfigs() +// bc := v1.Channels.Get("onebot") +// if len(bc.GroupTrigger.Prefixes) != 1 || bc.GroupTrigger.Prefixes[0] != "/" { +// t.Errorf("OneBot GroupTrigger.Prefixes = %v, want [\"/\"]", bc.GroupTrigger.Prefixes) +// } +//} +// +//// TestMigrateConfigV1_Combined verifies that configV1.Migrate applies both migrations. +//func TestMigrateConfigV1_Combined(t *testing.T) { +// v1 := &configV1{Config: Config{ +// ModelList: []*ModelConfig{ +// {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")}, +// }, +// Channels: ChannelsConfig{"discord": makeBaseChannelFromConfig(DiscordSettings{MentionOnly: true})}, +// }} +// result, err := v1.Migrate() +// if err != nil { +// t.Fatalf("Migrate: %v", err) +// } +// +// if !result.ModelList[0].Enabled { +// t.Error("model with API key should be enabled after V1→V2 migration") +// } +// dcResultBC := result.Channels.Get("discord") +// if !dcResultBC.GroupTrigger.MentionOnly { +// t.Error("Discord mention_only should be migrated after V1→V2 migration") +// } +//} + +// TestLoadConfig_V1ToV2Migration verifies end-to-end V1→V2 config migration +// through LoadConfig, including Enabled field inference and version bump. +func TestLoadConfig_V1ToV2Migration(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + // Write a V1 config with model_list but no "enabled" field + v1Config := `{ + "version": 1, + "model_list": [ + { + "model_name": "gpt-4", + "model": "openai/gpt-4" + }, + { + "model_name": "local-model", + "model": "vllm/custom-model", + "api_base": "http://localhost:8000/v1" + } + ], + "channels": { + "discord": { + "mention_only": true + } + }, + "gateway": {"host": "127.0.0.1", "port": 18790} + }` + + if err := os.WriteFile(configPath, []byte(v1Config), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + // Version should be bumped to 2 + if cfg.Version != CurrentVersion { + t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion) + } + + // gpt-4 has no API key → disabled + gpt4, err := cfg.GetModelConfig("gpt-4") + if err != nil { + t.Fatalf("GetModelConfig(gpt-4): %v", err) + } + if gpt4.Enabled { + t.Error("gpt-4 without API key should be disabled after migration") + } + + // local-model → enabled + local, err := cfg.GetModelConfig("local-model") + if err != nil { + t.Fatalf("GetModelConfig(local-model): %v", err) + } + if !local.Enabled { + t.Error("local-model should be enabled after migration") + } + + // Discord channel config should be migrated + dcMigBC := cfg.Channels.Get("discord") + if !dcMigBC.GroupTrigger.MentionOnly { + t.Error("Discord mention_only should be migrated to group_trigger.mention_only") + } + + // Verify backup was created with date suffix + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + var hasBackup bool + for _, e := range entries { + if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched { + hasBackup = true + break + } + } + if !hasBackup { + t.Error("expected backup file with date suffix to be created") + } + + // Verify the saved config on disk now has version 2 + saved, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile saved config: %v", err) + } + var versionCheck struct { + Version int `json:"version"` + } + if err := json.Unmarshal(saved, &versionCheck); err != nil { + t.Fatalf("Unmarshal saved config: %v", err) + } + if versionCheck.Version != 3 { + t.Errorf("saved config version = %d, want 3", versionCheck.Version) + } +} + +// TestLoadConfig_V1WithAPIKeysInferredEnabled verifies that V1 configs with +// API keys in the security file get Enabled=true after migration. +func TestLoadConfig_V1WithAPIKeysInferredEnabled(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + secPath := securityPath(configPath) + + v1Config := `{ + "version": 1, + "model_list": [ + {"model_name": "gpt-4", "model": "openai/gpt-4"}, + {"model_name": "claude", "model": "anthropic/claude"} + ], + "gateway": {"host": "127.0.0.1", "port": 18790} + }` + + securityConfig := `model_list: + gpt-4:0: + api_keys: + - "sk-gpt-key" + claude:0: + api_keys: + - "sk-claude-key" +` + + if err := os.WriteFile(configPath, []byte(v1Config), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if err := os.WriteFile(secPath, []byte(securityConfig), 0o600); err != nil { + t.Fatalf("WriteFile security: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + for _, m := range cfg.ModelList { + t.Logf("Model: %+v", m) + if !m.Enabled { + t.Errorf("model %q with API key in security file should be enabled", m.ModelName) + } + } +} + +// TestLoadConfig_V2DirectLoad verifies that V2 configs load directly without +// running any migration. +func TestLoadConfig_V2DirectLoad(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + v2Config := `{ + "version": 2, + "model_list": [ + { + "model_name": "gpt-4", + "model": "openai/gpt-4", + "enabled": true + }, + { + "model_name": "claude", + "model": "anthropic/claude" + } + ], + "gateway": {"host": "127.0.0.1", "port": 18790} + }` + + if err := os.WriteFile(configPath, []byte(v2Config), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + if cfg.Version != 3 { + t.Errorf("Version = %d, want 3", cfg.Version) + } + + gpt4, _ := cfg.GetModelConfig("gpt-4") + if !gpt4.Enabled { + t.Error("gpt-4 with explicit enabled=true should remain enabled") + } + + claude, _ := cfg.GetModelConfig("claude") + if claude.Enabled { + t.Error("claude without enabled field should be false") + } + + // V2→V3 migration creates a backup + entries, _ := os.ReadDir(tmpDir) + foundBackup := false + for _, e := range entries { + if matched, _ := filepath.Match("config.json.*.bak", e.Name()); matched { + foundBackup = true + } + } + if !foundBackup { + t.Error("V2→V3 migration should create backup") + } + + githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github") + if !ok { + t.Fatal("expected default github skills registry to survive V0 migration") + } + if !githubRegistry.Enabled { + t.Error("github skills registry should remain enabled after V0 migration") + } + if githubRegistry.BaseURL != "https://github.com" { + t.Errorf("github registry base_url = %q, want %q", githubRegistry.BaseURL, "https://github.com") + } +} diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go index d3019aab0..8bd3b3d26 100644 --- a/pkg/config/migration_test.go +++ b/pkg/config/migration_test.go @@ -6,560 +6,19 @@ package config import ( - "strings" + "os" + "path/filepath" "testing" + + "github.com/stretchr/testify/require" ) -func TestConvertProvidersToModelList_OpenAI(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ - ProviderConfig: ProviderConfig{ - APIKey: "sk-test-key", - APIBase: "https://custom.api.com/v1", - }, - }, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 1 { - t.Fatalf("len(result) = %d, want 1", len(result)) - } - - if result[0].ModelName != "openai" { - t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai") - } - if result[0].Model != "openai/gpt-5.2" { - t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.2") - } - if result[0].APIKey != "sk-test-key" { - t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key") - } -} - -func TestConvertProvidersToModelList_Anthropic(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - Anthropic: ProviderConfig{ - APIKey: "ant-key", - APIBase: "https://custom.anthropic.com", - }, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 1 { - t.Fatalf("len(result) = %d, want 1", len(result)) - } - - if result[0].ModelName != "anthropic" { - t.Errorf("ModelName = %q, want %q", result[0].ModelName, "anthropic") - } - if result[0].Model != "anthropic/claude-sonnet-4.6" { - t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-sonnet-4.6") - } -} - -func TestConvertProvidersToModelList_LiteLLM(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - LiteLLM: ProviderConfig{ - APIKey: "litellm-key", - APIBase: "http://localhost:4000/v1", - }, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 1 { - t.Fatalf("len(result) = %d, want 1", len(result)) - } - - if result[0].ModelName != "litellm" { - t.Errorf("ModelName = %q, want %q", result[0].ModelName, "litellm") - } - if result[0].Model != "litellm/auto" { - t.Errorf("Model = %q, want %q", result[0].Model, "litellm/auto") - } - if result[0].APIBase != "http://localhost:4000/v1" { - t.Errorf("APIBase = %q, want %q", result[0].APIBase, "http://localhost:4000/v1") - } -} - -func TestConvertProvidersToModelList_Multiple(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}}, - Groq: ProviderConfig{APIKey: "groq-key"}, - Zhipu: ProviderConfig{APIKey: "zhipu-key"}, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 3 { - t.Fatalf("len(result) = %d, want 3", len(result)) - } - - // Check that all providers are present - found := make(map[string]bool) - for _, mc := range result { - found[mc.ModelName] = true - } - - for _, name := range []string{"openai", "groq", "zhipu"} { - if !found[name] { - t.Errorf("Missing provider %q in result", name) - } - } -} - -func TestConvertProvidersToModelList_Empty(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{}, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 0 { - t.Errorf("len(result) = %d, want 0", len(result)) - } -} - -func TestConvertProvidersToModelList_Nil(t *testing.T) { - result := ConvertProvidersToModelList(nil) - - if result != nil { - t.Errorf("result = %v, want nil", result) - } -} - -func TestConvertProvidersToModelList_AllProviders(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}}, - LiteLLM: ProviderConfig{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"}, - Anthropic: ProviderConfig{APIKey: "key2"}, - OpenRouter: ProviderConfig{APIKey: "key3"}, - Groq: ProviderConfig{APIKey: "key4"}, - Zhipu: ProviderConfig{APIKey: "key5"}, - VLLM: ProviderConfig{APIKey: "key6"}, - Gemini: ProviderConfig{APIKey: "key7"}, - Nvidia: ProviderConfig{APIKey: "key8"}, - Ollama: ProviderConfig{APIKey: "key9"}, - Moonshot: ProviderConfig{APIKey: "key10"}, - ShengSuanYun: ProviderConfig{APIKey: "key11"}, - DeepSeek: ProviderConfig{APIKey: "key12"}, - Cerebras: ProviderConfig{APIKey: "key13"}, - Vivgrid: ProviderConfig{APIKey: "key14"}, - VolcEngine: ProviderConfig{APIKey: "key15"}, - GitHubCopilot: ProviderConfig{ConnectMode: "grpc"}, - Antigravity: ProviderConfig{AuthMethod: "oauth"}, - Qwen: ProviderConfig{APIKey: "key17"}, - Mistral: ProviderConfig{APIKey: "key18"}, - Avian: ProviderConfig{APIKey: "key19"}, - }, - } - - result := ConvertProvidersToModelList(cfg) - - // All 21 providers should be converted - if len(result) != 21 { - t.Errorf("len(result) = %d, want 21", len(result)) - } -} - -func TestConvertProvidersToModelList_Proxy(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ - ProviderConfig: ProviderConfig{ - APIKey: "key", - Proxy: "http://proxy:8080", - }, - }, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 1 { - t.Fatalf("len(result) = %d, want 1", len(result)) - } - - if result[0].Proxy != "http://proxy:8080" { - t.Errorf("Proxy = %q, want %q", result[0].Proxy, "http://proxy:8080") - } -} - -func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - Ollama: ProviderConfig{ - APIKey: "ollama-key", - RequestTimeout: 300, - }, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 1 { - t.Fatalf("len(result) = %d, want 1", len(result)) - } - - if result[0].RequestTimeout != 300 { - t.Errorf("RequestTimeout = %d, want %d", result[0].RequestTimeout, 300) - } -} - -func TestConvertProvidersToModelList_AuthMethod(t *testing.T) { - cfg := &Config{ - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ - ProviderConfig: ProviderConfig{ - AuthMethod: "oauth", - }, - }, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 0 { - t.Errorf("len(result) = %d, want 0 (AuthMethod alone should not create entry)", len(result)) - } -} - -// Tests for preserving user's configured model during migration - -func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ - Provider: "deepseek", - Model: "deepseek-reasoner", - }, - }, - Providers: ProvidersConfig{ - DeepSeek: ProviderConfig{APIKey: "sk-deepseek"}, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 1 { - t.Fatalf("len(result) = %d, want 1", len(result)) - } - - // Should use user's model, not default - if result[0].Model != "deepseek/deepseek-reasoner" { - t.Errorf("Model = %q, want %q (user's configured model)", result[0].Model, "deepseek/deepseek-reasoner") - } -} - -func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ - Provider: "openai", - Model: "gpt-4-turbo", - }, - }, - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}}, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 1 { - t.Fatalf("len(result) = %d, want 1", len(result)) - } - - if result[0].Model != "openai/gpt-4-turbo" { - t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-4-turbo") - } -} - -func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ - Provider: "claude", // alternative name - Model: "claude-opus-4-20250514", - }, - }, - Providers: ProvidersConfig{ - Anthropic: ProviderConfig{APIKey: "sk-ant"}, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 1 { - t.Fatalf("len(result) = %d, want 1", len(result)) - } - - if result[0].Model != "anthropic/claude-opus-4-20250514" { - t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-opus-4-20250514") - } -} - -func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ - Provider: "qwen", - Model: "qwen-plus", - }, - }, - Providers: ProvidersConfig{ - Qwen: ProviderConfig{APIKey: "sk-qwen"}, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 1 { - t.Fatalf("len(result) = %d, want 1", len(result)) - } - - if result[0].Model != "qwen/qwen-plus" { - t.Errorf("Model = %q, want %q", result[0].Model, "qwen/qwen-plus") - } -} - -func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ - Provider: "deepseek", - Model: "", // no model specified - }, - }, - Providers: ProvidersConfig{ - DeepSeek: ProviderConfig{APIKey: "sk-deepseek"}, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 1 { - t.Fatalf("len(result) = %d, want 1", len(result)) - } - - // Should use default model - if result[0].Model != "deepseek/deepseek-chat" { - t.Errorf("Model = %q, want %q (default)", result[0].Model, "deepseek/deepseek-chat") - } -} - -func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ - Provider: "deepseek", - Model: "deepseek-reasoner", - }, - }, - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}}, - DeepSeek: ProviderConfig{APIKey: "sk-deepseek"}, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 2 { - t.Fatalf("len(result) = %d, want 2", len(result)) - } - - // Find each provider and verify model - for _, mc := range result { - switch mc.ModelName { - case "openai": - if mc.Model != "openai/gpt-5.2" { - t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.2") - } - case "deepseek": - if mc.Model != "deepseek/deepseek-reasoner" { - t.Errorf("DeepSeek Model = %q, want %q (user's)", mc.Model, "deepseek/deepseek-reasoner") - } - } - } -} - -func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) { - tests := []struct { - providerAlias string - expectedModel string - provider ProviderConfig - }{ - {"gpt", "openai/gpt-4-custom", ProviderConfig{APIKey: "key"}}, - {"claude", "anthropic/claude-custom", ProviderConfig{APIKey: "key"}}, - {"doubao", "volcengine/doubao-custom", ProviderConfig{APIKey: "key"}}, - {"tongyi", "qwen/qwen-custom", ProviderConfig{APIKey: "key"}}, - {"kimi", "moonshot/kimi-custom", ProviderConfig{APIKey: "key"}}, - } - - for _, tt := range tests { - t.Run(tt.providerAlias, func(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ - Provider: tt.providerAlias, - Model: strings.TrimPrefix( - tt.expectedModel, - tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1], - ), - }, - }, - Providers: ProvidersConfig{}, - } - - // Set the appropriate provider config - switch tt.providerAlias { - case "gpt": - cfg.Providers.OpenAI = OpenAIProviderConfig{ProviderConfig: tt.provider} - case "claude": - cfg.Providers.Anthropic = tt.provider - case "doubao": - cfg.Providers.VolcEngine = tt.provider - case "tongyi": - cfg.Providers.Qwen = tt.provider - case "kimi": - cfg.Providers.Moonshot = tt.provider - } - - // Need to fix the model name in config - cfg.Agents.Defaults.Model = strings.TrimPrefix( - tt.expectedModel, - tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1], - ) - - result := ConvertProvidersToModelList(cfg) - if len(result) != 1 { - t.Fatalf("len(result) = %d, want 1", len(result)) - } - - // Extract just the model ID part (after the first /) - expectedModelID := tt.expectedModel - if result[0].Model != expectedModelID { - t.Errorf("Model = %q, want %q", result[0].Model, expectedModelID) - } - }) - } -} - -// Test for backward compatibility: single provider without explicit provider field -// This matches the legacy config pattern where users only set model, not provider - -func TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T) { - // This matches the user's actual config: - // - No provider field set - // - model = "glm-4.7" - // - Only zhipu has API key configured - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ - Provider: "", // Not set - Model: "glm-4.7", - }, - }, - Providers: ProvidersConfig{ - Zhipu: ProviderConfig{APIKey: "test-zhipu-key"}, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 1 { - t.Fatalf("len(result) = %d, want 1", len(result)) - } - - // ModelName should be the user's model value for backward compatibility - if result[0].ModelName != "glm-4.7" { - t.Errorf("ModelName = %q, want %q (user's model for backward compatibility)", result[0].ModelName, "glm-4.7") - } - - // Model should use the user's model with protocol prefix - if result[0].Model != "zhipu/glm-4.7" { - t.Errorf("Model = %q, want %q", result[0].Model, "zhipu/glm-4.7") - } -} - -func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testing.T) { - // When multiple providers are configured but no provider field is set, - // the FIRST provider (in migration order) will use userModel as ModelName - // for backward compatibility with legacy implicit provider selection - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ - Provider: "", // Not set - Model: "some-model", - }, - }, - Providers: ProvidersConfig{ - OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}}, - Zhipu: ProviderConfig{APIKey: "zhipu-key"}, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 2 { - t.Fatalf("len(result) = %d, want 2", len(result)) - } - - // The first provider (OpenAI in migration order) should use userModel as ModelName - // This ensures GetModelConfig("some-model") will find it - if result[0].ModelName != "some-model" { - t.Errorf("First provider ModelName = %q, want %q", result[0].ModelName, "some-model") - } - - // Other providers should use provider name as ModelName - if result[1].ModelName != "zhipu" { - t.Errorf("Second provider ModelName = %q, want %q", result[1].ModelName, "zhipu") - } -} - -func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) { - // Edge case: no provider, no model - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ - Provider: "", - Model: "", - }, - }, - Providers: ProvidersConfig{ - Zhipu: ProviderConfig{APIKey: "zhipu-key"}, - }, - } - - result := ConvertProvidersToModelList(cfg) - - if len(result) != 1 { - t.Fatalf("len(result) = %d, want 1", len(result)) - } - - // Should use default provider name since no model is specified - if result[0].ModelName != "zhipu" { - t.Errorf("ModelName = %q, want %q", result[0].ModelName, "zhipu") - } -} - -// Tests for buildModelWithProtocol helper function +// Tests for buildModelWithProtocol helper function. func TestBuildModelWithProtocol_NoPrefix(t *testing.T) { - result := buildModelWithProtocol("openai", "gpt-5.2") - if result != "openai/gpt-5.2" { - t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2") + result := buildModelWithProtocol("openai", "gpt-5.4") + if result != "openai/gpt-5.4" { + t.Errorf("buildModelWithProtocol(openai, gpt-5.4) = %q, want %q", result, "openai/gpt-5.4") } } @@ -581,33 +40,358 @@ func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) { } } -// Test for legacy config with protocol prefix in model name -func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) { - cfg := &Config{ - Agents: AgentsConfig{ - Defaults: AgentDefaults{ - Provider: "", // No explicit provider - Model: "openrouter/auto", // Model already has protocol prefix +// --------------------------------------------------------------------------- +// V0/V1/V2 → V3 migration tests +// --------------------------------------------------------------------------- + +// TestLoadConfig_V0MigrateProducesV2 verifies that V0→V3 migration produces +// correct Enabled fields and version. +func TestLoadConfig_V0MigrateProducesV2(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + v0Config := `{ + "model_list": [ + { + "model_name": "gpt-4", + "model": "openai/gpt-4", + "api_key": "sk-test" }, - }, - Providers: ProvidersConfig{ - OpenRouter: ProviderConfig{APIKey: "sk-or-test"}, - }, + { + "model_name": "claude", + "model": "anthropic/claude" + }, + { + "model_name": "local-model", + "model": "vllm/custom-model" + } + ], + "gateway": {"host": "127.0.0.1", "port": 18790} + }` + + if err := os.WriteFile(configPath, []byte(v0Config), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) } - result := ConvertProvidersToModelList(cfg) - - if len(result) < 1 { - t.Fatalf("len(result) = %d, want at least 1", len(result)) + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) } - // First provider should use userModel as ModelName for backward compatibility - if result[0].ModelName != "openrouter/auto" { - t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openrouter/auto") + if cfg.Version != CurrentVersion { + t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion) } - // Model should NOT have duplicated prefix - if result[0].Model != "openrouter/auto" { - t.Errorf("Model = %q, want %q (should not duplicate prefix)", result[0].Model, "openrouter/auto") + // Check enabled status + modelEnabled := func(name string) bool { + m, err := cfg.GetModelConfig(name) + if err != nil { + return false + } + return m.Enabled + } + + if !modelEnabled("gpt-4") { + t.Error("gpt-4 with API key from V0 should be enabled") + } + if modelEnabled("claude") { + t.Error("claude without API key from V0 should be disabled") + } + if !modelEnabled("local-model") { + t.Error("local-model from V0 should be enabled") } } + +// TestLoadConfig_UnsupportedVersion verifies that unsupported versions return an error. +func TestLoadConfig_UnsupportedVersion(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + badConfig := `{"version": 99, "gateway": {"host": "127.0.0.1", "port": 18790}}` + if err := os.WriteFile(configPath, []byte(badConfig), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("LoadConfig should return error for unsupported version") + } + if !containsString(err.Error(), "unsupported config version") { + t.Errorf("error = %q, want 'unsupported config version'", err.Error()) + } +} + +func containsString(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// TestMigrateV0ToV3 verifies V0 (legacy, no version) → V3 migration. +// V0 configs use the old providers format without model_list. +func TestMigrateV0ToV3(t *testing.T) { + // V0 config: no version field, uses legacy providers + v0Config := `{ + "agents": { + "defaults": { + "provider": "openai", + "model": "gpt-4" + } + }, + "providers": { + "openai": { + "api_key": "sk-test123", + "api_base": "https://api.openai.com/v1" + } + }, + "channels": { + "telegram": { + "token": "bot-token" + }, + "discord": { + "mention_only": true + } + } + }` + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + require.NoError(t, os.WriteFile(configPath, []byte(v0Config), 0o600)) + m, err := loadConfigMap(configPath) + require.NoError(t, err) + + err = migrateV0ToV1(m) + require.NoError(t, err) + err = migrateV1ToV2(m) + require.NoError(t, err) + err = migrateV2ToV3(m) + require.NoError(t, err) + + // Version should be set to CurrentVersion + require.Equal(t, CurrentVersion, m["version"]) + + // Providers should be converted to model_list + modelList, ok := m["model_list"].([]any) + require.True(t, ok, "model_list should exist") + require.NotEmpty(t, modelList, "model_list should not be empty") + + t.Logf("modelList: %+v", modelList) + // First model should be the user's configured provider with user's model + firstModel := modelList[0].(map[string]any) + require.Equal(t, "openai", firstModel["model_name"]) + require.Equal(t, "openai/gpt-4", firstModel["model"]) + // api_key is converted to api_keys during migration + require.Contains(t, firstModel, "api_keys", "api_keys should exist") + + // Channels should be converted to nested format with channel_list + channelList, ok := m["channel_list"].(map[string]any) + require.True(t, ok, "channel_list should exist") + require.NotContains(t, m, "channels", "old 'channels' key should be removed") + + // telegram channel should have settings + telegram := channelList["telegram"].(map[string]any) + require.Equal(t, "telegram", telegram["type"]) + require.Contains(t, telegram, "settings", "telegram should have settings") + settings := telegram["settings"].(map[string]any) + require.Equal(t, "bot-token", settings["token"]) + + // discord channel should have group_trigger and mention_only in group_trigger + discord := channelList["discord"].(map[string]any) + require.Equal(t, "discord", discord["type"]) + discordGroupTrigger := discord["group_trigger"].(map[string]any) + require.Equal(t, true, discordGroupTrigger["mention_only"]) +} + +// TestMigrateV0ToV3_WithExistingModelList preserves existing model_list when present. +func TestMigrateV0ToV3_WithExistingModelList(t *testing.T) { + v0Config := `{ + "model_list": [ + {"model_name": "custom", "model": "openai/custom-model", "api_key": "sk-existing"} + ], + "channels": { + "telegram": {"token": "bot123"} + } + }` + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + require.NoError(t, os.WriteFile(configPath, []byte(v0Config), 0o600)) + m, err := loadConfigMap(configPath) + require.NoError(t, err) + + err = migrateV0ToV1(m) + require.NoError(t, err) + err = migrateV1ToV2(m) + require.NoError(t, err) + err = migrateV2ToV3(m) + require.NoError(t, err) + + // Existing model_list should be preserved (not overridden by providers) + modelList := m["model_list"].([]any) + require.Len(t, modelList, 1) + firstModel := modelList[0].(map[string]any) + require.Equal(t, "custom", firstModel["model_name"]) +} + +// TestMigrateV1ToV3 verifies V1 → V3 migration. +// V1 uses flat channel format without "settings" wrapper. +func TestMigrateV1ToV3(t *testing.T) { + v1Config := `{ + "version": 1, + "model_list": [ + {"model_name": "gpt-4", "model": "openai/gpt-4", "api_key": "sk-test"} + ], + "channels": { + "telegram": { + "token": "bot-token", + "base_url": "https://custom.api.com" + }, + "discord": { + "mention_only": true, + "proxy": "socks5://localhost:1080" + }, + "onebot": { + "ws_url": "ws://localhost:3001", + "group_trigger_prefix": ["/"] + } + } + }` + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + require.NoError(t, os.WriteFile(configPath, []byte(v1Config), 0o600)) + m, err := loadConfigMap(configPath) + require.NoError(t, err) + + err = migrateV1ToV2(m) + require.NoError(t, err) + err = migrateV2ToV3(m) + require.NoError(t, err) + + // Version should be set to CurrentVersion + require.Equal(t, CurrentVersion, m["version"]) + + // Channels should be converted to nested format + channelList, ok := m["channel_list"].(map[string]any) + require.True(t, ok, "channel_list should exist") + require.NotContains(t, m, "channels", "old 'channels' key should be removed") + + // telegram: flat fields moved to settings + telegram := channelList["telegram"].(map[string]any) + require.Equal(t, "telegram", telegram["type"]) + tgSettings := telegram["settings"].(map[string]any) + require.Equal(t, "bot-token", tgSettings["token"]) + require.Equal(t, "https://custom.api.com", tgSettings["base_url"]) + + // discord: mention_only should be moved to group_trigger + discord := channelList["discord"].(map[string]any) + require.Equal(t, "discord", discord["type"]) + require.Contains(t, discord, "group_trigger", "mention_only should be migrated to group_trigger") + gt := discord["group_trigger"].(map[string]any) + require.Equal(t, true, gt["mention_only"]) + discordSettings := discord["settings"].(map[string]any) + require.Equal(t, "socks5://localhost:1080", discordSettings["proxy"]) + + // onebot: group_trigger_prefix should be moved to group_trigger.prefixes + onebot := channelList["onebot"].(map[string]any) + require.Equal(t, "onebot", onebot["type"]) + obGroupTrigger := onebot["group_trigger"].(map[string]any) + require.Equal( + t, + []any{"/"}, + obGroupTrigger["prefixes"], + "group_trigger_prefix should be moved to group_trigger.prefixes", + ) + obSettings := onebot["settings"].(map[string]any) + require.Equal(t, "ws://localhost:3001", obSettings["ws_url"]) +} + +// TestMigrateV1ToV3_ApiKeyConversion verifies api_key → api_keys conversion. +func TestMigrateV1ToV3_ApiKeyConversion(t *testing.T) { + v1Config := `{ + "version": 1, + "model_list": [ + {"model_name": "gpt-4", "model": "openai/gpt-4", "api_key": "sk-single"}, + {"model_name": "no-key", "model": "openai/no-key"} + ], + "channels": { + "telegram": {"token": "bot"} + } + }` + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + require.NoError(t, os.WriteFile(configPath, []byte(v1Config), 0o600)) + m, err := loadConfigMap(configPath) + require.NoError(t, err) + + err = migrateV1ToV2(m) + require.NoError(t, err) + err = migrateV2ToV3(m) + require.NoError(t, err) + + // api_key should be converted to api_keys array + modelList := m["model_list"].([]any) + firstModel := modelList[0].(map[string]any) + require.NotContains(t, firstModel, "api_key", "api_key should be removed") + require.Contains(t, firstModel, "api_keys", "api_keys should exist") + // api_keys can be []string or []any depending on how it was set + if apiKeys, ok := firstModel["api_keys"].([]string); ok { + require.Len(t, apiKeys, 1) + require.Equal(t, "sk-single", apiKeys[0]) + } else if apiKeys, ok := firstModel["api_keys"].([]any); ok { + require.Len(t, apiKeys, 1) + require.Equal(t, "sk-single", apiKeys[0]) + } else { + t.Fatalf("api_keys has unexpected type: %T", firstModel["api_keys"]) + } + + // Model without api_key should not have api_keys added + secondModel := modelList[1].(map[string]any) + require.NotContains(t, secondModel, "api_key") + require.NotContains(t, secondModel, "api_keys") +} + +// TestMigrateV1ToV3_AlreadyNestedFormat leaves already-nested channels unchanged. +func TestMigrateV1ToV3_AlreadyNestedFormat(t *testing.T) { + v1Config := `{ + "version": 1, + "model_list": [ + {"model_name": "gpt-4", "model": "openai/gpt-4"} + ], + "channels": { + "telegram": { + "type": "telegram", + "settings": { + "token": "bot-token" + } + } + } + }` + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + require.NoError(t, os.WriteFile(configPath, []byte(v1Config), 0o600)) + m, err := loadConfigMap(configPath) + require.NoError(t, err) + + err = migrateV1ToV2(m) + require.NoError(t, err) + err = migrateV2ToV3(m) + require.NoError(t, err) + + channelList := m["channel_list"].(map[string]any) + telegram := channelList["telegram"].(map[string]any) + // Should not be double-wrapped + require.Equal(t, "telegram", telegram["type"]) + settings := telegram["settings"].(map[string]any) + require.Equal(t, "bot-token", settings["token"]) + // Should NOT have nested settings inside settings + require.NotContains(t, settings, "settings") +} diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go index da6e506f8..d22eb290f 100644 --- a/pkg/config/model_config_test.go +++ b/pkg/config/model_config_test.go @@ -14,9 +14,10 @@ import ( func TestGetModelConfig_Found(t *testing.T) { cfg := &Config{ - ModelList: []ModelConfig{ - {ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"}, - {ModelName: "other-model", Model: "anthropic/claude", APIKey: "key2"}, + Version: CurrentVersion, + ModelList: []*ModelConfig{ + {ModelName: "test-model", Model: "openai/gpt-4o", APIKeys: SimpleSecureStrings("key1")}, + {ModelName: "other-model", Model: "anthropic/claude", APIKeys: SimpleSecureStrings("key2")}, }, } @@ -31,8 +32,8 @@ func TestGetModelConfig_Found(t *testing.T) { func TestGetModelConfig_NotFound(t *testing.T) { cfg := &Config{ - ModelList: []ModelConfig{ - {ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"}, + ModelList: []*ModelConfig{ + {ModelName: "test-model", Model: "openai/gpt-4o", APIKeys: SimpleSecureStrings("key1")}, }, } @@ -44,7 +45,7 @@ func TestGetModelConfig_NotFound(t *testing.T) { func TestGetModelConfig_EmptyList(t *testing.T) { cfg := &Config{ - ModelList: []ModelConfig{}, + ModelList: []*ModelConfig{}, } _, err := cfg.GetModelConfig("any-model") @@ -55,10 +56,10 @@ func TestGetModelConfig_EmptyList(t *testing.T) { func TestGetModelConfig_RoundRobin(t *testing.T) { cfg := &Config{ - ModelList: []ModelConfig{ - {ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"}, - {ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"}, - {ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"}, + ModelList: []*ModelConfig{ + {ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKeys: SimpleSecureStrings("key1")}, + {ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKeys: SimpleSecureStrings("key2")}, + {ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKeys: SimpleSecureStrings("key3")}, }, } @@ -80,11 +81,41 @@ func TestGetModelConfig_RoundRobin(t *testing.T) { } } +func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) { + rrCounter.Store(0) + + cfg := &Config{ + ModelList: []*ModelConfig{ + {ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKeys: SimpleSecureStrings("key1")}, + {ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKeys: SimpleSecureStrings("key2")}, + {ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKeys: SimpleSecureStrings("key3")}, + }, + } + + wantOrder := []string{ + "openai/gpt-4o-1", + "openai/gpt-4o-2", + "openai/gpt-4o-3", + "openai/gpt-4o-1", + "openai/gpt-4o-2", + } + + for i, want := range wantOrder { + result, err := cfg.GetModelConfig("lb-model") + if err != nil { + t.Fatalf("GetModelConfig() call %d error = %v", i, err) + } + if result.Model != want { + t.Fatalf("GetModelConfig() call %d model = %q, want %q", i, result.Model, want) + } + } +} + func TestGetModelConfig_Concurrent(t *testing.T) { cfg := &Config{ - ModelList: []ModelConfig{ - {ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKey: "key1"}, - {ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKey: "key2"}, + ModelList: []*ModelConfig{ + {ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKeys: SimpleSecureStrings("key1")}, + {ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKeys: SimpleSecureStrings("key2")}, }, } @@ -113,137 +144,6 @@ func TestGetModelConfig_Concurrent(t *testing.T) { } } -func TestAgentDefaults_GetModelName_BackwardCompat(t *testing.T) { - tests := []struct { - name string - defaults AgentDefaults - wantName string - }{ - { - name: "new model_name field only", - defaults: AgentDefaults{ModelName: "new-model"}, - wantName: "new-model", - }, - { - name: "old model field only", - defaults: AgentDefaults{Model: "legacy-model"}, - wantName: "legacy-model", - }, - { - name: "both fields - model_name takes precedence", - defaults: AgentDefaults{ModelName: "new-model", Model: "old-model"}, - wantName: "new-model", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.defaults.GetModelName(); got != tt.wantName { - t.Errorf("GetModelName() = %q, want %q", got, tt.wantName) - } - }) - } -} - -func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) { - tests := []struct { - name string - json string - wantName string - }{ - { - name: "new model_name field", - json: `{"model_name": "gpt4"}`, - wantName: "gpt4", - }, - { - name: "old model field", - json: `{"model": "gpt4"}`, - wantName: "gpt4", - }, - { - name: "both fields - model_name wins", - json: `{"model_name": "new", "model": "old"}`, - wantName: "new", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var defaults AgentDefaults - if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil { - t.Fatalf("Unmarshal error: %v", err) - } - if got := defaults.GetModelName(); got != tt.wantName { - t.Errorf("GetModelName() = %q, want %q", got, tt.wantName) - } - }) - } -} - -func TestFullConfig_JSON_BackwardCompat(t *testing.T) { - // Test complete config with both old and new formats - oldFormat := `{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "gpt4", - "max_tokens": 4096 - } - }, - "model_list": [ - { - "model_name": "gpt4", - "model": "openai/gpt-4o", - "api_key": "test-key" - } - ] - }` - - newFormat := `{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model_name": "gpt4", - "max_tokens": 4096 - } - }, - "model_list": [ - { - "model_name": "gpt4", - "model": "openai/gpt-4o", - "api_key": "test-key" - } - ] - }` - - for name, jsonStr := range map[string]string{ - "old format (model)": oldFormat, - "new format (model_name)": newFormat, - } { - t.Run(name, func(t *testing.T) { - cfg := &Config{} - if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil { - t.Fatalf("Unmarshal error: %v", err) - } - - // Check that GetModelName returns correct value - if got := cfg.Agents.Defaults.GetModelName(); got != "gpt4" { - t.Errorf("GetModelName() = %q, want %q", got, "gpt4") - } - - // Check that GetModelConfig works - modelCfg, err := cfg.GetModelConfig("gpt4") - if err != nil { - t.Fatalf("GetModelConfig error: %v", err) - } - if modelCfg.Model != "openai/gpt-4o" { - t.Errorf("Model = %q, want %q", modelCfg.Model, "openai/gpt-4o") - } - }) - } -} - func TestModelConfig_Validate(t *testing.T) { tests := []struct { name string @@ -258,6 +158,15 @@ func TestModelConfig_Validate(t *testing.T) { }, wantErr: false, }, + { + name: "valid tool schema transform", + config: ModelConfig{ + ModelName: "test", + Model: "openai/gpt-4o", + ToolSchemaTransform: "simple", + }, + wantErr: false, + }, { name: "missing model_name", config: ModelConfig{ @@ -277,6 +186,15 @@ func TestModelConfig_Validate(t *testing.T) { config: ModelConfig{}, wantErr: true, }, + { + name: "invalid tool schema transform", + config: ModelConfig{ + ModelName: "test", + Model: "openai/gpt-4o", + ToolSchemaTransform: "invalid", + }, + wantErr: true, + }, } for _, tt := range tests { @@ -299,7 +217,7 @@ func TestConfig_ValidateModelList(t *testing.T) { { name: "valid list", config: &Config{ - ModelList: []ModelConfig{ + ModelList: []*ModelConfig{ {ModelName: "test1", Model: "openai/gpt-4o"}, {ModelName: "test2", Model: "anthropic/claude"}, }, @@ -309,7 +227,7 @@ func TestConfig_ValidateModelList(t *testing.T) { { name: "invalid entry", config: &Config{ - ModelList: []ModelConfig{ + ModelList: []*ModelConfig{ {ModelName: "test1", Model: "openai/gpt-4o"}, {ModelName: "", Model: "anthropic/claude"}, // missing model_name }, @@ -320,7 +238,7 @@ func TestConfig_ValidateModelList(t *testing.T) { { name: "empty list", config: &Config{ - ModelList: []ModelConfig{}, + ModelList: []*ModelConfig{}, }, wantErr: false, }, @@ -328,10 +246,7 @@ func TestConfig_ValidateModelList(t *testing.T) { // Load balancing: multiple entries with same model_name are allowed name: "duplicate model_name for load balancing", config: &Config{ - ModelList: []ModelConfig{ - {ModelName: "gpt-4", Model: "openai/gpt-4o", APIKey: "key1"}, - {ModelName: "gpt-4", Model: "openai/gpt-4-turbo", APIKey: "key2"}, - }, + ModelList: []*ModelConfig{}, }, wantErr: false, // Changed: duplicates are allowed for load balancing }, @@ -339,7 +254,7 @@ func TestConfig_ValidateModelList(t *testing.T) { // Load balancing: non-adjacent entries with same model_name are also allowed name: "duplicate model_name non-adjacent for load balancing", config: &Config{ - ModelList: []ModelConfig{ + ModelList: []*ModelConfig{ {ModelName: "model-a", Model: "openai/gpt-4o"}, {ModelName: "model-b", Model: "anthropic/claude"}, {ModelName: "model-a", Model: "openai/gpt-4-turbo"}, diff --git a/pkg/config/multikey_test.go b/pkg/config/multikey_test.go new file mode 100644 index 000000000..073cb7826 --- /dev/null +++ b/pkg/config/multikey_test.go @@ -0,0 +1,373 @@ +package config + +import ( + "testing" +) + +func TestExpandMultiKeyModels_SingleKey(t *testing.T) { + models := []*ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4o", + APIKeys: SimpleSecureStrings("single-key"), + }, + } + + result := expandMultiKeyModels(models) + + if len(result) != 1 { + t.Fatalf("expected 1 model, got %d", len(result)) + } + + if result[0].ModelName != "gpt-4" { + t.Errorf("expected model_name 'gpt-4', got %q", result[0].ModelName) + } + + if result[0].APIKey() != "single-key" { + t.Errorf("expected api_key 'single-key', got %q", result[0].APIKey()) + } + + if len(result[0].Fallbacks) != 0 { + t.Errorf("expected no fallbacks, got %v", result[0].Fallbacks) + } +} + +func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) { + models := []*ModelConfig{ + { + ModelName: "glm-4.7", + Model: "zhipu/glm-4.7", + APIBase: "https://api.example.com", + APIKeys: SimpleSecureStrings("key1", "key2", "key3"), + }, + } + + result := expandMultiKeyModels(models) + + // Should expand to 3 models + if len(result) != 3 { + t.Fatalf("expected 3 models, got %d", len(result)) + } + + // First entry should be the primary with key1 and fallbacks + primary := result[2] // Primary is added last + if primary.ModelName != "glm-4.7" { + t.Errorf("expected primary model_name 'glm-4.7', got %q", primary.ModelName) + } + if primary.APIKey() != "key1" { + t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey()) + } + if len(primary.Fallbacks) != 2 { + t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks)) + } + if primary.Fallbacks[0] != "glm-4.7__key_1" { + t.Errorf("expected first fallback 'glm-4.7__key_1', got %q", primary.Fallbacks[0]) + } + if primary.Fallbacks[1] != "glm-4.7__key_2" { + t.Errorf("expected second fallback 'glm-4.7__key_2', got %q", primary.Fallbacks[1]) + } + + // Second entry should be key2 + second := result[0] + if second.ModelName != "glm-4.7__key_1" { + t.Errorf("expected second model_name 'glm-4.7__key_1', got %q", second.ModelName) + } + if second.APIKey() != "key2" { + t.Errorf("expected second api_key 'key2', got %q", second.APIKey()) + } + + // Third entry should be key3 + third := result[1] + if third.ModelName != "glm-4.7__key_2" { + t.Errorf("expected third model_name 'glm-4.7__key_2', got %q", third.ModelName) + } + if third.APIKey() != "key3" { + t.Errorf("expected third api_key 'key3', got %q", third.APIKey()) + } +} + +func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) { + models := []*ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4o", + APIKeys: SimpleSecureStrings("key0", "key1", "key2"), + }, + } + + result := expandMultiKeyModels(models) + + // Should expand to 3 models (key0 from APIKey + key1, key2 from APIKeys) + if len(result) != 3 { + t.Fatalf("expected 3 models, got %d", len(result)) + } + + // Primary should use key0 + primary := result[2] + if primary.APIKey() != "key0" { + t.Errorf("expected primary api_key 'key0', got %q", primary.APIKey()) + } + if len(primary.Fallbacks) != 2 { + t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks)) + } +} + +func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) { + modelCfg := &ModelConfig{ + ModelName: "gpt-4", + Model: "openai/gpt-4o", + } + modelCfg.APIKeys = SimpleSecureStrings("key0", "key1") // Use internal field for multi-key testing + modelCfg.Fallbacks = []string{"claude-3"} + models := []*ModelConfig{modelCfg} + + result := expandMultiKeyModels(models) + + primary := result[1] + // With 2 keys, we get 1 key fallback + 1 existing fallback = 2 total + if len(primary.Fallbacks) != 2 { + t.Fatalf("expected 2 fallbacks, got %d: %v", len(primary.Fallbacks), primary.Fallbacks) + } + + // Key fallbacks should come first, then existing fallbacks + if primary.Fallbacks[0] != "gpt-4__key_1" { + t.Errorf("expected first fallback 'gpt-4__key_1', got %q", primary.Fallbacks[0]) + } + if primary.Fallbacks[1] != "claude-3" { + t.Errorf("expected second fallback 'claude-3', got %q", primary.Fallbacks[1]) + } +} + +func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) { + models := []*ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4o", + APIKeys: SimpleSecureStrings(), + }, + } + + result := expandMultiKeyModels(models) + + // Should keep as-is with no changes + if len(result) != 1 { + t.Fatalf("expected 1 model, got %d", len(result)) + } + + if result[0].ModelName != "gpt-4" { + t.Errorf("expected model_name 'gpt-4', got %q", result[0].ModelName) + } +} + +func TestExpandMultiKeyModels_Deduplication(t *testing.T) { + models := []*ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4o", + APIKeys: SimpleSecureStrings("key1", "key2", "key1"), // Duplicate key1 + }, + } + + result := expandMultiKeyModels(models) + + t.Logf("result: %#v", result) + // Should only create 2 models (deduplicated keys) + if len(result) != 2 { + t.Fatalf("expected 2 models (deduplicated), got %d", len(result)) + } + + primary := result[1] + if primary.APIKey() != "key1" { + t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey()) + } + if len(primary.Fallbacks) != 1 { + t.Errorf("expected 1 fallback, got %d", len(primary.Fallbacks)) + } +} + +func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { + modelCfg := &ModelConfig{ + ModelName: "gpt-4", + Provider: "openrouter", + Model: "openai/gpt-4o", + APIBase: "https://api.example.com", + Proxy: "http://proxy:8080", + RPM: 60, + MaxTokensField: "max_completion_tokens", + RequestTimeout: 30, + ThinkingLevel: "high", + ToolSchemaTransform: "simple", + } + modelCfg.APIKeys = SimpleSecureStrings("key0", "key1") // Use internal field for multi-key testing + models := []*ModelConfig{modelCfg} + + result := expandMultiKeyModels(models) + + // Check primary entry preserves all fields + primary := result[1] + if primary.APIBase != "https://api.example.com" { + t.Errorf("expected api_base preserved, got %q", primary.APIBase) + } + if primary.Provider != "openrouter" { + t.Errorf("expected provider preserved, got %q", primary.Provider) + } + if primary.Proxy != "http://proxy:8080" { + t.Errorf("expected proxy preserved, got %q", primary.Proxy) + } + if primary.RPM != 60 { + t.Errorf("expected rpm preserved, got %d", primary.RPM) + } + if primary.MaxTokensField != "max_completion_tokens" { + t.Errorf("expected max_tokens_field preserved, got %q", primary.MaxTokensField) + } + if primary.RequestTimeout != 30 { + t.Errorf("expected request_timeout preserved, got %d", primary.RequestTimeout) + } + if primary.ThinkingLevel != "high" { + t.Errorf("expected thinking_level preserved, got %q", primary.ThinkingLevel) + } + if primary.ToolSchemaTransform != "simple" { + t.Errorf("expected tool_schema_transform preserved, got %q", primary.ToolSchemaTransform) + } + + // Check additional entry also preserves fields + additional := result[0] + if additional.Provider != "openrouter" { + t.Errorf("expected additional provider preserved, got %q", additional.Provider) + } + if additional.APIBase != "https://api.example.com" { + t.Errorf("expected additional api_base preserved, got %q", additional.APIBase) + } + if additional.RPM != 60 { + t.Errorf("expected additional rpm preserved, got %d", additional.RPM) + } + if additional.ToolSchemaTransform != "simple" { + t.Errorf("expected additional tool_schema_transform preserved, got %q", additional.ToolSchemaTransform) + } +} + +func TestExpandMultiKeyModels_IsVirtualFlag(t *testing.T) { + models := []*ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4o", + APIKeys: SimpleSecureStrings("key1", "key2", "key3"), + }, + } + + result := expandMultiKeyModels(models) + + // Should expand to 3 models + if len(result) != 3 { + t.Fatalf("expected 3 models, got %d", len(result)) + } + + // Primary model should NOT be virtual + primary := result[2] + if primary.isVirtual { + t.Errorf("primary model should not be virtual") + } + if primary.ModelName != "gpt-4" { + t.Errorf("expected primary model_name 'gpt-4', got %q", primary.ModelName) + } + + // Virtual models should have isVirtual = true + virtual1 := result[0] + if !virtual1.isVirtual { + t.Errorf("gpt-4__key_1 should be virtual") + } + if virtual1.ModelName != "gpt-4__key_1" { + t.Errorf("expected virtual model_name 'gpt-4__key_1', got %q", virtual1.ModelName) + } + + virtual2 := result[1] + if !virtual2.isVirtual { + t.Errorf("gpt-4__key_2 should be virtual") + } + if virtual2.ModelName != "gpt-4__key_2" { + t.Errorf("expected virtual model_name 'gpt-4__key_2', got %q", virtual2.ModelName) + } + + // IsVirtual() method should work + if !virtual1.IsVirtual() { + t.Errorf("IsVirtual() should return true for virtual model") + } + if primary.IsVirtual() { + t.Errorf("IsVirtual() should return false for primary model") + } +} + +func TestExpandMultiKeyModels_SingleKey_NotVirtual(t *testing.T) { + models := []*ModelConfig{ + { + ModelName: "gpt-4", + Model: "openai/gpt-4o", + APIKeys: SimpleSecureStrings("single-key"), + }, + } + + result := expandMultiKeyModels(models) + + if len(result) != 1 { + t.Fatalf("expected 1 model, got %d", len(result)) + } + + // Single key model should NOT be virtual + if result[0].isVirtual { + t.Errorf("single key model should not be virtual") + } +} + +func TestMergeAPIKeys(t *testing.T) { + tests := []struct { + name string + apiKey string + apiKeys []string + expected []string + }{ + { + name: "both empty", + apiKey: "", + apiKeys: nil, + expected: nil, + }, + { + name: "only ApiKey", + apiKey: "key1", + apiKeys: nil, + expected: []string{"key1"}, + }, + { + name: "only ApiKeys", + apiKey: "", + apiKeys: []string{"key1", "key2"}, + expected: []string{"key1", "key2"}, + }, + { + name: "both with overlap", + apiKey: "key1", + apiKeys: []string{"key1", "key2", "key3"}, + expected: []string{"key1", "key2", "key3"}, + }, + { + name: "with whitespace", + apiKey: " key1 ", + apiKeys: []string{" key2 ", " key1 "}, + expected: []string{"key1", "key2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mergeAPIKeys(tt.apiKey, tt.apiKeys) + if len(result) != len(tt.expected) { + t.Fatalf("expected %d keys, got %d", len(tt.expected), len(result)) + } + for i, k := range result { + if k != tt.expected[i] { + t.Errorf("expected key[%d] = %q, got %q", i, tt.expected[i], k) + } + } + }) + } +} diff --git a/pkg/config/security.go b/pkg/config/security.go new file mode 100644 index 000000000..9f0d1339c --- /dev/null +++ b/pkg/config/security.go @@ -0,0 +1,331 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package config + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + + "gopkg.in/yaml.v3" + + "github.com/sipeed/picoclaw/pkg/fileutil" +) + +const ( + SecurityConfigFile = ".security.yml" +) + +// securityPath returns the path to security.yml relative to the config file +func securityPath(configPath string) string { + configDir := filepath.Dir(configPath) + return filepath.Join(configDir, SecurityConfigFile) +} + +// loadSecurityConfig loads the security configuration from security.yml +// and merges secure field values into the config. +func loadSecurityConfig(cfg *Config, securityPath string) error { + if cfg == nil { + return fmt.Errorf("config is nil") + } + + data, err := os.ReadFile(securityPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to read security config: %w", err) + } + + // Save existing channels and ModelList before unmarshal + savedChannels := make(ChannelsConfig, len(cfg.Channels)) + for name, bc := range cfg.Channels { + savedChannels[name] = bc + } + // savedModelList := cfg.ModelList + + // Parse YAML into a yaml.Node tree to extract channels node + var rootNode yaml.Node + if err := yaml.Unmarshal(data, &rootNode); err != nil { + return fmt.Errorf("failed to parse security config: %w", err) + } + + // Extract channels node (support both 'channels' and 'channel_list' keys) + var channelsNode *yaml.Node + if len(rootNode.Content) > 0 { + content := rootNode.Content[0].Content + for i := 0; i < len(content); i += 2 { + if i+1 < len(content) { + key := content[i].Value + if key == "channels" || key == "channel_list" { + channelsNode = content[i+1] + break + } + } + } + } + + // Unmarshal non-channel fields from security.yml + // This will resolve encrypted values for model_list, tools, etc. + if err := yaml.Unmarshal(data, cfg); err != nil { + return fmt.Errorf("failed to parse security config %s: %w", securityPath, err) + } + if err := applyLegacySkillsSecurityConfig(cfg, data); err != nil { + return fmt.Errorf("failed to parse legacy skills security config: %w", err) + } + + // Restore channels from saved, then manually merge from security.yml + cfg.Channels = make(ChannelsConfig) + for name, savedBC := range savedChannels { + cfg.Channels[name] = savedBC + } + + // If we found a channels node in security.yml, merge it into existing channels + if channelsNode != nil { + if err := cfg.Channels.UnmarshalYAML(channelsNode); err != nil { + return fmt.Errorf("failed to merge channels from security config: %w", err) + } + } + + return nil +} + +func applyLegacySkillsSecurityConfig(cfg *Config, data []byte) error { + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return err + } + if len(root.Content) == 0 { + return nil + } + + rootMap := root.Content[0] + if rootMap == nil || rootMap.Kind != yaml.MappingNode { + return nil + } + + for i := 0; i+1 < len(rootMap.Content); i += 2 { + keyNode := rootMap.Content[i] + valueNode := rootMap.Content[i+1] + if keyNode == nil || valueNode == nil || strings.TrimSpace(keyNode.Value) != "skills" { + continue + } + return applyLegacySkillsSecurityNode(cfg, valueNode) + } + + return nil +} + +func applyLegacySkillsSecurityNode(cfg *Config, skillsNode *yaml.Node) error { + if cfg == nil || skillsNode == nil || skillsNode.Kind != yaml.MappingNode { + return nil + } + + for i := 0; i+1 < len(skillsNode.Content); i += 2 { + nameNode := skillsNode.Content[i] + valueNode := skillsNode.Content[i+1] + if nameNode == nil || valueNode == nil { + continue + } + + name := strings.TrimSpace(nameNode.Value) + if name == "" || name == "registries" { + continue + } + + if name == "github" { + var legacyGitHub SkillsGithubConfig + if err := valueNode.Decode(&legacyGitHub); err != nil { + return err + } + if cfg.Tools.Skills.Github.Token.String() == "" && legacyGitHub.Token.String() != "" { + cfg.Tools.Skills.Github.Token = legacyGitHub.Token + } + } + + var legacyRegistry SkillRegistryConfig + if err := valueNode.Decode(&legacyRegistry); err != nil { + return err + } + legacyRegistry.Name = name + if legacyRegistry.AuthToken.String() == "" { + if name == "github" && cfg.Tools.Skills.Github.Token.String() != "" { + legacyRegistry.AuthToken = cfg.Tools.Skills.Github.Token + } else { + continue + } + } + + registryCfg, ok := cfg.Tools.Skills.Registries.Get(name) + if !ok { + registryCfg = SkillRegistryConfig{ + Name: name, + Param: map[string]any{}, + } + } + if registryCfg.Param == nil { + registryCfg.Param = map[string]any{} + } + if registryCfg.AuthToken.String() == "" { + registryCfg.AuthToken = legacyRegistry.AuthToken + } + if registryCfg.BaseURL == "" && legacyRegistry.BaseURL != "" { + registryCfg.BaseURL = legacyRegistry.BaseURL + } + for key, value := range legacyRegistry.Param { + if _, exists := registryCfg.Param[key]; !exists { + registryCfg.Param[key] = value + } + } + cfg.Tools.Skills.Registries.Set(name, registryCfg) + } + + return nil +} + +// saveSecurityConfig saves the security configuration to security.yml +func saveSecurityConfig(securityPath string, sec *Config) error { + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + err := enc.Encode(sec) + if err != nil { + return fmt.Errorf("failed to marshal security config: %w", err) + } + return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600) +} + +// SensitiveDataCache caches the strings.Replacer for filtering sensitive data. +// Computed once on first access via sync.Once. +type SensitiveDataCache struct { + replacer *strings.Replacer + once sync.Once +} + +// SensitiveDataReplacer returns the strings.Replacer for filtering sensitive data. +// It is computed once on first access via sync.Once. +func (sec *Config) SensitiveDataReplacer() *strings.Replacer { + sec.initSensitiveCache() + return sec.sensitiveCache.replacer +} + +// initSensitiveCache initializes the sensitive data cache if not already done. +func (sec *Config) initSensitiveCache() { + if sec.sensitiveCache == nil { + sec.sensitiveCache = &SensitiveDataCache{} + } + sec.sensitiveCache.once.Do(func() { + values := sec.collectSensitiveValues() + if len(values) == 0 { + sec.sensitiveCache.replacer = strings.NewReplacer() + return + } + + // Build old/new pairs for strings.Replacer + var pairs []string + for _, v := range values { + if len(v) > 3 { + pairs = append(pairs, v, "[FILTERED]") + } + } + if len(pairs) == 0 { + sec.sensitiveCache.replacer = strings.NewReplacer() + return + } + sec.sensitiveCache.replacer = strings.NewReplacer(pairs...) + }) +} + +// collectSensitiveValues collects all sensitive strings from SecurityConfig using reflection. +func (sec *Config) collectSensitiveValues() []string { + var values []string + collectSensitive(reflect.ValueOf(sec), &values) + return values +} + +// collectSensitive recursively traverses the value and collects SecureString/SecureStrings values. +func collectSensitive(v reflect.Value, values *[]string) { + for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { + if v.IsNil() { + return + } + v = v.Elem() + } + + t := v.Type() + + // Channel: use CollectSensitiveValues() method + if t == reflect.TypeOf(Channel{}) { + if method := v.MethodByName("CollectSensitiveValues"); method.IsValid() { + results := method.Call(nil) + if len(results) > 0 { + if vals, ok := results[0].Interface().([]string); ok { + *values = append(*values, vals...) + } + } + } + return + } + + // SecureString: collect via String() method (defined on *SecureString) + if t == reflect.TypeOf(SecureString{}) { + // Create a new pointer to make it addressable for method calls + ptr := reflect.New(t) + ptr.Elem().Set(v) + result := ptr.MethodByName("String").Call(nil) + if len(result) > 0 { + if s := result[0].String(); s != "" { + *values = append(*values, s) + } + } + return + } + + // SecureStrings ([]*SecureString): iterate and collect each element + if t == reflect.TypeOf(SecureStrings{}) { + for i := 0; i < v.Len(); i++ { + elem := v.Index(i) + for elem.Kind() == reflect.Ptr || elem.Kind() == reflect.Interface { + if elem.IsNil() { + elem = reflect.Value{} + break + } + elem = elem.Elem() + } + if elem.IsValid() && elem.Type() == reflect.TypeOf(SecureString{}) { + result := elem.Addr().MethodByName("String").Call(nil) + if len(result) > 0 { + if s := result[0].String(); s != "" { + *values = append(*values, s) + } + } + } + } + return + } + + switch v.Kind() { + case reflect.Struct: + for i := 0; i < v.NumField(); i++ { + if !t.Field(i).IsExported() { + continue + } + collectSensitive(v.Field(i), values) + } + case reflect.Slice: + for i := 0; i < v.Len(); i++ { + collectSensitive(v.Index(i), values) + } + case reflect.Map: + for _, key := range v.MapKeys() { + collectSensitive(v.MapIndex(key), values) + } + } +} diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go new file mode 100644 index 000000000..8fc2f167c --- /dev/null +++ b/pkg/config/security_integration_test.go @@ -0,0 +1,635 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test JSON unmarshal of private fields (unexported fields are never filled, with or without json tag). +func TestJSONUnmarshalPrivateFields(t *testing.T) { + type testStruct struct { + PublicField string `json:"public"` + privateField string + } + + data := `{"public": "pub", "privateField": "priv"}` + var s testStruct + if err := json.Unmarshal([]byte(data), &s); err != nil { + t.Fatalf("JSON unmarshal failed: %v", err) + } + + t.Logf("PublicField: %s", s.PublicField) + t.Logf("privateField: %s", s.privateField) + + if s.PublicField != "pub" { + t.Errorf("PublicField = %q, want 'pub'", s.PublicField) + } + if s.privateField != "" { + t.Errorf("privateField = %q, want empty because unexported fields are ignored", s.privateField) + } +} + +func TestSecurityConfigIntegration(t *testing.T) { + t.Run("Full workflow with security references", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create config.json with direct security values using the current schema. + configPath := filepath.Join(tmpDir, "config.json") + configContent := `{ + "version": 2, + "model_list": [ + { + "model_name": "test-model", + "model": "openai/test-model", + "api_base": "https://api.openai.com/v1", + "api_keys": ["sk-from-config-json-direct"] + } + ], + "channels": { + "telegram": { + "enabled": true, + "token": "token-from-config-json-direct" + } + }, + "tools": { + "web": { + "brave": { + "enabled": true, + "api_keys": ["BSA-from-config-json-direct"] + } + }, + "skills": { + "github": { + "token": "ghp-from-config-json-direct" + } + } + } +}` + err := os.WriteFile(configPath, []byte(configContent), 0o644) + require.NoError(t, err) + + // Create .security.yml with different values + // These should be overridden by config.json values + securityPath := filepath.Join(tmpDir, SecurityConfigFile) + securityContent := `model_list: + test-model: + api_keys: + - "sk-from-security-yml" + +channels: + telegram: + token: "token-from-security-yml" + +skills: + github: + token: "ghp-from-security-yml"` + err = os.WriteFile(securityPath, []byte(securityContent), 0o600) + require.NoError(t, err) + + // Load config and verify config.json values take precedence + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + require.NotNil(t, cfg) + + // Verify model API key from config.json takes precedence + assert.Equal(t, 1, len(cfg.ModelList)) + assert.Equal(t, "test-model", cfg.ModelList[0].ModelName) + assert.Equal(t, "sk-from-security-yml", cfg.ModelList[0].APIKey()) + + // Verify channel token from config.json takes precedence + var tgTokenCfg *TelegramSettings + if bc := cfg.Channels.Get("telegram"); bc != nil { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + tgTokenCfg = decoded.(*TelegramSettings) + } + } + assert.Equal(t, "token-from-security-yml", tgTokenCfg.Token.String()) + + assert.Equal(t, "sk-from-security-yml", cfg.ModelList[0].APIKeys[0].String()) + + // Verify web tool API key from config.json takes precedence + assert.Equal(t, "BSA-from-config-json-direct", cfg.Tools.Web.Brave.APIKey()) + + // Verify skills token is resolved + assert.Equal(t, "ghp-from-security-yml", cfg.Tools.Skills.Github.Token.String()) + }) +} + +func TestSecurityConfigWithAPIKeysArray(t *testing.T) { + t.Run("Multiple API keys via security", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create config with APIKeys array + configPath := filepath.Join(tmpDir, "config.json") + configContent := `{ + "version": 1, + "model_list": [ + { + "model_name": "multi-key-model", + "model": "openai/multi-key-model" + } + ] +}` + err := os.WriteFile(configPath, []byte(configContent), 0o644) + require.NoError(t, err) + + // Create .security.yml + securityPath := filepath.Join(tmpDir, SecurityConfigFile) + securityContent := `model_list: + multi-key-model:0: + api_key: "sk-key-1" + api_keys: + - "sk-key-1" + - "sk-key-2" + - "sk-key-3" +` + err = os.WriteFile(securityPath, []byte(securityContent), 0o600) + require.NoError(t, err) + + // Load config + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + + t.Logf("Config: %+v", cfg.ModelList) + for _, m := range cfg.ModelList { + t.Logf("Model: %+v", m) + } + // Verify multi-key expansion works + assert.Equal(t, 3, len(cfg.ModelList)) + assert.Equal(t, "multi-key-model", cfg.ModelList[2].ModelName) + }) +} + +func TestAllSecurityKeysAccessible(t *testing.T) { + t.Run("All security keys accessible via Key() methods including file://", func(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files for file:// references + modelAPIKeyFile := filepath.Join(tmpDir, "model_api_key.txt") + err := os.WriteFile(modelAPIKeyFile, []byte("sk-model-from-file-12345"), 0o600) + require.NoError(t, err) + + braveAPIKeyFile := filepath.Join(tmpDir, "brave_api_key.txt") + err = os.WriteFile(braveAPIKeyFile, []byte("BSA-brave-from-file-67890"), 0o600) + require.NoError(t, err) + + tavilyAPIKeyFile := filepath.Join(tmpDir, "tavily_api_key.txt") + err = os.WriteFile(tavilyAPIKeyFile, []byte("tvly-tavily-from-file-11111"), 0o600) + require.NoError(t, err) + + perplexityAPIKeyFile := filepath.Join(tmpDir, "perplexity_api_key.txt") + err = os.WriteFile(perplexityAPIKeyFile, []byte("pplx-perplexity-from-file-22222"), 0o600) + require.NoError(t, err) + + githubTokenFile := filepath.Join(tmpDir, "github_token.txt") + err = os.WriteFile(githubTokenFile, []byte("ghp-github-from-file-abc123"), 0o600) + require.NoError(t, err) + + clawhubAuthTokenFile := filepath.Join(tmpDir, "clawhub_auth_token.txt") + err = os.WriteFile(clawhubAuthTokenFile, []byte("clawhub-auth-token-from-file"), 0o600) + require.NoError(t, err) + + // Create config.json without sensitive values (they'll be in .security.yml) + configPath := filepath.Join(tmpDir, "config.json") + configContent := `{ + "version": 1, + "model_list": [ + { + "model_name": "test-model-1", + "model": "openai/test-model-1" + } + ], + "channels": { + "telegram": { + "enabled": true + }, + "feishu": { + "enabled": true, + "app_id": "test_app_id" + }, + "discord": { + "enabled": true + }, + "dingtalk": { + "enabled": true, + "client_id": "test_client_id" + }, + "slack": { + "enabled": true + }, + "matrix": { + "enabled": true, + "homeserver": "https://matrix.org", + "user_id": "@test:matrix.org" + }, + "line": { + "enabled": true, + "webhook_host": "localhost", + "webhook_port": 8080, + "webhook_path": "/webhook" + }, + "onebot": { + "enabled": true, + "ws_url": "ws://localhost:8080" + }, + "wecom": { + "enabled": true, + "bot_id": "test_wecom_bot_id" + }, + "pico": { + "enabled": true + }, + "irc": { + "enabled": true, + "server": "irc.example.com", + "nick": "testbot" + }, + "qq": { + "enabled": true, + "app_id": "test_qq_app_id" + } + }, + "tools": { + "web": { + "brave": { + "enabled": true + }, + "tavily": { + "enabled": true + }, + "perplexity": { + "enabled": true + }, + "glm_search": { + "enabled": true + } + }, + "skills": { + "github": {} + } + } +}` + err = os.WriteFile(configPath, []byte(configContent), 0o644) + require.NoError(t, err) + + // Create .security.yml with file:// references and plaintext values + securityPath := filepath.Join(tmpDir, SecurityConfigFile) + securityContent := `model_list: + test-model-1: + api_keys: + - "file://model_api_key.txt" + +channels: + telegram: + token: "123456789:ABCdefGHIjklMNOpqrsTUVwxyz" + feishu: + app_secret: "feishu_test_app_secret" + encrypt_key: "feishu_test_encrypt_key" + verification_token: "feishu_test_verification_token" + discord: + token: "discord_test_bot_token_xyz" + dingtalk: + client_secret: "dingtalk_test_client_secret" + slack: + bot_token: "xoxb-slack-bot-token-123" + app_token: "xapp-slack-app-token-456" + matrix: + access_token: "matrix_test_access_token" + line: + channel_secret: "line_test_channel_secret" + channel_access_token: "line_test_channel_access_token" + onebot: + access_token: "onebot_test_access_token" + wecom: + secret: "wecom_test_secret" + pico: + token: "pico_test_token" + irc: + password: "irc_test_password" + nickserv_password: "irc_test_nickserv_password" + sasl_password: "irc_test_sasl_password" + qq: + app_secret: "qq_test_app_secret" + +web: + brave: + api_keys: + - "file://brave_api_key.txt" + tavily: + api_keys: + - "file://tavily_api_key.txt" + perplexity: + api_keys: + - "file://perplexity_api_key.txt" + glm_search: + api_key: "glm-test-glm-search-key" + +skills: + github: + token: "file://github_token.txt" + registries: + clawhub: + auth_token: "file://clawhub_auth_token.txt" +` + err = os.WriteFile(securityPath, []byte(securityContent), 0o600) + require.NoError(t, err) + + // Load config and verify all security keys are accessible + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + require.NotNil(t, cfg) + + // Verify Model API keys + assert.Equal(t, 1, len(cfg.ModelList)) + assert.Equal(t, "test-model-1", cfg.ModelList[0].ModelName) + // file:// reference should be resolved + assert.Equal(t, "sk-model-from-file-12345", cfg.ModelList[0].APIKey()) + t.Logf("Model APIKey(): %s", cfg.ModelList[0].APIKey()) + + // Helper function to decode channel settings + decodeChannel := func(name string) any { + bc := cfg.Channels.Get(name) + if bc == nil { + return nil + } + decoded, _ := bc.GetDecoded() + return decoded + } + + // Helper to get SecureString value + secureStr := func(s SecureString) string { + return s.String() + } + + // Verify Channel tokens via Key() methods + // Telegram + tgSec := decodeChannel("telegram") + assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", secureStr(tgSec.(*TelegramSettings).Token)) + t.Logf("Telegram Token(): %s", secureStr(tgSec.(*TelegramSettings).Token)) + + // Feishu + feiSec := decodeChannel("feishu") + assert.Equal(t, "feishu_test_app_secret", secureStr(feiSec.(*FeishuSettings).AppSecret)) + assert.Equal(t, "feishu_test_encrypt_key", secureStr(feiSec.(*FeishuSettings).EncryptKey)) + assert.Equal(t, "feishu_test_verification_token", secureStr(feiSec.(*FeishuSettings).VerificationToken)) + t.Logf("Feishu AppSecret(): %s", secureStr(feiSec.(*FeishuSettings).AppSecret)) + t.Logf("Feishu EncryptKey(): %s", secureStr(feiSec.(*FeishuSettings).EncryptKey)) + t.Logf("Feishu VerificationToken(): %s", secureStr(feiSec.(*FeishuSettings).VerificationToken)) + + // Discord + discSec := decodeChannel("discord") + assert.Equal(t, "discord_test_bot_token_xyz", secureStr(discSec.(*DiscordSettings).Token)) + t.Logf("Discord Token(): %s", secureStr(discSec.(*DiscordSettings).Token)) + + // DingTalk + dtSec := decodeChannel("dingtalk") + assert.Equal(t, "dingtalk_test_client_secret", secureStr(dtSec.(*DingTalkSettings).ClientSecret)) + t.Logf("DingTalk ClientSecret(): %s", secureStr(dtSec.(*DingTalkSettings).ClientSecret)) + + // Slack + slSec := decodeChannel("slack") + assert.Equal(t, "xoxb-slack-bot-token-123", secureStr(slSec.(*SlackSettings).BotToken)) + assert.Equal(t, "xapp-slack-app-token-456", secureStr(slSec.(*SlackSettings).AppToken)) + t.Logf("Slack BotToken(): %s", secureStr(slSec.(*SlackSettings).BotToken)) + t.Logf("Slack AppToken(): %s", secureStr(slSec.(*SlackSettings).AppToken)) + + // Matrix + matSec := decodeChannel("matrix") + assert.Equal(t, "matrix_test_access_token", secureStr(matSec.(*MatrixSettings).AccessToken)) + t.Logf("Matrix AccessToken(): %s", secureStr(matSec.(*MatrixSettings).AccessToken)) + + // LINE + lineSec := decodeChannel("line") + assert.Equal(t, "line_test_channel_secret", secureStr(lineSec.(*LINESettings).ChannelSecret)) + assert.Equal(t, "line_test_channel_access_token", secureStr(lineSec.(*LINESettings).ChannelAccessToken)) + t.Logf("LINE ChannelSecret(): %s", secureStr(lineSec.(*LINESettings).ChannelSecret)) + t.Logf("LINE ChannelAccessToken(): %s", secureStr(lineSec.(*LINESettings).ChannelAccessToken)) + + // OneBot + obSec := decodeChannel("onebot") + assert.Equal(t, "onebot_test_access_token", secureStr(obSec.(*OneBotSettings).AccessToken)) + t.Logf("OneBot AccessToken(): %s", secureStr(obSec.(*OneBotSettings).AccessToken)) + + // WeCom + wcSec := decodeChannel("wecom") + assert.Equal(t, "test_wecom_bot_id", wcSec.(*WeComSettings).BotID) + assert.Equal(t, "wecom_test_secret", secureStr(wcSec.(*WeComSettings).Secret)) + t.Logf("WeCom BotID: %s", wcSec.(*WeComSettings).BotID) + t.Logf("WeCom Secret(): %s", secureStr(wcSec.(*WeComSettings).Secret)) + + // Pico + picoSec := decodeChannel("pico") + assert.Equal(t, "pico_test_token", secureStr(picoSec.(*PicoSettings).Token)) + t.Logf("Pico Token(): %s", secureStr(picoSec.(*PicoSettings).Token)) + + // IRC + ircSec := decodeChannel("irc") + assert.Equal(t, "irc_test_password", secureStr(ircSec.(*IRCSettings).Password)) + assert.Equal(t, "irc_test_nickserv_password", secureStr(ircSec.(*IRCSettings).NickServPassword)) + assert.Equal(t, "irc_test_sasl_password", secureStr(ircSec.(*IRCSettings).SASLPassword)) + t.Logf("IRC Password(): %s", secureStr(ircSec.(*IRCSettings).Password)) + t.Logf("IRC NickServPassword(): %s", secureStr(ircSec.(*IRCSettings).NickServPassword)) + t.Logf("IRC SASLPassword(): %s", secureStr(ircSec.(*IRCSettings).SASLPassword)) + + // QQ + qqSec := decodeChannel("qq") + assert.Equal(t, "qq_test_app_secret", secureStr(qqSec.(*QQSettings).AppSecret)) + t.Logf("QQ AppSecret(): %s", secureStr(qqSec.(*QQSettings).AppSecret)) + + // Verify Web tool API keys + assert.Equal(t, "BSA-brave-from-file-67890", cfg.Tools.Web.Brave.APIKey()) + t.Logf("Brave APIKey(): %s", cfg.Tools.Web.Brave.APIKey()) + + assert.Equal(t, "tvly-tavily-from-file-11111", cfg.Tools.Web.Tavily.APIKey()) + t.Logf("Tavily APIKey(): %s", cfg.Tools.Web.Tavily.APIKey()) + + assert.Equal(t, "pplx-perplexity-from-file-22222", cfg.Tools.Web.Perplexity.APIKey()) + t.Logf("Perplexity APIKey(): %s", cfg.Tools.Web.Perplexity.APIKey()) + + // GLM Search - Note: GLM uses SetAPIKey (lowercase) internally + t.Logf("GLMSearch APIKey(): %s", cfg.Tools.Web.GLMSearch.APIKey.String()) + assert.Equal(t, "glm-test-glm-search-key", cfg.Tools.Web.GLMSearch.APIKey.String()) + + // Verify Skills tokens + assert.Equal(t, "ghp-github-from-file-abc123", cfg.Tools.Skills.Github.Token.String()) + t.Logf("Github Token(): %s", cfg.Tools.Skills.Github.Token.String()) + + clawHub, ok := cfg.Tools.Skills.Registries.Get("clawhub") + assert.True(t, ok) + assert.Equal(t, "clawhub-auth-token-from-file", clawHub.AuthToken.String()) + t.Logf("ClawHub AuthToken(): %s", clawHub.AuthToken.String()) + + t.Log("All security keys are successfully accessible via their respective Key() methods") + }) + + t.Run("Github registry token supports security overlay", func(t *testing.T) { + tmpDir := t.TempDir() + + githubTokenFile := filepath.Join(tmpDir, "github_registry_token.txt") + err := os.WriteFile(githubTokenFile, []byte("ghp-github-registry-token-from-file"), 0o600) + require.NoError(t, err) + + configPath := filepath.Join(tmpDir, "config.json") + configContent := `{ + "version": 1, + "tools": { + "skills": { + "registries": { + "github": { + "enabled": true, + "proxy": "http://127.0.0.1:7890" + } + } + } + } +}` + err = os.WriteFile(configPath, []byte(configContent), 0o644) + require.NoError(t, err) + + securityPath := filepath.Join(tmpDir, SecurityConfigFile) + securityContent := `skills: + registries: + github: + auth_token: "file://github_registry_token.txt" +` + err = os.WriteFile(securityPath, []byte(securityContent), 0o600) + require.NoError(t, err) + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + + githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github") + require.True(t, ok) + assert.Equal(t, "ghp-github-registry-token-from-file", githubRegistry.AuthToken.String()) + assert.Equal(t, "http://127.0.0.1:7890", githubRegistry.Param["proxy"]) + }) + + t.Run("Custom registry token supports security overlay", func(t *testing.T) { + tmpDir := t.TempDir() + + customTokenFile := filepath.Join(tmpDir, "custom_registry_token.txt") + err := os.WriteFile(customTokenFile, []byte("custom-registry-token-from-file"), 0o600) + require.NoError(t, err) + + configPath := filepath.Join(tmpDir, "config.json") + configContent := `{ + "version": 1, + "tools": { + "skills": { + "registries": { + "custom": { + "enabled": true, + "base_url": "https://skills.example.com" + } + } + } + } +}` + err = os.WriteFile(configPath, []byte(configContent), 0o644) + require.NoError(t, err) + + securityPath := filepath.Join(tmpDir, SecurityConfigFile) + securityContent := `skills: + registries: + custom: + auth_token: "file://custom_registry_token.txt" +` + err = os.WriteFile(securityPath, []byte(securityContent), 0o600) + require.NoError(t, err) + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + + customRegistry, ok := cfg.Tools.Skills.Registries.Get("custom") + require.True(t, ok) + assert.Equal(t, "https://skills.example.com", customRegistry.BaseURL) + assert.Equal(t, "custom-registry-token-from-file", customRegistry.AuthToken.String()) + + githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github") + require.True(t, ok) + assert.Equal(t, "https://github.com", githubRegistry.BaseURL) + }) + + t.Run("Legacy direct registry security entries remain supported", func(t *testing.T) { + tmpDir := t.TempDir() + + configPath := filepath.Join(tmpDir, "config.json") + configContent := `{ + "version": 1, + "tools": { + "skills": { + "registries": { + "clawhub": { + "enabled": true, + "base_url": "https://clawhub.ai" + } + } + } + } +}` + err := os.WriteFile(configPath, []byte(configContent), 0o644) + require.NoError(t, err) + + securityPath := filepath.Join(tmpDir, SecurityConfigFile) + securityContent := `skills: + clawhub: + auth_token: "legacy-clawhub-token" +` + err = os.WriteFile(securityPath, []byte(securityContent), 0o600) + require.NoError(t, err) + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + + registry, ok := cfg.Tools.Skills.Registries.Get("clawhub") + require.True(t, ok) + assert.Equal(t, "legacy-clawhub-token", registry.AuthToken.String()) + }) + + t.Run("Legacy github security token populates github registry", func(t *testing.T) { + tmpDir := t.TempDir() + + configPath := filepath.Join(tmpDir, "config.json") + configContent := `{ + "version": 1, + "tools": { + "skills": { + "registries": { + "github": { + "enabled": true, + "base_url": "https://github.com" + } + } + } + } +}` + err := os.WriteFile(configPath, []byte(configContent), 0o644) + require.NoError(t, err) + + securityPath := filepath.Join(tmpDir, SecurityConfigFile) + securityContent := `skills: + github: + token: "legacy-github-token" +` + err = os.WriteFile(securityPath, []byte(securityContent), 0o600) + require.NoError(t, err) + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + + registry, ok := cfg.Tools.Skills.Registries.Get("github") + require.True(t, ok) + assert.Equal(t, "legacy-github-token", cfg.Tools.Skills.Github.Token.String()) + assert.Equal(t, "legacy-github-token", registry.AuthToken.String()) + }) +} diff --git a/pkg/config/security_test.go b/pkg/config/security_test.go new file mode 100644 index 000000000..23daf3231 --- /dev/null +++ b/pkg/config/security_test.go @@ -0,0 +1,272 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/caarlos0/env/v11" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestSecurityConfig(t *testing.T) { + t.Run("LoadNonExistent", func(t *testing.T) { + sec := &Config{Channels: make(ChannelsConfig)} + err := loadSecurityConfig(sec, "/nonexistent/.security.yml") + require.NoError(t, err) + assert.NotNil(t, sec) + assert.Empty(t, sec.ModelList) + assert.NotNil(t, sec.Channels) + assert.NotNil(t, sec.Tools.Web) + assert.NotNil(t, sec.Tools.Skills) + }) +} + +func TestSecurityPath(t *testing.T) { + tests := []struct { + name string + configDir string + want string + }{ + { + name: "standard path", + configDir: "/home/user/.picoclaw/config.json", + want: "/home/user/.picoclaw/.security.yml", + }, + { + name: "nested path", + configDir: "/path/to/config/myconfig.json", + want: "/path/to/config/.security.yml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := securityPath(tt.configDir) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSaveAndLoadSecurityConfig(t *testing.T) { + t.Run("test for securestring", func(t *testing.T) { + type testStruct struct { + Secret SecureString `json:"secret,omitzero" yaml:"secret,omitempty" env:"TEST_SECURE_STRING"` + } + s := testStruct{Secret: *NewSecureString("test")} + out, err := yaml.Marshal(s) // 直接对 SecureString 进行序列化 + require.NoError(t, err) + t.Logf("output: %v", string(out)) + assert.Equal(t, "secret: test\n", string(out)) + out, err = json.Marshal(s) + require.NoError(t, err) + t.Logf("output: %v", string(out)) + assert.Equal(t, "{}", string(out)) + }) + tmpDir := t.TempDir() + secPath := filepath.Join(tmpDir, SecurityConfigFile) + + original := &Config{ + Version: CurrentVersion, + ModelList: SecureModelList{ + { + ModelName: "model1", + Model: "test/model", + APIBase: "api.example.com", + APIKeys: SecureStrings{NewSecureString("key1"), NewSecureString("key2")}, + }, + { + ModelName: "model2", + Model: "test/model2", + APIBase: "api2.example.com", + APIKeys: SecureStrings{NewSecureString("model2_key")}, + }, + }, + Tools: ToolsConfig{ + Web: WebToolsConfig{ + Brave: BraveConfig{ + Enabled: true, + APIKeys: SecureStrings{NewSecureString("brave_key")}, + }, + }, + Skills: SkillsToolsConfig{ + Github: SkillsGithubConfig{ + Token: *NewSecureString("github_token"), + Proxy: "test proxy", + }, + }, + }, + Channels: func() ChannelsConfig { + chs := make(ChannelsConfig) + type def struct { + name string + raw string // raw JSON with actual secure values (bypasses SecureString.MarshalJSON) + } + for _, d := range []def{ + {"telegram", `{"enabled":true,"settings":{"token":"telegram_token"}}`}, + {"feishu", `{"enabled":true,"settings":{"app_id":"feishu_app_id","app_secret":"feishu_app_secret"}}`}, + {"discord", `{"enabled":true,"settings":{"token":"discord_token"}}`}, + {"qq", `{"enabled":true,"settings":{"app_secret":"qq_app_secret"}}`}, + {"pico_client", `{"enabled":true,"settings":{"token":"pico_client_token"}}`}, + } { + bc := &Channel{} + json.Unmarshal([]byte(d.raw), bc) + bc.Type = d.name + switch bc.Type { + case "qq": + bc.Decode(&QQSettings{}) + case "telegram": + bc.Decode(&TelegramSettings{}) + case "discord": + bc.Decode(&DiscordSettings{}) + case "feishu": + bc.Decode(&FeishuSettings{}) + case "pico_client": + bc.Decode(&PicoClientSettings{}) + } + chs[d.name] = bc + } + return chs + }(), + } + + t.Run("test for original", func(t *testing.T) { + assert.Equal(t, 2, len(original.ModelList[0].APIKeys)) + assert.Equal(t, "key1", original.ModelList[0].APIKeys[0].String()) + }) + + cfg2 := &Config{} + t.Run("test for json", func(t *testing.T) { + marshal, err := json.Marshal(original) + require.NoError(t, err) + t.Logf("json: %s", string(marshal)) + assert.NotContains(t, string(marshal), "\"api_keys\"") + assert.NotContains(t, string(marshal), notHere) + + err = json.Unmarshal(marshal, cfg2) + require.NoError(t, err) + require.Equal(t, 2, len(cfg2.ModelList)) + assert.Empty(t, cfg2.ModelList[0].APIKeys) + assert.Empty(t, cfg2.ModelList[1].APIKeys) + }) + + t.Run("test for save yaml", func(t *testing.T) { + // Save + err := saveSecurityConfig(secPath, original) + require.NoError(t, err) + + // Verify file was created with correct permissions + info, err := os.Stat(secPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode()) + + file, err := os.ReadFile(secPath) + assert.NoError(t, err) + t.Logf("%s", string(file)) + + // Parse saved YAML and verify channelTestSaveConfig_EncryptsPlaintextAPIKey secure fields are present + var saved struct { + ChannelList map[string]map[string]any `yaml:"channel_list"` + } + require.NoError(t, yaml.Unmarshal(file, &saved)) + channels := saved.ChannelList + getSetting := func(name string) map[string]any { + return channels[name]["settings"].(map[string]any) + } + assert.Contains(t, getSetting("telegram")["token"], "telegram_token") + assert.Contains(t, getSetting("feishu")["app_secret"], "feishu_app_secret") + assert.Contains(t, getSetting("discord")["token"], "discord_token") + assert.Contains(t, getSetting("qq")["app_secret"], "qq_app_secret") + assert.Contains(t, getSetting("pico_client")["token"], "pico_client_token") + + // Rewrite file with deterministic content for load test (use channel_list) + yamlOutput := `channel_list: + telegram: + token: telegram_token + feishu: + app_secret: feishu_app_secret + discord: + token: discord_token + qq: + app_secret: qq_app_secret + pico_client: + token: pico_client_token +model_list: + model1:0: + api_keys: + - key1 + - key2 + model2:0: + api_keys: + - model2_key +web: + brave: + api_keys: + - brave_key +skills: + github: + token: github_token +` + err = os.WriteFile(secPath, []byte(yamlOutput), 0o600) + require.NoError(t, err) + }) + + t.Run("test for load yaml", func(t *testing.T) { + // Load + cfg := cfg2 + err := loadSecurityConfig(cfg, secPath) + require.NoError(t, err) + + t.Logf("%+v", cfg) + t.Logf("%+v", cfg.Tools.Web.Brave.APIKeys) + t.Logf("%+v", cfg.Tools.Skills.Github.Token) + require.EqualValues(t, 2, len(cfg.ModelList)) + assert.Equal(t, "key1", cfg.ModelList[0].APIKeys[0].String()) + assert.Equal(t, "key2", cfg.ModelList[0].APIKeys[1].String()) + assert.Equal(t, "model2_key", cfg.ModelList[1].APIKeys[0].String()) + assert.EqualValues(t, original.Tools.Web.Brave.APIKeys, cfg.Tools.Web.Brave.APIKeys) + }) + + t.Run("test for env overwrite", func(t *testing.T) { + // This will throw a COMPILER ERROR if SecureString doesn't + // correctly implement the yaml.Marshaler interface. + var _ yaml.Marshaler = (*SecureString)(nil) + // If you are using Value types in your config, also check: + var _ yaml.Marshaler = SecureString{} + + // Set up a fresh config with a qq channel + envCfg := &Config{ + Channels: ChannelsConfig{ + "qq": { + Enabled: true, + Type: "qq", + Settings: RawNode(`{"enabled":true,"app_secret":"qq_app_secret"}`), + }, + }, + Tools: original.Tools, + } + + t.Setenv("PICOCLAW_CHANNELS_QQ_APP_SECRET", "qq_app_secret_env") + t.Setenv("PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS", "brave_key_env,abc") + + require.NoError(t, env.Parse(envCfg)) + // Channel env overrides need explicit handling since ChannelsConfig is map-based + require.NoError(t, InitChannelList(envCfg.Channels)) + + bc := envCfg.Channels.Get("qq") + decoded, err := bc.GetDecoded() + require.NoError(t, err) + qqCfg := decoded.(*QQSettings) + assert.Equal(t, "qq_app_secret_env", qqCfg.AppSecret.raw) + assert.Equal(t, "brave_key_env", envCfg.Tools.Web.Brave.APIKeys[0].raw) + assert.Equal(t, "abc", envCfg.Tools.Web.Brave.APIKeys[1].raw) + }) +} diff --git a/pkg/config/version.go b/pkg/config/version.go new file mode 100644 index 000000000..b65d3cf33 --- /dev/null +++ b/pkg/config/version.go @@ -0,0 +1,44 @@ +package config + +import ( + "fmt" + "runtime" +) + +// Build-time variables injected via ldflags during build process. +// These are set by the Makefile or .goreleaser.yaml using the -X flag: +// +// -X github.com/sipeed/picoclaw/pkg/config.Version= +// -X github.com/sipeed/picoclaw/pkg/config.GitCommit= +// -X github.com/sipeed/picoclaw/pkg/config.BuildTime= +// -X github.com/sipeed/picoclaw/pkg/config.GoVersion= +var ( + Version = "dev" // Default value when not built with ldflags + GitCommit string // Git commit SHA (short) + BuildTime string // Build timestamp in RFC3339 format + GoVersion string // Go version used for building +) + +// FormatVersion returns the version string with optional git commit +func FormatVersion() string { + v := Version + if GitCommit != "" { + v += fmt.Sprintf(" (git: %s)", GitCommit) + } + return v +} + +// FormatBuildInfo returns build time and go version info +func FormatBuildInfo() (string, string) { + build := BuildTime + goVer := GoVersion + if goVer == "" { + goVer = runtime.Version() + } + return build, goVer +} + +// GetVersion returns the version string +func GetVersion() string { + return Version +} diff --git a/pkg/config/version_test.go b/pkg/config/version_test.go new file mode 100644 index 000000000..34bc906ce --- /dev/null +++ b/pkg/config/version_test.go @@ -0,0 +1,92 @@ +package config + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatVersion_NoGitCommit(t *testing.T) { + oldVersion, oldGit := Version, GitCommit + t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit }) + + Version = "1.2.3" + GitCommit = "" + + assert.Equal(t, "1.2.3", FormatVersion()) +} + +func TestFormatVersion_WithGitCommit(t *testing.T) { + oldVersion, oldGit := Version, GitCommit + t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit }) + + Version = "1.2.3" + GitCommit = "abc123" + + assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion()) +} + +func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) { + oldBuildTime, oldGoVersion := BuildTime, GoVersion + t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion }) + + BuildTime = "2026-02-20T00:00:00Z" + GoVersion = "go1.23.0" + + build, goVer := FormatBuildInfo() + + assert.Equal(t, BuildTime, build) + assert.Equal(t, GoVersion, goVer) +} + +func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) { + oldBuildTime, oldGoVersion := BuildTime, GoVersion + t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion }) + + BuildTime = "" + GoVersion = "go1.23.0" + + build, goVer := FormatBuildInfo() + + assert.Empty(t, build) + assert.Equal(t, GoVersion, goVer) +} + +func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) { + oldBuildTime, oldGoVersion := BuildTime, GoVersion + t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion }) + + BuildTime = "x" + GoVersion = "" + + build, goVer := FormatBuildInfo() + + assert.Equal(t, "x", build) + assert.Equal(t, runtime.Version(), goVer) +} + +func TestGetVersion(t *testing.T) { + oldVersion := Version + t.Cleanup(func() { Version = oldVersion }) + + Version = "dev" + assert.Equal(t, "dev", GetVersion()) +} + +func TestGetVersion_Custom(t *testing.T) { + oldVersion := Version + t.Cleanup(func() { Version = oldVersion }) + + Version = "v1.0.0" + assert.Equal(t, "v1.0.0", GetVersion()) +} + +func TestVersion_DefaultIsDev(t *testing.T) { + // Reset to default values + oldVersion := Version + Version = "dev" + t.Cleanup(func() { Version = oldVersion }) + + assert.Equal(t, "dev", Version) +} diff --git a/pkg/credential/credential.go b/pkg/credential/credential.go new file mode 100644 index 000000000..8ecd6783b --- /dev/null +++ b/pkg/credential/credential.go @@ -0,0 +1,343 @@ +// Package credential resolves API credential values for model_list entries. +// +// An API key is a form of authorization credential. This package centralizes +// how raw credential strings—plaintext or file references—are resolved into +// their actual values, keeping that logic out of the config loader. +// +// Supported formats for the api_key field: +// +// - Plaintext: "sk-abc123" → returned as-is +// - File ref: "file://filename.key" → content read from configDir/filename.key +// - Encrypted: "enc://" → AES-256-GCM decrypt via PICOCLAW_KEY_PASSPHRASE +// - Empty: "" → returned as-is (auth_method=oauth etc.) +// +// Encryption uses AES-256-GCM with HKDF-SHA256 key derivation (< 1ms, safe for embedded Linux). +// An SSH private key is required for both encryption and decryption. +// Key derivation: +// +// HKDF-SHA256(ikm=HMAC-SHA256(SHA256(sshKeyBytes), passphrase), salt, info) +// +// SSH key path resolution priority: +// +// 1. sshKeyPath argument to Encrypt (explicit) +// 2. PICOCLAW_SSH_KEY_PATH env var +// 3. ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform) +package credential + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// PassphraseEnvVar is the environment variable that holds the encryption passphrase. +// Other packages (e.g. config) reference this constant to avoid duplicating the string. +const PassphraseEnvVar = "PICOCLAW_KEY_PASSPHRASE" + +// PassphraseProvider is the function used to retrieve the passphrase for enc:// +// credential decryption. It defaults to reading PICOCLAW_KEY_PASSPHRASE from the +// process environment. Replace it at startup to use a different source, such as +// an in-memory SecureStore, so that all LoadConfig() calls everywhere share the +// same passphrase source without needing os.Environ. +// +// Example (launcher main.go): +// +// credential.PassphraseProvider = apiHandler.passphraseStore.Get +var PassphraseProvider func() string = func() string { + return os.Getenv(PassphraseEnvVar) +} + +// ErrPassphraseRequired is returned when an enc:// credential is encountered but +// no passphrase is available from PassphraseProvider. Callers can detect this +// with errors.Is to distinguish a missing-passphrase condition from other errors. +var ErrPassphraseRequired = errors.New("credential: enc:// passphrase required") + +// ErrDecryptionFailed is returned when an enc:// credential cannot be decrypted, +// indicating a wrong passphrase or SSH key. Callers can detect this with errors.Is. +var ErrDecryptionFailed = errors.New("credential: enc:// decryption failed (wrong passphrase or SSH key?)") + +// SSHKeyPathEnvVar is the environment variable that specifies the path to the +// SSH private key used for enc:// credential encryption and decryption. +const SSHKeyPathEnvVar = "PICOCLAW_SSH_KEY_PATH" + +// picoclawHome is a package-local copy of config.EnvHome. It is kept here to +// avoid a circular import between pkg/credential and pkg/config. +const picoclawHome = "PICOCLAW_HOME" + +const ( + FileScheme = "file://" + EncScheme = "enc://" + + hkdfInfo = "picoclaw-credential-v1" + saltLen = 16 + nonceLen = 12 + keyLen = 32 +) + +// Resolver resolves raw credential strings for model_list api_key fields. +// File references are resolved relative to the directory of the config file. +type Resolver struct { + configDir string + resolvedConfigDir string // symlink-resolved form of configDir +} + +// NewResolver returns a Resolver that resolves file:// references relative to +// configDir (typically filepath.Dir of the config file path). +func NewResolver(configDir string) *Resolver { + resolved := configDir + if configDir != "" { + if linkedPath, err := filepath.EvalSymlinks(configDir); err == nil { + resolved = linkedPath + } + } + return &Resolver{configDir: configDir, resolvedConfigDir: resolved} +} + +// Resolve returns the actual credential value for raw: +// +// - "" → "" (no error; auth_method=oauth needs no key) +// - "file://name.key" → trimmed content of configDir/name.key +// - anything else → raw unchanged (plaintext credential) +func (r *Resolver) Resolve(raw string) (string, error) { + if raw == "" { + return "", nil + } + + if strings.HasPrefix(raw, FileScheme) { + fileName := strings.TrimSpace(strings.TrimPrefix(raw, FileScheme)) + if fileName == "" { + return "", fmt.Errorf("credential: file:// reference has no filename") + } + + baseDir := r.resolvedConfigDir + if baseDir == "" { + baseDir = r.configDir + } + keyPath := filepath.Join(baseDir, fileName) + // Resolve symlinks before enforcing containment to prevent escaping via symlinks. + realKeyPath, err := filepath.EvalSymlinks(keyPath) + if err != nil { + return "", fmt.Errorf("credential: failed to resolve credential file path %q: %w", keyPath, err) + } + if !isWithinDir(realKeyPath, baseDir) { + return "", fmt.Errorf("credential: file:// path escapes config directory") + } + data, err := os.ReadFile(realKeyPath) + if err != nil { + return "", fmt.Errorf("credential: failed to read credential file %q: %w", realKeyPath, err) + } + + value := strings.TrimSpace(string(data)) + if value == "" { + return "", fmt.Errorf("credential: credential file %q is empty", realKeyPath) + } + + return value, nil + } + + if strings.HasPrefix(raw, EncScheme) { + return resolveEncrypted(raw) + } + + // Plaintext credential — return unchanged. + return raw, nil +} + +// resolveEncrypted decrypts an enc:// credential using PassphraseProvider. +func resolveEncrypted(raw string) (string, error) { + passphrase := PassphraseProvider() + if passphrase == "" { + return "", ErrPassphraseRequired + } + + sshKeyPath := pickSSHKeyPath("") // override="": consult env then auto-detect + + b64 := strings.TrimPrefix(raw, EncScheme) + blob, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", fmt.Errorf("credential: enc:// invalid base64: %w", err) + } + if len(blob) < saltLen+nonceLen+1 { + return "", fmt.Errorf("credential: enc:// payload too short") + } + + salt := blob[:saltLen] + nonce := blob[saltLen : saltLen+nonceLen] + ciphertext := blob[saltLen+nonceLen:] + + key, err := deriveKey(passphrase, sshKeyPath, salt) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("credential: enc:// cipher init: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("credential: enc:// gcm init: %w", err) + } + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrDecryptionFailed, err) + } + return string(plaintext), nil +} + +// Encrypt encrypts plaintext and returns an enc:// credential string. +// +// passphrase is required (PICOCLAW_KEY_PASSPHRASE value). +// sshKeyPath is the SSH private key file to use; pass "" to auto-detect via +// PICOCLAW_SSH_KEY_PATH env var or ~/.ssh/picoclaw_ed25519.key. +// An SSH private key must be resolvable or Encrypt returns an error. +func Encrypt(passphrase, sshKeyPath, plaintext string) (string, error) { + if passphrase == "" { + return "", fmt.Errorf("credential: passphrase must not be empty") + } + sshKeyPath = pickSSHKeyPath(sshKeyPath) + + salt := make([]byte, saltLen) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return "", fmt.Errorf("credential: failed to generate salt: %w", err) + } + + key, err := deriveKey(passphrase, sshKeyPath, salt) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("credential: cipher init: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("credential: gcm init: %w", err) + } + + nonce := make([]byte, nonceLen) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("credential: failed to generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nil, nonce, []byte(plaintext), nil) + blob := make([]byte, 0, saltLen+nonceLen+len(ciphertext)) + blob = append(blob, salt...) + blob = append(blob, nonce...) + blob = append(blob, ciphertext...) + return EncScheme + base64.StdEncoding.EncodeToString(blob), nil +} + +// isWithinDir reports whether path is contained within (or equal to) dir. +// Uses filepath.IsLocal on the relative path for robust cross-platform traversal detection. +func isWithinDir(path, dir string) bool { + rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(path)) + return err == nil && filepath.IsLocal(rel) +} + +// allowedSSHKeyPath reports whether path is in a permitted location for SSH key files: +// - exact match with PICOCLAW_SSH_KEY_PATH env var +// - within the PICOCLAW_HOME env var directory +// - within ~/.ssh/ +func allowedSSHKeyPath(path string) bool { + if path == "" { + return true // passphrase-only mode; no file will be read + } + clean := filepath.Clean(path) + + // Exact match with PICOCLAW_SSH_KEY_PATH. + if envPath, ok := os.LookupEnv(SSHKeyPathEnvVar); ok && envPath != "" { + if clean == filepath.Clean(envPath) { + return true + } + } + + // Within PICOCLAW_HOME. + if picoHome := os.Getenv(picoclawHome); picoHome != "" { + if isWithinDir(clean, picoHome) { + return true + } + } + + // Within ~/.ssh/. + if userHome, err := os.UserHomeDir(); err == nil { + if isWithinDir(clean, filepath.Join(userHome, ".ssh")) { + return true + } + } + + return false +} + +// deriveKey derives a 32-byte AES-256 key from passphrase and SSH private key. +// +// ikm = HMAC-SHA256(key=SHA256(sshKeyBytes), msg=passphrase) +// Final key: HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +// sshKeyPath must be non-empty; returns an error otherwise. +func deriveKey(passphrase, sshKeyPath string, salt []byte) ([]byte, error) { + if sshKeyPath == "" { + return nil, fmt.Errorf( + "credential: SSH private key is required but not found" + + " (set PICOCLAW_SSH_KEY_PATH or place key at ~/.ssh/picoclaw_ed25519.key)") + } + if !allowedSSHKeyPath(sshKeyPath) { + return nil, fmt.Errorf( + "credential: SSH key path %q is not in an allowed location (PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/)", + sshKeyPath, + ) + } + sshBytes, err := os.ReadFile(sshKeyPath) + if err != nil { + return nil, fmt.Errorf("credential: cannot read SSH key %q: %w", sshKeyPath, err) + } + sshHash := sha256.Sum256(sshBytes) + mac := hmac.New(sha256.New, sshHash[:]) + mac.Write([]byte(passphrase)) + ikm := mac.Sum(nil) + + key, err := hkdf.Key(sha256.New, ikm, salt, hkdfInfo, keyLen) + if err != nil { + return nil, fmt.Errorf("credential: HKDF expand failed: %w", err) + } + return key, nil +} + +// pickSSHKeyPath returns the SSH private key path to use for encryption/decryption. +// +// Priority: +// 1. override (non-empty explicit argument) +// 2. PICOCLAW_SSH_KEY_PATH env var +// 3. ~/.ssh/picoclaw_ed25519.key (auto-detection) +// +// Returns "" when no key is found; deriveKey will return an error in that case. +func pickSSHKeyPath(override string) string { + if override != "" { + return override + } + if p, ok := os.LookupEnv(SSHKeyPathEnvVar); ok { + return p // respect explicit setting, even if "" + } + return findDefaultSSHKey() +} + +// findDefaultSSHKey returns the picoclaw-specific SSH key path if it exists. +func findDefaultSSHKey() string { + p, err := DefaultSSHKeyPath() + if err != nil { + return "" + } + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} diff --git a/pkg/credential/credential_test.go b/pkg/credential/credential_test.go new file mode 100644 index 000000000..138af3134 --- /dev/null +++ b/pkg/credential/credential_test.go @@ -0,0 +1,283 @@ +package credential_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/credential" +) + +func TestResolve_PlainKey(t *testing.T) { + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve("sk-plaintext-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-plaintext-key" { + t.Fatalf("got %q, want %q", got, "sk-plaintext-key") + } +} + +func TestResolve_FileKey_Success(t *testing.T) { + dir := t.TempDir() + keyFile := "openai_plain.key" + if err := os.WriteFile(filepath.Join(dir, keyFile), []byte("sk-from-file\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(dir) + got, err := r.Resolve("file://" + keyFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-from-file" { + t.Fatalf("got %q, want %q", got, "sk-from-file") + } +} + +func TestResolve_FileKey_NotFound(t *testing.T) { + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("file://missing.key") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestResolve_FileKey_Empty(t *testing.T) { + dir := t.TempDir() + keyFile := "empty.key" + if err := os.WriteFile(filepath.Join(dir, keyFile), []byte(" \n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(dir) + _, err := r.Resolve("file://" + keyFile) + if err == nil { + t.Fatal("expected error for empty credential file, got nil") + } +} + +// TestResolve_EncKey_RoundTrip tests basic encryption/decryption round-trip with an SSH key. +func TestResolve_EncKey_RoundTrip(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + const passphrase = "test-passphrase-32bytes-long-ok!" + const plaintext = "sk-encrypted-secret" + + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt(passphrase, "", plaintext) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase) + + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve(enc) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != plaintext { + t.Fatalf("got %q, want %q", got, plaintext) + } +} + +// TestResolve_EncKey_WithSSHKey tests that the SSH key file is incorporated into key derivation. +func TestResolve_EncKey_WithSSHKey(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-private-key-material\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + const passphrase = "test-passphrase" + const plaintext = "sk-ssh-protected-secret" + + // Set PICOCLAW_SSH_KEY_PATH before Encrypt so the path passes allowedSSHKeyPath validation. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase) + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt(passphrase, sshKeyPath, plaintext) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve(enc) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != plaintext { + t.Fatalf("got %q, want %q", got, plaintext) + } +} + +func TestResolve_EncKey_NoPassphrase(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt("some-passphrase", "", "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected error when PICOCLAW_KEY_PASSPHRASE is unset, got nil") + } +} + +func TestResolve_EncKey_BadCiphertext(t *testing.T) { + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("enc://!!not-valid-base64!!") + if err == nil { + t.Fatal("expected error for invalid enc:// payload, got nil") + } +} + +func TestResolve_EncKey_PayloadTooShort(t *testing.T) { + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + // Valid base64 but fewer bytes than salt(16)+nonce(12)+1 minimum. + import64 := "dG9vc2hvcnQ=" // "tooshort" = 8 bytes + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("enc://" + import64) + if err == nil { + t.Fatal("expected error for too-short enc:// payload, got nil") + } +} + +func TestResolve_EncKey_WrongPassphrase(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt("correct-passphrase", "", "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "wrong-passphrase") + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected decryption error for wrong passphrase, got nil") + } +} + +func TestEncrypt_EmptyPassphrase(t *testing.T) { + _, err := credential.Encrypt("", "", "sk-secret") + if err == nil { + t.Fatal("expected error for empty passphrase, got nil") + } +} + +func TestDeriveKey_SSHKeyNotFound(t *testing.T) { + // Encrypt with a real SSH key path, then try to decrypt with a missing path. + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + // Register the real key path so allowedSSHKeyPath validation passes for Encrypt. + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + // Point to a non-existent SSH key so deriveKey's ReadFile fails. + // The path is still under the same dir, so allowedSSHKeyPath passes (exact env match). + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", filepath.Join(dir, "nonexistent_key")) + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected error when SSH key file is missing, got nil") + } +} + +// TestResolve_FileRef_PathTraversal verifies that file:// references cannot escape configDir +// via relative traversal ("../../etc/passwd") or absolute paths ("/abs/path"). +func TestResolve_FileRef_PathTraversal(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + // Create a file outside configDir that the traversal would point to. + outsideFile := filepath.Join(t.TempDir(), "secret.key") + if err := os.WriteFile(outsideFile, []byte("stolen"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(filepath.Dir(cfgPath)) + + cases := []string{ + "file://../../secret.key", + "file://../secret.key", + "file://" + outsideFile, // absolute path + } + for _, raw := range cases { + _, err := r.Resolve(raw) + if err == nil { + t.Errorf("Resolve(%q): expected path traversal error, got nil", raw) + } + } +} + +// TestResolve_FileRef_withinConfigDir verifies that a legitimate relative file:// ref works. +func TestResolve_FileRef_withinConfigDir(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "my.key"), []byte("sk-valid\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + r := credential.NewResolver(dir) + got, err := r.Resolve("file://my.key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-valid" { + t.Fatalf("got %q, want %q", got, "sk-valid") + } +} + +// TestEncrypt_SSHKeyOutsideAllowedDirs verifies that Encrypt rejects SSH key paths +// that are not under PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/. +func TestEncrypt_SSHKeyOutsideAllowedDirs(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + // Make sure none of the allowed env vars point here. + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + t.Setenv("PICOCLAW_HOME", "") + + _, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret") + if err == nil { + t.Fatal("expected error for SSH key outside allowed directories, got nil") + } +} diff --git a/pkg/credential/keygen.go b/pkg/credential/keygen.go new file mode 100644 index 000000000..c57564a76 --- /dev/null +++ b/pkg/credential/keygen.go @@ -0,0 +1,62 @@ +package credential + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh" +) + +// DefaultSSHKeyPath returns the canonical path for the picoclaw-specific SSH key. +// The path is always ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform). +func DefaultSSHKeyPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("credential: cannot determine home directory: %w", err) + } + return filepath.Join(home, ".ssh", "picoclaw_ed25519.key"), nil +} + +// GenerateSSHKey generates an Ed25519 SSH key pair and writes the private key +// to path (permissions 0600) and the public key to path+".pub" (permissions 0644). +// The ~/.ssh/ directory is created with 0700 if it does not exist. +// If the files already exist they are overwritten. +func GenerateSSHKey(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("credential: keygen: cannot create directory %q: %w", filepath.Dir(path), err) + } + + pubRaw, privRaw, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return fmt.Errorf("credential: keygen: ed25519 key generation failed: %w", err) + } + + // Marshal private key as OpenSSH PEM. + block, err := ssh.MarshalPrivateKey(privRaw, "") + if err != nil { + return fmt.Errorf("credential: keygen: marshal private key: %w", err) + } + privPEM := pem.EncodeToMemory(block) + + if err = os.WriteFile(path, privPEM, 0o600); err != nil { + return fmt.Errorf("credential: keygen: write private key %q: %w", path, err) + } + + // Marshal public key as authorized_keys line. + sshPub, err := ssh.NewPublicKey(pubRaw) + if err != nil { + return fmt.Errorf("credential: keygen: marshal public key: %w", err) + } + pubLine := ssh.MarshalAuthorizedKey(sshPub) + + pubPath := path + ".pub" + if err := os.WriteFile(pubPath, pubLine, 0o644); err != nil { + return fmt.Errorf("credential: keygen: write public key %q: %w", pubPath, err) + } + + return nil +} diff --git a/pkg/credential/keygen_test.go b/pkg/credential/keygen_test.go new file mode 100644 index 000000000..1e21ea0b9 --- /dev/null +++ b/pkg/credential/keygen_test.go @@ -0,0 +1,115 @@ +package credential + +import ( + "crypto/ed25519" + "os" + "path/filepath" + "runtime" + "testing" + + "golang.org/x/crypto/ssh" +) + +func TestGenerateSSHKey_CreatesFiles(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "test_ed25519.key") + + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("GenerateSSHKey() error = %v", err) + } + + // Private key must exist. + privInfo, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("private key file missing: %v", err) + } + + // Check permissions on non-Windows (Windows does not support Unix permission bits). + if runtime.GOOS != "windows" { + if got := privInfo.Mode().Perm(); got != 0o600 { + t.Errorf("private key permissions = %04o, want 0600", got) + } + } + + // Public key must exist. + pubPath := keyPath + ".pub" + pubInfo, err := os.Stat(pubPath) + if err != nil { + t.Fatalf("public key file missing: %v", err) + } + if runtime.GOOS != "windows" { + if got := pubInfo.Mode().Perm(); got != 0o644 { + t.Errorf("public key permissions = %04o, want 0644", got) + } + } + + // Private key must be parseable as an OpenSSH ed25519 key. + privPEM, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read private key: %v", err) + } + privKey, err := ssh.ParseRawPrivateKey(privPEM) + if err != nil { + t.Fatalf("parse private key: %v", err) + } + if _, ok := privKey.(*ed25519.PrivateKey); !ok { + t.Errorf("private key type = %T, want *ed25519.PrivateKey", privKey) + } + + // Public key must be parseable as authorized_keys line. + pubBytes, err := os.ReadFile(pubPath) + if err != nil { + t.Fatalf("read public key: %v", err) + } + pubKey, _, _, rest, err := ssh.ParseAuthorizedKey(pubBytes) + if err != nil { + t.Fatalf("parse public key: %v", err) + } + if pubKey == nil { + t.Fatal("expected non-nil public key") + } + if len(rest) > 0 { + t.Errorf("unexpected trailing bytes after public key: %d bytes", len(rest)) + } +} + +func TestGenerateSSHKey_OverwritesExisting(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "test_ed25519.key") + + // Generate twice; second call must not error and must produce a different key. + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("first GenerateSSHKey() error = %v", err) + } + first, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read first key: %v", err) + } + + if err = GenerateSSHKey(keyPath); err != nil { + t.Fatalf("second GenerateSSHKey() error = %v", err) + } + second, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read second key: %v", err) + } + + // Two independently generated Ed25519 keys must differ. + if string(first) == string(second) { + t.Error("expected overwritten key to differ from original") + } +} + +func TestGenerateSSHKey_CreatesDirectory(t *testing.T) { + dir := t.TempDir() + // Nested directory that does not yet exist. + keyPath := filepath.Join(dir, "subdir", ".ssh", "picoclaw_ed25519.key") + + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("GenerateSSHKey() error = %v", err) + } + + if _, err := os.Stat(keyPath); err != nil { + t.Fatalf("private key not created: %v", err) + } +} diff --git a/pkg/credential/store.go b/pkg/credential/store.go new file mode 100644 index 000000000..9c72974b0 --- /dev/null +++ b/pkg/credential/store.go @@ -0,0 +1,44 @@ +package credential + +import "sync/atomic" + +// SecureStore holds a passphrase in memory. +// +// Uses atomic.Pointer so reads and writes are lock-free. +// The passphrase is never written to disk; callers decide how to +// transport it outside this store (e.g., via cmd.Env or os.Environ). +type SecureStore struct { + val atomic.Pointer[string] +} + +// NewSecureStore creates an empty SecureStore. +func NewSecureStore() *SecureStore { + return &SecureStore{} +} + +// SetString stores the passphrase. An empty string clears the store. +func (s *SecureStore) SetString(passphrase string) { + if passphrase == "" { + s.val.Store(nil) + return + } + s.val.Store(&passphrase) +} + +// Get returns the stored passphrase, or "" if not set. +func (s *SecureStore) Get() string { + if p := s.val.Load(); p != nil { + return *p + } + return "" +} + +// IsSet reports whether a passphrase is currently stored. +func (s *SecureStore) IsSet() bool { + return s.val.Load() != nil +} + +// Clear removes the stored passphrase. +func (s *SecureStore) Clear() { + s.val.Store(nil) +} diff --git a/pkg/credential/store_test.go b/pkg/credential/store_test.go new file mode 100644 index 000000000..63299743a --- /dev/null +++ b/pkg/credential/store_test.go @@ -0,0 +1,81 @@ +package credential + +import ( + "sync" + "testing" +) + +func TestSecureStore_SetGet(t *testing.T) { + s := NewSecureStore() + if s.IsSet() { + t.Error("expected empty store") + } + + s.SetString("hunter2") + if !s.IsSet() { + t.Error("expected store to be set") + } + if got := s.Get(); got != "hunter2" { + t.Errorf("Get() = %q, want %q", got, "hunter2") + } +} + +func TestSecureStore_Clear(t *testing.T) { + s := NewSecureStore() + s.SetString("secret") + s.Clear() + + if s.IsSet() { + t.Error("expected store to be empty after Clear()") + } + if got := s.Get(); got != "" { + t.Errorf("Get() after Clear() = %q, want empty", got) + } +} + +func TestSecureStore_SetOverwrites(t *testing.T) { + s := NewSecureStore() + s.SetString("first") + s.SetString("second") + + if got := s.Get(); got != "second" { + t.Errorf("Get() = %q, want %q", got, "second") + } +} + +func TestSecureStore_EmptyPassphrase(t *testing.T) { + s := NewSecureStore() + s.SetString("") // empty → should not mark as set + + if s.IsSet() { + t.Error("empty passphrase should not mark store as set") + } +} + +func TestSecureStore_ConcurrentSetGet(t *testing.T) { + s := NewSecureStore() + const goroutines = 10 + const iterations = 1000 + + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + if id%2 == 0 { + s.SetString("even") + } else { + s.SetString("odd") + } + _ = s.Get() + } + }(i) + } + wg.Wait() + + final := s.Get() + if final != "" && final != "even" && final != "odd" { + t.Errorf("Get() returned unexpected value %q after concurrent Set/Get", final) + } +} diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 04775ac42..6a8728943 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -27,7 +27,6 @@ type CronPayload struct { Kind string `json:"kind"` Message string `json:"message"` Command string `json:"command,omitempty"` - Deliver bool `json:"deliver"` Channel string `json:"channel,omitempty"` To string `json:"to,omitempty"` } @@ -65,6 +64,7 @@ type CronService struct { mu sync.RWMutex running bool stopChan chan struct{} + wakeChan chan struct{} gronx *gronx.Gronx } @@ -73,6 +73,7 @@ func NewCronService(storePath string, onJob JobHandler) *CronService { storePath: storePath, onJob: onJob, gronx: gronx.New(), + wakeChan: make(chan struct{}), } // Initialize and load store on creation cs.loadStore() @@ -97,6 +98,9 @@ func (cs *CronService) Start() error { } cs.stopChan = make(chan struct{}) + if cs.wakeChan == nil { + cs.wakeChan = make(chan struct{}) + } cs.running = true go cs.runLoop(cs.stopChan) @@ -119,14 +123,47 @@ func (cs *CronService) Stop() { } func (cs *CronService) runLoop(stopChan chan struct{}) { - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() + timer := time.NewTimer(time.Hour) + if !timer.Stop() { + <-timer.C + } + defer timer.Stop() for { + // every loop, recalculate the next wake time + cs.mu.RLock() + nextWake := cs.getNextWakeMS() + cs.mu.RUnlock() + + var delay time.Duration + now := time.Now().UnixMilli() + + if nextWake == nil { + // no jobs, sleep for a long time (or until a new job is added) + delay = time.Hour + } else { + diff := *nextWake - now + if diff <= 0 { + delay = 0 + } else { + delay = time.Duration(diff) * time.Millisecond + } + } + + timer.Reset(delay) + select { case <-stopChan: return - case <-ticker.C: + case <-cs.wakeChan: // wake on new job or update + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + continue + case <-timer.C: cs.checkJobs() } } @@ -264,22 +301,19 @@ func (cs *CronService) executeJobByID(jobID string) { } func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int64 { - if schedule.Kind == "at" { + switch schedule.Kind { + case "at": if schedule.AtMS != nil && *schedule.AtMS > nowMS { return schedule.AtMS } return nil - } - - if schedule.Kind == "every" { + case "every": if schedule.EveryMS == nil || *schedule.EveryMS <= 0 { return nil } next := nowMS + *schedule.EveryMS return &next - } - - if schedule.Kind == "cron" { + case "cron": if schedule.Expr == "" { return nil } @@ -294,9 +328,19 @@ func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int6 nextMS := nextTime.UnixMilli() return &nextMS + default: + log.Printf("[cron] unknown schedule kind '%s'", schedule.Kind) + return nil } +} - return nil +// wake up the loop to re-evaluate next wake time immediately (e.g. after add/update/remove jobs) +func (cs *CronService) notify() { + select { + case cs.wakeChan <- struct{}{}: + default: + // if the channel is full, it means the loop will wake up soon anyway, so we can skip sending + } } func (cs *CronService) recomputeNextRuns() { @@ -364,7 +408,6 @@ func (cs *CronService) AddJob( name string, schedule CronSchedule, message string, - deliver bool, channel, to string, ) (*CronJob, error) { cs.mu.Lock() @@ -383,7 +426,6 @@ func (cs *CronService) AddJob( Payload: CronPayload{ Kind: "agent_turn", Message: message, - Deliver: deliver, Channel: channel, To: to, }, @@ -400,6 +442,8 @@ func (cs *CronService) AddJob( return nil, err } + cs.notify() + return &job, nil } @@ -411,6 +455,9 @@ func (cs *CronService) UpdateJob(job *CronJob) error { if cs.store.Jobs[i].ID == job.ID { cs.store.Jobs[i] = *job cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli() + + cs.notify() + return cs.saveStoreUnsafe() } } @@ -441,6 +488,8 @@ func (cs *CronService) removeJobUnsafe(jobID string) bool { } } + cs.notify() + return removed } @@ -463,6 +512,9 @@ func (cs *CronService) EnableJob(jobID string, enabled bool) *CronJob { if err := cs.saveStoreUnsafe(); err != nil { log.Printf("[cron] failed to save store after enable: %v", err) } + + cs.notify() + return job } } diff --git a/pkg/cron/service_test.go b/pkg/cron/service_test.go index 1a0dd1829..6dff3b387 100644 --- a/pkg/cron/service_test.go +++ b/pkg/cron/service_test.go @@ -1,10 +1,13 @@ package cron import ( + "fmt" "os" "path/filepath" "runtime" + "sync" "testing" + "time" ) func TestSaveStore_FilePermissions(t *testing.T) { @@ -17,7 +20,7 @@ func TestSaveStore_FilePermissions(t *testing.T) { cs := NewCronService(storePath, nil) - _, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", false, "cli", "direct") + _, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", "cli", "direct") if err != nil { t.Fatalf("AddJob failed: %v", err) } @@ -36,3 +39,199 @@ func TestSaveStore_FilePermissions(t *testing.T) { func int64Ptr(v int64) *int64 { return &v } + +func setupService(handler JobHandler) (*CronService, string) { + tmpFile := fmt.Sprintf("test_cron_%d.json", time.Now().UnixNano()) + cs := NewCronService(tmpFile, handler) + return cs, tmpFile +} + +func TestCronService_CRUD(t *testing.T) { + cs, path := setupService(nil) + defer os.Remove(path) + + // Test AddJob + at := time.Now().Add(time.Hour).UnixMilli() + job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", "ch", "to") + if err != nil || job.ID == "" { + t.Fatalf("AddJob failed: %v", err) + } + + // Test ListJobs + if len(cs.ListJobs(true)) != 1 { + t.Error("ListJobs should return 1 job") + } + + // Test UpdateJob + job.Name = "UpdatedName" + err = cs.UpdateJob(job) + if err != nil || cs.store.Jobs[0].Name != "UpdatedName" { + t.Error("UpdateJob failed") + } + + // Test EnableJob + cs.EnableJob(job.ID, false) + if cs.store.Jobs[0].Enabled != false || cs.store.Jobs[0].State.NextRunAtMS != nil { + t.Error("EnableJob(false) failed to clear state") + } + + // Test RemoveJob + removed := cs.RemoveJob(job.ID) + if !removed || len(cs.store.Jobs) != 0 { + t.Error("RemoveJob failed") + } +} + +// 2. Test Cron Expression Calculation Logic +func TestCronService_ComputeNextRun(t *testing.T) { + cs, path := setupService(nil) + defer os.Remove(path) + + now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli() + + tests := []struct { + name string + schedule CronSchedule + wantNil bool + }{ + {"Valid Cron", CronSchedule{Kind: "cron", Expr: "0 * * * *"}, false}, + {"Invalid Cron", CronSchedule{Kind: "cron", Expr: "invalid"}, true}, + {"Every MS", CronSchedule{Kind: "every", EveryMS: int64Ptr(5000)}, false}, + {"At Future", CronSchedule{Kind: "at", AtMS: int64Ptr(now + 1000)}, false}, + {"At Past", CronSchedule{Kind: "at", AtMS: int64Ptr(now - 1000)}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cs.computeNextRun(&tt.schedule, now) + if (got == nil) != tt.wantNil { + t.Errorf("%s: got %v, wantNil %v", tt.name, got, tt.wantNil) + } + }) + } +} + +// 3. Test Execution Flow +func TestCronService_ExecutionFlow(t *testing.T) { + var mu sync.Mutex + executedJobs := make(map[string]bool) + + handler := func(job *CronJob) (string, error) { + mu.Lock() + executedJobs[job.ID] = true + mu.Unlock() + return "ok", nil + } + + cs, path := setupService(handler) + defer os.Remove(path) + + // Start the service + if err := cs.Start(); err != nil { + t.Fatalf("Start failed: %v", err) + } + defer cs.Stop() + + // Add a job then runs 100ms from now + target := time.Now().Add(100 * time.Millisecond).UnixMilli() + job, _ := cs.AddJob("FastJob", CronSchedule{Kind: "at", AtMS: &target}, "", "", "") + + // Check for job execution with a timeout + success := false + for range 20 { + mu.Lock() + if executedJobs[job.ID] { + success = true + mu.Unlock() + break + } + mu.Unlock() + time.Sleep(100 * time.Millisecond) + } + + if !success { + t.Error("Job was not executed in time") + } + + // check that the job is removed after execution (DeleteAfterRun = true) + status := cs.Status() + if status["jobs"].(int) != 0 { + t.Errorf("Job should be deleted after run, got count: %v", status["jobs"]) + } +} + +func TestCronService_PersistenceIntegrity(t *testing.T) { + tmpFile := "persist_test.json" + defer os.Remove(tmpFile) + + // write a job and persist + cs1 := NewCronService(tmpFile, nil) + at := int64(2000000000000) + cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", "ch1", "") + + // check file exists + if _, err := os.Stat(tmpFile); os.IsNotExist(err) { + t.Fatal("Store file was not created") + } + + // reload and check data integrity + cs2 := NewCronService(tmpFile, nil) + if err := cs2.Load(); err != nil { + t.Fatalf("Failed to load store: %v", err) + } + + jobs := cs2.ListJobs(true) + if len(jobs) != 1 || jobs[0].Name != "PersistMe" { + t.Errorf("Data corruption after reload. Got: %+v", jobs) + } + + // test loading invalid JSON + os.WriteFile(tmpFile, []byte("{invalid json}"), 0o644) + cs3 := NewCronService(tmpFile, nil) + err := cs3.loadStore() + if err == nil { + t.Error("Should return error when loading invalid JSON") + } +} + +func TestCronService_ConcurrentAccess(t *testing.T) { + cs, path := setupService(nil) + defer os.Remove(path) + + cs.Start() + defer cs.Stop() + + var wg sync.WaitGroup + workers := 10 + iterations := 50 + + wg.Add(workers * 2) + + // add jobs concurrently + for i := range workers { + go func(id int) { + defer wg.Done() + for j := range iterations { + at := time.Now().Add(time.Hour).UnixMilli() + cs.AddJob(fmt.Sprintf("Job-%d-%d", id, j), CronSchedule{Kind: "at", AtMS: &at}, "", "", "") + time.Sleep(100 * time.Microsecond) + } + }(i) + } + + // read and update jobs concurrently + for range workers { + go func() { + defer wg.Done() + for j := range iterations { + jobs := cs.ListJobs(true) + if len(jobs) > 0 { + cs.EnableJob(jobs[0].ID, j%2 == 0) + } + time.Sleep(100 * time.Microsecond) + } + }() + } + + wg.Wait() +} diff --git a/pkg/devices/service.go b/pkg/devices/service.go index 1bafe6085..1cf2a686e 100644 --- a/pkg/devices/service.go +++ b/pkg/devices/service.go @@ -131,8 +131,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) { pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Channel: platform, - ChatID: userID, + Context: bus.NewOutboundContext(platform, userID, ""), Content: msg, }) diff --git a/pkg/env.go b/pkg/env.go new file mode 100644 index 000000000..b9a77dab2 --- /dev/null +++ b/pkg/env.go @@ -0,0 +1,12 @@ +// all environment variables including default values put here + +package pkg + +const ( + Logo = "🦞" + // AppName is the name of the app + AppName = "PicoClaw" + + DefaultPicoClawHome = ".picoclaw" + WorkspaceName = "workspace" +) diff --git a/pkg/events/bus.go b/pkg/events/bus.go new file mode 100644 index 000000000..f193ccb74 --- /dev/null +++ b/pkg/events/bus.go @@ -0,0 +1,243 @@ +package events + +import ( + "context" + "sort" + "strconv" + "sync" + "sync/atomic" + "time" +) + +var globalEventSeq atomic.Uint64 + +// Bus publishes runtime events and creates filtered channels. +type Bus interface { + Publish(ctx context.Context, evt Event) PublishResult + PublishNonBlocking(evt Event) PublishResult + Channel() EventChannel + Close() error + Stats() Stats +} + +// PublishResult reports per-publish delivery outcomes. +type PublishResult struct { + Matched int + Delivered int + Dropped int + Blocked int + Closed bool +} + +// EventBus is an in-process runtime event broadcaster. +type EventBus struct { + mu sync.RWMutex + subs map[uint64]*eventSubscription + orderedSubs []*eventSubscription + closed bool + + nextSubID atomic.Uint64 + published atomic.Uint64 + matched atomic.Uint64 + delivered atomic.Uint64 + dropped atomic.Uint64 + blocked atomic.Uint64 +} + +var _ Bus = (*EventBus)(nil) + +// NewBus creates an in-process runtime event bus. +func NewBus() *EventBus { + return &EventBus{ + subs: make(map[uint64]*eventSubscription), + } +} + +// Publish broadcasts evt to subscriptions whose filters match it. +func (b *EventBus) Publish(ctx context.Context, evt Event) PublishResult { + return b.publish(ctx, evt, false) +} + +// PublishNonBlocking broadcasts evt without waiting for subscriber queue capacity. +func (b *EventBus) PublishNonBlocking(evt Event) PublishResult { + return b.publish(context.Background(), evt, true) +} + +func (b *EventBus) publish(ctx context.Context, evt Event, nonBlocking bool) PublishResult { + if b == nil { + return PublishResult{Closed: true} + } + if ctx == nil { + ctx = context.Background() + } + if evt.Time.IsZero() { + evt.Time = time.Now() + } + if evt.ID == "" { + evt.ID = nextEventID() + } + + subs, closed := b.snapshotSubscribers() + if closed { + return PublishResult{Closed: true} + } + + b.published.Add(1) + result := PublishResult{} + + for _, sub := range subs { + if !matchesFilters(sub.filters, evt) { + continue + } + + result.Matched++ + b.matched.Add(1) + + delivery := sub.enqueue(ctx, evt, nonBlocking) + if delivery.closed { + continue + } + result.Delivered += delivery.delivered + result.Dropped += delivery.dropped + result.Blocked += delivery.blocked + b.delivered.Add(uint64(delivery.delivered)) + b.dropped.Add(uint64(delivery.dropped)) + b.blocked.Add(uint64(delivery.blocked)) + } + + return result +} + +// Channel returns the root event channel for this bus. +func (b *EventBus) Channel() EventChannel { + return eventChannel{bus: b} +} + +// Close closes the bus and all active subscriptions. +func (b *EventBus) Close() error { + if b == nil { + return nil + } + + b.mu.Lock() + if b.closed { + b.mu.Unlock() + return nil + } + b.closed = true + subs := b.orderedSubs + b.subs = nil + b.orderedSubs = nil + b.mu.Unlock() + + for _, sub := range subs { + sub.closeInput() + } + return nil +} + +// Stats returns a snapshot of bus and subscription counters. +func (b *EventBus) Stats() Stats { + if b == nil { + return Stats{Closed: true} + } + + b.mu.RLock() + closed := b.closed + subs := b.orderedSubs + b.mu.RUnlock() + + stats := Stats{ + Published: b.published.Load(), + Matched: b.matched.Load(), + Delivered: b.delivered.Load(), + Dropped: b.dropped.Load(), + Blocked: b.blocked.Load(), + Closed: closed, + Subscribers: len(subs), + SubscriberStats: make([]SubscriberStats, 0, len(subs)), + } + for _, sub := range subs { + stats.SubscriberStats = append(stats.SubscriberStats, sub.Stats()) + } + return stats +} + +func (b *EventBus) subscribe( + ctx context.Context, + filters []Filter, + opts SubscribeOptions, + handler Handler, + once bool, +) (Subscription, error) { + if b == nil { + return nil, ErrBusClosed + } + + id := b.nextSubID.Add(1) + sub := newSubscription(b, id, filters, opts, handler, once) + + b.mu.Lock() + if b.closed { + b.mu.Unlock() + sub.closeInput() + return nil, ErrBusClosed + } + b.subs[id] = sub + b.rebuildOrderedSubscribersLocked() + b.mu.Unlock() + + if handler != nil { + go sub.run(ctx) + } + sub.watchContext(ctx) + return sub, nil +} + +func (b *EventBus) unsubscribe(id uint64) { + b.mu.Lock() + sub, ok := b.subs[id] + if ok { + delete(b.subs, id) + b.rebuildOrderedSubscribersLocked() + } + b.mu.Unlock() + + if ok { + sub.closeInput() + } +} + +func (b *EventBus) snapshotSubscribers() ([]*eventSubscription, bool) { + b.mu.RLock() + defer b.mu.RUnlock() + + if b.closed { + return nil, true + } + + return b.orderedSubs, false +} + +func (b *EventBus) rebuildOrderedSubscribersLocked() { + subs := make([]*eventSubscription, 0, len(b.subs)) + for _, sub := range b.subs { + subs = append(subs, sub) + } + sortSubscriptions(subs) + b.orderedSubs = subs +} + +func sortSubscriptions(subs []*eventSubscription) { + sort.Slice(subs, func(i, j int) bool { + if subs[i].opts.Priority == subs[j].opts.Priority { + return subs[i].id < subs[j].id + } + return subs[i].opts.Priority > subs[j].opts.Priority + }) +} + +func nextEventID() string { + id := globalEventSeq.Add(1) + return "evt-" + strconv.FormatUint(id, 10) +} diff --git a/pkg/events/channel.go b/pkg/events/channel.go new file mode 100644 index 000000000..9cf6d8d8c --- /dev/null +++ b/pkg/events/channel.go @@ -0,0 +1,75 @@ +package events + +import "context" + +// EventChannel is a filtered view over an EventBus. +type EventChannel interface { + Filter(filter Filter) EventChannel + OfKind(kinds ...Kind) EventChannel + KindPrefix(prefix string) EventChannel + Source(component string, names ...string) EventChannel + Scope(scope ScopeFilter) EventChannel + + Subscribe(ctx context.Context, opts SubscribeOptions, handler Handler) (Subscription, error) + SubscribeChan(ctx context.Context, opts SubscribeOptions) (Subscription, <-chan Event, error) + SubscribeOnce(ctx context.Context, opts SubscribeOptions, handler Handler) (Subscription, error) +} + +type eventChannel struct { + bus *EventBus + filters []Filter +} + +// Filter returns a new EventChannel with filter appended. +func (c eventChannel) Filter(filter Filter) EventChannel { + filters := append([]Filter(nil), c.filters...) + if filter != nil { + filters = append(filters, filter) + } + return eventChannel{bus: c.bus, filters: filters} +} + +// OfKind returns a new EventChannel matching any of kinds. +func (c eventChannel) OfKind(kinds ...Kind) EventChannel { + return c.Filter(MatchKind(kinds...)) +} + +// KindPrefix returns a new EventChannel matching events with the kind prefix. +func (c eventChannel) KindPrefix(prefix string) EventChannel { + return c.Filter(MatchKindPrefix(prefix)) +} + +// Source returns a new EventChannel matching source component and optional names. +func (c eventChannel) Source(component string, names ...string) EventChannel { + return c.Filter(MatchSource(component, names...)) +} + +// Scope returns a new EventChannel matching non-empty scope fields. +func (c eventChannel) Scope(scope ScopeFilter) EventChannel { + return c.Filter(MatchScope(scope)) +} + +// Subscribe registers handler for events matching this channel. +func (c eventChannel) Subscribe(ctx context.Context, opts SubscribeOptions, handler Handler) (Subscription, error) { + if handler == nil { + return nil, ErrNilHandler + } + return c.bus.subscribe(ctx, c.filters, opts, handler, false) +} + +// SubscribeChan registers a channel subscription for events matching this channel. +func (c eventChannel) SubscribeChan(ctx context.Context, opts SubscribeOptions) (Subscription, <-chan Event, error) { + sub, err := c.bus.subscribe(ctx, c.filters, opts, nil, false) + if err != nil { + return nil, nil, err + } + return sub, sub.(*eventSubscription).ch, nil +} + +// SubscribeOnce registers handler and closes the subscription after the first event. +func (c eventChannel) SubscribeOnce(ctx context.Context, opts SubscribeOptions, handler Handler) (Subscription, error) { + if handler == nil { + return nil, ErrNilHandler + } + return c.bus.subscribe(ctx, c.filters, opts, handler, true) +} diff --git a/pkg/events/doc.go b/pkg/events/doc.go new file mode 100644 index 000000000..dc2f55631 --- /dev/null +++ b/pkg/events/doc.go @@ -0,0 +1,3 @@ +// Package events provides the process-local runtime event bus used to observe +// PicoClaw components without coupling them to agent-specific event envelopes. +package events diff --git a/pkg/events/events_test.go b/pkg/events/events_test.go new file mode 100644 index 000000000..6991e8291 --- /dev/null +++ b/pkg/events/events_test.go @@ -0,0 +1,254 @@ +package events + +import ( + "context" + "testing" + "time" +) + +func TestPublishDeliversToMatchingSubscriber(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + _, ch, err := bus.Channel().OfKind(KindAgentTurnStart).SubscribeChan( + context.Background(), + SubscribeOptions{Name: "turn-starts", Buffer: 1}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + unmatched := bus.Publish(context.Background(), Event{Kind: KindAgentTurnEnd}) + if unmatched.Matched != 0 || unmatched.Delivered != 0 { + t.Fatalf("unmatched Publish = %+v, want no delivery", unmatched) + } + + result := bus.Publish(context.Background(), Event{Kind: KindAgentTurnStart}) + if result.Matched != 1 || result.Delivered != 1 || result.Dropped != 0 { + t.Fatalf("Publish = %+v, want one delivered event", result) + } + + evt := receiveEvent(t, ch) + if evt.Kind != KindAgentTurnStart { + t.Fatalf("event kind = %q, want %q", evt.Kind, KindAgentTurnStart) + } + if evt.ID == "" { + t.Fatal("event ID is empty") + } + if evt.Time.IsZero() { + t.Fatal("event Time is zero") + } +} + +func TestDropNewestIncrementsStats(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + sub, _, err := bus.Channel().SubscribeChan( + context.Background(), + SubscribeOptions{Name: "drop-newest", Buffer: 1, Backpressure: DropNewest}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + first := bus.Publish(context.Background(), Event{Kind: KindAgentTurnStart}) + if first.Delivered != 1 || first.Dropped != 0 { + t.Fatalf("first Publish = %+v, want one delivered event", first) + } + + second := bus.Publish(context.Background(), Event{Kind: KindAgentTurnEnd}) + if second.Delivered != 0 || second.Dropped != 1 { + t.Fatalf("second Publish = %+v, want one dropped event", second) + } + + if got := sub.Stats().Dropped; got != 1 { + t.Fatalf("subscription dropped = %d, want 1", got) + } + if got := bus.Stats().Dropped; got != 1 { + t.Fatalf("bus dropped = %d, want 1", got) + } +} + +func TestDropOldestKeepsNewestEvent(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + sub, ch, err := bus.Channel().SubscribeChan( + context.Background(), + SubscribeOptions{Name: "drop-oldest", Buffer: 1, Backpressure: DropOldest}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + bus.Publish(context.Background(), Event{Kind: Kind("test.old"), Payload: "old"}) + result := bus.Publish(context.Background(), Event{Kind: Kind("test.new"), Payload: "new"}) + if result.Delivered != 1 || result.Dropped != 1 { + t.Fatalf("Publish = %+v, want replacement delivery", result) + } + + evt := receiveEvent(t, ch) + if evt.Payload != "new" { + t.Fatalf("payload = %v, want new", evt.Payload) + } + if got := sub.Stats().Dropped; got != 1 { + t.Fatalf("subscription dropped = %d, want 1", got) + } +} + +func TestBlockRespectsContext(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + _, _, err := bus.Channel().SubscribeChan( + context.Background(), + SubscribeOptions{Name: "block", Buffer: 1, Backpressure: Block}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + first := bus.Publish(context.Background(), Event{Kind: Kind("test.first")}) + if first.Delivered != 1 { + t.Fatalf("first Publish = %+v, want one delivered event", first) + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + + second := bus.Publish(ctx, Event{Kind: Kind("test.second")}) + if second.Blocked != 1 || second.Dropped != 1 || second.Delivered != 0 { + t.Fatalf("second Publish = %+v, want one blocked drop", second) + } +} + +func TestPublishNonBlockingDropsForFullBlockSubscriber(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + sub, _, err := bus.Channel().SubscribeChan( + context.Background(), + SubscribeOptions{Name: "block", Buffer: 1, Backpressure: Block}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + first := bus.PublishNonBlocking(Event{Kind: Kind("test.first")}) + if first.Delivered != 1 { + t.Fatalf("first PublishNonBlocking = %+v, want one delivered event", first) + } + + resultCh := make(chan PublishResult, 1) + go func() { + resultCh <- bus.PublishNonBlocking(Event{Kind: Kind("test.second")}) + }() + + select { + case second := <-resultCh: + if second.Matched != 1 || second.Delivered != 0 || second.Dropped != 1 || second.Blocked != 0 { + t.Fatalf("second PublishNonBlocking = %+v, want non-blocking drop", second) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("PublishNonBlocking blocked on full Block subscriber") + } + + if got := sub.Stats().Dropped; got != 1 { + t.Fatalf("subscription dropped = %d, want 1", got) + } +} + +func TestStatsSubscribersKeepPriorityOrder(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + low, _, err := bus.Channel().SubscribeChan( + context.Background(), + SubscribeOptions{Name: "low", Priority: -1}, + ) + if err != nil { + t.Fatalf("SubscribeChan low failed: %v", err) + } + high, _, err := bus.Channel().SubscribeChan( + context.Background(), + SubscribeOptions{Name: "high", Priority: 10}, + ) + if err != nil { + t.Fatalf("SubscribeChan high failed: %v", err) + } + peer, _, err := bus.Channel().SubscribeChan( + context.Background(), + SubscribeOptions{Name: "peer", Priority: 10}, + ) + if err != nil { + t.Fatalf("SubscribeChan peer failed: %v", err) + } + + stats := bus.Stats() + got := []string{ + stats.SubscriberStats[0].Name, + stats.SubscriberStats[1].Name, + stats.SubscriberStats[2].Name, + } + want := []string{"high", "peer", "low"} + if got[0] != want[0] || got[1] != want[1] || got[2] != want[2] { + t.Fatalf("subscriber order = %v, want %v", got, want) + } + + if err := high.Close(); err != nil { + t.Fatalf("Close high failed: %v", err) + } + + stats = bus.Stats() + got = []string{ + stats.SubscriberStats[0].Name, + stats.SubscriberStats[1].Name, + } + want = []string{"peer", "low"} + if got[0] != want[0] || got[1] != want[1] { + t.Fatalf("subscriber order after unsubscribe = %v, want %v", got, want) + } + + if err := peer.Close(); err != nil { + t.Fatalf("Close peer failed: %v", err) + } + if err := low.Close(); err != nil { + t.Fatalf("Close low failed: %v", err) + } +} + +func receiveEvent(t *testing.T, ch <-chan Event) Event { + t.Helper() + + select { + case evt, ok := <-ch: + if !ok { + t.Fatal("event channel closed before receive") + } + return evt + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + return Event{} + } +} + +func closeBus(t *testing.T, bus *EventBus) { + t.Helper() + + if err := bus.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } +} diff --git a/pkg/events/filter.go b/pkg/events/filter.go new file mode 100644 index 000000000..0af2c85c1 --- /dev/null +++ b/pkg/events/filter.go @@ -0,0 +1,131 @@ +package events + +import "strings" + +// Filter decides whether an event should pass through an EventChannel. +type Filter func(Event) bool + +// ScopeFilter matches selected non-empty fields against Event.Scope. +type ScopeFilter struct { + AgentID string + SessionKey string + TurnID string + Channel string + ChatID string + MessageID string +} + +// MatchKind matches events whose kind is in kinds. Empty kinds match all events. +func MatchKind(kinds ...Kind) Filter { + if len(kinds) == 0 { + return matchAll + } + + allowed := make(map[Kind]struct{}, len(kinds)) + for _, kind := range kinds { + allowed[kind] = struct{}{} + } + + return func(evt Event) bool { + _, ok := allowed[evt.Kind] + return ok + } +} + +// MatchKindPrefix matches events whose kind starts with prefix. +func MatchKindPrefix(prefix string) Filter { + if prefix == "" { + return matchAll + } + return func(evt Event) bool { + return strings.HasPrefix(evt.Kind.String(), prefix) + } +} + +// MatchSource matches events emitted by component and, optionally, one of names. +func MatchSource(component string, names ...string) Filter { + if component == "" && len(names) == 0 { + return matchAll + } + + allowedNames := make(map[string]struct{}, len(names)) + for _, name := range names { + allowedNames[name] = struct{}{} + } + + return func(evt Event) bool { + if component != "" && evt.Source.Component != component { + return false + } + if len(allowedNames) == 0 { + return true + } + _, ok := allowedNames[evt.Source.Name] + return ok + } +} + +// MatchScope matches events whose Scope contains all non-empty filter fields. +func MatchScope(scope ScopeFilter) Filter { + if scope == (ScopeFilter{}) { + return matchAll + } + + return func(evt Event) bool { + return matchesString(scope.AgentID, evt.Scope.AgentID) && + matchesString(scope.SessionKey, evt.Scope.SessionKey) && + matchesString(scope.TurnID, evt.Scope.TurnID) && + matchesString(scope.Channel, evt.Scope.Channel) && + matchesString(scope.ChatID, evt.Scope.ChatID) && + matchesString(scope.MessageID, evt.Scope.MessageID) + } +} + +// And combines filters and short-circuits on the first non-match. +func And(filters ...Filter) Filter { + if len(filters) == 0 { + return matchAll + } + + return func(evt Event) bool { + for _, filter := range filters { + if filter != nil && !filter(evt) { + return false + } + } + return true + } +} + +// Or combines filters and short-circuits on the first match. +func Or(filters ...Filter) Filter { + if len(filters) == 0 { + return matchAll + } + + return func(evt Event) bool { + for _, filter := range filters { + if filter == nil || filter(evt) { + return true + } + } + return false + } +} + +func matchAll(Event) bool { + return true +} + +func matchesString(want, got string) bool { + return want == "" || want == got +} + +func matchesFilters(filters []Filter, evt Event) bool { + for _, filter := range filters { + if filter != nil && !filter(evt) { + return false + } + } + return true +} diff --git a/pkg/events/filter_test.go b/pkg/events/filter_test.go new file mode 100644 index 000000000..9b0112754 --- /dev/null +++ b/pkg/events/filter_test.go @@ -0,0 +1,96 @@ +package events + +import "testing" + +func TestFilterKindPrefix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prefix string + event Event + want bool + }{ + { + name: "matches agent prefix", + prefix: "agent.", + event: Event{Kind: KindAgentTurnStart}, + want: true, + }, + { + name: "rejects different prefix", + prefix: "channel.", + event: Event{Kind: KindAgentTurnStart}, + want: false, + }, + { + name: "empty prefix matches all", + prefix: "", + event: Event{Kind: KindAgentTurnStart}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := MatchKindPrefix(tt.prefix)(tt.event); got != tt.want { + t.Fatalf("MatchKindPrefix(%q) = %v, want %v", tt.prefix, got, tt.want) + } + }) + } +} + +func TestFilterScope(t *testing.T) { + t.Parallel() + + evt := Event{ + Scope: Scope{ + AgentID: "agent-a", + SessionKey: "session-1", + TurnID: "turn-1", + Channel: "telegram", + ChatID: "chat-1", + MessageID: "msg-1", + }, + } + + tests := []struct { + name string + scope ScopeFilter + want bool + }{ + { + name: "empty filter matches", + scope: ScopeFilter{}, + want: true, + }, + { + name: "matches selected fields", + scope: ScopeFilter{ + AgentID: "agent-a", + ChatID: "chat-1", + }, + want: true, + }, + { + name: "rejects mismatched field", + scope: ScopeFilter{ + AgentID: "agent-a", + MessageID: "msg-2", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := MatchScope(tt.scope)(evt); got != tt.want { + t.Fatalf("MatchScope(%+v) = %v, want %v", tt.scope, got, tt.want) + } + }) + } +} diff --git a/pkg/events/kind.go b/pkg/events/kind.go new file mode 100644 index 000000000..b9327e155 --- /dev/null +++ b/pkg/events/kind.go @@ -0,0 +1,156 @@ +package events + +const ( + // KindAgentTurnStart is emitted when an agent turn starts. + KindAgentTurnStart Kind = "agent.turn.start" + // KindAgentTurnEnd is emitted when an agent turn ends. + KindAgentTurnEnd Kind = "agent.turn.end" + + // KindAgentLLMRequest is emitted before an LLM request. + KindAgentLLMRequest Kind = "agent.llm.request" + // KindAgentLLMDelta is emitted for streaming LLM deltas. + KindAgentLLMDelta Kind = "agent.llm.delta" + // KindAgentLLMResponse is emitted after an LLM response. + KindAgentLLMResponse Kind = "agent.llm.response" + // KindAgentLLMRetry is emitted before retrying an LLM request. + KindAgentLLMRetry Kind = "agent.llm.retry" + + // KindAgentContextCompress is emitted when agent context is compressed. + KindAgentContextCompress Kind = "agent.context.compress" + // KindAgentSessionSummarize is emitted when session summarization completes. + KindAgentSessionSummarize Kind = "agent.session.summarize" + + // KindAgentToolExecStart is emitted before a tool executes. + KindAgentToolExecStart Kind = "agent.tool.exec_start" + // KindAgentToolExecEnd is emitted after a tool finishes. + KindAgentToolExecEnd Kind = "agent.tool.exec_end" + // KindAgentToolExecSkipped is emitted when a tool call is skipped. + KindAgentToolExecSkipped Kind = "agent.tool.exec_skipped" + + // KindAgentSteeringInjected is emitted when steering is injected into context. + KindAgentSteeringInjected Kind = "agent.steering.injected" + // KindAgentFollowUpQueued is emitted when async follow-up input is queued. + KindAgentFollowUpQueued Kind = "agent.follow_up.queued" + // KindAgentInterruptReceived is emitted when a turn interrupt is accepted. + KindAgentInterruptReceived Kind = "agent.interrupt.received" + + // KindAgentSubTurnSpawn is emitted when a sub-turn is spawned. + KindAgentSubTurnSpawn Kind = "agent.subturn.spawn" + // KindAgentSubTurnEnd is emitted when a sub-turn ends. + KindAgentSubTurnEnd Kind = "agent.subturn.end" + // KindAgentSubTurnResultDelivered is emitted when a sub-turn result is delivered. + KindAgentSubTurnResultDelivered Kind = "agent.subturn.result_delivered" + // KindAgentSubTurnOrphan is emitted when a sub-turn result cannot be delivered. + KindAgentSubTurnOrphan Kind = "agent.subturn.orphan" + // KindAgentError is emitted when agent execution reports an error. + KindAgentError Kind = "agent.error" + + // KindChannelLifecycleStarted is emitted when a channel starts. + KindChannelLifecycleStarted Kind = "channel.lifecycle.started" + // KindChannelLifecycleInitialized is emitted when a channel is initialized. + KindChannelLifecycleInitialized Kind = "channel.lifecycle.initialized" + // KindChannelLifecycleStartFailed is emitted when a channel fails to start. + KindChannelLifecycleStartFailed Kind = "channel.lifecycle.start_failed" + // KindChannelLifecycleStopped is emitted when a channel stops. + KindChannelLifecycleStopped Kind = "channel.lifecycle.stopped" + // KindChannelWebhookRegistered is emitted when a channel webhook is registered. + KindChannelWebhookRegistered Kind = "channel.webhook.registered" + // KindChannelWebhookUnregistered is emitted when a channel webhook is unregistered. + KindChannelWebhookUnregistered Kind = "channel.webhook.unregistered" + // KindChannelMessageOutboundQueued is emitted when an outbound message is queued. + KindChannelMessageOutboundQueued Kind = "channel.message.outbound_queued" + // KindChannelMessageOutboundSent is emitted when an outbound channel message is sent. + KindChannelMessageOutboundSent Kind = "channel.message.outbound_sent" + // KindChannelMessageOutboundFailed is emitted when an outbound channel message fails. + KindChannelMessageOutboundFailed Kind = "channel.message.outbound_failed" + // KindChannelRateLimited is emitted when channel rate limiting blocks delivery. + KindChannelRateLimited Kind = "channel.rate_limited" + + // KindBusPublishFailed is emitted when message bus publish fails. + KindBusPublishFailed Kind = "bus.publish.failed" + // KindBusCloseStarted is emitted when message bus close starts. + KindBusCloseStarted Kind = "bus.close.started" + // KindBusCloseCompleted is emitted when message bus close completes. + KindBusCloseCompleted Kind = "bus.close.completed" + // KindBusCloseDrained is emitted when message bus close drains buffered messages. + KindBusCloseDrained Kind = "bus.close.drained" + + // KindGatewayStart is emitted when gateway startup reaches runtime bootstrap. + KindGatewayStart Kind = "gateway.start" + // KindGatewayReady is emitted when gateway services are started and ready. + KindGatewayReady Kind = "gateway.ready" + // KindGatewayShutdown is emitted when gateway shutdown starts. + KindGatewayShutdown Kind = "gateway.shutdown" + // KindGatewayReloadStarted is emitted when gateway reload starts. + KindGatewayReloadStarted Kind = "gateway.reload.started" + // KindGatewayReloadCompleted is emitted when gateway reload completes. + KindGatewayReloadCompleted Kind = "gateway.reload.completed" + // KindGatewayReloadFailed is emitted when gateway reload fails. + KindGatewayReloadFailed Kind = "gateway.reload.failed" + + // KindMCPServerConnected is emitted when an MCP server connects. + KindMCPServerConnected Kind = "mcp.server.connected" + // KindMCPServerConnecting is emitted before connecting to an MCP server. + KindMCPServerConnecting Kind = "mcp.server.connecting" + // KindMCPServerFailed is emitted when an MCP server fails. + KindMCPServerFailed Kind = "mcp.server.failed" + // KindMCPToolDiscovered is emitted when an MCP tool is discovered. + KindMCPToolDiscovered Kind = "mcp.tool.discovered" + // KindMCPToolCallStart is emitted when an MCP tool call starts. + KindMCPToolCallStart Kind = "mcp.tool.call.start" + // KindMCPToolCallEnd is emitted when an MCP tool call ends. + KindMCPToolCallEnd Kind = "mcp.tool.call.end" +) + +var knownKinds = []Kind{ + KindAgentTurnStart, + KindAgentTurnEnd, + KindAgentLLMRequest, + KindAgentLLMDelta, + KindAgentLLMResponse, + KindAgentLLMRetry, + KindAgentContextCompress, + KindAgentSessionSummarize, + KindAgentToolExecStart, + KindAgentToolExecEnd, + KindAgentToolExecSkipped, + KindAgentSteeringInjected, + KindAgentFollowUpQueued, + KindAgentInterruptReceived, + KindAgentSubTurnSpawn, + KindAgentSubTurnEnd, + KindAgentSubTurnResultDelivered, + KindAgentSubTurnOrphan, + KindAgentError, + KindChannelLifecycleStarted, + KindChannelLifecycleInitialized, + KindChannelLifecycleStartFailed, + KindChannelLifecycleStopped, + KindChannelWebhookRegistered, + KindChannelWebhookUnregistered, + KindChannelMessageOutboundQueued, + KindChannelMessageOutboundSent, + KindChannelMessageOutboundFailed, + KindChannelRateLimited, + KindBusPublishFailed, + KindBusCloseStarted, + KindBusCloseCompleted, + KindBusCloseDrained, + KindGatewayStart, + KindGatewayReady, + KindGatewayShutdown, + KindGatewayReloadStarted, + KindGatewayReloadCompleted, + KindGatewayReloadFailed, + KindMCPServerConnected, + KindMCPServerConnecting, + KindMCPServerFailed, + KindMCPToolDiscovered, + KindMCPToolCallStart, + KindMCPToolCallEnd, +} + +// KnownKinds returns the runtime event kinds declared by this package. +func KnownKinds() []Kind { + return append([]Kind(nil), knownKinds...) +} diff --git a/pkg/events/stats.go b/pkg/events/stats.go new file mode 100644 index 000000000..7931c5ef3 --- /dev/null +++ b/pkg/events/stats.go @@ -0,0 +1,26 @@ +package events + +// Stats reports aggregate EventBus counters. +type Stats struct { + Published uint64 + Matched uint64 + Delivered uint64 + Dropped uint64 + Blocked uint64 + Closed bool + Subscribers int + + SubscriberStats []SubscriberStats +} + +// SubscriberStats reports counters for one subscription. +type SubscriberStats struct { + ID uint64 + Name string + Received uint64 + Handled uint64 + Failed uint64 + Dropped uint64 + Panicked uint64 + TimedOut uint64 +} diff --git a/pkg/events/subscription.go b/pkg/events/subscription.go new file mode 100644 index 000000000..6619707a7 --- /dev/null +++ b/pkg/events/subscription.go @@ -0,0 +1,459 @@ +package events + +import ( + "context" + "errors" + "log" + "sync" + "sync/atomic" + "time" +) + +const defaultSubscriberBuffer = 16 + +var ( + // ErrBusClosed is returned when subscribing to a closed event bus. + ErrBusClosed = errors.New("events: bus is closed") + // ErrNilHandler is returned when subscribing without a handler. + ErrNilHandler = errors.New("events: handler is nil") +) + +// Handler processes a runtime event delivered to a subscription. +type Handler func(context.Context, Event) error + +// SubscribeOptions controls how a subscription receives events. +type SubscribeOptions struct { + Name string + Buffer int + Priority int + Concurrency ConcurrencyKind + Backpressure BackpressurePolicy + // Timeout bounds how long the subscription worker waits for one handler call. + // Handlers should still honor ctx cancellation; timed-out calls keep running + // until their handler returns. + Timeout time.Duration + PanicPolicy PanicPolicy +} + +// ConcurrencyKind controls how handler subscriptions process queued events. +type ConcurrencyKind string + +const ( + // Concurrent processes each event in its own goroutine. + Concurrent ConcurrencyKind = "concurrent" + // Locked processes events sequentially in subscription order. + Locked ConcurrencyKind = "locked" + // Keyed is reserved for keyed sequential processing and currently behaves as Locked. + Keyed ConcurrencyKind = "keyed" +) + +// BackpressurePolicy controls delivery when a subscription queue is full. +type BackpressurePolicy string + +const ( + // DropNewest drops the event being published when the queue is full. + DropNewest BackpressurePolicy = "drop_newest" + // DropOldest drops one queued event and enqueues the event being published. + DropOldest BackpressurePolicy = "drop_oldest" + // Block waits for queue capacity until Publish's context is canceled. + Block BackpressurePolicy = "block" +) + +// PanicPolicy controls handler panic behavior. +type PanicPolicy string + +const ( + // RecoverAndLog recovers handler panics and records them in subscription stats. + RecoverAndLog PanicPolicy = "recover_and_log" + // Crash lets handler panics propagate from the worker goroutine. + Crash PanicPolicy = "crash" +) + +// Subscription represents an active event subscription. +type Subscription interface { + ID() uint64 + Name() string + Close() error + Done() <-chan struct{} + Stats() SubscriberStats +} + +type subscriberCounters struct { + received atomic.Uint64 + handled atomic.Uint64 + failed atomic.Uint64 + dropped atomic.Uint64 + panicked atomic.Uint64 + timedOut atomic.Uint64 +} + +type eventSubscription struct { + bus *EventBus + id uint64 + name string + opts SubscribeOptions + filters []Filter + handler Handler + once bool + + ch chan Event + done chan struct{} + closing chan struct{} + + closeOnce sync.Once + doneOnce sync.Once + mu sync.RWMutex + closed bool + wg sync.WaitGroup + blockWG sync.WaitGroup + + counters subscriberCounters +} + +type handlerResult struct { + err error + panicked bool +} + +func normalizeSubscribeOptions(opts SubscribeOptions) SubscribeOptions { + if opts.Buffer <= 0 { + opts.Buffer = defaultSubscriberBuffer + } + if opts.Concurrency == "" { + opts.Concurrency = Locked + } + if opts.Backpressure == "" { + opts.Backpressure = DropNewest + } + if opts.PanicPolicy == "" { + opts.PanicPolicy = RecoverAndLog + } + return opts +} + +func newSubscription( + bus *EventBus, + id uint64, + filters []Filter, + opts SubscribeOptions, + handler Handler, + once bool, +) *eventSubscription { + opts = normalizeSubscribeOptions(opts) + return &eventSubscription{ + bus: bus, + id: id, + name: opts.Name, + opts: opts, + filters: append([]Filter(nil), filters...), + handler: handler, + once: once, + ch: make(chan Event, opts.Buffer), + done: make(chan struct{}), + closing: make(chan struct{}), + } +} + +// ID returns the subscription identifier. +func (s *eventSubscription) ID() uint64 { + if s == nil { + return 0 + } + return s.id +} + +// Name returns the subscription name. +func (s *eventSubscription) Name() string { + if s == nil { + return "" + } + return s.name +} + +// Close removes the subscription and closes its delivery channel. +func (s *eventSubscription) Close() error { + if s == nil || s.bus == nil { + return nil + } + s.bus.unsubscribe(s.id) + return nil +} + +// Done returns a channel closed after the subscription has stopped processing. +func (s *eventSubscription) Done() <-chan struct{} { + if s == nil { + ch := make(chan struct{}) + close(ch) + return ch + } + return s.done +} + +// Stats returns a snapshot of the subscription counters. +func (s *eventSubscription) Stats() SubscriberStats { + if s == nil { + return SubscriberStats{} + } + return SubscriberStats{ + ID: s.id, + Name: s.name, + Received: s.counters.received.Load(), + Handled: s.counters.handled.Load(), + Failed: s.counters.failed.Load(), + Dropped: s.counters.dropped.Load(), + Panicked: s.counters.panicked.Load(), + TimedOut: s.counters.timedOut.Load(), + } +} + +func (s *eventSubscription) run(ctx context.Context) { + defer func() { + s.wg.Wait() + s.closeDone() + }() + + for evt := range s.ch { + s.dispatch(ctx, evt) + if s.once { + _ = s.Close() + return + } + } +} + +func (s *eventSubscription) dispatch(ctx context.Context, evt Event) { + switch s.opts.Concurrency { + case Concurrent: + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.handle(ctx, evt) + }() + case Keyed: + // TODO: replace this with keyed executors when runtime events need + // per-scope ordering with cross-scope concurrency. + s.handle(ctx, evt) + default: + s.handle(ctx, evt) + } +} + +func (s *eventSubscription) handle(ctx context.Context, evt Event) { + if ctx == nil { + ctx = context.Background() + } + + if s.opts.Timeout <= 0 { + s.recordHandlerResult(ctx, s.invokeHandler(ctx, evt)) + return + } + + ctx, cancel := context.WithTimeout(ctx, s.opts.Timeout) + defer cancel() + + done := make(chan handlerResult, 1) + go func() { + done <- s.invokeHandler(ctx, evt) + }() + + select { + case result := <-done: + s.recordHandlerResult(ctx, result) + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + s.counters.timedOut.Add(1) + } + s.counters.failed.Add(1) + } +} + +func (s *eventSubscription) invokeHandler(ctx context.Context, evt Event) (result handlerResult) { + if s.opts.PanicPolicy != Crash { + defer func() { + if recovered := recover(); recovered != nil { + s.counters.panicked.Add(1) + result.panicked = true + log.Printf("events: subscriber %q recovered panic: %v", s.name, recovered) + } + }() + } + + result.err = s.handler(ctx, evt) + return result +} + +func (s *eventSubscription) recordHandlerResult(ctx context.Context, result handlerResult) { + if result.panicked { + return + } + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + s.counters.timedOut.Add(1) + } + if result.err != nil { + s.counters.failed.Add(1) + return + } + s.counters.handled.Add(1) +} + +func (s *eventSubscription) watchContext(ctx context.Context) { + if ctx == nil { + return + } + + go func() { + select { + case <-ctx.Done(): + _ = s.Close() + case <-s.done: + } + }() +} + +func (s *eventSubscription) closeInput() { + s.closeOnce.Do(func() { + close(s.closing) + s.mu.Lock() + s.closed = true + s.mu.Unlock() + s.blockWG.Wait() + s.mu.Lock() + close(s.ch) + s.mu.Unlock() + if s.handler == nil { + s.closeDone() + } + }) +} + +func (s *eventSubscription) closeDone() { + s.doneOnce.Do(func() { + close(s.done) + }) +} + +type deliveryResult struct { + delivered int + dropped int + blocked int + closed bool +} + +func (s *eventSubscription) enqueue(ctx context.Context, evt Event, nonBlocking bool) deliveryResult { + if ctx == nil { + ctx = context.Background() + } + + if nonBlocking { + return s.enqueueNonBlocking(evt) + } + + if s.opts.Backpressure == Block { + return s.enqueueBlocking(ctx, evt) + } + + s.mu.RLock() + defer s.mu.RUnlock() + + if s.closed { + return deliveryResult{closed: true} + } + + s.counters.received.Add(1) + + switch s.opts.Backpressure { + case DropOldest: + return s.enqueueDropOldest(evt) + default: + return s.enqueueDropNewest(evt) + } +} + +func (s *eventSubscription) enqueueBlocking(ctx context.Context, evt Event) deliveryResult { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return deliveryResult{closed: true} + } + s.blockWG.Add(1) + s.counters.received.Add(1) + s.mu.Unlock() + + defer s.blockWG.Done() + return s.enqueueBlock(ctx, evt) +} + +func (s *eventSubscription) enqueueNonBlocking(evt Event) deliveryResult { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.closed { + return deliveryResult{closed: true} + } + + s.counters.received.Add(1) + if s.opts.Backpressure == DropOldest { + return s.enqueueDropOldest(evt) + } + return s.enqueueDropNewest(evt) +} + +func (s *eventSubscription) enqueueDropNewest(evt Event) deliveryResult { + select { + case <-s.closing: + return deliveryResult{closed: true} + default: + } + + select { + case s.ch <- evt: + return deliveryResult{delivered: 1} + default: + s.counters.dropped.Add(1) + return deliveryResult{dropped: 1} + } +} + +func (s *eventSubscription) enqueueDropOldest(evt Event) deliveryResult { + select { + case <-s.closing: + return deliveryResult{closed: true} + default: + } + + select { + case s.ch <- evt: + return deliveryResult{delivered: 1} + default: + } + + dropped := 0 + select { + case <-s.ch: + s.counters.dropped.Add(1) + dropped = 1 + default: + } + + select { + case <-s.closing: + return deliveryResult{dropped: dropped, closed: true} + case s.ch <- evt: + return deliveryResult{delivered: 1, dropped: dropped} + default: + s.counters.dropped.Add(1) + return deliveryResult{dropped: dropped + 1} + } +} + +func (s *eventSubscription) enqueueBlock(ctx context.Context, evt Event) deliveryResult { + select { + case <-s.closing: + return deliveryResult{closed: true} + case s.ch <- evt: + return deliveryResult{delivered: 1} + case <-ctx.Done(): + s.counters.dropped.Add(1) + return deliveryResult{dropped: 1, blocked: 1} + } +} diff --git a/pkg/events/subscription_test.go b/pkg/events/subscription_test.go new file mode 100644 index 000000000..8fde731cc --- /dev/null +++ b/pkg/events/subscription_test.go @@ -0,0 +1,254 @@ +package events + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +func TestSubscribeOnceClosesAfterFirstEvent(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + var handled atomic.Uint64 + sub, err := bus.Channel().SubscribeOnce( + context.Background(), + SubscribeOptions{Name: "once", Buffer: 2}, + func(context.Context, Event) error { + handled.Add(1) + return nil + }, + ) + if err != nil { + t.Fatalf("SubscribeOnce failed: %v", err) + } + + bus.Publish(context.Background(), Event{Kind: KindAgentTurnStart}) + waitForSubscriptionDone(t, sub) + bus.Publish(context.Background(), Event{Kind: KindAgentTurnEnd}) + + if got := handled.Load(); got != 1 { + t.Fatalf("handled = %d, want 1", got) + } + if got := sub.Stats().Handled; got != 1 { + t.Fatalf("subscription handled = %d, want 1", got) + } +} + +func TestUnsubscribeClosesChannel(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + sub, ch, err := bus.Channel().SubscribeChan(context.Background(), SubscribeOptions{Name: "chan"}) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + if err := sub.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } + + select { + case _, ok := <-ch: + if ok { + t.Fatal("channel is open, want closed") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel close") + } + waitForSubscriptionDone(t, sub) +} + +func TestBlockBackpressureCloseUnblocksPublisher(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + sub, _, err := bus.Channel().SubscribeChan(context.Background(), SubscribeOptions{ + Name: "block-close", + Buffer: 1, + Backpressure: Block, + }) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + first := bus.Publish(context.Background(), Event{Kind: Kind("test.first")}) + if first.Delivered != 1 { + t.Fatalf("first Publish = %+v, want one delivered event", first) + } + + publishStarted := make(chan struct{}) + publishReturned := make(chan PublishResult, 1) + go func() { + close(publishStarted) + publishReturned <- bus.Publish(context.Background(), Event{Kind: Kind("test.second")}) + }() + + <-publishStarted + waitForStat(t, func() uint64 { + return sub.Stats().Received + }, 2) + select { + case result := <-publishReturned: + t.Fatalf("blocking Publish returned before close: %+v", result) + default: + } + + closeReturned := make(chan error, 1) + go func() { + closeReturned <- sub.Close() + }() + + select { + case err := <-closeReturned: + if err != nil { + t.Fatalf("Close failed: %v", err) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for Close to unblock") + } + + select { + case <-publishReturned: + case <-time.After(time.Second): + t.Fatal("timed out waiting for blocking Publish to return after close") + } + waitForSubscriptionDone(t, sub) +} + +func TestHandlerPanicRecovered(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + sub, err := bus.Channel().Subscribe( + context.Background(), + SubscribeOptions{Name: "panic", Buffer: 1}, + func(context.Context, Event) error { + panic("boom") + }, + ) + if err != nil { + t.Fatalf("Subscribe failed: %v", err) + } + + bus.Publish(context.Background(), Event{Kind: KindAgentError}) + waitForStat(t, func() uint64 { + return sub.Stats().Panicked + }, 1) +} + +func TestLockedHandlerProcessesSequentially(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + var active atomic.Int64 + var maxActive atomic.Int64 + sub, err := bus.Channel().Subscribe( + context.Background(), + SubscribeOptions{Name: "locked", Buffer: 8, Concurrency: Locked}, + func(context.Context, Event) error { + current := active.Add(1) + for { + currentMax := maxActive.Load() + if current <= currentMax || maxActive.CompareAndSwap(currentMax, current) { + break + } + } + time.Sleep(10 * time.Millisecond) + active.Add(-1) + return nil + }, + ) + if err != nil { + t.Fatalf("Subscribe failed: %v", err) + } + + for i := 0; i < 5; i++ { + bus.Publish(context.Background(), Event{Kind: KindAgentLLMDelta}) + } + waitForStat(t, func() uint64 { + return sub.Stats().Handled + }, 5) + + if got := maxActive.Load(); got != 1 { + t.Fatalf("max active handlers = %d, want 1", got) + } +} + +func TestHandlerTimeoutDoesNotWedgeLockedSubscription(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + releaseFirst := make(chan struct{}) + defer close(releaseFirst) + + var calls atomic.Uint64 + sub, err := bus.Channel().Subscribe( + context.Background(), + SubscribeOptions{Name: "timeout", Buffer: 2, Concurrency: Locked, Timeout: 20 * time.Millisecond}, + func(context.Context, Event) error { + if calls.Add(1) == 1 { + <-releaseFirst + } + return nil + }, + ) + if err != nil { + t.Fatalf("Subscribe failed: %v", err) + } + + bus.Publish(context.Background(), Event{Kind: Kind("test.first")}) + waitForStat(t, func() uint64 { + return sub.Stats().TimedOut + }, 1) + + bus.Publish(context.Background(), Event{Kind: Kind("test.second")}) + waitForStat(t, func() uint64 { + return sub.Stats().Handled + }, 1) + + if got := sub.Stats().Failed; got != 1 { + t.Fatalf("subscription failed = %d, want timeout failure", got) + } +} + +func waitForSubscriptionDone(t *testing.T, sub Subscription) { + t.Helper() + + select { + case <-sub.Done(): + case <-time.After(time.Second): + t.Fatal("timed out waiting for subscription to stop") + } +} + +func waitForStat(t *testing.T, stat func() uint64, want uint64) { + t.Helper() + + deadline := time.After(time.Second) + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + + for { + if got := stat(); got >= want { + return + } + select { + case <-ticker.C: + case <-deadline: + t.Fatalf("timed out waiting for stat >= %d", want) + } + } +} diff --git a/pkg/events/types.go b/pkg/events/types.go new file mode 100644 index 000000000..2cfc0eaac --- /dev/null +++ b/pkg/events/types.go @@ -0,0 +1,77 @@ +package events + +import "time" + +// Kind identifies a runtime event category. +type Kind string + +// String returns the string representation of the event kind. +func (k Kind) String() string { + return string(k) +} + +// Event is the runtime event envelope shared across PicoClaw components. +type Event struct { + ID string `json:"id"` + Kind Kind `json:"kind"` + Time time.Time `json:"time"` + Source Source `json:"source"` + Scope Scope `json:"scope,omitempty"` + Correlation Correlation `json:"correlation,omitempty"` + Severity Severity `json:"severity,omitempty"` + Payload any `json:"payload,omitempty"` + Attrs map[string]any `json:"attrs,omitempty"` +} + +// Source identifies the component that emitted an event. +type Source struct { + Component string `json:"component"` + Name string `json:"name,omitempty"` +} + +// Scope identifies the runtime ownership of an event. +// +// Scope is intentionally limited to agent, session, turn, channel, chat, +// message, and sender identity. Tool, provider, model, and MCP details belong +// in Source, Payload, or Attrs. +type Scope struct { + RuntimeID string `json:"runtime_id,omitempty"` + + AgentID string `json:"agent_id,omitempty"` + SessionKey string `json:"session_key,omitempty"` + TurnID string `json:"turn_id,omitempty"` + + Channel string `json:"channel,omitempty"` + Account string `json:"account,omitempty"` + ChatID string `json:"chat_id,omitempty"` + TopicID string `json:"topic_id,omitempty"` + + SpaceID string `json:"space_id,omitempty"` + SpaceType string `json:"space_type,omitempty"` + ChatType string `json:"chat_type,omitempty"` + + SenderID string `json:"sender_id,omitempty"` + MessageID string `json:"message_id,omitempty"` +} + +// Correlation carries cross-event tracing fields. +type Correlation struct { + TraceID string `json:"trace_id,omitempty"` + ParentTurnID string `json:"parent_turn_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + ReplyToID string `json:"reply_to_id,omitempty"` +} + +// Severity describes the operational severity of an event. +type Severity string + +const ( + // SeverityDebug is used for verbose diagnostic events. + SeverityDebug Severity = "debug" + // SeverityInfo is used for normal lifecycle and activity events. + SeverityInfo Severity = "info" + // SeverityWarn is used for recoverable abnormal events. + SeverityWarn Severity = "warn" + // SeverityError is used for failed operations and unrecoverable events. + SeverityError Severity = "error" +) diff --git a/pkg/fileutil/file.go b/pkg/fileutil/file.go index 7ca872374..22374ac3d 100644 --- a/pkg/fileutil/file.go +++ b/pkg/fileutil/file.go @@ -117,3 +117,11 @@ func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { cleanup = false return nil } + +func CopyFile(src, dst string, perm os.FileMode) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return WriteFileAtomic(dst, data, perm) +} diff --git a/pkg/fileutil/file_test.go b/pkg/fileutil/file_test.go new file mode 100644 index 000000000..b0494d0d3 --- /dev/null +++ b/pkg/fileutil/file_test.go @@ -0,0 +1,176 @@ +package fileutil + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +func TestWriteFileAtomic_Basic(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.txt") + data := []byte("hello picoclaw") + + err := WriteFileAtomic(path, data, 0o644) + if err != nil { + t.Fatalf("WriteFileAtomic failed: %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if string(got) != string(data) { + t.Errorf("got %q, want %q", got, data) + } +} + +func TestWriteFileAtomic_Permissions(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "secret.txt") + + err := WriteFileAtomic(path, []byte("secret"), 0o600) + if err != nil { + t.Fatalf("WriteFileAtomic failed: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat failed: %v", err) + } + // On Unix, check file mode (ignoring directory bits) + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("permissions = %o, want %o", got, 0o600) + } +} + +func TestWriteFileAtomic_Overwrite(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "overwrite.txt") + + // Write initial content + if err := WriteFileAtomic(path, []byte("old"), 0o644); err != nil { + t.Fatalf("first write failed: %v", err) + } + + // Overwrite + if err := WriteFileAtomic(path, []byte("new"), 0o644); err != nil { + t.Fatalf("second write failed: %v", err) + } + + got, _ := os.ReadFile(path) + if string(got) != "new" { + t.Errorf("got %q after overwrite, want %q", got, "new") + } +} + +func TestWriteFileAtomic_EmptyData(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty.txt") + + err := WriteFileAtomic(path, []byte{}, 0o644) + if err != nil { + t.Fatalf("WriteFileAtomic with empty data failed: %v", err) + } + + got, _ := os.ReadFile(path) + if len(got) != 0 { + t.Errorf("expected empty file, got %d bytes", len(got)) + } +} + +func TestWriteFileAtomic_CreatesParentDirs(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "a", "b", "c", "deep.txt") + + err := WriteFileAtomic(path, []byte("deep"), 0o644) + if err != nil { + t.Fatalf("WriteFileAtomic with nested dirs failed: %v", err) + } + + got, _ := os.ReadFile(path) + if string(got) != "deep" { + t.Errorf("got %q, want %q", got, "deep") + } +} + +func TestWriteFileAtomic_NoTempFileOnSuccess(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "clean.txt") + + if err := WriteFileAtomic(path, []byte("data"), 0o644); err != nil { + t.Fatalf("WriteFileAtomic failed: %v", err) + } + + // Verify no temp files remain + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if e.Name() != "clean.txt" { + t.Errorf("unexpected file remaining: %s", e.Name()) + } + } +} + +func TestWriteFileAtomic_LargeFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "large.bin") + + // 1MB of data + data := make([]byte, 1<<20) + for i := range data { + data[i] = byte(i % 256) + } + + if err := WriteFileAtomic(path, data, 0o644); err != nil { + t.Fatalf("WriteFileAtomic with large file failed: %v", err) + } + + got, _ := os.ReadFile(path) + if len(got) != len(data) { + t.Errorf("file size = %d, want %d", len(got), len(data)) + } +} + +func TestWriteFileAtomic_Concurrent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "concurrent.txt") + + var wg sync.WaitGroup + errs := make(chan error, 10) + + for i := 0; i < 10; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + data := []byte(string(rune('A' + n))) + if err := WriteFileAtomic(path, data, 0o644); err != nil { + errs <- err + } + }(i) + } + + wg.Wait() + close(errs) + + for err := range errs { + t.Errorf("concurrent write error: %v", err) + } + + // File should exist and contain exactly 1 byte (last writer wins) + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile after concurrent writes failed: %v", err) + } + if len(got) != 1 { + t.Errorf("expected 1 byte after concurrent writes, got %d", len(got)) + } +} + +func TestWriteFileAtomic_InvalidPath(t *testing.T) { + // /dev/null/impossible is not a valid path on any OS + err := WriteFileAtomic("/dev/null/impossible/file.txt", []byte("data"), 0o644) + if err == nil { + t.Error("expected error for invalid path, got nil") + } +} diff --git a/pkg/gateway/channel_matrix.go b/pkg/gateway/channel_matrix.go new file mode 100644 index 000000000..b6adbe498 --- /dev/null +++ b/pkg/gateway/channel_matrix.go @@ -0,0 +1,24 @@ +//go:build !mipsle && !netbsd && !(freebsd && arm) && !android + +package gateway + +import ( + // Matrix currently pulls in mautrix crypto and modernc sqlite transitively. + // + // We exclude it on: + // - linux/mipsle: mautrix crypto falls back to libolm when the `goolm` build + // tag is unavailable, and modernc.org/sqlite/modernc.org/libc also lacks a + // working build path for our mipsle + softfloat target. + // - netbsd/*: modernc.org/sqlite v1.46.1 fails to compile due to broken + // generated mutex code on NetBSD (for example sqlite_netbsd_amd64.go calls + // mu.enter/mu.leave, but the generated mutex type does not define them). + // - freebsd/arm: modernc.org/libc v1.67.6 fails to compile due to broken + // generated 32-bit FreeBSD code (size_t/uint64 and int32/int64 mismatches + // in libc_freebsd.go). + // + // This means Matrix is currently unavailable on those targets. The proper + // long-term fix is to split Matrix basic support from its E2EE/sqlite-backed + // crypto path, or to upgrade/replace the upstream sqlite dependency once the + // affected targets are supported. + _ "github.com/sipeed/picoclaw/pkg/channels/matrix" +) diff --git a/pkg/gateway/events.go b/pkg/gateway/events.go new file mode 100644 index 000000000..0f454ed7d --- /dev/null +++ b/pkg/gateway/events.go @@ -0,0 +1,53 @@ +package gateway + +import ( + "time" + + "github.com/sipeed/picoclaw/pkg/agent" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +type gatewayEventPayload struct { + DurationMS int64 `json:"duration_ms,omitempty"` + Error string `json:"error,omitempty"` +} + +func publishGatewayEvent( + al *agent.AgentLoop, + kind runtimeevents.Kind, + startedAt time.Time, + err error, +) { + if al == nil || al.RuntimeEventBus() == nil { + return + } + + severity := runtimeevents.SeverityInfo + payload := gatewayEventPayload{} + if !startedAt.IsZero() { + payload.DurationMS = time.Since(startedAt).Milliseconds() + } + if err != nil { + severity = runtimeevents.SeverityError + payload.Error = err.Error() + } + + al.RuntimeEventBus().PublishNonBlocking(runtimeevents.Event{ + Kind: kind, + Source: runtimeevents.Source{Component: "gateway"}, + Severity: severity, + Payload: payload, + Attrs: gatewayEventAttrs(payload), + }) +} + +func gatewayEventAttrs(payload gatewayEventPayload) map[string]any { + attrs := map[string]any{} + if payload.DurationMS > 0 { + attrs["duration_ms"] = payload.DurationMS + } + if payload.Error != "" { + attrs["error"] = payload.Error + } + return attrs +} diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go new file mode 100644 index 000000000..4fd06d836 --- /dev/null +++ b/pkg/gateway/gateway.go @@ -0,0 +1,828 @@ +package gateway + +import ( + "context" + "fmt" + "net" + "os" + "os/signal" + "path/filepath" + "sort" + "strconv" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/audio/asr" + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" + _ "github.com/sipeed/picoclaw/pkg/channels/discord" + _ "github.com/sipeed/picoclaw/pkg/channels/feishu" + _ "github.com/sipeed/picoclaw/pkg/channels/irc" + _ "github.com/sipeed/picoclaw/pkg/channels/line" + _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" + _ "github.com/sipeed/picoclaw/pkg/channels/onebot" + _ "github.com/sipeed/picoclaw/pkg/channels/pico" + _ "github.com/sipeed/picoclaw/pkg/channels/qq" + _ "github.com/sipeed/picoclaw/pkg/channels/slack" + _ "github.com/sipeed/picoclaw/pkg/channels/teams_webhook" + _ "github.com/sipeed/picoclaw/pkg/channels/telegram" + _ "github.com/sipeed/picoclaw/pkg/channels/vk" + _ "github.com/sipeed/picoclaw/pkg/channels/wecom" + _ "github.com/sipeed/picoclaw/pkg/channels/weixin" + _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" + _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/cron" + "github.com/sipeed/picoclaw/pkg/devices" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/health" + "github.com/sipeed/picoclaw/pkg/heartbeat" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/netbind" + "github.com/sipeed/picoclaw/pkg/pid" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/state" + "github.com/sipeed/picoclaw/pkg/tools" +) + +const ( + serviceShutdownTimeout = 30 * time.Second + providerReloadTimeout = 30 * time.Second + gracefulShutdownTimeout = 15 * time.Second + + logPath = "logs" + panicFile = "gateway_panic.log" + logFile = "gateway.log" +) + +type services struct { + CronService *cron.CronService + HeartbeatService *heartbeat.HeartbeatService + MediaStore media.MediaStore + ChannelManager *channels.Manager + DeviceService *devices.Service + HealthServer *health.Server + VoiceAgentCancel context.CancelFunc + manualReloadChan chan struct{} + reloading atomic.Bool + authToken string +} + +type startupBlockedProvider struct { + reason string +} + +func logChannelVoiceCapabilities(cm *channels.Manager, asrAvailable bool, ttsAvailable bool) { + if cm == nil { + return + } + + names := cm.GetEnabledChannels() + sort.Strings(names) + for _, name := range names { + ch, ok := cm.GetChannel(name) + if !ok { + continue + } + caps := channels.DetectVoiceCapabilities(name, ch, asrAvailable, ttsAvailable) + logger.InfoCF("voice", "Channel voice capabilities", map[string]any{ + "channel": name, + "asr": caps.ASR, + "tts": caps.TTS, + }) + } +} + +func (p *startupBlockedProvider) Chat( + _ context.Context, + _ []providers.Message, + _ []providers.ToolDefinition, + _ string, + _ map[string]any, +) (*providers.LLMResponse, error) { + return nil, fmt.Errorf("%s", p.reason) +} + +func (p *startupBlockedProvider) GetDefaultModel() string { + return "" +} + +// Run starts the gateway runtime using the configuration loaded from configPath. +func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runErr error) { + startedAt := time.Now() + panicPath := filepath.Join(homePath, logPath, panicFile) + panicFunc, err := logger.InitPanic(panicPath) + if err != nil { + return fmt.Errorf("error initializing panic log: %w", err) + } + defer panicFunc() + + if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, logFile)); err != nil { + logger.Fatal(fmt.Sprintf("error enabling file logging: %v", err)) + } + defer logger.DisableFileLogging() + + if debug { + logger.SetLevel(logger.DEBUG) + } else { + logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath)) + } + defer func() { + if runErr != nil { + logger.ErrorCF("gateway", "Gateway startup failed", map[string]any{ + "config_path": configPath, + "error": runErr.Error(), + "home_path": homePath, + "allow_empty": allowEmptyStartup, + "debug": debug, + }) + } + }() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + return fmt.Errorf("error loading config: %w", err) + } + + if err = preCheckConfig(cfg); err != nil { + return fmt.Errorf("config pre-check failed: %w", err) + } + + // Debug mode permanently overrides the config log level to DEBUG. + if debug { + fmt.Println("🔍 Debug mode enabled") + } else { + effectiveLogLevel := config.EffectiveGatewayLogLevel(cfg) + logger.SetLevelFromString(effectiveLogLevel) + logger.Infof("Log level set to %q", effectiveLogLevel) + } + + bindPlan, listenResult, err := openGatewayListeners(cfg.Gateway.Host, cfg.Gateway.Port) + if err != nil { + return fmt.Errorf("error opening gateway listeners: %w", err) + } + + // Enforce singleton: write PID file with generated token. + pidData, err := pid.WritePidFile(homePath, bindPlan.ProbeHost, cfg.Gateway.Port) + if err != nil { + logger.Warnf("write pid file failed: %v", err) + for _, ln := range listenResult.Listeners { + _ = ln.Close() + } + return fmt.Errorf("singleton check failed: %w", err) + } + defer pid.RemovePidFile(homePath) + closeListeners := true + defer func() { + if !closeListeners { + return + } + for _, ln := range listenResult.Listeners { + _ = ln.Close() + } + }() + + provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup) + if err != nil { + return fmt.Errorf("error creating provider: %w", err) + } + + if modelID != "" { + cfg.Agents.Defaults.ModelName = modelID + } + + msgBus := bus.NewMessageBus() + agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + msgBus.SetEventPublisher(agentLoop.RuntimeEventBus()) + publishGatewayEvent(agentLoop, runtimeevents.KindGatewayStart, startedAt, nil) + + fmt.Println("\n📦 Agent Status:") + startupInfo := agentLoop.GetStartupInfo() + toolsInfo := startupInfo["tools"].(map[string]any) + skillsInfo := startupInfo["skills"].(map[string]any) + fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) + fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"]) + + logger.InfoCF("agent", "Agent initialized", + map[string]any{ + "tools_count": toolsInfo["count"], + "skills_total": skillsInfo["total"], + "skills_available": skillsInfo["available"], + }) + + runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus, pidData.Token, listenResult) + if err != nil { + return err + } + publishGatewayEvent(agentLoop, runtimeevents.KindGatewayReady, startedAt, nil) + closeListeners = false + + // Setup manual reload channel for /reload endpoint + manualReloadChan := make(chan struct{}, 1) + runningServices.manualReloadChan = manualReloadChan + reloadTrigger := func() error { + if !runningServices.reloading.CompareAndSwap(false, true) { + return fmt.Errorf("reload already in progress") + } + select { + case manualReloadChan <- struct{}{}: + return nil + default: + // Should not happen, but reset flag if channel is full + runningServices.reloading.Store(false) + return fmt.Errorf("reload already queued") + } + } + runningServices.HealthServer.SetReloadFunc(reloadTrigger) + agentLoop.SetReloadFunc(reloadTrigger) + + for _, bindHost := range listenResult.BindHosts { + fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port))) + } + fmt.Println("Press Ctrl+C to stop") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go agentLoop.Run(ctx) + + var configReloadChan <-chan *config.Config + stopWatch := func() {} + if cfg.Gateway.HotReload { + configReloadChan, stopWatch = setupConfigWatcherPolling(configPath, debug) + logger.Info("Config hot reload enabled") + } + defer stopWatch() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + for { + select { + case <-sigChan: + logger.Info("Shutting down...") + shutdownGateway(runningServices, agentLoop, provider, msgBus, true) + return nil + case newCfg := <-configReloadChan: + if !runningServices.reloading.CompareAndSwap(false, true) { + logger.Warn("Config reload skipped: another reload is in progress") + continue + } + err := executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup, debug) + if err != nil { + logger.Errorf("Config reload failed: %v", err) + } + case <-manualReloadChan: + logger.Info("Manual reload triggered via /reload endpoint") + newCfg, err := config.LoadConfig(configPath) + if err != nil { + logger.Errorf("Error loading config for manual reload: %v", err) + runningServices.reloading.Store(false) + continue + } + if err = newCfg.ValidateModelList(); err != nil { + logger.Errorf("Config validation failed: %v", err) + runningServices.reloading.Store(false) + continue + } + err = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup, debug) + if err != nil { + logger.Errorf("Manual reload failed: %v", err) + } else { + logger.Info("Manual reload completed successfully") + } + } + } +} + +func preCheckConfig(cfg *config.Config) error { + if cfg.Gateway.Port <= 0 || cfg.Gateway.Port > 65535 { + return fmt.Errorf("invalid gateway port: %d, port must be between 1 and 65535", cfg.Gateway.Port) + } + return nil +} + +func executeReload( + ctx context.Context, + agentLoop *agent.AgentLoop, + newCfg *config.Config, + provider *providers.LLMProvider, + runningServices *services, + msgBus *bus.MessageBus, + allowEmptyStartup bool, + debug bool, +) (err error) { + startedAt := time.Now() + publishGatewayEvent(agentLoop, runtimeevents.KindGatewayReloadStarted, startedAt, nil) + defer runningServices.reloading.Store(false) + defer func() { + if err != nil { + publishGatewayEvent(agentLoop, runtimeevents.KindGatewayReloadFailed, startedAt, err) + return + } + publishGatewayEvent(agentLoop, runtimeevents.KindGatewayReloadCompleted, startedAt, nil) + }() + + err = handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup, debug) + return err +} + +func createStartupProvider( + cfg *config.Config, + allowEmptyStartup bool, +) (providers.LLMProvider, string, error) { + modelName := cfg.Agents.Defaults.GetModelName() + if modelName == "" && allowEmptyStartup { + reason := "no default model configured; gateway started in limited mode" + fmt.Printf("⚠ Warning: %s\n", reason) + logger.WarnCF("gateway", "Gateway started without default model", map[string]any{ + "limited_mode": true, + }) + return &startupBlockedProvider{reason: reason}, "", nil + } + + return providers.CreateProvider(cfg) +} + +func setupAndStartServices( + cfg *config.Config, + agentLoop *agent.AgentLoop, + msgBus *bus.MessageBus, + authToken string, + listenResult netbind.OpenResult, +) (*services, error) { + runningServices := &services{} + + execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute + var err error + runningServices.CronService, err = setupCronTool( + agentLoop, + msgBus, + cfg.WorkspacePath(), + cfg.Agents.Defaults.RestrictToWorkspace, + execTimeout, + cfg, + ) + if err != nil { + return nil, fmt.Errorf("error setting up cron service: %w", err) + } + if err = runningServices.CronService.Start(); err != nil { + return nil, fmt.Errorf("error starting cron service: %w", err) + } + fmt.Println("✓ Cron service started") + + runningServices.HeartbeatService = heartbeat.NewHeartbeatService( + cfg.WorkspacePath(), + cfg.Heartbeat.Interval, + cfg.Heartbeat.Enabled, + ) + runningServices.HeartbeatService.SetBus(msgBus) + runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(agentLoop)) + if err = runningServices.HeartbeatService.Start(); err != nil { + return nil, fmt.Errorf("error starting heartbeat service: %w", err) + } + fmt.Println("✓ Heartbeat service started") + + runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{ + Enabled: cfg.Tools.MediaCleanup.Enabled, + MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute, + Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute, + }) + if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { + fms.Start() + } + + runningServices.ChannelManager, err = channels.NewManager( + cfg, + msgBus, + runningServices.MediaStore, + channels.WithRuntimeEvents(agentLoop.RuntimeEventBus()), + ) + if err != nil { + if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { + fms.Stop() + } + return nil, fmt.Errorf("error creating channel manager: %w", err) + } + + agentLoop.SetChannelManager(runningServices.ChannelManager) + agentLoop.SetMediaStore(runningServices.MediaStore) + + transcriber := asr.DetectTranscriber(cfg) + if transcriber != nil { + agentLoop.SetTranscriber(transcriber) + logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) + } + + ttsAvailable := tts.DetectTTS(cfg) != nil + + enabledChannels := runningServices.ChannelManager.GetEnabledChannels() + if len(enabledChannels) > 0 { + fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) + } else { + fmt.Println("⚠ Warning: No channels enabled") + } + + runningServices.authToken = authToken + runningServices.HealthServer = health.NewServer(listenResult.ProbeHost, cfg.Gateway.Port, authToken) + + var listenAddr string + if len(listenResult.Listeners) > 0 { + listenAddr = listenResult.Listeners[0].Addr().String() + } else { + listenAddr = net.JoinHostPort(listenResult.ProbeHost, strconv.Itoa(cfg.Gateway.Port)) + } + runningServices.ChannelManager.SetupHTTPServerListeners( + listenResult.Listeners, + listenAddr, + runningServices.HealthServer, + ) + + if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil { + return nil, fmt.Errorf("error starting channels: %w", err) + } + + logChannelVoiceCapabilities(runningServices.ChannelManager, transcriber != nil, ttsAvailable) + + if transcriber != nil { + // Start Voice Agent Orchestrator after channels are ready. + vaCtx, vaCancel := context.WithCancel(context.Background()) + runningServices.VoiceAgentCancel = vaCancel + voiceAgent := asr.NewAgent(msgBus, transcriber) + voiceAgent.Start(vaCtx) + } + + healthAddr := net.JoinHostPort(listenResult.ProbeHost, strconv.Itoa(cfg.Gateway.Port)) + fmt.Printf( + "✓ Health endpoints available at http://%s/health, /ready and /reload (POST)\n", + healthAddr, + ) + + stateManager := state.NewManager(cfg.WorkspacePath()) + runningServices.DeviceService = devices.NewService(devices.Config{ + Enabled: cfg.Devices.Enabled, + MonitorUSB: cfg.Devices.MonitorUSB, + }, stateManager) + runningServices.DeviceService.SetBus(msgBus) + if err = runningServices.DeviceService.Start(context.Background()); err != nil { + logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()}) + } else if cfg.Devices.Enabled { + fmt.Println("✓ Device event service started") + } + + return runningServices, nil +} + +func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Duration, isReload bool) { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer shutdownCancel() + + // reload should not stop channel manager + if !isReload && runningServices.ChannelManager != nil { + runningServices.ChannelManager.StopAll(shutdownCtx) + } + if runningServices.VoiceAgentCancel != nil { + runningServices.VoiceAgentCancel() + } + if runningServices.DeviceService != nil { + runningServices.DeviceService.Stop() + } + if runningServices.HeartbeatService != nil { + runningServices.HeartbeatService.Stop() + } + if runningServices.CronService != nil { + runningServices.CronService.Stop() + } + if runningServices.MediaStore != nil { + if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { + fms.Stop() + } + } +} + +func shutdownGateway( + runningServices *services, + agentLoop *agent.AgentLoop, + provider providers.LLMProvider, + msgBus *bus.MessageBus, + fullShutdown bool, +) { + publishGatewayEvent(agentLoop, runtimeevents.KindGatewayShutdown, time.Time{}, nil) + + if cp, ok := provider.(providers.StatefulProvider); ok && fullShutdown { + cp.Close() + } + + stopAndCleanupServices(runningServices, gracefulShutdownTimeout, false) + + if fullShutdown && msgBus != nil { + msgBus.Close() + } + + agentLoop.Stop() + agentLoop.Close() + + logger.Info("✓ Gateway stopped") +} + +func handleConfigReload( + ctx context.Context, + al *agent.AgentLoop, + newCfg *config.Config, + providerRef *providers.LLMProvider, + runningServices *services, + msgBus *bus.MessageBus, + allowEmptyStartup bool, + debug bool, +) error { + logger.Info("🔄 Config file changed, reloading...") + + newModel := newCfg.Agents.Defaults.ModelName + + logger.Infof(" New model is '%s', recreating provider...", newModel) + + logger.Info(" Stopping all services...") + stopAndCleanupServices(runningServices, serviceShutdownTimeout, true) + + newProvider, newModelID, err := createStartupProvider(newCfg, allowEmptyStartup) + if err != nil { + logger.Errorf(" ⚠ Error creating new provider: %v", err) + logger.Warn(" Attempting to restart services with old provider and config...") + if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil { + logger.Errorf(" ⚠ Failed to restart services: %v", restartErr) + } + return fmt.Errorf("error creating new provider: %w", err) + } + + if newModelID != "" { + newCfg.Agents.Defaults.ModelName = newModelID + } + + reloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout) + defer reloadCancel() + + if err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil { + logger.Errorf(" ⚠ Error reloading agent loop: %v", err) + if cp, ok := newProvider.(providers.StatefulProvider); ok { + cp.Close() + } + logger.Warn(" Attempting to restart services with old provider and config...") + if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil { + logger.Errorf(" ⚠ Failed to restart services: %v", restartErr) + } + return fmt.Errorf("error reloading agent loop: %w", err) + } + + *providerRef = newProvider + + logger.Info(" Restarting all services with new configuration...") + if err := restartServices(al, runningServices, msgBus); err != nil { + logger.Errorf(" ⚠ Error restarting services: %v", err) + return fmt.Errorf("error restarting services: %w", err) + } + + logger.Info(" ✓ Provider, configuration, and services reloaded successfully (thread-safe)") + + // Debug mode permanently overrides the config log level to DEBUG. + if !debug { + // Update log level last so that reload-related info/warn logs above are not suppressed. + effectiveLogLevel := config.EffectiveGatewayLogLevel(newCfg) + logger.SetLevelFromString(effectiveLogLevel) + logger.Infof("Log level changing from current to %q", effectiveLogLevel) + } + + return nil +} + +func restartServices( + al *agent.AgentLoop, + runningServices *services, + msgBus *bus.MessageBus, +) error { + cfg := al.GetConfig() + + execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute + var err error + runningServices.CronService, err = setupCronTool( + al, + msgBus, + cfg.WorkspacePath(), + cfg.Agents.Defaults.RestrictToWorkspace, + execTimeout, + cfg, + ) + if err != nil { + return fmt.Errorf("error restarting cron service: %w", err) + } + if err = runningServices.CronService.Start(); err != nil { + return fmt.Errorf("error restarting cron service: %w", err) + } + fmt.Println(" ✓ Cron service restarted") + + runningServices.HeartbeatService = heartbeat.NewHeartbeatService( + cfg.WorkspacePath(), + cfg.Heartbeat.Interval, + cfg.Heartbeat.Enabled, + ) + runningServices.HeartbeatService.SetBus(msgBus) + runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(al)) + if err = runningServices.HeartbeatService.Start(); err != nil { + return fmt.Errorf("error restarting heartbeat service: %w", err) + } + fmt.Println(" ✓ Heartbeat service restarted") + + runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{ + Enabled: cfg.Tools.MediaCleanup.Enabled, + MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute, + Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute, + }) + if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { + fms.Start() + } + al.SetMediaStore(runningServices.MediaStore) + + al.SetChannelManager(runningServices.ChannelManager) + + if err = runningServices.ChannelManager.Reload(context.Background(), cfg); err != nil { + return fmt.Errorf("error reload channels: %w", err) + } + fmt.Println(" ✓ Channels restarted.") + + enabledChannels := runningServices.ChannelManager.GetEnabledChannels() + if len(enabledChannels) > 0 { + fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels) + } else { + fmt.Println(" ⚠ Warning: No channels enabled") + } + + stateManager := state.NewManager(cfg.WorkspacePath()) + runningServices.DeviceService = devices.NewService(devices.Config{ + Enabled: cfg.Devices.Enabled, + MonitorUSB: cfg.Devices.MonitorUSB, + }, stateManager) + runningServices.DeviceService.SetBus(msgBus) + if err := runningServices.DeviceService.Start(context.Background()); err != nil { + logger.WarnCF("device", "Failed to restart device service", map[string]any{"error": err.Error()}) + } else if cfg.Devices.Enabled { + fmt.Println(" ✓ Device event service restarted") + } + + transcriber := asr.DetectTranscriber(cfg) + al.SetTranscriber(transcriber) + if transcriber != nil { + logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) + + // Start Voice Agent Orchestrator on reload + vaCtx, vaCancel := context.WithCancel(context.Background()) + runningServices.VoiceAgentCancel = vaCancel + voiceAgent := asr.NewAgent(msgBus, transcriber) + voiceAgent.Start(vaCtx) + } else { + logger.InfoCF("voice", "Transcription disabled", nil) + } + + ttsAvailable := tts.DetectTTS(cfg) != nil + logChannelVoiceCapabilities(runningServices.ChannelManager, transcriber != nil, ttsAvailable) + // NOTE: PID file is written once at startup and not updated on reload. + // Changing the gateway listen address requires a full restart. + + return nil +} + +func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) { + configChan := make(chan *config.Config, 1) + stop := make(chan struct{}) + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + + lastModTime := getFileModTime(configPath) + lastSize := getFileSize(configPath) + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + currentModTime := getFileModTime(configPath) + currentSize := getFileSize(configPath) + + if currentModTime.After(lastModTime) || currentSize != lastSize { + if debug { + logger.Debugf("🔍 Config file change detected") + } + + time.Sleep(500 * time.Millisecond) + + lastModTime = currentModTime + lastSize = currentSize + + newCfg, err := config.LoadConfig(configPath) + if err != nil { + logger.Errorf("⚠ Error loading new config: %v", err) + logger.Warn(" Using previous valid config") + continue + } + + if err := newCfg.ValidateModelList(); err != nil { + logger.Errorf(" ⚠ New config validation failed: %v", err) + logger.Warn(" Using previous valid config") + continue + } + + logger.Info("✓ Config file validated and loaded") + + select { + case configChan <- newCfg: + default: + logger.Warn("⚠ Previous config reload still in progress, skipping") + } + } + case <-stop: + return + } + } + }() + + stopFunc := func() { + close(stop) + wg.Wait() + } + + return configChan, stopFunc +} + +func getFileModTime(path string) time.Time { + info, err := os.Stat(path) + if err != nil { + return time.Time{} + } + return info.ModTime() +} + +func getFileSize(path string) int64 { + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.Size() +} + +func setupCronTool( + agentLoop *agent.AgentLoop, + msgBus *bus.MessageBus, + workspace string, + restrict bool, + execTimeout time.Duration, + cfg *config.Config, +) (*cron.CronService, error) { + cronStorePath := filepath.Join(workspace, "cron", "jobs.json") + + cronService := cron.NewCronService(cronStorePath, nil) + + var cronTool *tools.CronTool + if cfg.Tools.IsToolEnabled("cron") { + var err error + cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) + if err != nil { + return nil, fmt.Errorf("critical error during CronTool initialization: %w", err) + } + + agentLoop.RegisterTool(cronTool) + } + + if cronTool != nil { + cronService.SetOnJob(func(job *cron.CronJob) (string, error) { + result := cronTool.ExecuteJob(context.Background(), job) + return result, nil + }) + } + + return cronService, nil +} + +func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult { + return func(prompt, channel, chatID string) *tools.ToolResult { + if channel == "" || chatID == "" { + channel, chatID = "cli", "direct" + } + + response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) + if err != nil { + return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) + } + if response == "HEARTBEAT_OK" { + return tools.SilentResult("Heartbeat OK") + } + return tools.SilentResult(response) + } +} diff --git a/pkg/gateway/gateway_test.go b/pkg/gateway/gateway_test.go new file mode 100644 index 000000000..ab3833ba6 --- /dev/null +++ b/pkg/gateway/gateway_test.go @@ -0,0 +1,211 @@ +package gateway + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +func TestRun_StartupFailuresReturnErrorAndEmitStructuredLog(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prepare func(t *testing.T, dir string) string + wantErr string + wantLogSub string + }{ + { + name: "invalid config returns load error", + prepare: func(t *testing.T, dir string) string { + t.Helper() + cfgPath := filepath.Join(dir, "invalid-config.json") + if err := os.WriteFile(cfgPath, []byte("{invalid-json"), 0o644); err != nil { + t.Fatalf("WriteFile(invalid config) error = %v", err) + } + return cfgPath + }, + wantErr: "error loading config:", + wantLogSub: "error loading config:", + }, + { + name: "invalid config returns pre-check error", + prepare: func(t *testing.T, dir string) string { + t.Helper() + cfg := config.DefaultConfig() + cfg.Gateway.Port = 0 + cfgPath := filepath.Join(dir, "config.json") + if err := config.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + return cfgPath + }, + wantErr: "config pre-check failed: invalid gateway port: 0", + wantLogSub: "config pre-check failed: invalid gateway port: 0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + homeDir := t.TempDir() + configPath := tt.prepare(t, homeDir) + + cmd := exec.Command(os.Args[0], "-test.run=TestGatewayRunStartupFailureHelper") + cmd.Env = append(os.Environ(), + "GO_WANT_GATEWAY_RUN_HELPER=1", + "PICO_TEST_HOME="+homeDir, + "PICO_TEST_CONFIG="+configPath, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("helper exited unexpectedly: %v\noutput:\n%s", err, string(output)) + } + + out := string(output) + if !strings.Contains(out, tt.wantErr) { + t.Fatalf("helper output missing expected error substring %q:\n%s", tt.wantErr, out) + } + + logData, readErr := os.ReadFile(filepath.Join(homeDir, logPath, logFile)) + if readErr != nil { + t.Fatalf("ReadFile(gateway.log) error = %v", readErr) + } + logText := string(logData) + if !strings.Contains(logText, "Gateway startup failed") { + t.Fatalf("gateway.log missing structured startup failure log:\n%s", logText) + } + if !strings.Contains(logText, tt.wantLogSub) { + t.Fatalf("gateway.log missing expected failure detail %q:\n%s", tt.wantLogSub, logText) + } + }) + } +} + +func TestGatewayRunStartupFailureHelper(t *testing.T) { + if os.Getenv("GO_WANT_GATEWAY_RUN_HELPER") != "1" { + return + } + + homeDir := os.Getenv("PICO_TEST_HOME") + configPath := os.Getenv("PICO_TEST_CONFIG") + + err := Run(false, homeDir, configPath, false) + if err == nil { + fmt.Fprintln(os.Stdout, "expected startup error, got nil") + os.Exit(2) + } + + fmt.Fprintln(os.Stdout, err.Error()) + os.Exit(0) +} + +func TestPublishGatewayEvent(t *testing.T) { + eventBus := runtimeevents.NewBus() + t.Cleanup(func() { + if err := eventBus.Close(); err != nil { + t.Fatalf("Close runtime event bus: %v", err) + } + }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + sub, eventsCh, err := eventBus.Channel().OfKind(runtimeevents.KindGatewayStart).SubscribeChan( + ctx, + runtimeevents.SubscribeOptions{Name: "gateway-test", Buffer: 4}, + ) + if err != nil { + t.Fatalf("SubscribeChan() error = %v", err) + } + t.Cleanup(func() { + if err := sub.Close(); err != nil { + t.Fatalf("Close subscription: %v", err) + } + }) + + al := agent.NewAgentLoop( + config.DefaultConfig(), + bus.NewMessageBus(), + &startupBlockedProvider{reason: "not used"}, + agent.WithRuntimeEvents(eventBus), + ) + t.Cleanup(al.Close) + + startedAt := time.Now().Add(-1500 * time.Millisecond) + publishGatewayEvent(al, runtimeevents.KindGatewayStart, startedAt, nil) + + evt := receiveGatewayRuntimeEvent(t, eventsCh) + if evt.Kind != runtimeevents.KindGatewayStart || + evt.Source.Component != "gateway" || + evt.Severity != runtimeevents.SeverityInfo { + t.Fatalf("gateway event = %+v", evt) + } + payload, ok := evt.Payload.(gatewayEventPayload) + if !ok { + t.Fatalf("payload type = %T, want gatewayEventPayload", evt.Payload) + } + if payload.DurationMS <= 0 { + t.Fatalf("DurationMS = %d, want positive", payload.DurationMS) + } + if evt.Attrs["duration_ms"] == nil { + t.Fatalf("gateway event attrs missing duration_ms: %#v", evt.Attrs) + } +} + +func TestShutdownGatewayClosesMessageBus(t *testing.T) { + msgBus := bus.NewMessageBus() + al := agent.NewAgentLoop( + config.DefaultConfig(), + msgBus, + &startupBlockedProvider{reason: "not used"}, + ) + msgBus.SetEventPublisher(al.RuntimeEventBus()) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sub, eventsCh, err := al.RuntimeEventBus().Channel().OfKind(runtimeevents.KindBusCloseCompleted).SubscribeChan( + ctx, + runtimeevents.SubscribeOptions{Name: "bus-close-test", Buffer: 4}, + ) + if err != nil { + t.Fatalf("SubscribeChan() error = %v", err) + } + defer func() { + _ = sub.Close() + }() + + shutdownGateway(&services{}, al, &startupBlockedProvider{reason: "not used"}, msgBus, true) + + evt := receiveGatewayRuntimeEvent(t, eventsCh) + if evt.Kind != runtimeevents.KindBusCloseCompleted { + t.Fatalf("shutdown event kind = %q, want %q", evt.Kind, runtimeevents.KindBusCloseCompleted) + } + if err := msgBus.PublishVoiceControl(context.Background(), bus.VoiceControl{}); !errors.Is(err, bus.ErrBusClosed) { + t.Fatalf("PublishVoiceControl after shutdown error = %v, want %v", err, bus.ErrBusClosed) + } +} + +func receiveGatewayRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event { + t.Helper() + + select { + case evt := <-ch: + return evt + case <-time.After(time.Second): + t.Fatal("timed out waiting for gateway runtime event") + return runtimeevents.Event{} + } +} diff --git a/pkg/gateway/listen.go b/pkg/gateway/listen.go new file mode 100644 index 000000000..99be63096 --- /dev/null +++ b/pkg/gateway/listen.go @@ -0,0 +1,21 @@ +package gateway + +import ( + "strconv" + + "github.com/sipeed/picoclaw/pkg/netbind" +) + +func openGatewayListeners(host string, port int) (netbind.Plan, netbind.OpenResult, error) { + plan, err := netbind.BuildPlan(host, netbind.DefaultLoopback) + if err != nil { + return netbind.Plan{}, netbind.OpenResult{}, err + } + + result, err := netbind.OpenPlan(plan, strconv.Itoa(port)) + if err != nil { + return netbind.Plan{}, netbind.OpenResult{}, err + } + + return plan, result, nil +} diff --git a/pkg/gateway/listen_test.go b/pkg/gateway/listen_test.go new file mode 100644 index 000000000..9b932f852 --- /dev/null +++ b/pkg/gateway/listen_test.go @@ -0,0 +1,130 @@ +package gateway + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "strconv" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/netbind" +) + +func TestOpenGatewayListeners_HonorsIPv6OnlyHost(t *testing.T) { + hasIPv4, hasIPv6 := netbind.DetectIPFamilies() + if !hasIPv6 { + t.Skip("IPv6 is unavailable in this environment") + } + + _, result, err := openGatewayListeners("::", 0) + if err != nil { + t.Fatalf("openGatewayListeners() error = %v", err) + } + startGatewayTestHTTPServer(t, result.Listeners) + port := mustGatewayAtoi(t, result.Port) + + requireGatewayHTTPReachable(t, "::1", port) + if hasIPv4 { + requireGatewayHTTPUnreachable(t, "127.0.0.1", port) + } +} + +func TestOpenGatewayListeners_SupportsExplicitMultiHost(t *testing.T) { + hasIPv4, hasIPv6 := netbind.DetectIPFamilies() + if !hasIPv4 || !hasIPv6 { + t.Skip("dual-stack loopback is unavailable in this environment") + } + + _, result, err := openGatewayListeners("127.0.0.1,::1", 0) + if err != nil { + t.Fatalf("openGatewayListeners() error = %v", err) + } + startGatewayTestHTTPServer(t, result.Listeners) + port := mustGatewayAtoi(t, result.Port) + + requireGatewayHTTPReachable(t, "127.0.0.1", port) + requireGatewayHTTPReachable(t, "::1", port) +} + +func startGatewayTestHTTPServer(t *testing.T, listeners []net.Listener) { + t.Helper() + + server := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + }), + } + + errCh := make(chan error, len(listeners)) + for _, listener := range listeners { + ln := listener + go func() { + errCh <- server.Serve(ln) + }() + } + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + for range listeners { + err := <-errCh + if err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("server.Serve() error = %v", err) + } + } + }) +} + +func requireGatewayHTTPReachable(t *testing.T, host string, port int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + err := gatewayHTTPGet(host, port) + if err == nil { + return + } + if time.Now().After(deadline) { + t.Fatalf("expected %s:%d to be reachable: %v", host, port, err) + } + time.Sleep(50 * time.Millisecond) + } +} + +func requireGatewayHTTPUnreachable(t *testing.T, host string, port int) { + t.Helper() + if err := gatewayHTTPGet(host, port); err == nil { + t.Fatalf("expected %s:%d to be unreachable", host, port) + } +} + +func gatewayHTTPGet(host string, port int) error { + client := &http.Client{ + Timeout: 300 * time.Millisecond, + Transport: &http.Transport{ + Proxy: nil, + }, + } + + resp, err := client.Get("http://" + net.JoinHostPort(host, strconv.Itoa(port))) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New(resp.Status) + } + return nil +} + +func mustGatewayAtoi(t *testing.T, value string) int { + t.Helper() + n, err := strconv.Atoi(value) + if err != nil { + t.Fatalf("Atoi(%q) error = %v", value, err) + } + return n +} diff --git a/pkg/health/server.go b/pkg/health/server.go index 5609ebdf6..22346490c 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -2,20 +2,25 @@ package health import ( "context" + "crypto/subtle" "encoding/json" - "fmt" "maps" + "net" "net/http" + "os" + "strconv" "sync" "time" ) type Server struct { - server *http.Server - mu sync.RWMutex - ready bool - checks map[string]Check - startTime time.Time + server *http.Server + mu sync.RWMutex + ready bool + checks map[string]Check + startTime time.Time + reloadFunc func() error + authToken string // optional bearer token for protected endpoints } type Check struct { @@ -28,21 +33,24 @@ type Check struct { type StatusResponse struct { Status string `json:"status"` Uptime string `json:"uptime"` + PID int `json:"pid,omitempty"` Checks map[string]Check `json:"checks,omitempty"` } -func NewServer(host string, port int) *Server { +func NewServer(host string, port int, token string) *Server { mux := http.NewServeMux() s := &Server{ ready: false, checks: make(map[string]Check), startTime: time.Now(), + authToken: token, } mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/ready", s.readyHandler) + mux.HandleFunc("/reload", s.reloadHandler) - addr := fmt.Sprintf("%s:%d", host, port) + addr := net.JoinHostPort(host, strconv.Itoa(port)) s.server = &http.Server{ Addr: addr, Handler: mux, @@ -104,6 +112,59 @@ func (s *Server) RegisterCheck(name string, checkFn func() (bool, string)) { } } +// SetReloadFunc sets the callback function for config reload. +func (s *Server) SetReloadFunc(fn func() error) { + s.mu.Lock() + defer s.mu.Unlock() + s.reloadFunc = fn +} + +func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed, use POST"}) + return + } + + // Token check + s.mu.RLock() + requiredToken := s.authToken + s.mu.RUnlock() + + if requiredToken != "" { + given := extractBearerToken(r.Header.Get("Authorization")) + if given == "" || subtle.ConstantTimeCompare([]byte(given), []byte(requiredToken)) != 1 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + } + + s.mu.Lock() + reloadFunc := s.reloadFunc + s.mu.Unlock() + + if reloadFunc == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(map[string]string{"error": "reload not configured"}) + return + } + + if err := reloadFunc(); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "reload triggered"}) +} + func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -112,6 +173,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { resp := StatusResponse{ Status: "ok", Uptime: uptime.String(), + PID: os.Getpid(), } json.NewEncoder(w).Encode(resp) @@ -155,11 +217,20 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) { }) } -// RegisterOnMux registers /health and /ready handlers onto the given mux. +// HandlerMux is the interface for registering HTTP handlers, used by +// RegisterOnMux so that callers can pass any mux implementation +// (e.g. *http.ServeMux or a custom dynamic mux). +type HandlerMux interface { + Handle(pattern string, handler http.Handler) + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) +} + +// RegisterOnMux registers /health, /ready and /reload handlers onto the given mux. // This allows the health endpoints to be served by a shared HTTP server. -func (s *Server) RegisterOnMux(mux *http.ServeMux) { +func (s *Server) RegisterOnMux(mux HandlerMux) { mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/ready", s.readyHandler) + mux.HandleFunc("/reload", s.reloadHandler) } func statusString(ok bool) string { @@ -168,3 +239,16 @@ func statusString(ok bool) string { } return "fail" } + +// extractBearerToken returns the token from an "Authorization: Bearer " header, +// or the empty string if the header is missing or malformed. +func extractBearerToken(header string) string { + const prefix = "Bearer " + if len(header) < len(prefix) { + return "" + } + if header[:len(prefix)] != prefix { + return "" + } + return header[len(prefix):] +} diff --git a/pkg/health/server_test.go b/pkg/health/server_test.go new file mode 100644 index 000000000..31dbc37c0 --- /dev/null +++ b/pkg/health/server_test.go @@ -0,0 +1,358 @@ +package health + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func newTestServer() *Server { + s := &Server{ + ready: false, + checks: make(map[string]Check), + startTime: time.Now(), + authToken: "test", + } + return s +} + +func TestHealthHandler_ReturnsOK(t *testing.T) { + s := newTestServer() + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + + s.healthHandler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("health status = %d, want %d", w.Code, http.StatusOK) + } + + var resp StatusResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Status != "ok" { + t.Errorf("status = %q, want %q", resp.Status, "ok") + } + if resp.Uptime == "" { + t.Error("uptime should not be empty") + } +} + +func TestReadyHandler_NotReady(t *testing.T) { + s := newTestServer() + // s.ready defaults to false + req := httptest.NewRequest(http.MethodGet, "/ready", nil) + w := httptest.NewRecorder() + + s.readyHandler(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("ready status = %d, want %d", w.Code, http.StatusServiceUnavailable) + } + + var resp StatusResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Status != "not ready" { + t.Errorf("status = %q, want %q", resp.Status, "not ready") + } +} + +func TestReadyHandler_Ready(t *testing.T) { + s := newTestServer() + s.SetReady(true) + + req := httptest.NewRequest(http.MethodGet, "/ready", nil) + w := httptest.NewRecorder() + + s.readyHandler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("ready status = %d, want %d", w.Code, http.StatusOK) + } + + var resp StatusResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Status != "ready" { + t.Errorf("status = %q, want %q", resp.Status, "ready") + } +} + +func TestReadyHandler_FailedCheck(t *testing.T) { + s := newTestServer() + s.SetReady(true) + + // Register a failing check + s.RegisterCheck("database", func() (bool, string) { + return false, "connection refused" + }) + + req := httptest.NewRequest(http.MethodGet, "/ready", nil) + w := httptest.NewRecorder() + + s.readyHandler(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("ready with failed check = %d, want %d", w.Code, http.StatusServiceUnavailable) + } + + var resp StatusResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Status != "not ready" { + t.Errorf("status = %q, want %q", resp.Status, "not ready") + } + check, ok := resp.Checks["database"] + if !ok { + t.Fatal("missing database check in response") + } + if check.Status != "fail" { + t.Errorf("check status = %q, want %q", check.Status, "fail") + } + if check.Message != "connection refused" { + t.Errorf("check message = %q, want %q", check.Message, "connection refused") + } +} + +func TestReadyHandler_PassingCheck(t *testing.T) { + s := newTestServer() + s.SetReady(true) + + s.RegisterCheck("redis", func() (bool, string) { + return true, "connected" + }) + + req := httptest.NewRequest(http.MethodGet, "/ready", nil) + w := httptest.NewRecorder() + + s.readyHandler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("ready with passing check = %d, want %d", w.Code, http.StatusOK) + } + + var resp StatusResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Checks["redis"].Status != "ok" { + t.Errorf("redis check status = %q, want %q", resp.Checks["redis"].Status, "ok") + } +} + +func TestReloadHandler_MethodNotAllowed(t *testing.T) { + s := newTestServer() + + req := httptest.NewRequest(http.MethodGet, "/reload", nil) + w := httptest.NewRecorder() + + s.reloadHandler(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("reload GET status = %d, want %d", w.Code, http.StatusMethodNotAllowed) + } +} + +func TestReloadHandler_NoReloadFunc(t *testing.T) { + s := newTestServer() + + req := httptest.NewRequest(http.MethodPost, "/reload", nil) + req.Header.Set("Authorization", "Bearer test") + w := httptest.NewRecorder() + + s.reloadHandler(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("reload without func = %d, want %d", w.Code, http.StatusServiceUnavailable) + } +} + +func TestReloadHandler_Success(t *testing.T) { + s := newTestServer() + called := false + s.SetReloadFunc(func() error { + called = true + return nil + }) + + req := httptest.NewRequest(http.MethodPost, "/reload", nil) + req.Header.Set("Authorization", "Bearer test") + w := httptest.NewRecorder() + + s.reloadHandler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("reload status = %d, want %d", w.Code, http.StatusOK) + } + if !called { + t.Error("reload function was not called") + } +} + +func TestReloadHandler_Error(t *testing.T) { + s := newTestServer() + s.SetReloadFunc(func() error { + return errors.New("config parse error") + }) + + req := httptest.NewRequest(http.MethodPost, "/reload", nil) + req.Header.Set("Authorization", "Bearer test") + w := httptest.NewRecorder() + + s.reloadHandler(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("reload error status = %d, want %d", w.Code, http.StatusInternalServerError) + } +} + +func TestSetReady_Toggle(t *testing.T) { + s := newTestServer() + + s.SetReady(true) + req := httptest.NewRequest(http.MethodGet, "/ready", nil) + w := httptest.NewRecorder() + s.readyHandler(w, req) + if w.Code != http.StatusOK { + t.Errorf("after SetReady(true): status = %d, want %d", w.Code, http.StatusOK) + } + + s.SetReady(false) + w = httptest.NewRecorder() + s.readyHandler(w, httptest.NewRequest(http.MethodGet, "/ready", nil)) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("after SetReady(false): status = %d, want %d", w.Code, http.StatusServiceUnavailable) + } +} + +func TestRegisterCheck_MultipleChecks(t *testing.T) { + s := newTestServer() + s.SetReady(true) + + s.RegisterCheck("db", func() (bool, string) { + return true, "ok" + }) + s.RegisterCheck("cache", func() (bool, string) { + return true, "ok" + }) + s.RegisterCheck("queue", func() (bool, string) { + return false, "timeout" + }) + + req := httptest.NewRequest(http.MethodGet, "/ready", nil) + w := httptest.NewRecorder() + s.readyHandler(w, req) + + // Should be not ready because queue check fails + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want %d (queue check failed)", w.Code, http.StatusServiceUnavailable) + } + + var resp StatusResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(resp.Checks) != 3 { + t.Errorf("checks count = %d, want 3", len(resp.Checks)) + } +} + +func TestRegisterOnMux(t *testing.T) { + s := newTestServer() + s.SetReady(true) + + mux := http.NewServeMux() + s.RegisterOnMux(mux) + + // Test /health on custom mux + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("/health on custom mux = %d, want %d", w.Code, http.StatusOK) + } + + // Test /ready on custom mux + req = httptest.NewRequest(http.MethodGet, "/ready", nil) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("/ready on custom mux = %d, want %d", w.Code, http.StatusOK) + } +} + +func TestNewServer(t *testing.T) { + s := NewServer("127.0.0.1", 0, "") + if s == nil { + t.Fatal("NewServer returned nil") + } + if s.ready { + t.Error("new server should not be ready by default") + } + if s.checks == nil { + t.Error("checks map should be initialized") + } +} + +func TestNewServer_IPv6ListenAddrFormatting(t *testing.T) { + s := NewServer("::", 18790, "") + if s.server == nil { + t.Fatal("server should be initialized") + } + if s.server.Addr != "[::]:18790" { + t.Fatalf("server.Addr = %q, want %q", s.server.Addr, "[::]:18790") + } +} + +func TestStartContext_Cancellation(t *testing.T) { + s := NewServer("127.0.0.1", 0, "") + + ctx, cancel := context.WithCancel(context.Background()) + + errCh := make(chan error, 1) + go func() { + errCh <- s.StartContext(ctx) + }() + + // Give server time to start + time.Sleep(50 * time.Millisecond) + + // Cancel context should trigger shutdown + cancel() + + select { + case err := <-errCh: + if err != nil { + t.Errorf("StartContext returned unexpected error: %v", err) + } + case <-time.After(2 * time.Second): + t.Error("StartContext did not return after context cancellation") + } +} + +func TestStatusString(t *testing.T) { + tests := []struct { + input bool + want string + }{ + {true, "ok"}, + {false, "fail"}, + } + for _, tt := range tests { + got := statusString(tt.input) + if got != tt.want { + t.Errorf("statusString(%v) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index 09c93fc6b..e5b28ec11 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -26,6 +26,7 @@ import ( const ( minIntervalMinutes = 5 defaultIntervalMinutes = 30 + userTasksMarker = "Add your heartbeat tasks below this line:" ) // HeartbeatHandler is the function type for handling heartbeat. @@ -232,7 +233,7 @@ func (hs *HeartbeatService) buildPrompt() string { } content := string(data) - if len(content) == 0 { + if !heartbeatHasUserTasks(content) { return "" } @@ -284,6 +285,32 @@ Add your heartbeat tasks below this line: } } +func heartbeatHasUserTasks(content string) bool { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return false + } + + markerIdx := strings.Index(content, userTasksMarker) + if markerIdx < 0 { + return true + } + + tasksSection := content[markerIdx+len(userTasksMarker):] + for _, line := range strings.Split(tasksSection, "\n") { + trimmedLine := strings.TrimSpace(line) + if trimmedLine == "" { + continue + } + if strings.HasPrefix(trimmedLine, "#") { + continue + } + return true + } + + return false +} + // sendResponse sends the heartbeat response to the last channel func (hs *HeartbeatService) sendResponse(response string) { hs.mu.RLock() @@ -312,8 +339,7 @@ func (hs *HeartbeatService) sendResponse(response string) { pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Channel: platform, - ChatID: userID, + Context: bus.NewOutboundContext(platform, userID, ""), Content: response, }) diff --git a/pkg/heartbeat/service_test.go b/pkg/heartbeat/service_test.go index 3b7eeeefb..309b4378f 100644 --- a/pkg/heartbeat/service_test.go +++ b/pkg/heartbeat/service_test.go @@ -3,6 +3,7 @@ package heartbeat import ( "os" "path/filepath" + "strings" "testing" "time" @@ -203,3 +204,47 @@ func TestHeartbeatFilePath(t *testing.T) { t.Errorf("Expected HEARTBEAT.md at %s, but it doesn't exist", expectedPath) } } + +func TestBuildPrompt_DefaultTemplateStaysIdle(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + hs := NewHeartbeatService(tmpDir, 30, true) + hs.createDefaultHeartbeatTemplate() + + if prompt := hs.buildPrompt(); prompt != "" { + t.Fatalf("buildPrompt() = %q, want empty prompt for untouched default template", prompt) + } +} + +func TestBuildPrompt_UserTasksAfterMarkerProducePrompt(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + hs := NewHeartbeatService(tmpDir, 30, true) + hs.createDefaultHeartbeatTemplate() + + path := filepath.Join(tmpDir, "HEARTBEAT.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Failed to read HEARTBEAT.md: %v", err) + } + updated := string(data) + "\n- Check unread Feishu messages\n" + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + t.Fatalf("Failed to update HEARTBEAT.md: %v", err) + } + + prompt := hs.buildPrompt() + if prompt == "" { + t.Fatal("buildPrompt() = empty, want non-empty prompt when user tasks are present") + } + if !strings.Contains(prompt, "Check unread Feishu messages") { + t.Fatalf("prompt = %q, want user task content", prompt) + } +} diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go index 6bc09c210..045725a8d 100644 --- a/pkg/identity/identity.go +++ b/pkg/identity/identity.go @@ -59,6 +59,9 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool { } } + // Keep track of explicit username format + isAtUsername := strings.HasPrefix(allowed, "@") + // Strip leading "@" for username matching trimmed := strings.TrimPrefix(allowed, "@") @@ -75,11 +78,9 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool { return true } - // Match against Username - if sender.Username != "" { - if sender.Username == trimmed || sender.Username == allowedUser { - return true - } + // Match against Username only when explicitly requested via "@username" + if isAtUsername && sender.Username != "" && sender.Username == trimmed { + return true } // Match compound sender format against allowed parts @@ -93,13 +94,18 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool { return false } -// isNumeric returns true if s consists entirely of digits. +// isNumeric returns true if s consists entirely of digits, allowing for an optional leading minus sign +// (required for Telegram group/channel IDs like -1001234567890). func isNumeric(s string) bool { if s == "" { return false } - for _, r := range s { - if r < '0' || r > '9' { + start := 0 + if s[0] == '-' && len(s) > 1 { + start = 1 + } + for i := start; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { return false } } diff --git a/pkg/identity/identity_test.go b/pkg/identity/identity_test.go index 3d24bd794..c60402d19 100644 --- a/pkg/identity/identity_test.go +++ b/pkg/identity/identity_test.go @@ -97,6 +97,15 @@ func TestMatchAllowed(t *testing.T) { allowed: "654321", want: false, }, + { + name: "negative numeric ID matches PlatformID", + sender: bus.SenderInfo{ + Platform: "telegram", + PlatformID: "-1001234567890", + }, + allowed: "-1001234567890", + want: true, + }, // Username matching { name: "@username matches Username", @@ -104,6 +113,16 @@ func TestMatchAllowed(t *testing.T) { allowed: "@alice", want: true, }, + { + name: "plain entry does not match username", + sender: bus.SenderInfo{ + Platform: "discord", + PlatformID: "999999", + Username: "123456", + }, + allowed: "123456", + want: false, + }, { name: "@username does not match", sender: telegramSender, @@ -123,6 +142,16 @@ func TestMatchAllowed(t *testing.T) { allowed: "999|alice", want: true, }, + { + name: "compound matches by ID when username differs", + sender: bus.SenderInfo{ + Platform: "discord", + PlatformID: "123456", + Username: "not123456", + }, + allowed: "123456|alice", + want: true, + }, { name: "compound does not match", sender: telegramSender, @@ -218,6 +247,9 @@ func TestIsNumeric(t *testing.T) { {"abc", false}, {"12a34", false}, {"telegram", false}, + {"-1001234567890", true}, + {"-", false}, + {"-12a34", false}, } for _, tt := range tests { diff --git a/pkg/isolation/README.md b/pkg/isolation/README.md new file mode 100644 index 000000000..de16ce505 --- /dev/null +++ b/pkg/isolation/README.md @@ -0,0 +1,238 @@ +# `pkg/isolation` + +`pkg/isolation` provides process-level isolation for child processes started by `picoclaw`. + +It does not sandbox the main `picoclaw` process itself. + +## Scope + +The current scope is the child-process startup path: + +- `exec` tool +- CLI providers such as `claude-cli` and `codex-cli` +- process hooks +- MCP `stdio` servers + +## One-Sentence Model + +- The `picoclaw` main process still runs in the host environment. +- Every child process should enter the shared `pkg/isolation` startup path first. +- The startup path applies platform-specific isolation according to config. + +## Architecture + +The implementation has four layers: + +1. Configuration layer: reads `config.Config.Isolation` and injects it through `isolation.Configure(cfg)`. +2. Instance layout layer: resolves `config.GetHome()`, prepares instance directories, and builds the runtime user environment. +3. Platform backend layer: Linux uses `bwrap`; Windows uses a restricted token, low integrity, and a `Job Object`; other platforms are not implemented. +4. Unified startup layer: `PrepareCommand(cmd)`, `Start(cmd)`, and `Run(cmd)`. + +All integrations that spawn subprocesses should reuse these helpers instead of calling `cmd.Start` or `cmd.Run` directly. + +## Configuration + +Isolation lives under: + +```json +{ + "isolation": { + "enabled": false, + "expose_paths": [] + } +} +``` + +Field meanings: + +- `enabled`: enables or disables subprocess isolation. Default: `false`. +- `expose_paths`: explicitly exposes host paths inside the isolated environment. It only matters when `enabled=true`. This is currently supported on Linux only. + +Example: + +```json +{ + "isolation": { + "enabled": true, + "expose_paths": [ + { + "source": "/opt/toolchains/go", + "target": "/opt/toolchains/go", + "mode": "ro" + }, + { + "source": "/data/shared-assets", + "target": "/opt/picoclaw-instance-a/workspace/assets", + "mode": "rw" + } + ] + } +} +``` + +Rules for `expose_paths`: + +- `source` is a host path. +- `target` is the path inside the isolated environment. +- `mode` must be `ro` or `rw`. +- When `target` is empty, it defaults to `source`. +- Only one final rule may exist for the same `target`. +- Later-loaded config overrides earlier rules for the same `target`. + +Platform note: + +- Linux uses a real `source -> target` mount view. +- Windows does not currently support `expose_paths`. + +## Instance Root And Directories + +The instance root follows `config.GetHome()`: + +- If `PICOCLAW_HOME` is set, use it. +- Otherwise use the default `.picoclaw` directory under the user home. + +If `config.GetHome()` falls back to `.` while isolation is enabled, startup should fail. + +Default instance directories include: + +- instance root +- `skills` +- `logs` +- `cache` +- `state` +- `runtime-user-env` + +`workspace` is derived from `cfg.WorkspacePath()` when configured, otherwise from the default workspace rule. + +Windows also prepares: + +- `runtime-user-env/AppData/Roaming` +- `runtime-user-env/AppData/Local` + +## User Environment Redirect + +When isolation is enabled, child processes receive a redirected per-instance user environment. + +Linux variables: + +- `HOME` +- `TMPDIR` +- `XDG_CONFIG_HOME` +- `XDG_CACHE_HOME` +- `XDG_STATE_HOME` + +Windows variables: + +- `USERPROFILE` +- `HOME` +- `TEMP` +- `TMP` +- `APPDATA` +- `LOCALAPPDATA` + +These paths point into `runtime-user-env` under the instance root. + +## Platform Behavior + +### Linux + +The Linux backend currently depends on `bwrap` (`bubblewrap`). + +Capabilities: + +- minimal filesystem view +- `ipc` namespace isolation +- redirected child-process user environment +- `source -> target` read-only or read-write mounts + +Default mounts include the instance root plus the minimum runtime system paths such as `/usr`, `/bin`, `/lib`, `/lib64`, and `/etc/resolv.conf`. + +At runtime, PicoClaw also adds the executable path, its directory, the effective working directory, and absolute path arguments when needed. + +There is no automatic fallback when `bwrap` is missing. + +Install examples: + +- `apt install bubblewrap` +- `dnf install bubblewrap` +- `yum install bubblewrap` +- `pacman -S bubblewrap` +- `apk add bubblewrap` + +If isolation must be disabled temporarily: + +```json +{ + "isolation": { + "enabled": false + } +} +``` + +Disabling isolation increases the risk that child processes can access or modify more host files. + +### Windows + +Windows isolation currently supports process-level restrictions such as restricted tokens, low integrity, job objects, and redirected user-environment directories. + +`expose_paths` is not currently supported on Windows. If it is configured, startup should fail instead of pretending the paths were exposed. + +The Windows backend currently uses: + +- a restricted primary token +- low integrity level +- a `Job Object` +- redirected child-process user environment + +It does not currently implement true `source -> target` filesystem remapping. + +### macOS And Other Platforms + +They are not implemented yet. + +When isolation is explicitly enabled on an unsupported platform, the higher-level runtime should surface that as an unsupported configuration instead of pretending isolation succeeded. + +## Logging And Debugging + +When isolation is enabled, PicoClaw logs the generated isolation plan. + +Linux log name: + +- `linux isolation mount plan` + +Windows log name: + +- `windows isolation access rules` + +If you suspect isolation is ineffective, check whether unexpected host paths appear in those logs. + +## Relationship To `restrict_to_workspace` + +- `restrict_to_workspace` limits the paths an agent is normally allowed to access. +- `pkg/isolation` limits what a child process can see and where its user environment points. + +They complement each other and do not replace each other. + +## Current Limits + +- Linux isolation is implemented with `bwrap`, not a custom in-process isolation runtime. +- Linux does not currently enable a dedicated `pid` namespace by default. +- Windows does not yet implement full host ACL enforcement for every allowed or denied path. +- macOS is not implemented. +- The current design isolates child processes, not the main `picoclaw` process. + +## Suggested Reading Order + +If you are new to this code, read it in this order: + +1. `pkg/config/config.go` +2. `pkg/isolation/runtime.go` +3. `pkg/isolation/platform_linux.go` +4. `pkg/isolation/platform_windows.go` +5. Call sites: +6. `pkg/tools/shell.go` +7. `pkg/providers/*.go` +8. `pkg/agent/hook_process.go` +9. `pkg/mcp/manager.go` + +That path gives the fastest overview of the configuration model, runtime flow, and platform-specific limits. diff --git a/pkg/isolation/README.zh.md b/pkg/isolation/README.zh.md new file mode 100644 index 000000000..0529a84bd --- /dev/null +++ b/pkg/isolation/README.zh.md @@ -0,0 +1,238 @@ +# `pkg/isolation` + +`pkg/isolation` 为 `picoclaw` 启动的子进程提供进程级隔离能力。 + +它当前不会把 `picoclaw` 主进程自身放进沙箱中运行。 + +## 生效范围 + +当前生效范围是子进程启动链路: + +- `exec` 工具 +- `claude-cli`、`codex-cli` 等 CLI provider +- 进程型 hooks +- MCP `stdio` server + +## 一句话理解 + +- `picoclaw` 主进程仍运行在宿主环境中。 +- 所有子进程都应先经过 `pkg/isolation` 的统一启动入口。 +- 入口会根据配置和平台,为子进程施加对应隔离。 + +## 架构 + +当前实现可以分为四层: + +1. 配置层:读取 `config.Config.Isolation`,并通过 `isolation.Configure(cfg)` 注入运行时。 +2. 实例目录层:解析 `config.GetHome()`,准备实例目录,并构建运行时用户环境目录。 +3. 平台后端层:Linux 使用 `bwrap`;Windows 使用受限 token、低完整性级别和 `Job Object`;其他平台未实现。 +4. 统一启动层:`PrepareCommand(cmd)`、`Start(cmd)`、`Run(cmd)`。 + +所有启动子进程的接入点都应复用这组入口,而不是各自直接调用 `cmd.Start` 或 `cmd.Run`。 + +## 配置 + +隔离配置位于: + +```json +{ + "isolation": { + "enabled": false, + "expose_paths": [] + } +} +``` + +字段说明: + +- `enabled`:是否启用子进程隔离。默认值:`false`。 +- `expose_paths`:显式把宿主路径带入隔离环境。仅在 `enabled=true` 时生效。目前只在 Linux 上支持。 + +示例: + +```json +{ + "isolation": { + "enabled": true, + "expose_paths": [ + { + "source": "/opt/toolchains/go", + "target": "/opt/toolchains/go", + "mode": "ro" + }, + { + "source": "/data/shared-assets", + "target": "/opt/picoclaw-instance-a/workspace/assets", + "mode": "rw" + } + ] + } +} +``` + +`expose_paths` 规则: + +- `source`:宿主机路径。 +- `target`:隔离环境内的目标路径。 +- `mode`:只能是 `ro` 或 `rw`。 +- `target` 为空时,默认等于 `source`。 +- 同一个 `target` 最终只能保留一条规则。 +- 后加载的配置会覆盖先加载的同目标规则。 + +平台说明: + +- Linux 会真实使用 `source -> target` 挂载视图。 +- Windows 当前不支持 `expose_paths`。 + +## 实例根与目录 + +实例根遵循 `config.GetHome()`: + +- 如果设置了 `PICOCLAW_HOME`,使用该值。 +- 否则默认使用用户目录下的 `.picoclaw`。 + +如果 `config.GetHome()` 在隔离开启时最终回退到当前目录 `.`,启动应直接失败。 + +默认实例目录包括: + +- 实例根本身 +- `skills` +- `logs` +- `cache` +- `state` +- `runtime-user-env` + +`workspace` 优先使用 `cfg.WorkspacePath()` 的结果;未显式配置时才按默认规则派生。 + +Windows 还会额外准备: + +- `runtime-user-env/AppData/Roaming` +- `runtime-user-env/AppData/Local` + +## 用户环境重定向 + +隔离开启后,子进程会收到重定向到实例目录下的独立用户环境。 + +Linux 注入变量: + +- `HOME` +- `TMPDIR` +- `XDG_CONFIG_HOME` +- `XDG_CACHE_HOME` +- `XDG_STATE_HOME` + +Windows 注入变量: + +- `USERPROFILE` +- `HOME` +- `TEMP` +- `TMP` +- `APPDATA` +- `LOCALAPPDATA` + +这些路径都会指向实例根下的 `runtime-user-env`。 + +## 平台行为 + +### Linux + +Linux 后端当前依赖 `bwrap`(`bubblewrap`)。 + +能力: + +- 最小文件系统视图 +- `ipc namespace` +- 子进程用户环境重定向 +- `source -> target` 只读或读写挂载 + +默认映射包括实例根,以及 `/usr`、`/bin`、`/lib`、`/lib64`、`/etc/resolv.conf` 等最小运行时系统路径。 + +运行时还会按需补充可执行文件本身、其所在目录、生效后的工作目录,以及命令行中的绝对路径参数。 + +缺少 `bwrap` 时不会自动回退。 + +安装示例: + +- `apt install bubblewrap` +- `dnf install bubblewrap` +- `yum install bubblewrap` +- `pacman -S bubblewrap` +- `apk add bubblewrap` + +如果需要临时关闭隔离: + +```json +{ + "isolation": { + "enabled": false + } +} +``` + +关闭隔离后,子进程访问或修改更多宿主文件的风险会明显上升。 + +### Windows + +Windows 隔离当前提供的是进程级限制,例如 restricted token、low integrity、job object,以及用户环境目录重定向。 + +`expose_paths` 目前不支持 Windows。如果配置了该字段,启动应直接失败,而不是假装这些路径已经被暴露进隔离环境。 + +Windows 后端当前使用: + +- 受限 primary token +- 低完整性级别 +- `Job Object` +- 子进程用户环境重定向 + +它当前不会实现真正的 `source -> target` 文件系统重映射。 + +### macOS 与其他平台 + +当前尚未实现。 + +当在未支持的平台上显式开启隔离时,上层运行时应将其视为不支持的配置,而不是假装隔离成功。 + +## 日志与排障 + +隔离开启后,PicoClaw 会打印生成后的隔离计划,便于排障。 + +Linux 日志名: + +- `linux isolation mount plan` + +Windows 日志名: + +- `windows isolation access rules` + +如果你怀疑隔离未生效,先检查这些日志里是否出现了不应暴露的宿主路径。 + +## 与 `restrict_to_workspace` 的关系 + +- `restrict_to_workspace` 限制的是 agent 默认可访问的路径。 +- `pkg/isolation` 限制的是子进程运行时能看到什么文件系统,以及它的用户环境指向哪里。 + +两者互补,不互相替代。 + +## 当前限制 + +- Linux 基于 `bwrap` 实现,而不是纯内建 isolation runtime。 +- Linux 当前没有默认启用独立的 `pid namespace`。 +- Windows 还没有对所有允许/拒绝路径做完整 ACL 落地。 +- macOS 尚未实现。 +- 当前隔离的是子进程,不是 `picoclaw` 主进程自身。 + +## 建议阅读顺序 + +如果你是第一次看这部分代码,建议按这个顺序阅读: + +1. `pkg/config/config.go` +2. `pkg/isolation/runtime.go` +3. `pkg/isolation/platform_linux.go` +4. `pkg/isolation/platform_windows.go` +5. 调用点: +6. `pkg/tools/shell.go` +7. `pkg/providers/*.go` +8. `pkg/agent/hook_process.go` +9. `pkg/mcp/manager.go` + +这样能最快建立对配置模型、运行流程和平台边界的整体理解。 diff --git a/pkg/isolation/platform_linux.go b/pkg/isolation/platform_linux.go new file mode 100644 index 000000000..9a282a4ad --- /dev/null +++ b/pkg/isolation/platform_linux.go @@ -0,0 +1,264 @@ +//go:build linux + +package isolation + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error { + if !isolation.Enabled { + return nil + } + // Bubblewrap is the only supported Linux backend right now. Fail closed when + // it is unavailable instead of silently running the child process unisolated. + bwrapPath, err := exec.LookPath("bwrap") + if err != nil { + hint := bwrapInstallHint() + disableHint := `set "isolation.enabled": false in config.json` + logger.WarnCF("isolation", "bubblewrap is required for Linux isolation", + map[string]any{ + "binary": "bwrap", + "install": hint, + "disable_isolation": disableHint, + "risk": "disabling isolation lets child processes run without Linux filesystem isolation", + }) + return fmt.Errorf( + "linux isolation requires bwrap and does not fall back automatically: %w; install bubblewrap with one of: %s; or disable isolation by setting %s; disabling isolation means child processes can run without Linux filesystem isolation and may access or modify more host files", + err, + hint, + disableHint, + ) + } + if cmd == nil || cmd.Path == "" || len(cmd.Args) == 0 { + return nil + } + + originalPath := cmd.Path + originalArgs := append([]string{}, cmd.Args...) + _, execDir, err := resolveLinuxWorkingDir(cmd.Dir, originalPath) + if err != nil { + return err + } + resolvedPath, err := resolveLinuxCommandPath(originalPath, execDir) + if err != nil { + return err + } + + // Start from the configured mount plan, then add only the executable, its + // resolved path, the effective working directory, and any absolute path + // arguments needed to preserve the original command semantics. + plan := BuildLinuxMountPlan(root, isolation.ExposePaths) + plan = ensureLinuxMountRule(plan, resolvedPath, resolvedPath, "ro") + plan = ensureLinuxMountRule(plan, filepath.Dir(resolvedPath), filepath.Dir(resolvedPath), "ro") + if resolved, resolveErr := filepath.EvalSymlinks(resolvedPath); resolveErr == nil && resolved != resolvedPath { + plan = ensureLinuxMountRule(plan, resolved, resolved, "ro") + plan = ensureLinuxMountRule(plan, filepath.Dir(resolved), filepath.Dir(resolved), "ro") + } + if execDir != "" { + plan = ensureLinuxMountRule(plan, execDir, execDir, "rw") + if resolved, resolveErr := filepath.EvalSymlinks(execDir); resolveErr == nil && resolved != execDir { + plan = ensureLinuxMountRule(plan, resolved, resolved, "rw") + } + } + plan = appendLinuxArgumentMounts(plan, originalArgs[1:]) + logger.DebugCF("isolation", "linux isolation mount plan", + map[string]any{ + "root": root, + "command": resolvedPath, + "working_dir": execDir, + "mounts": formatLinuxMountPlan(plan), + }) + bwrapArgs, err := buildLinuxBwrapArgs(originalPath, resolvedPath, originalArgs, execDir, plan) + if err != nil { + return err + } + + cmd.Path = bwrapPath + cmd.Args = bwrapArgs + cmd.Dir = "" + return nil +} + +func bwrapInstallHint() string { + return "apt install bubblewrap; dnf install bubblewrap; yum install bubblewrap; pacman -S bubblewrap; apk add bubblewrap" +} + +// formatLinuxMountPlan reshapes the internal plan for structured logging. +func formatLinuxMountPlan(plan []MountRule) []map[string]string { + formatted := make([]map[string]string, 0, len(plan)) + for _, rule := range plan { + formatted = append(formatted, map[string]string{ + "source": rule.Source, + "target": rule.Target, + "mode": rule.Mode, + }) + } + return formatted +} + +func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error { + return nil +} + +func cleanupPendingPlatformResources(cmd *exec.Cmd) { +} + +// buildLinuxBwrapArgs translates the mount plan into the bubblewrap command +// line that re-executes the original process inside the isolated mount view. +func buildLinuxBwrapArgs( + originalPath string, + resolvedPath string, + originalArgs []string, + execDir string, + plan []MountRule, +) ([]string, error) { + bwrapArgs := []string{ + "bwrap", + "--die-with-parent", + "--unshare-ipc", + "--proc", "/proc", + "--dev", "/dev", + } + for _, rule := range plan { + flag, err := linuxBindFlag(rule) + if err != nil { + return nil, err + } + bwrapArgs = append(bwrapArgs, flag, rule.Source, rule.Target) + } + if execDir != "" { + bwrapArgs = append(bwrapArgs, "--chdir", execDir) + } + execPath := originalPath + if isRelativeCommandPath(originalPath) { + execPath = resolvedPath + } + bwrapArgs = append(bwrapArgs, "--", execPath) + if len(originalArgs) > 1 { + bwrapArgs = append(bwrapArgs, originalArgs[1:]...) + } + return bwrapArgs, nil +} + +func resolveLinuxWorkingDir(originalDir, originalPath string) (string, string, error) { + if originalDir != "" { + resolved, err := filepath.Abs(originalDir) + if err != nil { + return "", "", fmt.Errorf("resolve command dir %s: %w", originalDir, err) + } + return resolved, resolved, nil + } + if !isRelativeCommandPath(originalPath) { + return "", "", nil + } + wd, err := os.Getwd() + if err != nil { + return "", "", fmt.Errorf("resolve current working dir: %w", err) + } + return "", wd, nil +} + +func resolveLinuxCommandPath(originalPath, execDir string) (string, error) { + if filepath.IsAbs(originalPath) || !isRelativeCommandPath(originalPath) { + return filepath.Clean(originalPath), nil + } + base := execDir + if base == "" { + var err error + base, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("resolve current working dir: %w", err) + } + } + return filepath.Clean(filepath.Join(base, originalPath)), nil +} + +func appendLinuxArgumentMounts(plan []MountRule, args []string) []MountRule { + for _, arg := range args { + path, ok := linuxArgumentPath(arg) + if !ok { + continue + } + clean := filepath.Clean(path) + if info, err := os.Stat(clean); err == nil { + mode := "ro" + if info.IsDir() { + mode = "rw" + } + plan = ensureLinuxMountRule(plan, clean, clean, mode) + if resolved, resolveErr := filepath.EvalSymlinks(clean); resolveErr == nil && resolved != clean { + plan = ensureLinuxMountRule(plan, resolved, resolved, mode) + } + continue + } else if !errors.Is(err, os.ErrNotExist) { + continue + } + parent := filepath.Dir(clean) + if parent == clean { + continue + } + if _, err := os.Stat(parent); err == nil { + plan = ensureLinuxMountRule(plan, parent, parent, "rw") + } + } + return plan +} + +func linuxArgumentPath(arg string) (string, bool) { + if filepath.IsAbs(arg) { + return arg, true + } + idx := strings.IndexRune(arg, '=') + if idx <= 0 || idx == len(arg)-1 { + return "", false + } + value := arg[idx+1:] + if !filepath.IsAbs(value) { + return "", false + } + return value, true +} + +func isRelativeCommandPath(path string) bool { + return !filepath.IsAbs(path) && strings.ContainsRune(path, filepath.Separator) +} + +// ensureLinuxMountRule appends a mount rule unless another rule already owns +// the same target path. +func ensureLinuxMountRule(plan []MountRule, source, target, mode string) []MountRule { + cleanSource := filepath.Clean(source) + cleanTarget := filepath.Clean(target) + for _, rule := range plan { + if filepath.Clean(rule.Target) == cleanTarget { + return plan + } + } + return append(plan, MountRule{Source: cleanSource, Target: cleanTarget, Mode: mode}) +} + +// linuxBindFlag selects the correct bubblewrap bind flag based on mount mode. +func linuxBindFlag(rule MountRule) (string, error) { + info, err := os.Stat(rule.Source) + if err != nil { + return "", fmt.Errorf("stat linux mount source %s: %w", rule.Source, err) + } + if !info.IsDir() { + if rule.Mode == "rw" { + return "--bind", nil + } + return "--ro-bind", nil + } + if rule.Mode == "rw" { + return "--bind", nil + } + return "--ro-bind", nil +} diff --git a/pkg/isolation/platform_linux_test.go b/pkg/isolation/platform_linux_test.go new file mode 100644 index 000000000..2dcca96ce --- /dev/null +++ b/pkg/isolation/platform_linux_test.go @@ -0,0 +1,148 @@ +//go:build linux + +package isolation + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestBuildLinuxBwrapArgs_IncludesNamespaceFlagsAndExec(t *testing.T) { + root := t.TempDir() + binaryDir := filepath.Join(root, "bin") + if err := os.MkdirAll(binaryDir, 0o755); err != nil { + t.Fatal(err) + } + binaryPath := filepath.Join(binaryDir, "tool") + if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + plan := BuildLinuxMountPlan(root, []config.ExposePath{{Source: binaryDir, Target: binaryDir, Mode: "ro"}}) + args, err := buildLinuxBwrapArgs(binaryPath, binaryPath, []string{binaryPath, "--flag"}, root, plan) + if err != nil { + t.Fatalf("buildLinuxBwrapArgs() error = %v", err) + } + hasNet := false + hasIPC := false + hasExec := false + for i := range args { + switch args[i] { + case "--unshare-net": + hasNet = true + case "--unshare-ipc": + hasIPC = true + case "--": + if i+1 < len(args) && args[i+1] == binaryPath { + hasExec = true + } + } + } + if hasNet { + t.Fatalf("bwrap args should not unshare net by default: %v", args) + } + if !hasIPC || !hasExec { + t.Fatalf("bwrap args missing required items: %v", args) + } +} + +func TestResolveLinuxWorkingDir_ResolvesRelativeDir(t *testing.T) { + cwd := t.TempDir() + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer func() { + if chdirErr := os.Chdir(previous); chdirErr != nil { + t.Fatalf("restore cwd: %v", chdirErr) + } + }() + if chdirErr := os.Chdir(cwd); chdirErr != nil { + t.Fatal(chdirErr) + } + + resolvedDir, execDir, err := resolveLinuxWorkingDir("./hooks", "./hook.sh") + if err != nil { + t.Fatalf("resolveLinuxWorkingDir() error = %v", err) + } + want := filepath.Join(cwd, "hooks") + if resolvedDir != want || execDir != want { + t.Fatalf("resolveLinuxWorkingDir() = (%q, %q), want (%q, %q)", resolvedDir, execDir, want, want) + } +} + +func TestResolveLinuxCommandPath_UsesExecDirForRelativeCommand(t *testing.T) { + execDir := filepath.Join(t.TempDir(), "hooks") + got, err := resolveLinuxCommandPath("./hook.sh", execDir) + if err != nil { + t.Fatalf("resolveLinuxCommandPath() error = %v", err) + } + want := filepath.Join(execDir, "hook.sh") + if got != want { + t.Fatalf("resolveLinuxCommandPath() = %q, want %q", got, want) + } +} + +func TestBuildLinuxBwrapArgs_UsesResolvedPathForRelativeCommand(t *testing.T) { + root := t.TempDir() + execDir := filepath.Join(root, "hooks") + if err := os.MkdirAll(execDir, 0o755); err != nil { + t.Fatal(err) + } + resolvedPath := filepath.Join(execDir, "hook.sh") + if err := os.WriteFile(resolvedPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + plan := []MountRule{ + {Source: execDir, Target: execDir, Mode: "rw"}, + {Source: resolvedPath, Target: resolvedPath, Mode: "ro"}, + } + args, err := buildLinuxBwrapArgs("./hook.sh", resolvedPath, []string{"./hook.sh"}, execDir, plan) + if err != nil { + t.Fatalf("buildLinuxBwrapArgs() error = %v", err) + } + hasExecDir := false + for _, arg := range args { + if arg == execDir { + hasExecDir = true + break + } + } + if !hasExecDir { + t.Fatalf("buildLinuxBwrapArgs() missing resolved chdir: %v", args) + } + for i := range args { + if args[i] == "--" { + if i+1 >= len(args) || args[i+1] != resolvedPath { + t.Fatalf("buildLinuxBwrapArgs() exec path = %v, want %q after --", args, resolvedPath) + } + return + } + } + t.Fatalf("buildLinuxBwrapArgs() missing exec delimiter: %v", args) +} + +func TestAppendLinuxArgumentMounts_AddsAbsoluteArgumentPaths(t *testing.T) { + root := t.TempDir() + input := filepath.Join(root, "input.txt") + if err := os.WriteFile(input, []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + output := filepath.Join(root, "out", "result.txt") + if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil { + t.Fatal(err) + } + + plan := appendLinuxArgumentMounts(nil, []string{input, "--output=" + output}) + if len(plan) != 2 { + t.Fatalf("appendLinuxArgumentMounts() len = %d, want 2", len(plan)) + } + if plan[0].Source != input || plan[0].Mode != "ro" { + t.Fatalf("appendLinuxArgumentMounts()[0] = %+v, want source=%q mode=ro", plan[0], input) + } + if plan[1].Source != filepath.Dir(output) || plan[1].Mode != "rw" { + t.Fatalf("appendLinuxArgumentMounts()[1] = %+v, want source=%q mode=rw", plan[1], filepath.Dir(output)) + } +} diff --git a/pkg/isolation/platform_other.go b/pkg/isolation/platform_other.go new file mode 100644 index 000000000..d8d06e2ec --- /dev/null +++ b/pkg/isolation/platform_other.go @@ -0,0 +1,22 @@ +//go:build !linux && !windows + +package isolation + +import ( + "os/exec" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error { + // Unsupported platforms currently keep the command unchanged. Callers rely on + // Preflight and higher-level checks to surface unsupported isolation modes. + return nil +} + +func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error { + return nil +} + +func cleanupPendingPlatformResources(cmd *exec.Cmd) { +} diff --git a/pkg/isolation/platform_windows.go b/pkg/isolation/platform_windows.go new file mode 100644 index 000000000..1b3be8bd3 --- /dev/null +++ b/pkg/isolation/platform_windows.go @@ -0,0 +1,217 @@ +//go:build windows + +package isolation + +import ( + "fmt" + "os/exec" + "sync" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const disableMaxPrivilege = 0x1 + +// windowsProcessResources holds native handles that must live for the lifetime +// of an isolated child process. +type windowsProcessResources struct { + job windows.Handle + token windows.Token +} + +var ( + windowsProcessResourcesByPID sync.Map + windowsPendingResources sync.Map + advapi32 = windows.NewLazySystemDLL("advapi32.dll") + procCreateRestrictedToken = advapi32.NewProc("CreateRestrictedToken") +) + +func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error { + if !isolation.Enabled || cmd == nil { + return nil + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + rules := BuildWindowsAccessRules(root, isolation.ExposePaths) + logger.InfoCF("isolation", "windows isolation process constraints", + map[string]any{ + "root": root, + "command": cmd.Path, + "rules": formatWindowsAccessRules(rules), + "note": "Windows currently enforces restricted token, low integrity, and job object limits; expose_paths filesystem remapping is rejected during preflight", + }) + // Create the restricted token before the process starts so CreateProcess uses + // the reduced privilege set from the first instruction. + restrictedToken, err := createRestrictedPrimaryToken() + if err != nil { + return fmt.Errorf("create restricted primary token: %w", err) + } + cmd.SysProcAttr.CreationFlags |= windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_BREAKAWAY_FROM_JOB + cmd.SysProcAttr.Token = syscall.Token(restrictedToken) + windowsPendingResources.Store(cmd, windowsProcessResources{token: restrictedToken}) + return nil +} + +func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error { + if !isolation.Enabled || cmd == nil || cmd.Process == nil { + return nil + } + resourcesAny, _ := windowsPendingResources.LoadAndDelete(cmd) + resources, _ := resourcesAny.(windowsProcessResources) + // Job objects can only be attached after the process exists, so the Windows + // backend finishes isolation in this post-start hook. + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + if resources.token != 0 { + _ = resources.token.Close() + } + return fmt.Errorf("create windows job object: %w", err) + } + + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if _, err = windows.SetInformationJobObject( + job, + windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ); err != nil { + _ = windows.CloseHandle(job) + if resources.token != 0 { + _ = resources.token.Close() + } + return fmt.Errorf("set windows job object info: %w", err) + } + + proc, err := windows.OpenProcess( + windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, + false, + uint32(cmd.Process.Pid), + ) + if err != nil { + _ = windows.CloseHandle(job) + if resources.token != 0 { + _ = resources.token.Close() + } + return fmt.Errorf("open process for job assignment: %w", err) + } + + if err = windows.AssignProcessToJobObject(job, proc); err != nil { + _ = windows.CloseHandle(proc) + _ = windows.CloseHandle(job) + if resources.token != 0 { + _ = resources.token.Close() + } + return fmt.Errorf("assign process to job object: %w", err) + } + + if resources.token != 0 { + _ = resources.token.Close() + } + resources.job = job + windowsProcessResourcesByPID.Store(cmd.Process.Pid, resources) + go reapWindowsProcessResources(cmd.Process.Pid, proc, job) + return nil +} + +func cleanupPendingPlatformResources(cmd *exec.Cmd) { + if cmd == nil { + return + } + resourcesAny, ok := windowsPendingResources.LoadAndDelete(cmd) + if !ok { + return + } + resources, _ := resourcesAny.(windowsProcessResources) + if resources.token != 0 { + _ = resources.token.Close() + } +} + +func reapWindowsProcessResources(pid int, proc windows.Handle, job windows.Handle) { + _, _ = windows.WaitForSingleObject(proc, windows.INFINITE) + _ = windows.CloseHandle(proc) + _ = windows.CloseHandle(job) + windowsProcessResourcesByPID.Delete(pid) +} + +// createRestrictedPrimaryToken duplicates the current process token, removes +// maximum privileges, and lowers integrity before it is assigned to a child. +func createRestrictedPrimaryToken() (windows.Token, error) { + var current windows.Token + if err := windows.OpenProcessToken( + windows.CurrentProcess(), + windows.TOKEN_DUPLICATE|windows.TOKEN_ASSIGN_PRIMARY|windows.TOKEN_QUERY|windows.TOKEN_ADJUST_DEFAULT, + ¤t, + ); err != nil { + return 0, err + } + defer current.Close() + + var restricted windows.Token + r1, _, e1 := procCreateRestrictedToken.Call( + uintptr(current), + uintptr(disableMaxPrivilege), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + uintptr(unsafe.Pointer(&restricted)), + ) + if r1 == 0 { + if e1 != nil && e1 != syscall.Errno(0) { + return 0, e1 + } + return 0, syscall.EINVAL + } + if err := setTokenLowIntegrity(restricted); err != nil { + _ = restricted.Close() + return 0, err + } + return restricted, nil +} + +// setTokenLowIntegrity lowers the token integrity level so writes to higher +// integrity locations are blocked by the OS. +func setTokenLowIntegrity(token windows.Token) error { + lowSID, err := windows.CreateWellKnownSid(windows.WinLowLabelSid) + if err != nil { + return fmt.Errorf("create low integrity sid: %w", err) + } + tml := windows.Tokenmandatorylabel{ + Label: windows.SIDAndAttributes{ + Sid: lowSID, + Attributes: windows.SE_GROUP_INTEGRITY, + }, + } + if err := windows.SetTokenInformation( + token, + windows.TokenIntegrityLevel, + (*byte)(unsafe.Pointer(&tml)), + tml.Size(), + ); err != nil { + return fmt.Errorf("set token low integrity: %w", err) + } + return nil +} + +// formatWindowsAccessRules reshapes the internal rules for structured logging. +func formatWindowsAccessRules(rules []AccessRule) []map[string]string { + formatted := make([]map[string]string, 0, len(rules)) + for _, rule := range rules { + formatted = append(formatted, map[string]string{ + "path": rule.Path, + "mode": rule.Mode, + }) + } + return formatted +} diff --git a/pkg/isolation/runtime.go b/pkg/isolation/runtime.go new file mode 100644 index 000000000..b2de98b88 --- /dev/null +++ b/pkg/isolation/runtime.go @@ -0,0 +1,443 @@ +package isolation + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg" + "github.com/sipeed/picoclaw/pkg/config" +) + +// MountRule describes a source-to-target mount exposed inside the Linux +// isolation view. +type MountRule struct { + Source string + Target string + Mode string +} + +// AccessRule describes the effective Windows-side access rule for a host path. +type AccessRule struct { + Path string + Mode string +} + +// UserEnv contains the redirected per-instance user directories injected into +// isolated child processes. +type UserEnv struct { + Home string + Tmp string + Config string + Cache string + State string + AppData string + LocalAppData string +} + +var ( + isolationMu sync.RWMutex + currentIsolation = config.DefaultConfig().Isolation +) + +// Configure updates the process-wide isolation state used by subsequent child +// process launches. +func Configure(cfg *config.Config) { + isolationMu.Lock() + defer isolationMu.Unlock() + if cfg == nil { + defaults := config.DefaultConfig() + currentIsolation = defaults.Isolation + return + } + currentIsolation = cfg.Isolation +} + +// CurrentConfig returns the currently active isolation settings. +func CurrentConfig() config.IsolationConfig { + isolationMu.RLock() + defer isolationMu.RUnlock() + return currentIsolation +} + +// ResolveInstanceRoot resolves the instance root used to build the isolated +// filesystem and redirected user environment. +func ResolveInstanceRoot() (string, error) { + root := filepath.Clean(config.GetHome()) + if root == "." { + return "", fmt.Errorf("instance root resolved to current directory") + } + return root, nil +} + +// PrepareInstanceRoot creates the directories required by the isolation runtime. +func PrepareInstanceRoot(root string) error { + for _, dir := range InstanceDirs(root) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("prepare instance dir %s: %w", dir, err) + } + } + return nil +} + +// InstanceDirs returns the directories that must exist under the instance root +// for isolation-aware child processes. +func InstanceDirs(root string) []string { + dirs := []string{ + root, + filepath.Join(root, "skills"), + filepath.Join(root, "logs"), + filepath.Join(root, "cache"), + filepath.Join(root, "state"), + filepath.Join(root, "runtime-user-env"), + filepath.Join(root, "runtime-user-env", "home"), + filepath.Join(root, "runtime-user-env", "tmp"), + filepath.Join(root, "runtime-user-env", "config"), + filepath.Join(root, "runtime-user-env", "cache"), + filepath.Join(root, "runtime-user-env", "state"), + } + dirs = append(dirs, filepath.Join(root, pkg.WorkspaceName)) + if runtime.GOOS == "windows" { + dirs = append(dirs, + filepath.Join(root, "runtime-user-env", "AppData", "Roaming"), + filepath.Join(root, "runtime-user-env", "AppData", "Local"), + ) + } + return dirs +} + +// ResolveUserEnv derives the redirected user directories rooted under the +// instance runtime area. +func ResolveUserEnv(root string) UserEnv { + base := filepath.Join(root, "runtime-user-env") + return UserEnv{ + Home: filepath.Join(base, "home"), + Tmp: filepath.Join(base, "tmp"), + Config: filepath.Join(base, "config"), + Cache: filepath.Join(base, "cache"), + State: filepath.Join(base, "state"), + AppData: filepath.Join(base, "AppData", "Roaming"), + LocalAppData: filepath.Join(base, "AppData", "Local"), + } +} + +// ApplyUserEnv rewrites the child process environment so home, temp, and +// platform-specific user-data directories point into the instance root. +func ApplyUserEnv(cmd *exec.Cmd, root string) { + userEnv := ResolveUserEnv(root) + envMap := make(map[string]string) + for _, item := range cmd.Environ() { + if idx := strings.IndexRune(item, '='); idx > 0 { + envMap[item[:idx]] = item[idx+1:] + } + } + + if runtime.GOOS == "windows" { + envMap["USERPROFILE"] = userEnv.Home + envMap["HOME"] = userEnv.Home + envMap["TEMP"] = userEnv.Tmp + envMap["TMP"] = userEnv.Tmp + envMap["APPDATA"] = userEnv.AppData + envMap["LOCALAPPDATA"] = userEnv.LocalAppData + } else { + envMap["HOME"] = userEnv.Home + envMap["TMPDIR"] = userEnv.Tmp + envMap["XDG_CONFIG_HOME"] = userEnv.Config + envMap["XDG_CACHE_HOME"] = userEnv.Cache + envMap["XDG_STATE_HOME"] = userEnv.State + } + + env := make([]string, 0, len(envMap)) + for k, v := range envMap { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + cmd.Env = env +} + +// ValidateExposePaths verifies the user-supplied path exposure rules before a +// child process is started. +func ValidateExposePaths(items []config.ExposePath) error { + seen := map[string]struct{}{} + for _, item := range items { + if item.Source == "" { + return fmt.Errorf("source is required") + } + if item.Mode != "ro" && item.Mode != "rw" { + return fmt.Errorf("invalid expose_paths mode: %s", item.Mode) + } + + source := filepath.Clean(item.Source) + target := item.Target + if target == "" { + target = source + } + target = filepath.Clean(target) + + if !filepath.IsAbs(source) || !filepath.IsAbs(target) { + return fmt.Errorf("source and target must be absolute paths") + } + if _, ok := seen[target]; ok { + return fmt.Errorf("duplicate expose_path target: %s", target) + } + seen[target] = struct{}{} + } + return nil +} + +// NormalizeExposePath fills implicit defaults and cleans path values so merge +// and validation logic can work with canonical paths. +func NormalizeExposePath(item config.ExposePath) config.ExposePath { + source := filepath.Clean(item.Source) + target := item.Target + if target == "" { + target = source + } + return config.ExposePath{ + Source: source, + Target: filepath.Clean(target), + Mode: item.Mode, + } +} + +// DefaultExposePaths returns the minimum built-in host paths required for the +// current platform to run isolated child processes. +func DefaultExposePaths(root string) []config.ExposePath { + items := []config.ExposePath{{ + Source: root, + Target: root, + Mode: "rw", + }} + if runtime.GOOS == "linux" { + items = append(items, defaultLinuxSystemExposePaths()...) + } + return items +} + +func defaultLinuxSystemExposePaths() []config.ExposePath { + return existingExposePaths([]config.ExposePath{ + {Source: "/usr", Target: "/usr", Mode: "ro"}, + {Source: "/bin", Target: "/bin", Mode: "ro"}, + {Source: "/lib", Target: "/lib", Mode: "ro"}, + {Source: "/lib64", Target: "/lib64", Mode: "ro"}, + {Source: "/etc/resolv.conf", Target: "/etc/resolv.conf", Mode: "ro"}, + {Source: "/etc/hosts", Target: "/etc/hosts", Mode: "ro"}, + {Source: "/etc/nsswitch.conf", Target: "/etc/nsswitch.conf", Mode: "ro"}, + {Source: "/etc/passwd", Target: "/etc/passwd", Mode: "ro"}, + {Source: "/etc/group", Target: "/etc/group", Mode: "ro"}, + {Source: "/etc/ssl", Target: "/etc/ssl", Mode: "ro"}, + {Source: "/etc/pki", Target: "/etc/pki", Mode: "ro"}, + {Source: "/etc/ca-certificates", Target: "/etc/ca-certificates", Mode: "ro"}, + {Source: "/usr/share/ca-certificates", Target: "/usr/share/ca-certificates", Mode: "ro"}, + {Source: "/usr/local/share/ca-certificates", Target: "/usr/local/share/ca-certificates", Mode: "ro"}, + {Source: "/etc/alternatives", Target: "/etc/alternatives", Mode: "ro"}, + {Source: "/usr/share/zoneinfo", Target: "/usr/share/zoneinfo", Mode: "ro"}, + {Source: "/etc/localtime", Target: "/etc/localtime", Mode: "ro"}, + }) +} + +// existingExposePaths keeps only the builtin host paths that exist on the +// current machine so Linux isolation does not fail on distro-specific paths. +func existingExposePaths(items []config.ExposePath) []config.ExposePath { + filtered := make([]config.ExposePath, 0, len(items)) + for _, item := range items { + if _, err := os.Stat(item.Source); err == nil { + filtered = append(filtered, item) + } + } + return filtered +} + +// MergeExposePaths merges built-in rules with user overrides. Rules are keyed +// by target path so later entries replace earlier ones for the same target. +func MergeExposePaths(defaults []config.ExposePath, overrides []config.ExposePath) []config.ExposePath { + merged := make([]config.ExposePath, 0, len(defaults)+len(overrides)) + indexByTarget := make(map[string]int, len(defaults)+len(overrides)) + appendOrReplace := func(item config.ExposePath) { + normalized := NormalizeExposePath(item) + if idx, ok := indexByTarget[normalized.Target]; ok { + merged[idx] = normalized + return + } + indexByTarget[normalized.Target] = len(merged) + merged = append(merged, normalized) + } + for _, item := range defaults { + appendOrReplace(item) + } + for _, item := range overrides { + appendOrReplace(item) + } + return merged +} + +// BuildLinuxMountPlan converts the merged expose-path configuration into the +// mount rules consumed by the Linux bubblewrap backend. +func BuildLinuxMountPlan(root string, overrides []config.ExposePath) []MountRule { + merged := MergeExposePaths(DefaultExposePaths(root), overrides) + plan := make([]MountRule, 0, len(merged)) + for _, item := range merged { + plan = append(plan, MountRule{Source: item.Source, Target: item.Target, Mode: item.Mode}) + } + return plan +} + +// BuildWindowsAccessRules derives the host-path access policy used by the +// Windows restricted-token backend. +func BuildWindowsAccessRules(root string, overrides []config.ExposePath) []AccessRule { + merged := MergeExposePaths(nil, overrides) + rules := make([]AccessRule, 0, len(merged)+1) + rules = append(rules, AccessRule{Path: root, Mode: "rw"}) + for _, item := range merged { + rules = append(rules, AccessRule{Path: item.Source, Mode: item.Mode}) + } + return rules +} + +func validateWindowsExposePaths(items []config.ExposePath) error { + if len(items) == 0 { + return nil + } + return fmt.Errorf("windows isolation does not yet support expose_paths filesystem rules") +} + +// IsSupported reports whether the current platform has an implemented isolation +// backend. +func IsSupported() bool { + return isSupportedOn(runtime.GOOS) +} + +func isSupportedOn(goos string) bool { + switch goos { + case "linux", "windows": + return true + default: + return false + } +} + +// Preflight validates the configured isolation state and prepares the instance +// runtime directories before any child process is launched. +func Preflight() error { + isolation := CurrentConfig() + if !isolation.Enabled { + return nil + } + if !IsSupported() { + return fmt.Errorf("subprocess isolation is not supported on %s", runtime.GOOS) + } + root, err := ResolveInstanceRoot() + if err != nil { + return err + } + if err := PrepareInstanceRoot(root); err != nil { + return err + } + if err := ValidateExposePaths(isolation.ExposePaths); err != nil { + return err + } + if runtime.GOOS == "linux" { + for _, rule := range BuildLinuxMountPlan(root, isolation.ExposePaths) { + if rule.Source == "" || rule.Target == "" { + return fmt.Errorf("invalid linux mount rule") + } + } + } + if runtime.GOOS == "windows" { + if err := validateWindowsExposePaths(isolation.ExposePaths); err != nil { + return err + } + for _, rule := range BuildWindowsAccessRules(root, isolation.ExposePaths) { + if rule.Path == "" { + return fmt.Errorf("invalid windows access rule") + } + } + } + return nil +} + +// Start prepares isolation for the command, starts it, and applies any +// post-start platform hooks required by the active backend. +func Start(cmd *exec.Cmd) error { + if err := PrepareCommand(cmd); err != nil { + return err + } + if err := cmd.Start(); err != nil { + cleanupPendingPlatformResources(cmd) + return err + } + isolation := CurrentConfig() + root := "" + if isolation.Enabled { + var err error + root, err = ResolveInstanceRoot() + if err != nil { + terminateStartedCommand(cmd) + return err + } + } + if err := postStartPlatformIsolation(cmd, isolation, root); err != nil { + terminateStartedCommand(cmd) + return err + } + return nil +} + +// Run is the Start-and-Wait helper that keeps the same isolation behavior as +// Start while returning the command's final exit status. +func Run(cmd *exec.Cmd) error { + if err := PrepareCommand(cmd); err != nil { + return err + } + if err := cmd.Start(); err != nil { + cleanupPendingPlatformResources(cmd) + return err + } + isolation := CurrentConfig() + root := "" + if isolation.Enabled { + var err error + root, err = ResolveInstanceRoot() + if err != nil { + terminateStartedCommand(cmd) + return err + } + } + if err := postStartPlatformIsolation(cmd, isolation, root); err != nil { + terminateStartedCommand(cmd) + return err + } + return cmd.Wait() +} + +func terminateStartedCommand(cmd *exec.Cmd) { + cleanupPendingPlatformResources(cmd) + if cmd == nil || cmd.Process == nil { + return + } + _ = cmd.Process.Kill() + _ = cmd.Wait() +} + +// PrepareCommand mutates the command in-place so it inherits the configured +// isolated environment before being started by the caller. +func PrepareCommand(cmd *exec.Cmd) error { + isolation := CurrentConfig() + if err := Preflight(); err != nil { + return err + } + if isolation.Enabled { + root, err := ResolveInstanceRoot() + if err != nil { + return err + } + ApplyUserEnv(cmd, root) + if err := applyPlatformIsolation(cmd, isolation, root); err != nil { + return err + } + } + return nil +} diff --git a/pkg/isolation/runtime_test.go b/pkg/isolation/runtime_test.go new file mode 100644 index 000000000..aca484bba --- /dev/null +++ b/pkg/isolation/runtime_test.go @@ -0,0 +1,248 @@ +package isolation + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/sipeed/picoclaw/pkg" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestResolveInstanceRoot_UsesPicoclawHome(t *testing.T) { + t.Setenv(config.EnvHome, "/custom/picoclaw/home") + root, err := ResolveInstanceRoot() + if err != nil { + t.Fatalf("ResolveInstanceRoot() error = %v", err) + } + if root != "/custom/picoclaw/home" { + t.Fatalf("ResolveInstanceRoot() = %q, want %q", root, "/custom/picoclaw/home") + } +} + +func TestPrepareInstanceRoot_CreatesDirectories(t *testing.T) { + root := filepath.Join(t.TempDir(), "instance") + if err := PrepareInstanceRoot(root); err != nil { + t.Fatalf("PrepareInstanceRoot() error = %v", err) + } + for _, dir := range InstanceDirs(root) { + if info, err := os.Stat(dir); err != nil { + t.Fatalf("os.Stat(%q): %v", dir, err) + } else if !info.IsDir() { + t.Fatalf("%q is not a directory", dir) + } + } +} + +func TestInstanceDirs_UsesInstanceWorkspaceNotGlobalState(t *testing.T) { + root := filepath.Join(t.TempDir(), "instance") + cfg := config.DefaultConfig() + cfg.Isolation.Enabled = true + cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "external-workspace") + Configure(cfg) + t.Cleanup(func() { Configure(config.DefaultConfig()) }) + + dirs := InstanceDirs(root) + wantWorkspace := filepath.Join(root, pkg.WorkspaceName) + found := false + for _, dir := range dirs { + if dir == wantWorkspace { + found = true + } + if dir == cfg.WorkspacePath() { + t.Fatalf("InstanceDirs() should not depend on process-wide workspace state: %q", dir) + } + } + if !found { + t.Fatalf("InstanceDirs() missing instance workspace dir %q", wantWorkspace) + } +} + +func TestIsSupportedOn(t *testing.T) { + tests := []struct { + goos string + want bool + }{ + {goos: "linux", want: true}, + {goos: "windows", want: true}, + {goos: "darwin", want: false}, + {goos: "freebsd", want: false}, + } + for _, tt := range tests { + if got := isSupportedOn(tt.goos); got != tt.want { + t.Fatalf("isSupportedOn(%q) = %v, want %v", tt.goos, got, tt.want) + } + } +} + +func TestValidateExposePaths(t *testing.T) { + err := ValidateExposePaths([]config.ExposePath{{Source: "/src", Target: "/dst", Mode: "ro"}}) + if err != nil { + t.Fatalf("ValidateExposePaths() error = %v", err) + } + + err = ValidateExposePaths([]config.ExposePath{{Source: "/src", Target: "/dst", Mode: "bad"}}) + if err == nil { + t.Fatal("ValidateExposePaths() expected invalid mode error") + } + + err = ValidateExposePaths( + []config.ExposePath{ + {Source: "/src", Target: "/dst", Mode: "ro"}, + {Source: "/other", Target: "/dst", Mode: "rw"}, + }, + ) + if err == nil { + t.Fatal("ValidateExposePaths() expected duplicate target error") + } +} + +func TestMergeExposePaths_OverrideByTarget(t *testing.T) { + merged := MergeExposePaths( + []config.ExposePath{{Source: "/src-a", Target: "/dst", Mode: "ro"}}, + []config.ExposePath{{Source: "/src-b", Target: "/dst", Mode: "rw"}}, + ) + if len(merged) != 1 { + t.Fatalf("MergeExposePaths len = %d, want 1", len(merged)) + } + if got := merged[0]; got.Source != "/src-b" || got.Target != "/dst" || got.Mode != "rw" { + t.Fatalf("merged[0] = %+v, want source=/src-b target=/dst mode=rw", got) + } +} + +func TestBuildLinuxMountPlan(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("linux-only default mount set") + } + plan := BuildLinuxMountPlan("/rootdir", []config.ExposePath{{Source: "/src", Target: "/dst", Mode: "ro"}}) + if len(plan) == 0 { + t.Fatal("BuildLinuxMountPlan returned empty plan") + } + foundRoot := false + foundOverride := false + for _, rule := range plan { + if rule.Source == "/rootdir" && rule.Target == "/rootdir" && rule.Mode == "rw" { + foundRoot = true + } + if rule.Source == "/src" && rule.Target == "/dst" && rule.Mode == "ro" { + foundOverride = true + } + } + if !foundRoot { + t.Fatal("BuildLinuxMountPlan missing root mapping") + } + if !foundOverride { + t.Fatal("BuildLinuxMountPlan missing override mapping") + } +} + +func TestBuildWindowsAccessRules(t *testing.T) { + rules := BuildWindowsAccessRules( + `C:\picoclaw`, + []config.ExposePath{{Source: `D:\data`, Target: `C:\mapped`, Mode: "ro"}}, + ) + if len(rules) == 0 { + t.Fatal("BuildWindowsAccessRules returned empty rules") + } + foundRoot := false + foundOverride := false + for _, rule := range rules { + if rule.Path == `C:\picoclaw` && rule.Mode == "rw" { + foundRoot = true + } + if rule.Path == `D:\data` && rule.Mode == "ro" { + foundOverride = true + } + } + if !foundRoot { + t.Fatal("BuildWindowsAccessRules missing root rule") + } + if !foundOverride { + t.Fatal("BuildWindowsAccessRules missing override rule") + } +} + +func TestValidateWindowsExposePaths(t *testing.T) { + if err := validateWindowsExposePaths(nil); err != nil { + t.Fatalf("validateWindowsExposePaths(nil) error = %v", err) + } + err := validateWindowsExposePaths([]config.ExposePath{{Source: `D:\data`, Target: `D:\data`, Mode: "ro"}}) + if err == nil { + t.Fatal("validateWindowsExposePaths() expected error for expose_paths") + } +} + +func TestDefaultLinuxSystemExposePaths(t *testing.T) { + paths := defaultLinuxSystemExposePaths() + needed := map[string]bool{} + for _, path := range []string{"/etc/hosts", "/etc/nsswitch.conf", "/etc/ssl", "/usr/share/zoneinfo", "/etc/localtime"} { + if _, err := os.Stat(path); err == nil { + needed[path] = false + } + } + for _, item := range paths { + if _, ok := needed[item.Source]; ok { + needed[item.Source] = true + } + } + for path, found := range needed { + if !found { + t.Fatalf("defaultLinuxSystemExposePaths missing %s", path) + } + } +} + +func TestExistingExposePaths_SkipsMissingPaths(t *testing.T) { + existing := filepath.Join(t.TempDir(), "existing") + if err := os.MkdirAll(existing, 0o755); err != nil { + t.Fatalf("os.MkdirAll() error = %v", err) + } + filtered := existingExposePaths([]config.ExposePath{ + {Source: existing, Target: existing, Mode: "ro"}, + {Source: filepath.Join(t.TempDir(), "missing"), Target: "/missing", Mode: "ro"}, + }) + if len(filtered) != 1 { + t.Fatalf("existingExposePaths() len = %d, want 1", len(filtered)) + } + if got := filtered[0]; got.Source != existing { + t.Fatalf("existingExposePaths()[0] = %+v, want source=%q", got, existing) + } +} + +func TestPrepareCommand_AppliesUserEnv(t *testing.T) { + if !isSupportedOn(runtime.GOOS) { + t.Skipf("isolation not supported on %s", runtime.GOOS) + } + t.Setenv(config.EnvHome, filepath.Join(t.TempDir(), "home")) + if runtime.GOOS == "linux" { + binDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("os.MkdirAll() error = %v", err) + } + fakeBwrap := filepath.Join(binDir, "bwrap") + if err := os.WriteFile(fakeBwrap, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + } + cfg := config.DefaultConfig() + cfg.Isolation.Enabled = true + Configure(cfg) + t.Cleanup(func() { Configure(config.DefaultConfig()) }) + cmd := exec.Command("sh", "-c", "true") + if err := PrepareCommand(cmd); err != nil { + t.Fatalf("PrepareCommand() error = %v", err) + } + hasHome := false + for _, env := range cmd.Env { + if len(env) > 5 && env[:5] == "HOME=" { + hasHome = true + break + } + } + if runtime.GOOS != "windows" && !hasHome { + t.Fatal("PrepareCommand() did not inject HOME") + } +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 56dc87a53..6d2e31791 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -1,24 +1,29 @@ package logger import ( - "encoding/json" "fmt" - "log" + "io" "os" + "path/filepath" "runtime" + "strconv" "strings" "sync" - "time" + + "github.com/rs/zerolog" + "golang.org/x/term" ) -type LogLevel int +type LogLevel = zerolog.Level const ( - DEBUG LogLevel = iota - INFO - WARN - ERROR - FATAL + DEBUG = zerolog.DebugLevel + INFO = zerolog.InfoLevel + WARN = zerolog.WarnLevel + ERROR = zerolog.ErrorLevel + FATAL = zerolog.FatalLevel + + Component = "component" ) var ( @@ -30,35 +35,106 @@ var ( FATAL: "FATAL", } - currentLevel = INFO - logger *Logger - once sync.Once - mu sync.RWMutex + currentLevel = INFO + logger zerolog.Logger + logFile *os.File + once sync.Once + mu sync.RWMutex + writers []io.Writer + consoleWriter zerolog.ConsoleWriter ) -type Logger struct { - file *os.File -} - -type LogEntry struct { - Level string `json:"level"` - Timestamp string `json:"timestamp"` - Component string `json:"component,omitempty"` - Message string `json:"message"` - Fields map[string]any `json:"fields,omitempty"` - Caller string `json:"caller,omitempty"` -} - func init() { once.Do(func() { - logger = &Logger{} + zerolog.SetGlobalLevel(zerolog.InfoLevel) + + isTTY := term.IsTerminal(int(os.Stdout.Fd())) + + consoleWriter = zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: "15:04:05", // TODO: make it configurable??? + + // Custom formatter to handle multiline strings and JSON objects + FormatFieldValue: formatFieldValue, + PartsOrder: []string{ + zerolog.TimestampFieldName, + zerolog.LevelFieldName, + Component, + zerolog.CallerFieldName, + zerolog.MessageFieldName, + }, + FieldsExclude: []string{Component}, + FormatPrepare: func(fields map[string]any) error { + if isTTY { + fields[Component] = fmt.Sprintf("\x1b[33m%v\x1b[0m", fields[Component]) + } + return nil + }, + NoColor: !isTTY, + } + + writers = append(writers, consoleWriter) + + logger = zerolog.New(io.MultiWriter(writers...)).With().Timestamp().Caller().Logger() }) } +func formatFieldValue(i any) string { + var s string + + switch val := i.(type) { + case string: + s = val + case []byte: + s = string(val) + default: + return fmt.Sprintf("%v", i) + } + + if unquoted, err := strconv.Unquote(s); err == nil { + s = unquoted + } + + if strings.Contains(s, "\n") { + return fmt.Sprintf("\n%s", s) + } + + if strings.Contains(s, " ") { + if (strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}")) || + (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) { + return s + } + return fmt.Sprintf("%q", s) + } + + return s +} + func SetLevel(level LogLevel) { mu.Lock() defer mu.Unlock() currentLevel = level + zerolog.SetGlobalLevel(level) +} + +func SetConsoleLevel(level LogLevel) { + mu.Lock() + defer mu.Unlock() + logger = logger.Level(level) +} + +func DisableConsole() { + mu.Lock() + defer mu.Unlock() + writers[0] = io.Discard + logger = logger.Output(io.MultiWriter(writers...)) +} + +func EnableConsole() { + mu.Lock() + defer mu.Unlock() + writers[0] = consoleWriter + logger = logger.Output(io.MultiWriter(writers...)) } func GetLevel() LogLevel { @@ -67,21 +143,63 @@ func GetLevel() LogLevel { return currentLevel } +// ParseLevel converts a case-insensitive level name to a LogLevel. +// Returns the level and true if valid, or (INFO, false) if unrecognized. +func ParseLevel(s string) (LogLevel, bool) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return DEBUG, true + case "info": + return INFO, true + case "warn", "warning": + return WARN, true + case "error": + return ERROR, true + case "fatal": + return FATAL, true + default: + return INFO, false + } +} + +// SetLevelFromString sets the log level from a string value. +// If the string is empty or not a recognized level name, the current level is kept. +func SetLevelFromString(s string) { + if s == "" { + return + } + if level, ok := ParseLevel(s); ok { + SetLevel(level) + } +} + func EnableFileLogging(filePath string) error { mu.Lock() defer mu.Unlock() - file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + return fmt.Errorf("failed to create log directory: %w", err) + } + + newFile, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return fmt.Errorf("failed to open log file: %w", err) } - if logger.file != nil { - logger.file.Close() + // Close old file if exists + if logFile != nil { + logFile.Close() } - logger.file = file - log.Println("File logging enabled:", filePath) + logFile = newFile + + if len(writers) != 1 { + return fmt.Errorf("failed to configure file logging: %w", err) + } + + writers = append(writers, logFile) + logger = logger.Output(io.MultiWriter(writers...)) + return nil } @@ -89,10 +207,98 @@ func DisableFileLogging() { mu.Lock() defer mu.Unlock() - if logger.file != nil { - logger.file.Close() - logger.file = nil - log.Println("File logging disabled") + if logFile != nil { + logFile.Close() + logFile = nil + } + if len(writers) > 1 { + writers = writers[:1] + logger = logger.Output(io.MultiWriter(writers...)) + } +} + +func ConfigureFromEnv() { + if logFile := os.Getenv("PICOCLAW_LOG_FILE"); logFile != "" { + if strings.HasPrefix(logFile, "~/") { + if home := os.Getenv("HOME"); home != "" { + logFile = filepath.Join(home, logFile[2:]) + } + } + + if err := EnableFileLogging(logFile); err != nil { + fmt.Fprintf(os.Stderr, "failed to enable file logging: %v\n", err) + } else { + DisableConsole() + } + } +} + +const ( + locUnknown = "" +) + +func getPackageNameFromFile(filePath string) string { + dir := filepath.Dir(filePath) + importPath := filepath.ToSlash(dir) + + parts := strings.Split(importPath, "/") + if len(parts) == 0 { + return locUnknown + } + + pkg := parts[len(parts)-1] + if pkg == "." { + return "
" + } + + return pkg +} + +func getCallerSkip() (int, string) { + for i := 2; i < 15; i++ { + pc, file, _, ok := runtime.Caller(i) + if !ok { + continue + } + + fn := runtime.FuncForPC(pc) + if fn == nil { + continue + } + + // bypass common loggers + if strings.HasSuffix(file, "/logger.go") || + strings.HasSuffix(file, "/logger_3rd_party.go") || + strings.HasSuffix(file, "/log.go") { + continue + } + + funcName := fn.Name() + if strings.HasPrefix(funcName, "runtime.") { + continue + } + + return i - 1, getPackageNameFromFile(file) + } + + return 3, locUnknown +} + +//nolint:zerologlint +func getEvent(logger zerolog.Logger, level LogLevel) *zerolog.Event { + switch level { + case zerolog.DebugLevel: + return logger.Debug() + case zerolog.InfoLevel: + return logger.Info() + case zerolog.WarnLevel: + return logger.Warn() + case zerolog.ErrorLevel: + return logger.Error() + case zerolog.FatalLevel: + return logger.Fatal() + default: + return logger.Info() } } @@ -101,63 +307,41 @@ func logMessage(level LogLevel, component string, message string, fields map[str return } - entry := LogEntry{ - Level: logLevelNames[level], - Timestamp: time.Now().UTC().Format(time.RFC3339), - Component: component, - Message: message, - Fields: fields, - } + skip, pkg := getCallerSkip() - if pc, file, line, ok := runtime.Caller(2); ok { - fn := runtime.FuncForPC(pc) - if fn != nil { - entry.Caller = fmt.Sprintf("%s:%d (%s)", file, line, fn.Name()) - } - } + event := getEvent(logger, level) - if logger.file != nil { - jsonData, err := json.Marshal(entry) - if err == nil { - logger.file.Write(append(jsonData, '\n')) - } - } - - var fieldStr string - if len(fields) > 0 { - fieldStr = " " + formatFields(fields) - } else { - fieldStr = "" - } - - logLine := fmt.Sprintf("[%s] [%s]%s %s%s", - entry.Timestamp, - logLevelNames[level], - formatComponent(component), - message, - fieldStr, - ) - - log.Println(logLine) - - if level == FATAL { - os.Exit(1) - } -} - -func formatComponent(component string) string { if component == "" { - return "" + component = pkg } - return fmt.Sprintf(" %s:", component) + + event.Str(Component, component) + + appendFields(event, fields) + + event.CallerSkipFrame(skip).Msg(message) } -func formatFields(fields map[string]any) string { - parts := make([]string, 0, len(fields)) +func appendFields(event *zerolog.Event, fields map[string]any) { for k, v := range fields { - parts = append(parts, fmt.Sprintf("%s=%v", k, v)) + // Type switch to avoid double JSON serialization of strings + switch val := v.(type) { + case error: + event.Str(k, val.Error()) + case string: + event.Str(k, val) + case int: + event.Int(k, val) + case int64: + event.Int64(k, val) + case float64: + event.Float64(k, val) + case bool: + event.Bool(k, val) + default: + event.Interface(k, v) // Fallback for struct, slice and maps + } } - return fmt.Sprintf("{%s}", strings.Join(parts, ", ")) } func Debug(message string) { @@ -168,6 +352,10 @@ func DebugC(component string, message string) { logMessage(DEBUG, component, message, nil) } +func Debugf(message string, ss ...any) { + logMessage(DEBUG, "", fmt.Sprintf(message, ss...), nil) +} + func DebugF(message string, fields map[string]any) { logMessage(DEBUG, "", message, fields) } @@ -188,6 +376,10 @@ func InfoF(message string, fields map[string]any) { logMessage(INFO, "", message, fields) } +func Infof(message string, ss ...any) { + logMessage(INFO, "", fmt.Sprintf(message, ss...), nil) +} + func InfoCF(component string, message string, fields map[string]any) { logMessage(INFO, component, message, fields) } @@ -208,6 +400,10 @@ func WarnCF(component string, message string, fields map[string]any) { logMessage(WARN, component, message, fields) } +func Warnf(message string, ss ...any) { + logMessage(WARN, "", fmt.Sprintf(message, ss...), nil) +} + func Error(message string) { logMessage(ERROR, "", message, nil) } @@ -216,6 +412,10 @@ func ErrorC(component string, message string) { logMessage(ERROR, component, message, nil) } +func Errorf(message string, ss ...any) { + logMessage(ERROR, "", fmt.Sprintf(message, ss...), nil) +} + func ErrorF(message string, fields map[string]any) { logMessage(ERROR, "", message, fields) } @@ -232,6 +432,10 @@ func FatalC(component string, message string) { logMessage(FATAL, component, message, nil) } +func Fatalf(message string, ss ...any) { + logMessage(FATAL, "", fmt.Sprintf(message, ss...), nil) +} + func FatalF(message string, fields map[string]any) { logMessage(FATAL, "", message, fields) } diff --git a/pkg/logger/logger_3rd_party.go b/pkg/logger/logger_3rd_party.go new file mode 100644 index 000000000..d0cb178c5 --- /dev/null +++ b/pkg/logger/logger_3rd_party.go @@ -0,0 +1,108 @@ +// this file is for compatible with 3rd party loggers, should not be called in PicoClaw project + +package logger + +import ( + "fmt" + "regexp" +) + +// botTokenRe matches the bot ID prefix and the secret part of a Telegram bot token. +// Groups: 1 = "bot:", 2 = first 4 chars of secret, 3 = middle, 4 = last 4 chars. +var botTokenRe = regexp.MustCompile(`(bot\d+:)([A-Za-z0-9_-]{4})[A-Za-z0-9_-]{12,}([A-Za-z0-9_-]{4})`) + +// maskSecrets replaces any embedded bot tokens in s with a redacted placeholder +// that keeps the first and last 4 characters of the secret for identification. +func maskSecrets(s string) string { + return botTokenRe.ReplaceAllString(s, "${1}${2}****${3}") +} + +// Logger implements common Logger interface +type Logger struct { + component string + levels map[int]LogLevel +} + +// Debug logs debug messages +func (b *Logger) Debug(v ...any) { + logMessage(DEBUG, b.component, maskSecrets(fmt.Sprint(v...)), nil) +} + +// Info logs info messages +func (b *Logger) Info(v ...any) { + logMessage(INFO, b.component, maskSecrets(fmt.Sprint(v...)), nil) +} + +// Warn logs warning messages +func (b *Logger) Warn(v ...any) { + logMessage(WARN, b.component, maskSecrets(fmt.Sprint(v...)), nil) +} + +// Error logs error messages +func (b *Logger) Error(v ...any) { + logMessage(ERROR, b.component, maskSecrets(fmt.Sprint(v...)), nil) +} + +// Debugf logs formatted debug messages +func (b *Logger) Debugf(format string, v ...any) { + logMessage(DEBUG, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) +} + +// Infof logs formatted info messages +func (b *Logger) Infof(format string, v ...any) { + logMessage(INFO, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) +} + +// Warnf logs formatted warning messages +func (b *Logger) Warnf(format string, v ...any) { + logMessage(WARN, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) +} + +// Warningf logs formatted warning messages +func (b *Logger) Warningf(format string, v ...any) { + logMessage(WARN, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) +} + +// Errorf logs formatted error messages +func (b *Logger) Errorf(format string, v ...any) { + logMessage(ERROR, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) +} + +// Fatalf logs formatted fatal messages and exits +func (b *Logger) Fatalf(format string, v ...any) { + logMessage(FATAL, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil) +} + +// Log logs a message at a given level with caller information +// the func name must be this because 3rd party loggers expect this +// msgL: message level (DEBUG, INFO, WARN, ERROR, FATAL) +// caller: unused parameter reserved for compatibility +// format: format string +// a: format arguments +// +//nolint:goprintffuncname +func (b *Logger) Log(msgL, caller int, format string, a ...any) { + level := LogLevel(msgL) + if b.levels != nil { + if lvl, ok := b.levels[msgL]; ok { + level = lvl + } + } + logMessage(level, b.component, maskSecrets(fmt.Sprintf(format, a...)), nil) +} + +// Sync flushes log buffer (no-op for this implementation) +func (b *Logger) Sync() error { + return nil +} + +// WithLevels sets log levels mapping for this logger +func (b *Logger) WithLevels(levels map[int]LogLevel) *Logger { + b.levels = levels + return b +} + +// NewLogger creates a new logger instance with optional component name +func NewLogger(component string) *Logger { + return &Logger{component: component} +} diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index 6e6f8dfa8..7a7712de0 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -1,7 +1,16 @@ package logger import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" "testing" + "time" + + "github.com/rs/zerolog" ) func TestLogLevelFiltering(t *testing.T) { @@ -123,17 +132,302 @@ func TestLoggerHelperFunctions(t *testing.T) { SetLevel(INFO) Debug("This should not log") + Debugf("this should not log") Info("This should log") Warn("This should log") Error("This should log") InfoC("test", "Component message") InfoF("Fields message", map[string]any{"key": "value"}) + Infof("test from %v", "Infof") WarnC("test", "Warning with component") ErrorF("Error with fields", map[string]any{"error": "test"}) + Errorf("test from %v", "Errorf") SetLevel(DEBUG) DebugC("test", "Debug with component") + Debugf("test from %v", "Debugf") WarnF("Warning with fields", map[string]any{"key": "value"}) } + +func TestFormatFieldValue(t *testing.T) { + tests := []struct { + name string + input any + expected string + }{ + // Basic types test (default case of the switch) + { + name: "Integer Type", + input: 42, + expected: "42", + }, + { + name: "Boolean Type", + input: true, + expected: "true", + }, + { + name: "Unsupported Struct Type", + input: struct{ A int }{A: 1}, + expected: "{1}", + }, + + // Simple strings and byte slices test + { + name: "Simple string without spaces", + input: "simple_value", + expected: "simple_value", + }, + { + name: "Simple byte slice", + input: []byte("byte_value"), + expected: "byte_value", + }, + + // Unquoting test (strconv.Unquote) + { + name: "Quoted string", + input: `"quoted_value"`, + expected: "quoted_value", + }, + + // Strings with newline (\n) test + { + name: "String with newline", + input: "line1\nline2", + expected: "\nline1\nline2", + }, + { + name: "Quoted string with newline (Unquote -> newline)", + input: `"line1\nline2"`, // Escaped \n that Unquote will resolve + expected: "\nline1\nline2", + }, + + // Strings with spaces test (which should be quoted) + { + name: "String with spaces", + input: "hello world", + expected: `"hello world"`, + }, + { + name: "Quoted string with spaces (Unquote -> has spaces -> Re-quote)", + input: `"hello world"`, + expected: `"hello world"`, + }, + + // JSON formats test (strings with spaces that start/end with brackets) + { + name: "Valid JSON object", + input: `{"key": "value"}`, + expected: `{"key": "value"}`, + }, + { + name: "Valid JSON array", + input: `[1, 2, "three"]`, + expected: `[1, 2, "three"]`, + }, + { + name: "Fake JSON (starts with { but doesn't end with })", + input: `{"key": "value"`, // Missing closing bracket, has spaces + expected: `"{\"key\": \"value\""`, + }, + { + name: "Empty JSON (object)", + input: `{ }`, + expected: `{ }`, + }, + + // 7. Edge Cases + { + name: "Empty string", + input: "", + expected: "", + }, + { + name: "Whitespace only string", + input: " ", + expected: `" "`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := formatFieldValue(tt.input) + if actual != tt.expected { + t.Errorf("formatFieldValue() = %q, expected %q", actual, tt.expected) + } + }) + } +} + +func TestDefaultLevelIsInfo(t *testing.T) { + // The package-level default (before any SetLevel call) should be INFO. + // Because earlier tests may have changed it, we just verify the constant is wired correctly. + if logLevelNames[INFO] != "INFO" { + t.Errorf("INFO constant mapped to %q, want \"INFO\"", logLevelNames[INFO]) + } +} + +func TestParseLevelValid(t *testing.T) { + tests := []struct { + input string + want LogLevel + }{ + {"debug", DEBUG}, + {"DEBUG", DEBUG}, + {"Debug", DEBUG}, + {"info", INFO}, + {"INFO", INFO}, + {"warn", WARN}, + {"WARN", WARN}, + {"warning", WARN}, + {"WARNING", WARN}, + {"error", ERROR}, + {"ERROR", ERROR}, + {"fatal", FATAL}, + {"FATAL", FATAL}, + {" info ", INFO}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, ok := ParseLevel(tt.input) + if !ok { + t.Fatalf("ParseLevel(%q) returned ok=false, want true", tt.input) + } + if got != tt.want { + t.Errorf("ParseLevel(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseLevelInvalid(t *testing.T) { + tests := []string{"", "garbage", "verbose", "trace", "critical"} + + for _, input := range tests { + t.Run(input, func(t *testing.T) { + _, ok := ParseLevel(input) + if ok { + t.Errorf("ParseLevel(%q) returned ok=true, want false", input) + } + }) + } +} + +func TestSetLevelFromString(t *testing.T) { + initialLevel := GetLevel() + defer SetLevel(initialLevel) + + // Valid string changes the level + SetLevel(INFO) + SetLevelFromString("error") + if got := GetLevel(); got != ERROR { + t.Errorf("after SetLevelFromString(\"error\"): GetLevel() = %v, want ERROR", got) + } + + // Empty string is a no-op + SetLevelFromString("") + if got := GetLevel(); got != ERROR { + t.Errorf("after SetLevelFromString(\"\"): GetLevel() = %v, want ERROR (unchanged)", got) + } + + // Invalid string is a no-op + SetLevelFromString("garbage") + if got := GetLevel(); got != ERROR { + t.Errorf("after SetLevelFromString(\"garbage\"): GetLevel() = %v, want ERROR (unchanged)", got) + } + + // Case-insensitive + SetLevelFromString("FATAL") + if got := GetLevel(); got != FATAL { + t.Errorf("after SetLevelFromString(\"FATAL\"): GetLevel() = %v, want FATAL", got) + } +} + +func TestAppendFields_ErrorUsesErrorString(t *testing.T) { + var buf bytes.Buffer + l := zerolog.New(&buf) + + event := l.Info() + appendFields(event, map[string]any{"error": errors.New("transcription request failed")}) + event.Msg("test") + + lines := bytes.Split(bytes.TrimSpace(buf.Bytes()), []byte("\n")) + if len(lines) == 0 { + t.Fatal("expected log output, got none") + } + + var got map[string]any + if err := json.Unmarshal(lines[0], &got); err != nil { + t.Fatalf("unmarshal log line: %v", err) + } + + if got["error"] != "transcription request failed" { + t.Fatalf("error field = %#v, want %q", got["error"], "transcription request failed") + } +} + +func TestDisableConsole(t *testing.T) { + DisableConsole() + Info("this should go to nowhere") +} + +func TestConfigureFromEnv(t *testing.T) { + home := os.Getenv("HOME") + if home == "" { + t.Skip("HOME not set") + } + + tmpFile := "/tmp/picoclaw_test_log_" + fmt.Sprintf("%d", time.Now().UnixNano()) + defer os.Remove(tmpFile) + + os.Setenv("PICOCLAW_LOG_FILE", tmpFile) + defer os.Unsetenv("PICOCLAW_LOG_FILE") + + ConfigureFromEnv() + + if logFile == nil { + t.Error("expected log file to be set") + } + + Info("test message") + + os.Setenv("PICOCLAW_LOG_FILE", "~/test_log") + ConfigureFromEnv() + + expanded := filepath.Join(home, "test_log") + defer os.Remove(expanded) +} + +func TestConfigureFromEnvNoEnv(t *testing.T) { + os.Unsetenv("PICOCLAW_LOG_FILE") + ConfigureFromEnv() +} + +func TestGetPackageNameFromFile(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + {"normal package path", "/home/user/project/pkg/logger/logger.go", "logger"}, + {"nested package", "/home/user/project/internal/service/auth/handler.go", "auth"}, + {"cmd package", "/home/user/project/cmd/server/main.go", "server"}, + {"project root returns main", "./main.go", "
"}, + {"single dot returns main", ".", "
"}, + {"single directory", "mypkg/file.go", "mypkg"}, + {"deep nesting", "/a/b/c/d/e/f.go", "e"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getPackageNameFromFile(tt.path) + if got != tt.want { + t.Errorf("getPackageNameFromFile(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} diff --git a/pkg/logger/panic.go b/pkg/logger/panic.go new file mode 100644 index 000000000..0a9125dda --- /dev/null +++ b/pkg/logger/panic.go @@ -0,0 +1,54 @@ +package logger + +import ( + "fmt" + "io" + "os" + "path/filepath" + "runtime/debug" + "time" +) + +var panicWriter io.WriteCloser + +func InitPanic(filePath string) (func(), error) { + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + return nil, fmt.Errorf("failed to create log directory: %w", err) + } + writer := initPanicFile(filePath) + if writer == nil { + return nil, fmt.Errorf("failed to create log file: %s", filePath) + } + if panicWriter != nil { + _ = panicWriter.Close() + } + panicWriter = writer + return func() { + defer func() { + writer.Close() + panicWriter = nil + }() + if err := recover(); err != nil { + RecoverPanicNoExit(err) + + os.Exit(1) + } + }, nil +} + +func RecoverPanicNoExit(err any) { + if panicWriter == nil { + Errorf("panicWriter is nil, should not happen") + return + } + now := time.Now().Format("2006-01-02 15:04:05") + stack := debug.Stack() + logMsg := "\n\n====================\n[" + now + "] PANIC OCCURRED: " + fmt.Sprintf( + "%v", + err, + ) + "\n" + string( + stack, + ) + + panicWriter.Write([]byte(logMsg)) +} diff --git a/pkg/logger/panic_unix.go b/pkg/logger/panic_unix.go new file mode 100644 index 000000000..48f393b45 --- /dev/null +++ b/pkg/logger/panic_unix.go @@ -0,0 +1,22 @@ +//go:build !windows + +package logger + +import ( + "fmt" + "io" + "os" + + "golang.org/x/sys/unix" +) + +func initPanicFile(panicFile string) io.WriteCloser { + file, err := os.OpenFile(panicFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND|os.O_SYNC, 0o600) + if err != nil { + panic(fmt.Sprintf("error in open panic: %v", err)) + } + if err = unix.Dup2(int(file.Fd()), int(os.Stderr.Fd())); err != nil { + panic(fmt.Sprintf("error in syscall.Dup2: %v", err)) + } + return file +} diff --git a/pkg/logger/panic_win.go b/pkg/logger/panic_win.go new file mode 100644 index 000000000..1e6eead02 --- /dev/null +++ b/pkg/logger/panic_win.go @@ -0,0 +1,25 @@ +//go:build windows +// +build windows + +package logger + +import ( + "fmt" + "io" + "os" + + "golang.org/x/sys/windows" +) + +func initPanicFile(panicFile string) io.WriteCloser { + file, err := os.OpenFile(panicFile, os.O_WRONLY|os.O_CREATE|os.O_SYNC|os.O_APPEND, 0o600) + if err != nil { + panic(fmt.Sprintf("error in open panic: %v", err)) + } + err = windows.SetStdHandle(windows.STD_ERROR_HANDLE, windows.Handle(file.Fd())) + if err != nil { + panic(fmt.Sprintf("Failed to redirect stderr to file: %v", err)) + } + os.Stderr = file + return file +} diff --git a/pkg/mcp/events.go b/pkg/mcp/events.go new file mode 100644 index 000000000..3b7f53f96 --- /dev/null +++ b/pkg/mcp/events.go @@ -0,0 +1,92 @@ +package mcp + +import ( + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +func (m *Manager) publishServerEvent( + kind runtimeevents.Kind, + serverName string, + cfg config.MCPServerConfig, + toolCount int, + err error, +) { + if m == nil || m.runtimeEvents == nil { + return + } + + severity := runtimeevents.SeverityInfo + if err != nil { + severity = runtimeevents.SeverityError + } + payload := ServerEventPayload{ + Server: serverName, + Type: mcpTransportType(cfg), + URL: cfg.URL, + Command: cfg.Command, + ToolCount: toolCount, + } + if err != nil { + payload.Error = err.Error() + } + + m.runtimeEvents.PublishNonBlocking(runtimeevents.Event{ + Kind: kind, + Source: runtimeevents.Source{Component: "mcp", Name: serverName}, + Severity: severity, + Payload: payload, + Attrs: mcpServerEventAttrs(payload), + }) +} + +func (m *Manager) publishToolDiscovered(serverName string, cfg config.MCPServerConfig, toolName string) { + if m == nil || m.runtimeEvents == nil { + return + } + payload := ServerEventPayload{ + Server: serverName, + Type: mcpTransportType(cfg), + URL: cfg.URL, + Command: cfg.Command, + Tool: toolName, + } + m.runtimeEvents.PublishNonBlocking(runtimeevents.Event{ + Kind: runtimeevents.KindMCPToolDiscovered, + Source: runtimeevents.Source{Component: "mcp", Name: serverName}, + Severity: runtimeevents.SeverityInfo, + Payload: payload, + Attrs: mcpServerEventAttrs(payload), + }) +} + +func mcpServerEventAttrs(payload ServerEventPayload) map[string]any { + attrs := map[string]any{} + setMCPAttrString(attrs, "server", payload.Server) + setMCPAttrString(attrs, "type", payload.Type) + setMCPAttrString(attrs, "tool", payload.Tool) + if payload.ToolCount > 0 { + attrs["tool_count"] = payload.ToolCount + } + setMCPAttrString(attrs, "error", payload.Error) + return attrs +} + +func setMCPAttrString(attrs map[string]any, key, value string) { + if value != "" { + attrs[key] = value + } +} + +func mcpTransportType(cfg config.MCPServerConfig) string { + if cfg.Type != "" { + return cfg.Type + } + if cfg.URL != "" { + return "sse" + } + if cfg.Command != "" { + return "stdio" + } + return "" +} diff --git a/pkg/mcp/isolated_command_transport.go b/pkg/mcp/isolated_command_transport.go new file mode 100644 index 000000000..f54b4af8b --- /dev/null +++ b/pkg/mcp/isolated_command_transport.go @@ -0,0 +1,226 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os/exec" + "sync" + "syscall" + "time" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/sipeed/picoclaw/pkg/isolation" +) + +var isolatedCommandTerminateDuration = 5 * time.Second + +// isolatedCommandTransport mirrors the SDK command transport but routes +// process startup through pkg/isolation so Windows post-start hooks run too. +type isolatedCommandTransport struct { + Command *exec.Cmd + TerminateDuration time.Duration +} + +func (t *isolatedCommandTransport) Connect(ctx context.Context) (sdkmcp.Connection, error) { + stdout, err := t.Command.StdoutPipe() + if err != nil { + return nil, err + } + stdout = io.NopCloser(stdout) + stdin, err := t.Command.StdinPipe() + if err != nil { + return nil, err + } + if err := isolation.Start(t.Command); err != nil { + return nil, err + } + td := t.TerminateDuration + if td <= 0 { + td = isolatedCommandTerminateDuration + } + return newIsolatedIOConn(&isolatedPipeRWC{cmd: t.Command, stdout: stdout, stdin: stdin, terminateDuration: td}), nil +} + +type isolatedPipeRWC struct { + cmd *exec.Cmd + stdout io.ReadCloser + stdin io.WriteCloser + terminateDuration time.Duration +} + +func (s *isolatedPipeRWC) Read(p []byte) (n int, err error) { + return s.stdout.Read(p) +} + +func (s *isolatedPipeRWC) Write(p []byte) (n int, err error) { + return s.stdin.Write(p) +} + +func (s *isolatedPipeRWC) Close() error { + if err := s.stdin.Close(); err != nil { + return fmt.Errorf("closing stdin: %v", err) + } + resChan := make(chan error, 1) + go func() { + resChan <- s.cmd.Wait() + }() + wait := func() (error, bool) { + select { + case err := <-resChan: + return err, true + case <-time.After(s.terminateDuration): + } + return nil, false + } + if err, ok := wait(); ok { + return err + } + if err := s.cmd.Process.Signal(syscall.SIGTERM); err == nil { + if err, ok := wait(); ok { + return err + } + } + if err := s.cmd.Process.Kill(); err != nil { + return err + } + if err, ok := wait(); ok { + return err + } + return fmt.Errorf("unresponsive subprocess") +} + +type isolatedIOConn struct { + writeMu sync.Mutex + rwc io.ReadWriteCloser + incoming <-chan isolatedMsgOrErr + queue []jsonrpc.Message + closeOnce sync.Once + closed chan struct{} + closeErr error +} + +type isolatedMsgOrErr struct { + msg json.RawMessage + err error +} + +func newIsolatedIOConn(rwc io.ReadWriteCloser) *isolatedIOConn { + incoming := make(chan isolatedMsgOrErr) + closed := make(chan struct{}) + go func() { + dec := json.NewDecoder(rwc) + for { + var raw json.RawMessage + err := dec.Decode(&raw) + if err == nil { + var tr [1]byte + if n, readErr := dec.Buffered().Read(tr[:]); n > 0 { + if tr[0] != '\n' && tr[0] != '\r' { + err = fmt.Errorf("invalid trailing data at the end of stream") + } + } else if readErr != nil && readErr != io.EOF { + err = readErr + } + } + select { + case incoming <- isolatedMsgOrErr{msg: raw, err: err}: + case <-closed: + return + } + if err != nil { + return + } + } + }() + return &isolatedIOConn{rwc: rwc, incoming: incoming, closed: closed} +} + +func (c *isolatedIOConn) SessionID() string { return "" } + +func (c *isolatedIOConn) Read(ctx context.Context) (jsonrpc.Message, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + if len(c.queue) > 0 { + next := c.queue[0] + c.queue = c.queue[1:] + return next, nil + } + var raw json.RawMessage + select { + case <-ctx.Done(): + return nil, ctx.Err() + case v := <-c.incoming: + if v.err != nil { + return nil, v.err + } + raw = v.msg + case <-c.closed: + return nil, io.EOF + } + msgs, err := readIsolatedBatch(raw) + if err != nil { + return nil, err + } + c.queue = msgs[1:] + return msgs[0], nil +} + +func readIsolatedBatch(data []byte) ([]jsonrpc.Message, error) { + var rawBatch []json.RawMessage + if err := json.Unmarshal(data, &rawBatch); err == nil { + if len(rawBatch) == 0 { + return nil, fmt.Errorf("empty batch") + } + msgs := make([]jsonrpc.Message, 0, len(rawBatch)) + for _, raw := range rawBatch { + msg, err := jsonrpc.DecodeMessage(raw) + if err != nil { + return nil, err + } + msgs = append(msgs, msg) + } + return msgs, nil + } + msg, err := jsonrpc.DecodeMessage(data) + if err != nil { + return nil, err + } + return []jsonrpc.Message{msg}, nil +} + +func (c *isolatedIOConn) Write(ctx context.Context, msg jsonrpc.Message) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + c.writeMu.Lock() + defer c.writeMu.Unlock() + data, err := jsonrpc.EncodeMessage(msg) + if err != nil { + return fmt.Errorf("marshaling message: %v", err) + } + data = append(data, '\n') + _, err = c.rwc.Write(data) + return err +} + +func (c *isolatedIOConn) Close() error { + c.closeOnce.Do(func() { + c.closeErr = c.rwc.Close() + close(c.closed) + }) + return c.closeErr +} + +var ( + _ sdkmcp.Transport = (*isolatedCommandTransport)(nil) + _ sdkmcp.Connection = (*isolatedIOConn)(nil) +) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index 7b63cc979..958927767 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -16,6 +16,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -25,6 +26,24 @@ type headerTransport struct { headers map[string]string } +func expandHomeCommandPath(command string) string { + if command == "" || command[0] != '~' { + return command + } + + home, err := os.UserHomeDir() + if err != nil { + return command + } + if command == "~" { + return home + } + if strings.HasPrefix(command, "~/") || strings.HasPrefix(command, "~\\") { + return filepath.Join(home, command[2:]) + } + return command +} + func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Clone the request to avoid modifying the original req = req.Clone(req.Context()) @@ -99,25 +118,57 @@ func loadEnvFile(path string) (map[string]string, error) { // ServerConnection represents a connection to an MCP server type ServerConnection struct { - Name string - Client *mcp.Client - Session *mcp.ClientSession - Tools []*mcp.Tool + Name string + Config config.MCPServerConfig + Client *mcp.Client + Session *mcp.ClientSession + Tools []*mcp.Tool + reconnectMu sync.Mutex } // Manager manages multiple MCP server connections type Manager struct { - servers map[string]*ServerConnection - mu sync.RWMutex - closed atomic.Bool // changed from bool to atomic.Bool to avoid TOCTOU race - wg sync.WaitGroup // tracks in-flight CallTool calls + servers map[string]*ServerConnection + runtimeEvents runtimeevents.Bus + mu sync.RWMutex + closed atomic.Bool // changed from bool to atomic.Bool to avoid TOCTOU race + wg sync.WaitGroup // tracks in-flight CallTool calls +} + +var connectServerFunc = connectServer + +// ManagerOption configures an MCP manager. +type ManagerOption func(*Manager) + +// WithRuntimeEvents injects the runtime event bus used for MCP observations. +func WithRuntimeEvents(eventBus runtimeevents.Bus) ManagerOption { + return func(m *Manager) { + m.runtimeEvents = eventBus + } +} + +// ServerEventPayload describes MCP server connection events. +type ServerEventPayload struct { + Server string `json:"server"` + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` + Command string `json:"command,omitempty"` + Tool string `json:"tool,omitempty"` + ToolCount int `json:"tool_count,omitempty"` + Error string `json:"error,omitempty"` } // NewManager creates a new MCP manager -func NewManager() *Manager { - return &Manager{ +func NewManager(opts ...ManagerOption) *Manager { + m := &Manager{ servers: make(map[string]*ServerConnection), } + for _, opt := range opts { + if opt != nil { + opt(m) + } + } + return m } // LoadFromConfig loads MCP servers from configuration @@ -242,6 +293,39 @@ func (m *Manager) ConnectServer( name string, cfg config.MCPServerConfig, ) error { + m.publishServerEvent(runtimeevents.KindMCPServerConnecting, name, cfg, 0, nil) + conn, err := connectServerFunc(ctx, name, cfg) + if err != nil { + m.publishServerEvent(runtimeevents.KindMCPServerFailed, name, cfg, 0, err) + return err + } + + m.mu.Lock() + defer m.mu.Unlock() + + if m.closed.Load() { + _ = conn.Session.Close() + m.publishServerEvent(runtimeevents.KindMCPServerFailed, name, cfg, 0, fmt.Errorf("manager is closed")) + return fmt.Errorf("manager is closed") + } + + m.servers[name] = conn + for _, tool := range conn.Tools { + toolName := "" + if tool != nil { + toolName = tool.Name + } + m.publishToolDiscovered(name, cfg, toolName) + } + m.publishServerEvent(runtimeevents.KindMCPServerConnected, name, cfg, len(conn.Tools), nil) + return nil +} + +func connectServer( + ctx context.Context, + name string, + cfg config.MCPServerConfig, +) (*ServerConnection, error) { logger.InfoCF("mcp", "Connecting to MCP server", map[string]any{ "server": name, @@ -267,23 +351,34 @@ func (m *Manager) ConnectServer( } else if cfg.Command != "" { transportType = "stdio" } else { - return fmt.Errorf("either URL or command must be provided") + return nil, fmt.Errorf("either URL or command must be provided") } } switch transportType { case "sse", "http": if cfg.URL == "" { - return fmt.Errorf("URL is required for SSE/HTTP transport") + return nil, fmt.Errorf("URL is required for SSE/HTTP transport") } + + // Configure DisableStandaloneSSE based on transport type. + // - "http": Request-response only mode. Disable the standalone SSE stream + // to avoid compatibility issues with servers that don't support GET /mcp. + // - "sse": Bidirectional mode. Enable the standalone SSE stream to receive + // server-initiated notifications (e.g., ToolListChangedNotification). + // - Empty or auto-detected: Defaults to "sse" behavior (standalone SSE enabled). + disableStandaloneSSE := (cfg.Type == "http") + logger.DebugCF("mcp", "Using SSE/HTTP transport", map[string]any{ - "server": name, - "url": cfg.URL, + "server": name, + "url": cfg.URL, + "disableStandaloneSSE": disableStandaloneSSE, }) sseTransport := &mcp.StreamableClientTransport{ - Endpoint: cfg.URL, + Endpoint: cfg.URL, + DisableStandaloneSSE: disableStandaloneSSE, } // Add custom headers if provided @@ -305,7 +400,7 @@ func (m *Manager) ConnectServer( transport = sseTransport case "stdio": if cfg.Command == "" { - return fmt.Errorf("command is required for stdio transport") + return nil, fmt.Errorf("command is required for stdio transport") } logger.DebugCF("mcp", "Using stdio transport", map[string]any{ @@ -313,7 +408,7 @@ func (m *Manager) ConnectServer( "command": cfg.Command, }) // Create command with context - cmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...) + cmd := exec.CommandContext(ctx, expandHomeCommandPath(cfg.Command), cfg.Args...) // Build environment variables with proper override semantics // Use a map to ensure config variables override file variables @@ -330,7 +425,7 @@ func (m *Manager) ConnectServer( if cfg.EnvFile != "" { envVars, err := loadEnvFile(cfg.EnvFile) if err != nil { - return fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err) + return nil, fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err) } for k, v := range envVars { envMap[k] = v @@ -354,10 +449,9 @@ func (m *Manager) ConnectServer( env = append(env, fmt.Sprintf("%s=%s", k, v)) } cmd.Env = env - - transport = &mcp.CommandTransport{Command: cmd} + transport = &isolatedCommandTransport{Command: cmd} default: - return fmt.Errorf( + return nil, fmt.Errorf( "unsupported transport type: %s (supported: stdio, sse, http)", transportType, ) @@ -366,7 +460,7 @@ func (m *Manager) ConnectServer( // Connect to server session, err := client.Connect(ctx, transport, nil) if err != nil { - return fmt.Errorf("failed to connect: %w", err) + return nil, fmt.Errorf("failed to connect: %w", err) } // Get server info @@ -380,38 +474,19 @@ func (m *Manager) ConnectServer( }) // List available tools if supported - var tools []*mcp.Tool - if initResult.Capabilities.Tools != nil { - for tool, err := range session.Tools(ctx, nil) { - if err != nil { - logger.WarnCF("mcp", "Error listing tool", - map[string]any{ - "server": name, - "error": err.Error(), - }) - continue - } - tools = append(tools, tool) - } - - logger.InfoCF("mcp", "Listed tools from MCP server", - map[string]any{ - "server": name, - "toolCount": len(tools), - }) + tools, err := listServerTools(ctx, name, session, initResult) + if err != nil { + _ = session.Close() + return nil, err } - // Store connection - m.mu.Lock() - m.servers[name] = &ServerConnection{ + return &ServerConnection{ Name: name, + Config: cfg, Client: client, Session: session, Tools: tools, - } - m.mu.Unlock() - - return nil + }, nil } // GetServers returns all connected servers @@ -470,12 +545,131 @@ func (m *Manager) CallTool( result, err := conn.Session.CallTool(ctx, params) if err != nil { + if shouldReconnectCallError(err) { + logger.WarnCF("mcp", "MCP server session was lost during tool call, reconnecting", + map[string]any{ + "server": serverName, + "tool": toolName, + "error": err.Error(), + }) + + reconnectedConn, reconnectErr := m.reconnectServer(ctx, serverName, conn) + if reconnectErr != nil { + return nil, fmt.Errorf("failed to recover lost MCP session: %w", reconnectErr) + } + + result, err = reconnectedConn.Session.CallTool(ctx, params) + if err == nil { + return result, nil + } + } + return nil, fmt.Errorf("failed to call tool: %w", err) } return result, nil } +func listServerTools( + ctx context.Context, + name string, + session *mcp.ClientSession, + initResult *mcp.InitializeResult, +) ([]*mcp.Tool, error) { + var tools []*mcp.Tool + if initResult.Capabilities.Tools == nil { + return tools, nil + } + + for tool, err := range session.Tools(ctx, nil) { + if err != nil { + logger.WarnCF("mcp", "Error listing tool", + map[string]any{ + "server": name, + "error": err.Error(), + }) + continue + } + tools = append(tools, tool) + } + + logger.InfoCF("mcp", "Listed tools from MCP server", + map[string]any{ + "server": name, + "toolCount": len(tools), + }) + + return tools, nil +} + +func shouldReconnectCallError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, mcp.ErrSessionMissing) { + return true + } + return strings.Contains(strings.ToLower(err.Error()), mcp.ErrSessionMissing.Error()) +} + +func (m *Manager) reconnectServer( + ctx context.Context, + serverName string, + staleConn *ServerConnection, +) (*ServerConnection, error) { + if staleConn == nil { + return nil, fmt.Errorf("server %s not found", serverName) + } + + staleConn.reconnectMu.Lock() + defer staleConn.reconnectMu.Unlock() + + if m.closed.Load() { + return nil, fmt.Errorf("manager is closed") + } + + m.mu.RLock() + currentConn, ok := m.servers[serverName] + m.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("server %s not found", serverName) + } + if currentConn != staleConn { + return currentConn, nil + } + + freshConn, err := connectServerFunc(ctx, serverName, staleConn.Config) + if err != nil { + return nil, err + } + + m.mu.Lock() + if m.closed.Load() { + m.mu.Unlock() + _ = freshConn.Session.Close() + return nil, fmt.Errorf("manager is closed") + } + + currentConn, ok = m.servers[serverName] + if !ok { + m.mu.Unlock() + _ = freshConn.Session.Close() + return nil, fmt.Errorf("server %s not found", serverName) + } + + if currentConn == staleConn { + m.servers[serverName] = freshConn + staleToClose := staleConn + m.mu.Unlock() + _ = staleToClose.Session.Close() + return freshConn, nil + } + + m.mu.Unlock() + _ = freshConn.Session.Close() + return currentConn, nil +} + // Close closes all server connections func (m *Manager) Close() error { // Use Swap to atomically set closed=true and get the previous value diff --git a/pkg/mcp/manager_test.go b/pkg/mcp/manager_test.go index f353942ab..5789a37a9 100644 --- a/pkg/mcp/manager_test.go +++ b/pkg/mcp/manager_test.go @@ -2,14 +2,21 @@ package mcp import ( "context" + "encoding/json" + "fmt" + "io" "os" "path/filepath" "strings" + "sync" "testing" + "time" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" ) func TestLoadEnvFile(t *testing.T) { @@ -136,6 +143,22 @@ func TestLoadEnvFileNotFound(t *testing.T) { } } +func TestExpandHomeCommandPath(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + want := filepath.Join(homeDir, "bin", "my-mcp") + got := expandHomeCommandPath("~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp")) + if got != want { + t.Fatalf("expandHomeCommandPath() = %q, want %q", got, want) + } + + if got := expandHomeCommandPath("npx"); got != "npx" { + t.Fatalf("expandHomeCommandPath() should leave bare commands unchanged, got %q", got) + } +} + func TestEnvFilePriority(t *testing.T) { // Create a temporary .env file tmpDir := t.TempDir() @@ -227,6 +250,95 @@ func TestNewManager_InitialState(t *testing.T) { } } +func TestConnectServerPublishesRuntimeEvents(t *testing.T) { + originalConnectServerFunc := connectServerFunc + t.Cleanup(func() { + connectServerFunc = originalConnectServerFunc + }) + + eventBus := runtimeevents.NewBus() + defer func() { + if err := eventBus.Close(); err != nil { + t.Errorf("event bus close failed: %v", err) + } + }() + + _, eventsCh, err := eventBus.Channel().OfKind( + runtimeevents.KindMCPServerConnected, + runtimeevents.KindMCPServerFailed, + ).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "mcp-events", Buffer: 2}) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + connectServerFunc = func( + _ context.Context, + name string, + cfg config.MCPServerConfig, + ) (*ServerConnection, error) { + if name == "bad" { + return nil, fmt.Errorf("connect failed") + } + return &ServerConnection{ + Name: name, + Config: cfg, + Tools: []*sdkmcp.Tool{{Name: "echo"}}, + }, nil + } + + mgr := NewManager(WithRuntimeEvents(eventBus)) + err = mgr.ConnectServer(context.Background(), "good", config.MCPServerConfig{ + Type: "stdio", + Command: "echo", + }) + if err != nil { + t.Fatalf("ConnectServer(good) error = %v", err) + } + connected := receiveMCPRuntimeEvent(t, eventsCh) + if connected.Kind != runtimeevents.KindMCPServerConnected || + connected.Source.Name != "good" || + connected.Severity != runtimeevents.SeverityInfo { + t.Fatalf("connected event = %+v", connected) + } + if connected.Attrs["server"] != "good" || + connected.Attrs["type"] != "stdio" || + connected.Attrs["tool_count"] != 1 { + t.Fatalf("connected attrs = %#v", connected.Attrs) + } + + err = mgr.ConnectServer(context.Background(), "bad", config.MCPServerConfig{ + Type: "stdio", + Command: "echo", + }) + if err == nil { + t.Fatal("expected ConnectServer(bad) to fail") + } + failed := receiveMCPRuntimeEvent(t, eventsCh) + if failed.Kind != runtimeevents.KindMCPServerFailed || + failed.Source.Name != "bad" || + failed.Severity != runtimeevents.SeverityError { + t.Fatalf("failed event = %+v", failed) + } + if failed.Attrs["server"] != "bad" || failed.Attrs["error"] != "connect failed" { + t.Fatalf("failed attrs = %#v", failed.Attrs) + } +} + +func receiveMCPRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event { + t.Helper() + + select { + case evt, ok := <-ch: + if !ok { + t.Fatal("runtime event channel closed before expected event") + } + return evt + case <-time.After(time.Second): + t.Fatal("timed out waiting for runtime event") + return runtimeevents.Event{} + } +} + func TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) { mgr := NewManager() @@ -296,6 +408,81 @@ func TestCallTool_ErrorsForClosedOrMissingServer(t *testing.T) { }) } +func TestCallTool_ReconnectsWhenHTTPServerLosesSession(t *testing.T) { + originalConnectServerFunc := connectServerFunc + t.Cleanup(func() { + connectServerFunc = originalConnectServerFunc + }) + + staleConn, staleTransport, err := newScriptedServerConnection( + "session-1", + nil, + fmt.Errorf(`sending "tools/call": failed to connect (session ID: session-1): %w`, sdkmcp.ErrSessionMissing), + ) + if err != nil { + t.Fatalf("newScriptedServerConnection(stale) error = %v", err) + } + freshConn, freshTransport, err := newScriptedServerConnection( + "session-2", + &sdkmcp.CallToolResult{ + Content: []sdkmcp.Content{ + &sdkmcp.TextContent{Text: "reconnected"}, + }, + }, + nil, + ) + if err != nil { + t.Fatalf("newScriptedServerConnection(fresh) error = %v", err) + } + + connectCalls := 0 + connectServerFunc = func(ctx context.Context, name string, cfg config.MCPServerConfig) (*ServerConnection, error) { + connectCalls++ + if connectCalls == 1 { + return freshConn, nil + } + return nil, fmt.Errorf("unexpected reconnect attempt %d", connectCalls) + } + + mgr := NewManager() + mgr.servers["flaky"] = staleConn + + result, err := mgr.CallTool(context.Background(), "flaky", "echo", map[string]any{ + "query": "hello", + }) + if err != nil { + t.Fatalf("CallTool() error = %v", err) + } + if result == nil || len(result.Content) != 1 { + t.Fatalf("CallTool() returned unexpected content: %#v", result) + } + + text, ok := result.Content[0].(*sdkmcp.TextContent) + if !ok { + t.Fatalf("CallTool() content type = %T, want *sdkmcp.TextContent", result.Content[0]) + } + if text.Text != "reconnected" { + t.Fatalf("CallTool() text = %q, want %q", text.Text, "reconnected") + } + + conn, ok := mgr.GetServer("flaky") + if !ok { + t.Fatal("expected flaky server to remain connected after reconnect") + } + if conn.Session.ID() != "session-2" { + t.Fatalf("Session.ID() = %q, want %q", conn.Session.ID(), "session-2") + } + if connectCalls != 1 { + t.Fatalf("connectCalls = %d, want 1", connectCalls) + } + if staleTransport.toolCallCalls != 1 { + t.Fatalf("stale toolCallCalls = %d, want 1", staleTransport.toolCallCalls) + } + if freshTransport.toolCallCalls != 1 { + t.Fatalf("fresh toolCallCalls = %d, want 1", freshTransport.toolCallCalls) + } +} + func TestClose_IdempotentOnEmptyManager(t *testing.T) { mgr := NewManager() @@ -306,3 +493,138 @@ func TestClose_IdempotentOnEmptyManager(t *testing.T) { t.Fatalf("second close should be idempotent, got: %v", err) } } + +func newScriptedServerConnection( + sessionID string, + toolCallResult *sdkmcp.CallToolResult, + toolCallErr error, +) (*ServerConnection, *scriptedTransport, error) { + transport := &scriptedTransport{ + sessionID: sessionID, + toolCallResult: toolCallResult, + toolCallErr: toolCallErr, + } + + client := sdkmcp.NewClient(&sdkmcp.Implementation{ + Name: "picoclaw-test", + Version: "1.0.0", + }, nil) + session, err := client.Connect(context.Background(), transport, nil) + if err != nil { + return nil, nil, err + } + + return &ServerConnection{ + Name: "flaky", + Config: config.MCPServerConfig{Enabled: true, Type: "http", URL: "https://example.invalid/mcp"}, + Client: client, + Session: session, + Tools: []*sdkmcp.Tool{ + { + Name: "echo", + Description: "Echo test tool", + InputSchema: map[string]any{"type": "object"}, + }, + }, + }, transport, nil +} + +type scriptedTransport struct { + sessionID string + toolCallResult *sdkmcp.CallToolResult + toolCallErr error + + mu sync.Mutex + toolCallCalls int + closed bool + incoming chan jsonrpc.Message +} + +func (t *scriptedTransport) Connect(context.Context) (sdkmcp.Connection, error) { + if t.incoming == nil { + t.incoming = make(chan jsonrpc.Message, 4) + } + return t, nil +} + +func (t *scriptedTransport) Read(ctx context.Context) (jsonrpc.Message, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case msg, ok := <-t.incoming: + if !ok { + return nil, io.EOF + } + return msg, nil + } +} + +func (t *scriptedTransport) Write(ctx context.Context, msg jsonrpc.Message) error { + req, ok := msg.(*jsonrpc.Request) + if !ok { + return nil + } + + switch req.Method { + case "initialize": + payload, err := json.Marshal(&sdkmcp.InitializeResult{ + ProtocolVersion: "2025-11-25", + ServerInfo: &sdkmcp.Implementation{ + Name: "scripted-test-server", + Version: "1.0.0", + }, + Capabilities: &sdkmcp.ServerCapabilities{ + Tools: &sdkmcp.ToolCapabilities{}, + }, + }) + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case t.incoming <- &jsonrpc.Response{ID: req.ID, Result: payload}: + return nil + } + + case "notifications/initialized": + return nil + + case "tools/call": + t.mu.Lock() + t.toolCallCalls++ + t.mu.Unlock() + + if t.toolCallErr != nil { + return t.toolCallErr + } + + payload, err := json.Marshal(t.toolCallResult) + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case t.incoming <- &jsonrpc.Response{ID: req.ID, Result: payload}: + return nil + } + } + + return fmt.Errorf("unexpected method %q", req.Method) +} + +func (t *scriptedTransport) Close() error { + t.mu.Lock() + defer t.mu.Unlock() + if t.closed { + return nil + } + t.closed = true + close(t.incoming) + return nil +} + +func (t *scriptedTransport) SessionID() string { + return t.sessionID +} diff --git a/pkg/media/store.go b/pkg/media/store.go index 30220986c..78cff8bb6 100644 --- a/pkg/media/store.go +++ b/pkg/media/store.go @@ -11,11 +11,25 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" ) +// CleanupPolicy controls how the MediaStore treats the underlying file when +// a ref is released or expires. +type CleanupPolicy string + +const ( + // CleanupPolicyDeleteOnCleanup means the file is store-managed and may be + // deleted once the final ref for that path is gone. + CleanupPolicyDeleteOnCleanup CleanupPolicy = "delete_on_cleanup" + // CleanupPolicyForgetOnly means the store should only drop ref mappings and + // must never delete the underlying file. + CleanupPolicyForgetOnly CleanupPolicy = "forget_only" +) + // MediaMeta holds metadata about a stored media file. type MediaMeta struct { - Filename string - ContentType string - Source string // "telegram", "discord", "tool:image-gen", etc. + Filename string + ContentType string + Source string // "telegram", "discord", "tool:image-gen", etc. + CleanupPolicy CleanupPolicy // defaults to CleanupPolicyDeleteOnCleanup } // MediaStore manages the lifecycle of media files associated with processing scopes. @@ -23,6 +37,7 @@ type MediaStore interface { // Store registers an existing local file under the given scope. // Returns a ref identifier (e.g. "media://"). // Store does not move or copy the file; it only records the mapping. + // If meta.CleanupPolicy is empty, CleanupPolicyDeleteOnCleanup is assumed. Store(localPath string, meta MediaMeta, scope string) (ref string, err error) // Resolve returns the local file path for a given ref. @@ -43,6 +58,11 @@ type mediaEntry struct { storedAt time.Time } +type pathRefState struct { + refCount int + deleteEligible bool +} + // MediaCleanerConfig configures the background TTL cleanup. type MediaCleanerConfig struct { Enabled bool @@ -57,6 +77,8 @@ type FileMediaStore struct { refs map[string]mediaEntry scopeToRefs map[string]map[string]struct{} refToScope map[string]string + refToPath map[string]string + pathStates map[string]pathRefState cleanerCfg MediaCleanerConfig stop chan struct{} @@ -71,6 +93,8 @@ func NewFileMediaStore() *FileMediaStore { refs: make(map[string]mediaEntry), scopeToRefs: make(map[string]map[string]struct{}), refToScope: make(map[string]string), + refToPath: make(map[string]string), + pathStates: make(map[string]pathRefState), nowFunc: time.Now, } } @@ -81,6 +105,8 @@ func NewFileMediaStoreWithCleanup(cfg MediaCleanerConfig) *FileMediaStore { refs: make(map[string]mediaEntry), scopeToRefs: make(map[string]map[string]struct{}), refToScope: make(map[string]string), + refToPath: make(map[string]string), + pathStates: make(map[string]pathRefState), cleanerCfg: cfg, stop: make(chan struct{}), nowFunc: time.Now, @@ -94,6 +120,7 @@ func (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) ( } ref := "media://" + uuid.New().String() + meta.CleanupPolicy = normalizeCleanupPolicy(meta.CleanupPolicy) s.mu.Lock() defer s.mu.Unlock() @@ -104,6 +131,18 @@ func (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) ( } s.scopeToRefs[scope][ref] = struct{}{} s.refToScope[ref] = scope + s.refToPath[ref] = localPath + + pathState := s.pathStates[localPath] + if pathState.refCount == 0 { + pathState.deleteEligible = meta.CleanupPolicy == CleanupPolicyDeleteOnCleanup + } else if meta.CleanupPolicy == CleanupPolicyForgetOnly { + // Be conservative: once a path is borrowed externally, never let this + // lifecycle auto-delete it even if store-managed refs also exist. + pathState.deleteEligible = false + } + pathState.refCount++ + s.pathStates[localPath] = pathState return ref, nil } @@ -134,7 +173,8 @@ func (s *FileMediaStore) ResolveWithMeta(ref string) (string, MediaMeta, error) // ReleaseAll removes all files under the given scope and cleans up mappings. // Phase 1 (under lock): remove entries from maps. -// Phase 2 (no lock): delete files from disk. +// Phase 2 (no lock): delete store-managed files from disk once their final +// path ref is gone. func (s *FileMediaStore) ReleaseAll(scope string) error { // Phase 1: collect paths and remove from maps under lock var paths []string @@ -147,11 +187,13 @@ func (s *FileMediaStore) ReleaseAll(scope string) error { } for ref := range refs { + fallbackPath := "" if entry, exists := s.refs[ref]; exists { - paths = append(paths, entry.path) + fallbackPath = entry.path + } + if removablePath, shouldDelete := s.releaseRefLocked(ref, fallbackPath); shouldDelete { + paths = append(paths, removablePath) } - delete(s.refs, ref) - delete(s.refToScope, ref) } delete(s.scopeToRefs, scope) s.mu.Unlock() @@ -171,7 +213,7 @@ func (s *FileMediaStore) ReleaseAll(scope string) error { // CleanExpired removes all entries older than MaxAge. // Phase 1 (under lock): identify expired entries and remove from maps. -// Phase 2 (no lock): delete files from disk to minimize lock contention. +// Phase 2 (no lock): delete store-managed files from disk to minimize lock contention. func (s *FileMediaStore) CleanExpired() int { if s.cleanerCfg.MaxAge <= 0 { return 0 @@ -179,8 +221,8 @@ func (s *FileMediaStore) CleanExpired() int { // Phase 1: collect expired entries under lock type expiredEntry struct { - ref string - path string + ref string + deletePath string } s.mu.Lock() @@ -189,8 +231,6 @@ func (s *FileMediaStore) CleanExpired() int { for ref, entry := range s.refs { if entry.storedAt.Before(cutoff) { - expired = append(expired, expiredEntry{ref: ref, path: entry.path}) - if scope, ok := s.refToScope[ref]; ok { if scopeRefs, ok := s.scopeToRefs[scope]; ok { delete(scopeRefs, ref) @@ -200,17 +240,23 @@ func (s *FileMediaStore) CleanExpired() int { } } - delete(s.refs, ref) - delete(s.refToScope, ref) + expiredItem := expiredEntry{ref: ref} + if deletePath, shouldDelete := s.releaseRefLocked(ref, entry.path); shouldDelete { + expiredItem.deletePath = deletePath + } + expired = append(expired, expiredItem) } } s.mu.Unlock() // Phase 2: delete files without holding the lock for _, e := range expired { - if err := os.Remove(e.path); err != nil && !os.IsNotExist(err) { + if e.deletePath == "" { + continue + } + if err := os.Remove(e.deletePath); err != nil && !os.IsNotExist(err) { logger.WarnCF("media", "cleanup: failed to remove file", map[string]any{ - "path": e.path, + "path": e.deletePath, "error": err.Error(), }) } @@ -219,6 +265,45 @@ func (s *FileMediaStore) CleanExpired() int { return len(expired) } +func normalizeCleanupPolicy(policy CleanupPolicy) CleanupPolicy { + switch policy { + case "", CleanupPolicyDeleteOnCleanup: + return CleanupPolicyDeleteOnCleanup + case CleanupPolicyForgetOnly: + return CleanupPolicyForgetOnly + default: + return CleanupPolicyDeleteOnCleanup + } +} + +func (s *FileMediaStore) releaseRefLocked(ref, fallbackPath string) (string, bool) { + path := fallbackPath + if storedPath, ok := s.refToPath[ref]; ok { + path = storedPath + delete(s.refToPath, ref) + } + + delete(s.refs, ref) + delete(s.refToScope, ref) + + if path == "" { + return "", false + } + + pathState, ok := s.pathStates[path] + if !ok { + return "", false + } + if pathState.refCount <= 1 { + delete(s.pathStates, path) + return path, pathState.deleteEligible + } + + pathState.refCount-- + s.pathStates[path] = pathState + return "", false +} + // Start begins the background cleanup goroutine if cleanup is enabled. // Safe to call multiple times; only the first call starts the goroutine. func (s *FileMediaStore) Start() { diff --git a/pkg/media/store_test.go b/pkg/media/store_test.go index 1dcfdf350..dabcc3142 100644 --- a/pkg/media/store_test.go +++ b/pkg/media/store_test.go @@ -77,6 +77,106 @@ func TestReleaseAll(t *testing.T) { } } +func TestReleaseAllForgetOnlyKeepsFile(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + path := createTempFile(t, dir, "workspace.txt") + ref, err := store.Store(path, MediaMeta{ + Source: "test", + CleanupPolicy: CleanupPolicyForgetOnly, + }, "scope1") + if err != nil { + t.Fatalf("Store failed: %v", err) + } + + if err := store.ReleaseAll("scope1"); err != nil { + t.Fatalf("ReleaseAll failed: %v", err) + } + + if _, err := store.Resolve(ref); err == nil { + t.Error("forget-only ref should be unresolvable after release") + } + if _, err := os.Stat(path); err != nil { + t.Errorf("forget-only file should remain on disk: %v", err) + } +} + +func TestReleaseAllSharedPathDeletesOnFinalRefOnly(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + path := createTempFile(t, dir, "shared.jpg") + refA, err := store.Store(path, MediaMeta{ + Source: "test", + CleanupPolicy: CleanupPolicyDeleteOnCleanup, + }, "scopeA") + if err != nil { + t.Fatalf("Store(scopeA) failed: %v", err) + } + refB, err := store.Store(path, MediaMeta{ + Source: "test", + CleanupPolicy: CleanupPolicyDeleteOnCleanup, + }, "scopeB") + if err != nil { + t.Fatalf("Store(scopeB) failed: %v", err) + } + + if err := store.ReleaseAll("scopeA"); err != nil { + t.Fatalf("ReleaseAll(scopeA) failed: %v", err) + } + + if _, err := store.Resolve(refA); err == nil { + t.Error("refA should be unresolvable after ReleaseAll(scopeA)") + } + if _, err := store.Resolve(refB); err != nil { + t.Fatalf("refB should still resolve: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("shared file should remain until final ref is released: %v", err) + } + + if err := store.ReleaseAll("scopeB"); err != nil { + t.Fatalf("ReleaseAll(scopeB) failed: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("shared file should be deleted after final ref is released") + } +} + +func TestReleaseAllMixedPoliciesKeepsFile(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + path := createTempFile(t, dir, "shared.txt") + if _, err := store.Store(path, MediaMeta{ + Source: "test", + CleanupPolicy: CleanupPolicyDeleteOnCleanup, + }, "owned"); err != nil { + t.Fatalf("Store(owned) failed: %v", err) + } + if _, err := store.Store(path, MediaMeta{ + Source: "test", + CleanupPolicy: CleanupPolicyForgetOnly, + }, "borrowed"); err != nil { + t.Fatalf("Store(borrowed) failed: %v", err) + } + + if err := store.ReleaseAll("owned"); err != nil { + t.Fatalf("ReleaseAll(owned) failed: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("mixed-policy file should remain after owned ref release: %v", err) + } + + if err := store.ReleaseAll("borrowed"); err != nil { + t.Fatalf("ReleaseAll(borrowed) failed: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("mixed-policy path should not be auto-deleted: %v", err) + } +} + func TestMultiScopeIsolation(t *testing.T) { dir := t.TempDir() store := NewFileMediaStore() @@ -293,6 +393,35 @@ func TestCleanExpiredRemovesOldEntries(t *testing.T) { } } +func TestCleanExpiredForgetOnlyKeepsFile(t *testing.T) { + dir := t.TempDir() + now := time.Now() + store := newTestStoreWithCleanup(10 * time.Minute) + store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) } + + path := createTempFile(t, dir, "workspace.txt") + ref, err := store.Store(path, MediaMeta{ + Source: "test", + CleanupPolicy: CleanupPolicyForgetOnly, + }, "scope1") + if err != nil { + t.Fatalf("Store failed: %v", err) + } + + store.nowFunc = func() time.Time { return now } + removed := store.CleanExpired() + + if removed != 1 { + t.Errorf("expected 1 removed, got %d", removed) + } + if _, err := store.Resolve(ref); err == nil { + t.Error("expired forget-only ref should be unresolvable") + } + if _, err := os.Stat(path); err != nil { + t.Errorf("forget-only file should remain on disk: %v", err) + } +} + func TestCleanExpiredKeepsNonExpired(t *testing.T) { dir := t.TempDir() now := time.Now() @@ -346,6 +475,53 @@ func TestCleanExpiredMixedAges(t *testing.T) { } } +func TestCleanExpiredSharedPathDeletesOnFinalRefOnly(t *testing.T) { + dir := t.TempDir() + now := time.Now() + store := newTestStoreWithCleanup(10 * time.Minute) + + path := createTempFile(t, dir, "shared.jpg") + + store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) } + oldRef, err := store.Store(path, MediaMeta{ + Source: "test", + CleanupPolicy: CleanupPolicyDeleteOnCleanup, + }, "scope-old") + if err != nil { + t.Fatalf("Store(old) failed: %v", err) + } + + store.nowFunc = func() time.Time { return now } + freshRef, err := store.Store(path, MediaMeta{ + Source: "test", + CleanupPolicy: CleanupPolicyDeleteOnCleanup, + }, "scope-fresh") + if err != nil { + t.Fatalf("Store(fresh) failed: %v", err) + } + + removed := store.CleanExpired() + if removed != 1 { + t.Errorf("expected 1 removed, got %d", removed) + } + if _, err := store.Resolve(oldRef); err == nil { + t.Error("old ref should be gone after cleanup") + } + if _, err := store.Resolve(freshRef); err != nil { + t.Fatalf("fresh ref should still resolve: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("shared file should remain while fresh ref exists: %v", err) + } + + if err := store.ReleaseAll("scope-fresh"); err != nil { + t.Fatalf("ReleaseAll(scope-fresh) failed: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("shared file should be deleted after final ref is released") + } +} + func TestCleanExpiredCleansEmptyScopes(t *testing.T) { dir := t.TempDir() now := time.Now() diff --git a/pkg/media/tempdir.go b/pkg/media/tempdir.go new file mode 100644 index 000000000..45942b34f --- /dev/null +++ b/pkg/media/tempdir.go @@ -0,0 +1,13 @@ +package media + +import ( + "os" + "path/filepath" +) + +const TempDirName = "picoclaw_media" + +// TempDir returns the shared temporary directory used for downloaded media. +func TempDir() string { + return filepath.Join(os.TempDir(), TempDirName) +} diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index e12e2c5ab..492205114 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -10,12 +10,14 @@ import ( "log" "os" "path/filepath" + "sort" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" ) const ( @@ -32,14 +34,19 @@ const ( maxLineSize = 10 * 1024 * 1024 // 10 MB ) -// sessionMeta holds per-session metadata stored in a .meta.json file. -type sessionMeta struct { - Key string `json:"key"` - Summary string `json:"summary"` - Skip int `json:"skip"` - Count int `json:"count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +// SessionMeta holds per-session metadata stored in a .meta.json file. +// +// Scope is stored as raw JSON so pkg/memory can stay decoupled from the +// higher-level session package while still preserving structured scope data. +type SessionMeta struct { + Key string `json:"key"` + Summary string `json:"summary"` + Skip int `json:"skip"` + Count int `json:"count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Scope json.RawMessage `json:"scope,omitempty"` + Aliases []string `json:"aliases,omitempty"` } // JSONLStore implements Store using append-only JSONL files. @@ -86,37 +93,43 @@ func (s *JSONLStore) metaPath(key string) string { // sanitizeKey converts a session key to a safe filename component. // Mirrors pkg/session.sanitizeFilename so that migration paths match. -// -// Note: this is a lossy mapping — "telegram:123" and "telegram_123" -// both produce the same filename. This is an intentional tradeoff: -// keys with colons (e.g. from channels) are by far the common case, -// and a bidirectional encoding (like URL-encoding) would complicate -// file listings and debugging. +// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' +// so composite IDs (e.g. Telegram forum "chatID/threadID", Slack "channel/thread_ts") +// do not create subdirectories or break on Windows. func sanitizeKey(key string) string { - return strings.ReplaceAll(key, ":", "_") + s := strings.ReplaceAll(key, ":", "_") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + return s } // readMeta loads the metadata file for a session. // Returns a zero-value sessionMeta if the file does not exist. -func (s *JSONLStore) readMeta(key string) (sessionMeta, error) { +func (s *JSONLStore) readMeta(key string) (SessionMeta, error) { data, err := os.ReadFile(s.metaPath(key)) if os.IsNotExist(err) { - return sessionMeta{Key: key}, nil + return SessionMeta{Key: key}, nil } if err != nil { - return sessionMeta{}, fmt.Errorf("memory: read meta: %w", err) + return SessionMeta{}, fmt.Errorf("memory: read meta: %w", err) } - var meta sessionMeta + var meta SessionMeta err = json.Unmarshal(data, &meta) if err != nil { - return sessionMeta{}, fmt.Errorf("memory: decode meta: %w", err) + return SessionMeta{}, fmt.Errorf("memory: decode meta: %w", err) + } + if meta.Key == "" { + meta.Key = key } return meta, nil } // writeMeta atomically writes the metadata file using the project's // standard WriteFileAtomic (temp + fsync + rename). -func (s *JSONLStore) writeMeta(key string, meta sessionMeta) error { +func (s *JSONLStore) writeMeta(key string, meta SessionMeta) error { + if strings.TrimSpace(meta.Key) == "" { + meta.Key = key + } data, err := json.MarshalIndent(meta, "", " ") if err != nil { return fmt.Errorf("memory: encode meta: %w", err) @@ -124,6 +137,311 @@ func (s *JSONLStore) writeMeta(key string, meta sessionMeta) error { return fileutil.WriteFileAtomic(s.metaPath(key), data, 0o644) } +func cloneRawJSON(data json.RawMessage) json.RawMessage { + if len(data) == 0 { + return nil + } + return append(json.RawMessage(nil), data...) +} + +func normalizeAliases(canonicalKey string, aliases []string) []string { + if len(aliases) == 0 { + return nil + } + normalized := make([]string, 0, len(aliases)) + seen := make(map[string]struct{}, len(aliases)) + canonicalKey = strings.TrimSpace(canonicalKey) + for _, alias := range aliases { + alias = strings.TrimSpace(alias) + if alias == "" || alias == canonicalKey { + continue + } + if _, ok := seen[alias]; ok { + continue + } + seen[alias] = struct{}{} + normalized = append(normalized, alias) + } + if len(normalized) == 0 { + return nil + } + return normalized +} + +func (s *JSONLStore) sessionExists(key string) bool { + if key == "" { + return false + } + if _, err := os.Stat(s.jsonlPath(key)); err == nil { + return true + } + if _, err := os.Stat(s.metaPath(key)); err == nil { + return true + } + return false +} + +// GetSessionMeta returns the current metadata snapshot for sessionKey. +func (s *JSONLStore) GetSessionMeta(_ context.Context, sessionKey string) (SessionMeta, error) { + l := s.sessionLock(sessionKey) + l.Lock() + defer l.Unlock() + + meta, err := s.readMeta(sessionKey) + if err != nil { + return SessionMeta{}, err + } + meta.Scope = cloneRawJSON(meta.Scope) + if len(meta.Aliases) > 0 { + meta.Aliases = append([]string(nil), meta.Aliases...) + } + return meta, nil +} + +// UpsertSessionMeta stores structured session metadata while preserving +// summary/count/skip timestamps maintained by the core JSONL store. +func (s *JSONLStore) UpsertSessionMeta( + _ context.Context, + sessionKey string, + scope json.RawMessage, + aliases []string, +) error { + l := s.sessionLock(sessionKey) + l.Lock() + defer l.Unlock() + + meta, err := s.readMeta(sessionKey) + if err != nil { + return err + } + meta.Scope = cloneRawJSON(scope) + meta.Aliases = normalizeAliases(sessionKey, aliases) + now := time.Now() + if meta.CreatedAt.IsZero() { + meta.CreatedAt = now + } + meta.UpdatedAt = now + + return s.writeMeta(sessionKey, meta) +} + +// PromoteAliasHistory atomically promotes the first non-empty alias session +// into the canonical session when the canonical session is still empty. +func (s *JSONLStore) PromoteAliasHistory( + _ context.Context, + sessionKey string, + scope json.RawMessage, + aliases []string, +) (bool, error) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return false, nil + } + + aliases = normalizeAliases(sessionKey, aliases) + for _, alias := range aliases { + unlock := s.lockSessionPair(sessionKey, alias) + promoted, err := s.promoteAliasHistoryLocked(sessionKey, alias, scope, aliases) + unlock() + if err != nil || promoted { + return promoted, err + } + } + + return false, nil +} + +// ResolveSessionKey returns the canonical session key for a candidate key. +// It short-circuits direct canonical keys when possible, then scans metadata +// once to resolve aliases or canonical metadata keys. +func (s *JSONLStore) ResolveSessionKey(_ context.Context, sessionKey string) (string, bool, error) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return "", false, nil + } + + hasDirectSession := s.sessionExists(sessionKey) + if hasDirectSession && shouldShortCircuitSessionResolve(sessionKey) { + return sessionKey, true, nil + } + + entries, err := os.ReadDir(s.dir) + if err != nil { + return "", false, fmt.Errorf("memory: read sessions dir: %w", err) + } + + var directMetaMatch string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") { + continue + } + + data, readErr := os.ReadFile(filepath.Join(s.dir, entry.Name())) + if readErr != nil { + log.Printf("memory: skipping unreadable meta %s: %v", entry.Name(), readErr) + continue + } + + var meta SessionMeta + if err := json.Unmarshal(data, &meta); err != nil { + log.Printf("memory: skipping corrupt meta %s: %v", entry.Name(), err) + continue + } + + if meta.Key == "" { + continue + } + + if meta.Key == sessionKey { + directMetaMatch = meta.Key + } + + for _, alias := range meta.Aliases { + if alias == sessionKey && meta.Key != sessionKey { + return meta.Key, true, nil + } + } + } + + if directMetaMatch != "" { + return directMetaMatch, true, nil + } + + if hasDirectSession { + return sessionKey, true, nil + } + + return "", false, nil +} + +func shouldShortCircuitSessionResolve(sessionKey string) bool { + sessionKey = strings.TrimSpace(strings.ToLower(sessionKey)) + if sessionKey == "" { + return false + } + return !strings.ContainsAny(sessionKey, ":/\\") +} + +func (s *JSONLStore) lockSessionPair(keyA, keyB string) func() { + lockA := s.sessionLock(keyA) + lockB := s.sessionLock(keyB) + if lockA == lockB { + lockA.Lock() + return func() { lockA.Unlock() } + } + if keyA <= keyB { + lockA.Lock() + lockB.Lock() + return func() { + lockB.Unlock() + lockA.Unlock() + } + } + lockB.Lock() + lockA.Lock() + return func() { + lockA.Unlock() + lockB.Unlock() + } +} + +func (s *JSONLStore) promoteAliasHistoryLocked( + sessionKey string, + alias string, + scope json.RawMessage, + aliases []string, +) (bool, error) { + canonicalMeta, err := s.readMeta(sessionKey) + if err != nil { + return false, err + } + canonicalHasContent, err := s.sessionHasVisibleContentLocked(sessionKey, canonicalMeta) + if err != nil { + return false, err + } + if canonicalHasContent { + return false, nil + } + + aliasMeta, err := s.readMeta(alias) + if err != nil { + return false, err + } + aliasHistory, err := readMessages(s.jsonlPath(alias), aliasMeta.Skip) + if err != nil { + return false, err + } + aliasSummary := strings.TrimSpace(aliasMeta.Summary) + if len(aliasHistory) == 0 && aliasSummary == "" { + return false, nil + } + + previousJSONL, hadPreviousJSONL, err := s.readRawJSONL(sessionKey) + if err != nil { + return false, err + } + + now := time.Now() + if canonicalMeta.CreatedAt.IsZero() { + canonicalMeta.CreatedAt = now + } + canonicalMeta.Scope = cloneRawJSON(scope) + canonicalMeta.Aliases = normalizeAliases(sessionKey, aliases) + canonicalMeta.Skip = 0 + canonicalMeta.Count = len(aliasHistory) + canonicalMeta.UpdatedAt = now + if aliasSummary != "" { + canonicalMeta.Summary = aliasSummary + } + + if err := s.rewriteJSONL(sessionKey, aliasHistory); err != nil { + return false, err + } + if err := s.writeMeta(sessionKey, canonicalMeta); err != nil { + if rollbackErr := s.restoreRawJSONL(sessionKey, previousJSONL, hadPreviousJSONL); rollbackErr != nil { + return false, fmt.Errorf("memory: write promoted meta: %w (rollback jsonl: %v)", err, rollbackErr) + } + return false, err + } + return true, nil +} + +func (s *JSONLStore) sessionHasVisibleContentLocked(sessionKey string, meta SessionMeta) (bool, error) { + if strings.TrimSpace(meta.Summary) != "" { + return true, nil + } + history, err := readMessages(s.jsonlPath(sessionKey), meta.Skip) + if err != nil { + return false, err + } + return len(history) > 0, nil +} + +func (s *JSONLStore) readRawJSONL(sessionKey string) ([]byte, bool, error) { + data, err := os.ReadFile(s.jsonlPath(sessionKey)) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("memory: read jsonl: %w", err) + } + return data, true, nil +} + +func (s *JSONLStore) restoreRawJSONL(sessionKey string, data []byte, existed bool) error { + path := s.jsonlPath(sessionKey) + if !existed { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("memory: remove jsonl rollback: %w", err) + } + return nil + } + if err := fileutil.WriteFileAtomic(path, data, 0o644); err != nil { + return fmt.Errorf("memory: restore jsonl rollback: %w", err) + } + return nil +} + // readMessages reads valid JSON lines from a .jsonl file, skipping // the first `skip` lines without unmarshaling them. This avoids the // cost of json.Unmarshal on logically truncated messages. @@ -163,6 +481,9 @@ func readMessages(path string, skip int) ([]providers.Message, error) { lineNum, filepath.Base(path), err) continue } + if messageutil.IsTransientAssistantThoughtMessage(msg) { + continue + } msgs = append(msgs, msg) } if scanner.Err() != nil { @@ -175,28 +496,44 @@ func readMessages(path string, skip int) ([]providers.Message, error) { return msgs, nil } -// countLines counts the total number of non-empty lines in a .jsonl file. -// Used by TruncateHistory to reconcile a stale meta.Count without -// the overhead of unmarshaling every message. -func countLines(path string) (int, error) { +// scanRetainedMessageLines returns the total number of non-empty raw JSONL +// lines plus the raw line numbers that survive readMessages filtering. +// TruncateHistory uses this to compute keepLast against retained messages +// while preserving the raw-line skip offset stored in metadata. +func scanRetainedMessageLines(path string) (int, []int, error) { f, err := os.Open(path) if os.IsNotExist(err) { - return 0, nil + return 0, []int{}, nil } if err != nil { - return 0, fmt.Errorf("memory: open jsonl: %w", err) + return 0, nil, fmt.Errorf("memory: open jsonl: %w", err) } defer f.Close() - n := 0 + rawCount := 0 + retained := make([]int, 0) scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) for scanner.Scan() { - if len(scanner.Bytes()) > 0 { - n++ + line := scanner.Bytes() + if len(line) == 0 { + continue } + rawCount++ + + var msg providers.Message + if err := json.Unmarshal(line, &msg); err != nil { + continue + } + if messageutil.IsTransientAssistantThoughtMessage(msg) { + continue + } + retained = append(retained, rawCount) } - return n, scanner.Err() + if err := scanner.Err(); err != nil { + return 0, nil, err + } + return rawCount, retained, nil } func (s *JSONLStore) AddMessage( @@ -216,6 +553,10 @@ func (s *JSONLStore) AddFullMessage( // addMsg is the shared implementation for AddMessage and AddFullMessage. func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error { + if messageutil.IsTransientAssistantThoughtMessage(msg) { + return nil + } + l := s.sessionLock(sessionKey) l.Lock() defer l.Unlock() @@ -336,24 +677,26 @@ func (s *JSONLStore) TruncateHistory( return err } - // Always reconcile meta.Count with the actual line count on disk. - // A crash between the JSONL append and the meta update in addMsg - // leaves meta.Count stale (e.g. file has 101 lines but meta says - // 100). Counting lines is cheap — no unmarshal, just a scan — and - // TruncateHistory is not a hot path, so always re-count. - n, countErr := countLines(s.jsonlPath(sessionKey)) - if countErr != nil { - return countErr + rawCount, retainedRawLines, scanErr := scanRetainedMessageLines(s.jsonlPath(sessionKey)) + if scanErr != nil { + return scanErr } - meta.Count = n - - if keepLast <= 0 { + meta.Count = rawCount + if meta.Skip > meta.Count { meta.Skip = meta.Count - } else { - effective := meta.Count - meta.Skip - if keepLast < effective { - meta.Skip = meta.Count - keepLast - } + } + + activeStart := sort.Search(len(retainedRawLines), func(i int) bool { + return retainedRawLines[i] > meta.Skip + }) + activeRetainedCount := len(retainedRawLines) - activeStart + + switch { + case keepLast <= 0 || activeRetainedCount == 0: + meta.Skip = meta.Count + case keepLast < activeRetainedCount: + activeRawLines := retainedRawLines[activeStart:] + meta.Skip = activeRawLines[activeRetainedCount-keepLast-1] } meta.UpdatedAt = time.Now() @@ -365,6 +708,8 @@ func (s *JSONLStore) SetHistory( sessionKey string, history []providers.Message, ) error { + history = messageutil.FilterInvalidHistoryMessages(history) + l := s.sessionLock(sessionKey) l.Lock() defer l.Unlock() @@ -443,6 +788,8 @@ func (s *JSONLStore) Compact( func (s *JSONLStore) rewriteJSONL( sessionKey string, msgs []providers.Message, ) error { + msgs = messageutil.FilterInvalidHistoryMessages(msgs) + var buf bytes.Buffer for i, msg := range msgs { line, err := json.Marshal(msg) @@ -455,6 +802,33 @@ func (s *JSONLStore) rewriteJSONL( return fileutil.WriteFileAtomic(s.jsonlPath(sessionKey), buf.Bytes(), 0o644) } +// ListSessions returns all known session keys by reading .meta.json files. +func (s *JSONLStore) ListSessions() []string { + entries, err := os.ReadDir(s.dir) + if err != nil { + return nil + } + var keys []string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") { + continue + } + // Read the meta file to get the original key + data, err := os.ReadFile(filepath.Join(s.dir, entry.Name())) + if err != nil { + continue + } + var meta SessionMeta + if err := json.Unmarshal(data, &meta); err != nil { + continue + } + if meta.Key != "" { + keys = append(keys, meta.Key) + } + } + return keys +} + func (s *JSONLStore) Close() error { return nil } diff --git a/pkg/memory/jsonl_test.go b/pkg/memory/jsonl_test.go index 356ff14ff..3a7b98130 100644 --- a/pkg/memory/jsonl_test.go +++ b/pkg/memory/jsonl_test.go @@ -2,10 +2,14 @@ package memory import ( "context" + "encoding/json" "os" "path/filepath" + "reflect" + "strings" "sync" "testing" + "time" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -153,6 +157,27 @@ func TestAddFullMessage_ToolCallID(t *testing.T) { } } +func TestAddFullMessage_DropsTransientAssistantThought(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + err := store.AddFullMessage(ctx, "transient-thought", providers.Message{ + Role: "assistant", + ReasoningContent: "internal chain of thought", + }) + if err != nil { + t.Fatalf("AddFullMessage: %v", err) + } + + history, err := store.GetHistory(ctx, "transient-thought") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 0 { + t.Fatalf("expected transient thought to be discarded, got %d messages", len(history)) + } +} + func TestGetHistory_EmptySession(t *testing.T) { store := newTestStore(t) ctx := context.Background() @@ -241,6 +266,182 @@ func TestSetSummary_GetSummary(t *testing.T) { } } +func TestSetHistory_DropsTransientAssistantThought(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + newHistory := []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", ReasoningContent: "internal chain of thought"}, + {Role: "assistant", Content: "visible answer", ReasoningContent: "visible thought"}, + } + + err := store.SetHistory(ctx, "replace", newHistory) + if err != nil { + t.Fatalf("SetHistory: %v", err) + } + + history, err := store.GetHistory(ctx, "replace") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 2 { + t.Fatalf("expected transient thought to be removed, got %d messages", len(history)) + } + if history[0].Role != "user" || history[0].Content != "hello" { + t.Fatalf("history[0] = %+v, want user/hello", history[0]) + } + if history[1].Role != "assistant" || history[1].Content != "visible answer" || + history[1].ReasoningContent != "visible thought" { + t.Fatalf("history[1] = %+v, want assistant visible answer with reasoning", history[1]) + } + + data, err := os.ReadFile(store.jsonlPath("replace")) + if err != nil { + t.Fatalf("ReadFile(jsonl): %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("jsonl line count = %d, want 2", len(lines)) + } +} + +func TestSessionMetaScopeAndAliasesPersist(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + scope := json.RawMessage(`{"version":1,"channel":"telegram","values":{"chat":"group:c1"}}`) + aliases := []string{"legacy:one", "legacy:one", "canonical"} + if err := store.UpsertSessionMeta(ctx, "canonical", scope, aliases); err != nil { + t.Fatalf("UpsertSessionMeta() error = %v", err) + } + + meta, err := store.GetSessionMeta(ctx, "canonical") + if err != nil { + t.Fatalf("GetSessionMeta() error = %v", err) + } + var gotScope map[string]any + if err := json.Unmarshal(meta.Scope, &gotScope); err != nil { + t.Fatalf("Unmarshal(meta.Scope) error = %v", err) + } + var wantScope map[string]any + if err := json.Unmarshal(scope, &wantScope); err != nil { + t.Fatalf("Unmarshal(scope) error = %v", err) + } + if !reflect.DeepEqual(gotScope, wantScope) { + t.Fatalf("meta.Scope = %#v, want %#v", gotScope, wantScope) + } + if len(meta.Aliases) != 1 || meta.Aliases[0] != "legacy:one" { + t.Fatalf("meta.Aliases = %#v, want [legacy:one]", meta.Aliases) + } +} + +func TestResolveSessionKeyByAlias(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if err := store.AddMessage(ctx, "canonical", "user", "hello"); err != nil { + t.Fatalf("AddMessage() error = %v", err) + } + if err := store.UpsertSessionMeta(ctx, "canonical", nil, []string{"legacy:key"}); err != nil { + t.Fatalf("UpsertSessionMeta() error = %v", err) + } + + resolved, found, err := store.ResolveSessionKey(ctx, "legacy:key") + if err != nil { + t.Fatalf("ResolveSessionKey() error = %v", err) + } + if !found { + t.Fatal("ResolveSessionKey() did not find alias") + } + if resolved != "canonical" { + t.Fatalf("resolved = %q, want %q", resolved, "canonical") + } +} + +func TestResolveSessionKeyByAlias_PrefersMetadataOverLegacyFile(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if err := store.AddMessage(ctx, "legacy:key", "user", "legacy"); err != nil { + t.Fatalf("AddMessage(legacy) error = %v", err) + } + if err := store.AddMessage(ctx, "canonical", "user", "canonical"); err != nil { + t.Fatalf("AddMessage(canonical) error = %v", err) + } + if err := store.UpsertSessionMeta(ctx, "canonical", nil, []string{"legacy:key"}); err != nil { + t.Fatalf("UpsertSessionMeta() error = %v", err) + } + + resolved, found, err := store.ResolveSessionKey(ctx, "legacy:key") + if err != nil { + t.Fatalf("ResolveSessionKey() error = %v", err) + } + if !found { + t.Fatal("ResolveSessionKey() did not find alias") + } + if resolved != "canonical" { + t.Fatalf("resolved = %q, want %q", resolved, "canonical") + } +} + +func TestResolveSessionKey_DirectHitSkipsCorruptMetadata(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if err := store.AddMessage(ctx, "canonical", "user", "hello"); err != nil { + t.Fatalf("AddMessage() error = %v", err) + } + if err := os.WriteFile( + filepath.Join(store.dir, "broken.meta.json"), + []byte("{not-json"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(broken.meta.json) error = %v", err) + } + + resolved, found, err := store.ResolveSessionKey(ctx, "canonical") + if err != nil { + t.Fatalf("ResolveSessionKey() error = %v", err) + } + if !found { + t.Fatal("ResolveSessionKey() did not find direct session") + } + if resolved != "canonical" { + t.Fatalf("resolved = %q, want %q", resolved, "canonical") + } +} + +func TestResolveSessionKey_SkipsCorruptMetadataDuringAliasScan(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if err := store.AddMessage(ctx, "canonical", "user", "hello"); err != nil { + t.Fatalf("AddMessage() error = %v", err) + } + if err := store.UpsertSessionMeta(ctx, "canonical", nil, []string{"legacy:key"}); err != nil { + t.Fatalf("UpsertSessionMeta() error = %v", err) + } + if err := os.WriteFile( + filepath.Join(store.dir, "broken.meta.json"), + []byte("{not-json"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(broken.meta.json) error = %v", err) + } + + resolved, found, err := store.ResolveSessionKey(ctx, "legacy:key") + if err != nil { + t.Fatalf("ResolveSessionKey() error = %v", err) + } + if !found { + t.Fatal("ResolveSessionKey() did not find alias") + } + if resolved != "canonical" { + t.Fatalf("resolved = %q, want %q", resolved, "canonical") + } +} + func TestTruncateHistory_KeepLast(t *testing.T) { store := newTestStore(t) ctx := context.Background() @@ -595,6 +796,56 @@ func TestTruncateHistory_StaleMetaCount(t *testing.T) { } } +func TestTruncateHistory_IgnoresTransientThoughtForKeepLast(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + sessionKey := "transient-keep-last" + now := time.Now() + + rawJSONL := strings.Join([]string{ + `{"role":"user","content":"a"}`, + `{"role":"assistant","content":"b"}`, + `{"role":"assistant","content":"","reasoning_content":"dangling thought"}`, + `{"role":"user","content":"c"}`, + `{"role":"assistant","content":"d"}`, + }, "\n") + "\n" + if err := os.WriteFile(store.jsonlPath(sessionKey), []byte(rawJSONL), 0o644); err != nil { + t.Fatalf("WriteFile(jsonl): %v", err) + } + if err := store.writeMeta(sessionKey, SessionMeta{ + Key: sessionKey, + Count: 5, + Skip: 0, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("writeMeta: %v", err) + } + + if err := store.TruncateHistory(ctx, sessionKey, 2); err != nil { + t.Fatalf("TruncateHistory: %v", err) + } + + history, err := store.GetHistory(ctx, sessionKey) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 2 { + t.Fatalf("expected 2 retained messages, got %d", len(history)) + } + if history[0].Content != "c" || history[1].Content != "d" { + t.Fatalf("kept history = %+v, want c,d", history) + } + + meta, err := store.readMeta(sessionKey) + if err != nil { + t.Fatalf("readMeta: %v", err) + } + if meta.Skip != 2 { + t.Fatalf("meta.Skip = %d, want 2 raw lines skipped", meta.Skip) + } +} + func TestCrashRecovery_PartialLine(t *testing.T) { store := newTestStore(t) ctx := context.Background() diff --git a/pkg/memory/migration.go b/pkg/memory/migration.go index c9d5176ab..b64c62a9f 100644 --- a/pkg/memory/migration.go +++ b/pkg/memory/migration.go @@ -48,6 +48,12 @@ func MigrateFromJSON( if !strings.HasSuffix(name, ".json") { continue } + // Skip JSONL metadata files. They are part of the new storage format, + // not legacy session snapshots, and re-importing them would overwrite + // the paired .jsonl history with an empty message list. + if strings.HasSuffix(name, ".meta.json") { + continue + } // Skip already-migrated files. if strings.HasSuffix(name, ".migrated") { continue diff --git a/pkg/memory/migration_test.go b/pkg/memory/migration_test.go index 3170758b7..4466c96f9 100644 --- a/pkg/memory/migration_test.go +++ b/pkg/memory/migration_test.go @@ -382,3 +382,55 @@ func TestMigrateFromJSON_NonexistentDir(t *testing.T) { t.Errorf("expected 0, got %d", count) } } + +func TestMigrateFromJSON_SkipsMetaJSONFiles(t *testing.T) { + sessionsDir := t.TempDir() + store, err := NewJSONLStore(sessionsDir) + if err != nil { + t.Fatalf("NewJSONLStore: %v", err) + } + ctx := context.Background() + + if addErr := store.AddMessage(ctx, "agent:main:pico:direct:pico:test", "user", "keep me"); addErr != nil { + t.Fatalf("AddMessage: %v", addErr) + } + if summaryErr := store.SetSummary(ctx, "agent:main:pico:direct:pico:test", "keep summary"); summaryErr != nil { + t.Fatalf("SetSummary: %v", summaryErr) + } + + metaPath := filepath.Join(sessionsDir, "agent_main_pico_direct_pico_test.meta.json") + if _, statErr := os.Stat(metaPath); statErr != nil { + t.Fatalf("meta file missing before migration: %v", statErr) + } + + count, err := MigrateFromJSON(ctx, sessionsDir, store) + if err != nil { + t.Fatalf("MigrateFromJSON: %v", err) + } + if count != 0 { + t.Fatalf("expected 0 migrated, got %d", count) + } + + history, err := store.GetHistory(ctx, "agent:main:pico:direct:pico:test") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 || history[0].Content != "keep me" { + t.Fatalf("history = %+v, want preserved single message", history) + } + + summary, err := store.GetSummary(ctx, "agent:main:pico:direct:pico:test") + if err != nil { + t.Fatalf("GetSummary: %v", err) + } + if summary != "keep summary" { + t.Fatalf("summary = %q, want %q", summary, "keep summary") + } + + if _, statErr := os.Stat(metaPath); statErr != nil { + t.Fatalf("meta file should remain in place: %v", statErr) + } + if _, statErr := os.Stat(metaPath + ".migrated"); !os.IsNotExist(statErr) { + t.Fatalf("meta file should not be renamed, stat err = %v", statErr) + } +} diff --git a/pkg/memory/store.go b/pkg/memory/store.go index b6e11707d..11526b27c 100644 --- a/pkg/memory/store.go +++ b/pkg/memory/store.go @@ -37,6 +37,9 @@ type Store interface { // data. Backends that do not accumulate dead data may return nil. Compact(ctx context.Context, sessionKey string) error + // ListSessions returns all known session keys. + ListSessions() []string + // Close releases any resources held by the store. Close() error } diff --git a/pkg/migrate/internal/common.go b/pkg/migrate/internal/common.go index c77ab9f26..f1179c3a9 100644 --- a/pkg/migrate/internal/common.go +++ b/pkg/migrate/internal/common.go @@ -1,24 +1,18 @@ package internal import ( - "fmt" "io" "os" "path/filepath" + + "github.com/sipeed/picoclaw/pkg/config" ) func ResolveTargetHome(override string) (string, error) { if override != "" { return ExpandHome(override), nil } - if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" { - return ExpandHome(envHome), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolving home directory: %w", err) - } - return filepath.Join(home, ".picoclaw"), nil + return config.GetHome(), nil } func ExpandHome(path string) string { diff --git a/pkg/migrate/sources/openclaw/common.go b/pkg/migrate/sources/openclaw/common.go index d57dbe34f..938f15b80 100644 --- a/pkg/migrate/sources/openclaw/common.go +++ b/pkg/migrate/sources/openclaw/common.go @@ -4,7 +4,6 @@ var migrateableFiles = []string{ "AGENTS.md", "SOUL.md", "USER.md", - "TOOLS.md", "HEARTBEAT.md", } @@ -14,17 +13,16 @@ var migrateableDirs = []string{ } var supportedChannels = map[string]bool{ - "whatsapp": true, - "telegram": true, - "feishu": true, - "discord": true, - "maixcam": true, - "qq": true, - "dingtalk": true, - "slack": true, - "matrix": true, - "line": true, - "onebot": true, - "wecom": true, - "wecom_app": true, + "whatsapp": true, + "telegram": true, + "feishu": true, + "discord": true, + "maixcam": true, + "qq": true, + "dingtalk": true, + "slack": true, + "matrix": true, + "line": true, + "onebot": true, + "wecom": true, } diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go index 19d63bb77..4b8fec229 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config.go +++ b/pkg/migrate/sources/openclaw/openclaw_config.go @@ -132,11 +132,12 @@ type OpenClawChannels struct { } type OpenClawTelegramConfig struct { - BotToken *string `json:"botToken"` - AllowFrom []string `json:"allowFrom"` - GroupPolicy *string `json:"groupPolicy"` - DmPolicy *string `json:"dmPolicy"` - Enabled *bool `json:"enabled"` + BotToken *string `json:"botToken"` + AllowFrom []string `json:"allowFrom"` + GroupPolicy *string `json:"groupPolicy"` + DmPolicy *string `json:"dmPolicy"` + Enabled *bool `json:"enabled"` + UseMarkdownV2 *bool `json:"useMarkdownV2"` } type OpenClawDiscordConfig struct { @@ -645,10 +646,11 @@ type WhatsAppConfig struct { } type TelegramConfig struct { - Enabled bool `json:"enabled"` - Token string `json:"token"` - Proxy string `json:"proxy"` - AllowFrom []string `json:"allow_from"` + Enabled bool `json:"enabled"` + Token string `json:"token"` + Proxy string `json:"proxy"` + AllowFrom []string `json:"allow_from"` + UseMarkdownV2 bool `json:"use_markdown_v2"` } type FeishuConfig struct { @@ -733,16 +735,18 @@ type WebToolsConfig struct { } type BraveConfig struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - MaxResults int `json:"max_results"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + APIKeys []string `json:"api_keys"` + MaxResults int `json:"max_results"` } type TavilyConfig struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - BaseURL string `json:"base_url"` - MaxResults int `json:"max_results"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + APIKeys []string `json:"api_keys"` + BaseURL string `json:"base_url"` + MaxResults int `json:"max_results"` } type DuckDuckGoConfig struct { @@ -751,9 +755,10 @@ type DuckDuckGoConfig struct { } type PerplexityConfig struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - MaxResults int `json:"max_results"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + APIKeys []string `json:"api_keys"` + MaxResults int `json:"max_results"` } type CronConfig struct { @@ -774,9 +779,11 @@ func (c *OpenClawConfig) convertChannels(warnings *[]string) ChannelsConfig { if c.Channels.Telegram != nil { enabled := c.Channels.Telegram.Enabled == nil || *c.Channels.Telegram.Enabled + useMarkdownV2 := c.Channels.Telegram.UseMarkdownV2 != nil && *c.Channels.Telegram.UseMarkdownV2 channels.Telegram = TelegramConfig{ - Enabled: enabled, - AllowFrom: c.Channels.Telegram.AllowFrom, + Enabled: enabled, + AllowFrom: c.Channels.Telegram.AllowFrom, + UseMarkdownV2: useMarkdownV2, } if c.Channels.Telegram.BotToken != nil { channels.Telegram.Token = *c.Channels.Telegram.BotToken @@ -974,13 +981,16 @@ func (c *PicoClawConfig) ToStandardConfig() *config.Config { cfg.Agents.Defaults.ModelFallbacks = c.Agents.Defaults.ModelFallbacks for _, m := range c.ModelList { - cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + mc := &config.ModelConfig{ ModelName: m.ModelName, Model: m.Model, APIBase: m.APIBase, - APIKey: m.APIKey, Proxy: m.Proxy, - }) + } + if m.APIKey != "" { + mc.SetAPIKey(m.APIKey) + } + cfg.ModelList = append(cfg.ModelList, mc) } cfg.Channels = c.Channels.ToStandardChannels() @@ -1008,65 +1018,155 @@ func (c *PicoClawConfig) ToStandardConfig() *config.Config { } func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig { - return config.ChannelsConfig{ - WhatsApp: config.WhatsAppConfig{ - Enabled: c.WhatsApp.Enabled, - BridgeURL: c.WhatsApp.BridgeURL, - }, - Telegram: config.TelegramConfig{ - Enabled: c.Telegram.Enabled, - Token: c.Telegram.Token, - Proxy: c.Telegram.Proxy, - }, - Feishu: config.FeishuConfig{ - Enabled: c.Feishu.Enabled, - AppID: c.Feishu.AppID, - AppSecret: c.Feishu.AppSecret, - EncryptKey: c.Feishu.EncryptKey, - VerificationToken: c.Feishu.VerificationToken, - }, - Discord: config.DiscordConfig{ - Enabled: c.Discord.Enabled, - Token: c.Discord.Token, - MentionOnly: c.Discord.MentionOnly, - }, - MaixCam: config.MaixCamConfig{ - Enabled: c.MaixCam.Enabled, - Host: c.MaixCam.Host, - Port: c.MaixCam.Port, - }, - QQ: config.QQConfig{ - Enabled: c.QQ.Enabled, - AppID: c.QQ.AppID, - AppSecret: c.QQ.AppSecret, - }, - DingTalk: config.DingTalkConfig{ - Enabled: c.DingTalk.Enabled, - ClientID: c.DingTalk.ClientID, - ClientSecret: c.DingTalk.ClientSecret, - }, - Slack: config.SlackConfig{ - Enabled: c.Slack.Enabled, - BotToken: c.Slack.BotToken, - AppToken: c.Slack.AppToken, - }, - Matrix: config.MatrixConfig{ - Enabled: c.Matrix.Enabled, - Homeserver: c.Matrix.Homeserver, - UserID: c.Matrix.UserID, - AccessToken: c.Matrix.AccessToken, - AllowFrom: c.Matrix.AllowFrom, - JoinOnInvite: true, - }, - LINE: config.LINEConfig{ - Enabled: c.LINE.Enabled, - ChannelSecret: c.LINE.ChannelSecret, - ChannelAccessToken: c.LINE.ChannelAccessToken, - WebhookHost: c.LINE.WebhookHost, - WebhookPort: c.LINE.WebhookPort, - WebhookPath: c.LINE.WebhookPath, - }, + channels := make(config.ChannelsConfig) + + setChannel(channels, "whatsapp", map[string]any{ + "enabled": c.WhatsApp.Enabled, + "bridge_url": c.WhatsApp.BridgeURL, + }) + + setChannel(channels, "telegram", func() map[string]any { + m := map[string]any{ + "enabled": c.Telegram.Enabled, + "proxy": c.Telegram.Proxy, + } + if c.Telegram.Token != "" { + m["token"] = config.NewSecureString(c.Telegram.Token) + } + return m + }()) + + setChannel(channels, "feishu", func() map[string]any { + m := map[string]any{ + "enabled": c.Feishu.Enabled, + "app_id": c.Feishu.AppID, + } + if c.Feishu.AppSecret != "" { + m["app_secret"] = config.NewSecureString(c.Feishu.AppSecret) + } + if c.Feishu.EncryptKey != "" { + m["encrypt_key"] = config.NewSecureString(c.Feishu.EncryptKey) + } + if c.Feishu.VerificationToken != "" { + m["verification_token"] = config.NewSecureString(c.Feishu.VerificationToken) + } + return m + }()) + + setChannel(channels, "discord", func() map[string]any { + m := map[string]any{ + "enabled": c.Discord.Enabled, + "mention_only": c.Discord.MentionOnly, + } + if c.Discord.Token != "" { + m["token"] = config.NewSecureString(c.Discord.Token) + } + return m + }()) + + setChannel(channels, "maixcam", map[string]any{ + "enabled": c.MaixCam.Enabled, + "host": c.MaixCam.Host, + "port": c.MaixCam.Port, + }) + + setChannel(channels, "qq", func() map[string]any { + m := map[string]any{ + "enabled": c.QQ.Enabled, + "app_id": c.QQ.AppID, + } + if c.QQ.AppSecret != "" { + m["app_secret"] = config.NewSecureString(c.QQ.AppSecret) + } + return m + }()) + + setChannel(channels, "dingtalk", func() map[string]any { + m := map[string]any{ + "enabled": c.DingTalk.Enabled, + "client_id": c.DingTalk.ClientID, + } + if c.DingTalk.ClientSecret != "" { + m["client_secret"] = config.NewSecureString(c.DingTalk.ClientSecret) + } + return m + }()) + + setChannel(channels, "slack", func() map[string]any { + m := map[string]any{ + "enabled": c.Slack.Enabled, + } + if c.Slack.BotToken != "" { + m["bot_token"] = config.NewSecureString(c.Slack.BotToken) + } + if c.Slack.AppToken != "" { + m["app_token"] = config.NewSecureString(c.Slack.AppToken) + } + return m + }()) + + setChannel(channels, "matrix", func() map[string]any { + m := map[string]any{ + "enabled": c.Matrix.Enabled, + "homeserver": c.Matrix.Homeserver, + "user_id": c.Matrix.UserID, + "allow_from": c.Matrix.AllowFrom, + "join_on_invite": true, + } + if c.Matrix.AccessToken != "" { + m["access_token"] = config.NewSecureString(c.Matrix.AccessToken) + } + return m + }()) + + setChannel(channels, "line", func() map[string]any { + m := map[string]any{ + "enabled": c.LINE.Enabled, + "webhook_host": c.LINE.WebhookHost, + "webhook_port": c.LINE.WebhookPort, + "webhook_path": c.LINE.WebhookPath, + } + if c.LINE.ChannelSecret != "" { + m["channel_secret"] = config.NewSecureString(c.LINE.ChannelSecret) + } + if c.LINE.ChannelAccessToken != "" { + m["channel_access_token"] = config.NewSecureString(c.LINE.ChannelAccessToken) + } + return m + }()) + + return channels +} + +func setChannel(channels config.ChannelsConfig, name string, cfg any) { + data, err := json.Marshal(cfg) + if err != nil { + return } + // Wrap in "settings" for nested format + var m map[string]any + if err = json.Unmarshal(data, &m); err != nil { + return + } + settings := make(map[string]any) + for k, v := range m { + if _, exists := config.BaseFieldNames[k]; !exists { + settings[k] = v + delete(m, k) + } + } + if len(settings) > 0 { + m["settings"] = settings + } + nestedData, err := json.Marshal(m) + if err != nil { + return + } + bc := &config.Channel{} + if err := json.Unmarshal(nestedData, bc); err != nil { + return + } + channels[name] = bc } func (c GatewayConfig) ToStandardGateway() config.GatewayConfig { @@ -1077,29 +1177,44 @@ func (c GatewayConfig) ToStandardGateway() config.GatewayConfig { } func (c ToolsConfig) ToStandardTools() config.ToolsConfig { + brave := config.BraveConfig{ + Enabled: c.Web.Brave.Enabled, + MaxResults: c.Web.Brave.MaxResults, + } + if c.Web.Brave.APIKey != "" { + brave.SetAPIKey(c.Web.Brave.APIKey) + } + if len(c.Web.Brave.APIKeys) > 0 { + brave.SetAPIKeys(c.Web.Brave.APIKeys) + } + + tavily := config.TavilyConfig{ + Enabled: c.Web.Tavily.Enabled, + BaseURL: c.Web.Tavily.BaseURL, + MaxResults: c.Web.Tavily.MaxResults, + } + if c.Web.Tavily.APIKey != "" { + tavily.SetAPIKey(c.Web.Tavily.APIKey) + } + + perplexity := config.PerplexityConfig{ + Enabled: c.Web.Perplexity.Enabled, + MaxResults: c.Web.Perplexity.MaxResults, + } + if c.Web.Perplexity.APIKey != "" { + perplexity.SetAPIKey(c.Web.Perplexity.APIKey) + } + return config.ToolsConfig{ Web: config.WebToolsConfig{ - Brave: config.BraveConfig{ - Enabled: c.Web.Brave.Enabled, - APIKey: c.Web.Brave.APIKey, - MaxResults: c.Web.Brave.MaxResults, - }, - Tavily: config.TavilyConfig{ - Enabled: c.Web.Tavily.Enabled, - APIKey: c.Web.Tavily.APIKey, - BaseURL: c.Web.Tavily.BaseURL, - MaxResults: c.Web.Tavily.MaxResults, - }, + Brave: brave, + Tavily: tavily, DuckDuckGo: config.DuckDuckGoConfig{ Enabled: c.Web.DuckDuckGo.Enabled, MaxResults: c.Web.DuckDuckGo.MaxResults, }, - Perplexity: config.PerplexityConfig{ - Enabled: c.Web.Perplexity.Enabled, - APIKey: c.Web.Perplexity.APIKey, - MaxResults: c.Web.Perplexity.MaxResults, - }, - Proxy: c.Web.Proxy, + Perplexity: perplexity, + Proxy: c.Web.Proxy, }, Cron: config.CronToolsConfig{ ExecTimeoutMinutes: c.Cron.ExecTimeoutMinutes, @@ -1107,6 +1222,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig { Exec: config.ExecConfig{ EnableDenyPatterns: c.Exec.EnableDenyPatterns, CustomDenyPatterns: c.Exec.CustomDenyPatterns, + AllowRemote: config.DefaultConfig().Tools.Exec.AllowRemote, }, } } diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go index 3a7d0c686..ceb27c4d8 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config_test.go +++ b/pkg/migrate/sources/openclaw/openclaw_config_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/sipeed/picoclaw/pkg/config" ) func TestLoadOpenClawConfig(t *testing.T) { @@ -290,6 +292,20 @@ func TestConvertToPicoClaw(t *testing.T) { } } +func TestToStandardConfig_ExecAllowRemoteDefaultsTrue(t *testing.T) { + cfg := (&PicoClawConfig{ + Tools: ToolsConfig{ + Exec: ExecConfig{ + EnableDenyPatterns: true, + }, + }, + }).ToStandardConfig() + + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("ToStandardConfig() should preserve the default tools.exec.allow_remote=true") + } +} + func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") @@ -683,7 +699,7 @@ func TestToStandardConfig(t *testing.T) { for _, m := range stdCfg.ModelList { if m.ModelName == "claude-sonnet-4-20250514" { foundModel = true - foundAPIKey = m.APIKey + foundAPIKey = m.APIKey() break } } @@ -694,11 +710,16 @@ func TestToStandardConfig(t *testing.T) { t.Errorf("expected api key 'sk-ant-test', got '%s'", foundAPIKey) } - if !stdCfg.Channels.Telegram.Enabled { + if !stdCfg.Channels["telegram"].Enabled { t.Error("telegram should be enabled") } - if stdCfg.Channels.Telegram.Token != "test-token" { - t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token) + decoded, err := stdCfg.Channels["telegram"].GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + if tCfg, ok := decoded.(*config.TelegramSettings); ok && + tCfg.Token.String() != "test-token" { + t.Errorf("expected token 'test-token', got '%s'", tCfg.Token.String()) } if stdCfg.Gateway.Port != 8080 { diff --git a/pkg/migrate/sources/openclaw/openclaw_handler.go b/pkg/migrate/sources/openclaw/openclaw_handler.go index aaff119f1..5e5241268 100644 --- a/pkg/migrate/sources/openclaw/openclaw_handler.go +++ b/pkg/migrate/sources/openclaw/openclaw_handler.go @@ -10,6 +10,11 @@ import ( "github.com/sipeed/picoclaw/pkg/migrate/internal" ) +// OpenclawHomeEnvVar is the environment variable that overrides the source +// openclaw home directory when migrating from openclaw to picoclaw. +// Default: ~/.openclaw +const OpenclawHomeEnvVar = "OPENCLAW_HOME" + var providerMapping = map[string]string{ "anthropic": "anthropic", "claude": "anthropic", @@ -112,7 +117,7 @@ func resolveSourceHome(override string) (string, error) { if override != "" { return internal.ExpandHome(override), nil } - if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" { + if envHome := os.Getenv(OpenclawHomeEnvVar); envHome != "" { return internal.ExpandHome(envHome), nil } home, err := os.UserHomeDir() diff --git a/pkg/netbind/netbind.go b/pkg/netbind/netbind.go new file mode 100644 index 000000000..ae6cacf49 --- /dev/null +++ b/pkg/netbind/netbind.go @@ -0,0 +1,606 @@ +package netbind + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "strings" + "sync" +) + +type DefaultMode int + +const ( + DefaultLoopback DefaultMode = iota + DefaultAny +) + +type groupKind int + +const ( + groupAdaptiveLoopback groupKind = iota + groupAdaptiveAny + groupExact +) + +type exactBinding struct { + host string + network string + v6Only bool +} + +type bindGroup struct { + kind groupKind + allowIPv4 bool + allowIPv6 bool + exact exactBinding +} + +type Plan struct { + groups []bindGroup + ProbeHost string +} + +type OpenResult struct { + Listeners []net.Listener + BindHosts []string + Port string + ProbeHost string +} + +type tokenKind int + +const ( + tokenName tokenKind = iota + tokenLocalhost + tokenStar + tokenIPv4 + tokenIPv6 + tokenIPv4Any + tokenIPv6Any +) + +type hostToken struct { + kind tokenKind + canonical string + key string +} + +var ( + ipFamiliesOnce sync.Once + hasIPv4 bool + hasIPv6 bool +) + +func DetectIPFamilies() (bool, bool) { + ipFamiliesOnce.Do(func() { + if ips, err := net.LookupIP("localhost"); err == nil { + for _, ip := range ips { + if ip == nil { + continue + } + if ip.To4() != nil { + hasIPv4 = true + continue + } + hasIPv6 = true + } + } + + if hasIPv4 && hasIPv6 { + return + } + + if addrs, err := net.InterfaceAddrs(); err == nil { + for _, addr := range addrs { + ipnet, ok := addr.(*net.IPNet) + if !ok || ipnet.IP == nil { + continue + } + if ipnet.IP.To4() != nil { + hasIPv4 = true + continue + } + hasIPv6 = true + } + } + }) + + return hasIPv4, hasIPv6 +} + +func SelectAdaptiveLoopbackHost(hasIPv4, hasIPv6 bool) string { + switch { + case hasIPv4 && hasIPv6: + return "localhost" + case hasIPv6: + return "::1" + case hasIPv4: + return "127.0.0.1" + default: + return "localhost" + } +} + +func SelectAdaptiveAnyHost(hasIPv4, hasIPv6 bool) string { + switch { + case hasIPv4 && hasIPv6: + return "::" + case hasIPv6: + return "::" + case hasIPv4: + return "0.0.0.0" + default: + return "::" + } +} + +func ResolveAdaptiveLoopbackHost() string { + hasIPv4, hasIPv6 := DetectIPFamilies() + return SelectAdaptiveLoopbackHost(hasIPv4, hasIPv6) +} + +func ResolveAdaptiveAnyHost() string { + hasIPv4, hasIPv6 := DetectIPFamilies() + return SelectAdaptiveAnyHost(hasIPv4, hasIPv6) +} + +func IsLoopbackHost(host string) bool { + host = strings.TrimSpace(host) + if host == "" { + return false + } + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(strings.Trim(host, "[]")) + return ip != nil && ip.IsLoopback() +} + +func IsUnspecifiedHost(host string) bool { + host = strings.TrimSpace(host) + if host == "" { + return false + } + ip := net.ParseIP(strings.Trim(host, "[]")) + return ip != nil && ip.IsUnspecified() +} + +func NormalizeHostInput(raw string) (string, error) { + tokens, err := parseHostTokens(raw) + if err != nil { + return "", err + } + + parts := make([]string, 0, len(tokens)) + for _, token := range tokens { + parts = append(parts, token.canonical) + } + return strings.Join(parts, ","), nil +} + +func BuildPlan(raw string, defaultMode DefaultMode) (Plan, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return buildDefaultPlan(defaultMode), nil + } + + tokens, err := parseHostTokens(raw) + if err != nil { + return Plan{}, err + } + + for _, token := range tokens { + if token.kind == tokenStar { + return Plan{ + groups: []bindGroup{{kind: groupAdaptiveAny}}, + ProbeHost: ResolveAdaptiveLoopbackHost(), + }, nil + } + } + + hasIPv4Any := false + hasIPv6Any := false + for _, token := range tokens { + switch token.kind { + case tokenIPv4Any: + hasIPv4Any = true + case tokenIPv6Any: + hasIPv6Any = true + } + } + + allowLocalhostIPv4 := !hasIPv4Any + allowLocalhostIPv6 := !hasIPv6Any + + groups := make([]bindGroup, 0, len(tokens)) + seenExact := make(map[string]struct{}, len(tokens)) + addedLocalhost := false + + for _, token := range tokens { + switch token.kind { + case tokenLocalhost: + if addedLocalhost || (!allowLocalhostIPv4 && !allowLocalhostIPv6) { + continue + } + groups = append(groups, bindGroup{ + kind: groupAdaptiveLoopback, + allowIPv4: allowLocalhostIPv4, + allowIPv6: allowLocalhostIPv6, + }) + addedLocalhost = true + case tokenIPv4Any: + key := "exact:tcp4:0.0.0.0" + if _, ok := seenExact[key]; ok { + continue + } + seenExact[key] = struct{}{} + groups = append(groups, bindGroup{ + kind: groupExact, + exact: exactBinding{ + host: "0.0.0.0", + network: "tcp4", + }, + }) + case tokenIPv6Any: + key := "exact:tcp6:::" + if _, ok := seenExact[key]; ok { + continue + } + seenExact[key] = struct{}{} + groups = append(groups, bindGroup{ + kind: groupExact, + exact: exactBinding{ + host: "::", + network: "tcp6", + v6Only: true, + }, + }) + case tokenIPv4: + if hasIPv4Any { + continue + } + key := "exact:tcp4:" + strings.ToLower(token.canonical) + if _, ok := seenExact[key]; ok { + continue + } + seenExact[key] = struct{}{} + groups = append(groups, bindGroup{ + kind: groupExact, + exact: exactBinding{ + host: token.canonical, + network: "tcp4", + }, + }) + case tokenIPv6: + if hasIPv6Any { + continue + } + key := "exact:tcp6:" + strings.ToLower(token.canonical) + if _, ok := seenExact[key]; ok { + continue + } + seenExact[key] = struct{}{} + groups = append(groups, bindGroup{ + kind: groupExact, + exact: exactBinding{ + host: token.canonical, + network: "tcp6", + v6Only: true, + }, + }) + case tokenName: + key := "exact:tcp:" + token.key + if _, ok := seenExact[key]; ok { + continue + } + seenExact[key] = struct{}{} + groups = append(groups, bindGroup{ + kind: groupExact, + exact: exactBinding{ + host: token.canonical, + network: "tcp", + }, + }) + } + } + + plan := Plan{groups: groups} + plan.ProbeHost = probeHostForGroups(groups) + return plan, nil +} + +func OpenPlan(plan Plan, port string) (OpenResult, error) { + if port == "" { + return OpenResult{}, errors.New("port cannot be empty") + } + + selectedPort := port + listeners := make([]net.Listener, 0, len(plan.groups)) + bindHosts := make([]string, 0, len(plan.groups)) + bindSeen := make(map[string]struct{}, len(plan.groups)) + + closeAll := func() { + for _, ln := range listeners { + _ = ln.Close() + } + } + + for _, group := range plan.groups { + groupListeners, groupHosts, actualPort, err := openGroup(group, selectedPort) + if err != nil { + closeAll() + return OpenResult{}, err + } + if selectedPort == "0" && actualPort != "" { + selectedPort = actualPort + } + listeners = append(listeners, groupListeners...) + for _, host := range groupHosts { + key := strings.ToLower(host) + if _, ok := bindSeen[key]; ok { + continue + } + bindSeen[key] = struct{}{} + bindHosts = append(bindHosts, host) + } + } + + return OpenResult{ + Listeners: listeners, + BindHosts: bindHosts, + Port: selectedPort, + ProbeHost: plan.ProbeHost, + }, nil +} + +func buildDefaultPlan(defaultMode DefaultMode) Plan { + switch defaultMode { + case DefaultAny: + return Plan{ + groups: []bindGroup{{kind: groupAdaptiveAny}}, + ProbeHost: ResolveAdaptiveLoopbackHost(), + } + default: + return Plan{ + groups: []bindGroup{{ + kind: groupAdaptiveLoopback, + allowIPv4: true, + allowIPv6: true, + }}, + ProbeHost: ResolveAdaptiveLoopbackHost(), + } + } +} + +func probeHostForGroups(groups []bindGroup) string { + hasIPv4Any := false + hasIPv6Any := false + for _, group := range groups { + if group.kind == groupAdaptiveLoopback { + switch { + case group.allowIPv4 && group.allowIPv6: + return ResolveAdaptiveLoopbackHost() + case group.allowIPv6: + return "::1" + case group.allowIPv4: + return "127.0.0.1" + } + } + if group.kind == groupAdaptiveAny { + return ResolveAdaptiveLoopbackHost() + } + if group.kind != groupExact { + continue + } + switch group.exact.host { + case "0.0.0.0": + hasIPv4Any = true + case "::": + hasIPv6Any = true + } + } + + switch { + case hasIPv4Any && hasIPv6Any: + return ResolveAdaptiveLoopbackHost() + case hasIPv6Any: + return "::1" + case hasIPv4Any: + return "127.0.0.1" + } + + for _, group := range groups { + if group.kind == groupExact { + return group.exact.host + } + } + return ResolveAdaptiveLoopbackHost() +} + +func parseHostTokens(raw string) ([]hostToken, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("host cannot be empty") + } + + parts := strings.Split(raw, ",") + tokens := make([]hostToken, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + token, err := parseHostToken(part) + if err != nil { + return nil, err + } + if _, ok := seen[token.key]; ok { + continue + } + seen[token.key] = struct{}{} + tokens = append(tokens, token) + } + + if len(tokens) == 0 { + return nil, errors.New("host cannot be empty") + } + + return tokens, nil +} + +func parseHostToken(raw string) (hostToken, error) { + host := strings.TrimSpace(raw) + if host == "" { + return hostToken{}, errors.New("host list contains an empty entry") + } + + if host == "*" { + return hostToken{kind: tokenStar, canonical: "*", key: "*"}, nil + } + if strings.EqualFold(host, "localhost") { + return hostToken{kind: tokenLocalhost, canonical: "localhost", key: "localhost"}, nil + } + + trimmed := strings.Trim(host, "[]") + if ip := net.ParseIP(trimmed); ip != nil { + if ip4 := ip.To4(); ip4 != nil { + canonical := ip4.String() + kind := tokenIPv4 + if ip4.IsUnspecified() { + kind = tokenIPv4Any + } + return hostToken{kind: kind, canonical: canonical, key: canonical}, nil + } + + canonical := ip.String() + kind := tokenIPv6 + if ip.IsUnspecified() { + kind = tokenIPv6Any + } + return hostToken{kind: kind, canonical: canonical, key: strings.ToLower(canonical)}, nil + } + + return hostToken{ + kind: tokenName, + canonical: host, + key: strings.ToLower(host), + }, nil +} + +func openGroup(group bindGroup, port string) ([]net.Listener, []string, string, error) { + switch group.kind { + case groupAdaptiveLoopback: + return openAdaptiveLoopbackGroup(group.allowIPv6, group.allowIPv4, port) + case groupAdaptiveAny: + return openAdaptiveAnyGroup(port) + case groupExact: + ln, actualPort, err := openExactListener(group.exact, port) + if err != nil { + return nil, nil, "", err + } + return []net.Listener{ln}, []string{group.exact.host}, actualPort, nil + default: + return nil, nil, "", fmt.Errorf("unsupported bind group kind: %d", group.kind) + } +} + +func openAdaptiveLoopbackGroup(allowIPv6, allowIPv4 bool, port string) ([]net.Listener, []string, string, error) { + if allowIPv6 && allowIPv4 { + if ln6, actualPort, err6 := openExactListener( + exactBinding{host: "::1", network: "tcp6", v6Only: true}, + port, + ); err6 == nil { + if ln4, _, err4 := openExactListener( + exactBinding{host: "127.0.0.1", network: "tcp4"}, + actualPort, + ); err4 == nil { + return []net.Listener{ln6, ln4}, []string{"::1", "127.0.0.1"}, actualPort, nil + } + _ = ln6.Close() + } + } + + if allowIPv6 { + ln6, actualPort, err := openExactListener(exactBinding{host: "::1", network: "tcp6", v6Only: true}, port) + if err == nil { + return []net.Listener{ln6}, []string{"::1"}, actualPort, nil + } + } + + if allowIPv4 { + ln4, actualPort, err := openExactListener(exactBinding{host: "127.0.0.1", network: "tcp4"}, port) + if err == nil { + return []net.Listener{ln4}, []string{"127.0.0.1"}, actualPort, nil + } + } + + return nil, nil, "", fmt.Errorf("failed to open adaptive localhost listener on port %s", port) +} + +func openAdaptiveAnyGroup(port string) ([]net.Listener, []string, string, error) { + hasIPv4, hasIPv6 := DetectIPFamilies() + + if hasIPv4 && hasIPv6 { + if ln6, actualPort, err6 := openExactListener( + exactBinding{host: "::", network: "tcp6", v6Only: true}, + port, + ); err6 == nil { + if ln4, _, err4 := openExactListener( + exactBinding{host: "0.0.0.0", network: "tcp4"}, + actualPort, + ); err4 == nil { + return []net.Listener{ln6, ln4}, []string{"::", "0.0.0.0"}, actualPort, nil + } + _ = ln6.Close() + } + } + + if hasIPv6 { + ln6, actualPort, err := openExactListener(exactBinding{host: "::", network: "tcp6", v6Only: true}, port) + if err == nil { + return []net.Listener{ln6}, []string{"::"}, actualPort, nil + } + } + + if hasIPv4 { + ln4, actualPort, err := openExactListener(exactBinding{host: "0.0.0.0", network: "tcp4"}, port) + if err == nil { + return []net.Listener{ln4}, []string{"0.0.0.0"}, actualPort, nil + } + } + + return nil, nil, "", fmt.Errorf("failed to open adaptive any-host listener on port %s", port) +} + +func openExactListener(binding exactBinding, port string) (net.Listener, string, error) { + listenConfig := net.ListenConfig{} + if binding.network == "tcp6" && binding.v6Only { + listenConfig.Control = applyIPv6OnlyControl(true) + } + + ln, err := listenConfig.Listen(context.Background(), binding.network, net.JoinHostPort(binding.host, port)) + if err != nil { + return nil, "", err + } + + actualPort, err := listenerPort(ln) + if err != nil { + _ = ln.Close() + return nil, "", err + } + + return ln, actualPort, nil +} + +func listenerPort(ln net.Listener) (string, error) { + addr, ok := ln.Addr().(*net.TCPAddr) + if ok { + return strconv.Itoa(addr.Port), nil + } + + _, port, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + return "", err + } + return port, nil +} diff --git a/pkg/netbind/netbind_test.go b/pkg/netbind/netbind_test.go new file mode 100644 index 000000000..20b7ff141 --- /dev/null +++ b/pkg/netbind/netbind_test.go @@ -0,0 +1,280 @@ +package netbind + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "strconv" + "testing" + "time" +) + +func TestNormalizeHostInput(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "single host", raw: "127.0.0.1", want: "127.0.0.1"}, + {name: "trim and dedupe", raw: " [::1] , ::1 , 127.0.0.1 ", want: "::1,127.0.0.1"}, + {name: "star preserved", raw: "*,127.0.0.1", want: "*,127.0.0.1"}, + {name: "reject empty", raw: "127.0.0.1, ", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeHostInput(tt.raw) + if (err != nil) != tt.wantErr { + t.Fatalf("NormalizeHostInput() err = %v, wantErr %t", err, tt.wantErr) + } + if tt.wantErr { + return + } + if got != tt.want { + t.Fatalf("NormalizeHostInput() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildPlan_DefaultAnyUsesLoopbackProbe(t *testing.T) { + plan, err := BuildPlan("", DefaultAny) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + if plan.ProbeHost != ResolveAdaptiveLoopbackHost() { + t.Fatalf("ProbeHost = %q, want %q", plan.ProbeHost, ResolveAdaptiveLoopbackHost()) + } +} + +func TestOpenPlan_LocalhostSupportsLoopbackCommunication(t *testing.T) { + hasIPv4, hasIPv6 := DetectIPFamilies() + + plan, err := BuildPlan("localhost", DefaultLoopback) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + result, err := OpenPlan(plan, "0") + if err != nil { + t.Fatalf("OpenPlan() error = %v", err) + } + startTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + if hasIPv6 { + requireHTTPReachable(t, "::1", port) + } + if hasIPv4 { + requireHTTPReachable(t, "127.0.0.1", port) + } +} + +func TestOpenPlan_DefaultAnySupportsDualStackLoopback(t *testing.T) { + hasIPv4, hasIPv6 := DetectIPFamilies() + + plan, err := BuildPlan("", DefaultAny) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + result, err := OpenPlan(plan, "0") + if err != nil { + t.Fatalf("OpenPlan() error = %v", err) + } + startTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + if hasIPv6 { + requireHTTPReachable(t, "::1", port) + } + if hasIPv4 { + requireHTTPReachable(t, "127.0.0.1", port) + } + + switch { + case hasIPv4 && hasIPv6: + if len(result.BindHosts) != 2 { + t.Fatalf("len(BindHosts) = %d, want 2 (%#v)", len(result.BindHosts), result.BindHosts) + } + case hasIPv6 || hasIPv4: + if len(result.BindHosts) != 1 { + t.Fatalf("len(BindHosts) = %d, want 1 (%#v)", len(result.BindHosts), result.BindHosts) + } + } +} + +func TestOpenPlan_ExplicitIPv6AnyIsIPv6Only(t *testing.T) { + hasIPv4, hasIPv6 := DetectIPFamilies() + if !hasIPv6 { + t.Skip("IPv6 is unavailable in this environment") + } + + plan, err := BuildPlan("::", DefaultLoopback) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + result, err := OpenPlan(plan, "0") + if err != nil { + t.Fatalf("OpenPlan() error = %v", err) + } + startTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + requireHTTPReachable(t, "::1", port) + if hasIPv4 { + requireHTTPUnreachable(t, "127.0.0.1", port) + } +} + +func TestOpenPlan_ExplicitIPv4AnyIsIPv4Only(t *testing.T) { + hasIPv4, hasIPv6 := DetectIPFamilies() + if !hasIPv4 { + t.Skip("IPv4 is unavailable in this environment") + } + + plan, err := BuildPlan("0.0.0.0", DefaultLoopback) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + result, err := OpenPlan(plan, "0") + if err != nil { + t.Fatalf("OpenPlan() error = %v", err) + } + startTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + requireHTTPReachable(t, "127.0.0.1", port) + if hasIPv6 { + requireHTTPUnreachable(t, "::1", port) + } +} + +func TestOpenPlan_MultiHostSupportsExplicitIPv4AndIPv6(t *testing.T) { + hasIPv4, hasIPv6 := DetectIPFamilies() + if !hasIPv4 || !hasIPv6 { + t.Skip("dual-stack loopback is unavailable in this environment") + } + + plan, err := BuildPlan("127.0.0.1,::1", DefaultLoopback) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + result, err := OpenPlan(plan, "0") + if err != nil { + t.Fatalf("OpenPlan() error = %v", err) + } + startTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + requireHTTPReachable(t, "127.0.0.1", port) + requireHTTPReachable(t, "::1", port) +} + +func TestOpenPlan_WildcardRulesKeepIPv4AndIPv6AnyHosts(t *testing.T) { + hasIPv4, hasIPv6 := DetectIPFamilies() + if !hasIPv4 || !hasIPv6 { + t.Skip("dual-stack loopback is unavailable in this environment") + } + + plan, err := BuildPlan("::,::1,0.0.0.0,127.0.0.1", DefaultLoopback) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + result, err := OpenPlan(plan, "0") + if err != nil { + t.Fatalf("OpenPlan() error = %v", err) + } + startTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + requireHTTPReachable(t, "127.0.0.1", port) + requireHTTPReachable(t, "::1", port) + if len(result.BindHosts) != 2 { + t.Fatalf("len(BindHosts) = %d, want 2 (%#v)", len(result.BindHosts), result.BindHosts) + } +} + +func startTestHTTPServer(t *testing.T, listeners []net.Listener) { + t.Helper() + + server := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + }), + } + + errCh := make(chan error, len(listeners)) + for _, listener := range listeners { + ln := listener + go func() { + errCh <- server.Serve(ln) + }() + } + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + for range listeners { + err := <-errCh + if err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("server.Serve() error = %v", err) + } + } + }) +} + +func requireHTTPReachable(t *testing.T, host string, port int) { + t.Helper() + + deadline := time.Now().Add(2 * time.Second) + for { + err := httpGET(host, port) + if err == nil { + return + } + if time.Now().After(deadline) { + t.Fatalf("expected %s:%d to be reachable: %v", host, port, err) + } + time.Sleep(50 * time.Millisecond) + } +} + +func requireHTTPUnreachable(t *testing.T, host string, port int) { + t.Helper() + + if err := httpGET(host, port); err == nil { + t.Fatalf("expected %s:%d to be unreachable", host, port) + } +} + +func httpGET(host string, port int) error { + client := &http.Client{ + Timeout: 300 * time.Millisecond, + Transport: &http.Transport{ + Proxy: nil, + }, + } + + resp, err := client.Get("http://" + net.JoinHostPort(host, strconv.Itoa(port))) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errors.New(resp.Status) + } + return nil +} + +func mustAtoi(t *testing.T, value string) int { + t.Helper() + n, err := strconv.Atoi(value) + if err != nil { + t.Fatalf("Atoi(%q) error = %v", value, err) + } + return n +} diff --git a/pkg/netbind/socket_v6only_unix.go b/pkg/netbind/socket_v6only_unix.go new file mode 100644 index 000000000..20cf7bbce --- /dev/null +++ b/pkg/netbind/socket_v6only_unix.go @@ -0,0 +1,25 @@ +//go:build !windows + +package netbind + +import ( + "syscall" + + "golang.org/x/sys/unix" +) + +func applyIPv6OnlyControl(enabled bool) func(string, string, syscall.RawConn) error { + return func(_, _ string, rawConn syscall.RawConn) error { + var controlErr error + if err := rawConn.Control(func(fd uintptr) { + value := 0 + if enabled { + value = 1 + } + controlErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_V6ONLY, value) + }); err != nil { + return err + } + return controlErr + } +} diff --git a/pkg/netbind/socket_v6only_windows.go b/pkg/netbind/socket_v6only_windows.go new file mode 100644 index 000000000..006b4e1ac --- /dev/null +++ b/pkg/netbind/socket_v6only_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package netbind + +import ( + "syscall" + + "golang.org/x/sys/windows" +) + +func applyIPv6OnlyControl(enabled bool) func(string, string, syscall.RawConn) error { + return func(_, _ string, rawConn syscall.RawConn) error { + var controlErr error + if err := rawConn.Control(func(fd uintptr) { + value := 0 + if enabled { + value = 1 + } + controlErr = windows.SetsockoptInt(windows.Handle(fd), windows.IPPROTO_IPV6, windows.IPV6_V6ONLY, value) + }); err != nil { + return err + } + return controlErr + } +} diff --git a/pkg/pid/pidfile.go b/pkg/pid/pidfile.go new file mode 100644 index 000000000..00601195f --- /dev/null +++ b/pkg/pid/pidfile.go @@ -0,0 +1,210 @@ +package pid + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const pidFileName = ".picoclaw.pid" + +var errInvalidPidFile = errors.New("invalid pid file") + +// PidFileData is the JSON structure stored in the PID file. +type PidFileData struct { + PID int `json:"pid"` + Token string `json:"token"` + Version string `json:"version"` + Port int `json:"port"` + Host string `json:"host"` +} + +var pidMu sync.Mutex + +// pidFilePath returns the absolute path for the PID file given the home directory. +func pidFilePath(homePath string) string { + return filepath.Join(homePath, pidFileName) +} + +// generateToken creates a cryptographically random 32-character hex token. +func generateToken() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // Fallback to something pseudo-random if crypto/rand fails + return fmt.Sprintf("%032x", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + +// WritePidFile creates (or overwrites) the PID file atomically. +// It returns an error if another gateway instance appears to be running +// (a valid PID file exists with a live process). +func WritePidFile(homePath, host string, port int) (*PidFileData, error) { + pidMu.Lock() + defer pidMu.Unlock() + + pidPath := pidFilePath(homePath) + + // Check for existing PID file → singleton enforcement. + if data, err := readPidFileUnlocked(pidPath); err == nil { + if os.Getpid() != data.PID { + logger.Infof("found pid file (PID: %d, version: %s)", data.PID, data.Version) + // PID 1 is typically init/systemd on the host or the entrypoint + // inside a container. When a container stops and leaves behind a + // PID file on a shared volume, the host's PID 1 (init) would + // pass the isProcessRunning check, blocking new gateway starts. + // Treat recorded PID 1 as always stale. + if data.PID != 1 && isProcessRunning(data.PID) { + return nil, fmt.Errorf("gateway is already running (PID: %d, version: %s)", data.PID, data.Version) + } + logger.Warnf("not running (PID: %d) so will remove the pid file: %s", data.PID, pidPath) + } + // Stale PID file; process no longer exists → clean up. + os.Remove(pidPath) + } + + data := &PidFileData{ + PID: os.Getpid(), + Version: config.GetVersion(), + Port: port, + Host: host, + } + + token := generateToken() + data.Token = token + + raw, err := json.MarshalIndent(data, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal pid file: %w", err) + } + + // Ensure parent directory exists. + dir := filepath.Dir(pidPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create pid directory: %w", err) + } + + // Write atomically via temp file + rename. + tmp := pidPath + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return nil, fmt.Errorf("failed to write pid file: %w", err) + } + if err := os.Rename(tmp, pidPath); err != nil { + os.Remove(tmp) + return nil, fmt.Errorf("failed to rename pid file: %w", err) + } + logger.Debugf("wrote pid file: %s success", pidPath) + + return data, nil +} + +// ReadPidFileWithCheck reads the PID file and additionally checks if +// the recorded process is still alive. Returns nil if the file is +// missing, unreadable, or the process has exited. +func ReadPidFileWithCheck(homePath string) *PidFileData { + pidMu.Lock() + defer pidMu.Unlock() + + pidPath := pidFilePath(homePath) + data, err := readPidFileUnlocked(pidPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + if errors.Is(err, errInvalidPidFile) { + logger.Warnf("invalid pid file, remove it: %s (%v)", pidPath, err) + _ = os.Remove(pidPath) + return nil + } + logger.Debugf("failed to read pid file: %s", err) + return nil + } + + // Treat PID 1 as stale when we are not PID 1 ourselves (container + // leftover on a shared volume — host PID 1 is init, not gateway). + if data.PID == 1 && os.Getpid() != 1 { + logger.Debugf("stale container PID 1, remove pid file: %s", pidPath) + os.Remove(pidPath) + return nil + } + + if !isProcessRunning(data.PID) { + logger.Debugf("process not running, remove pid file: %s", pidPath) + os.Remove(pidPath) + return nil + } + + return data +} + +// RemovePidFile deletes the PID file (e.g. on graceful shutdown). +func RemovePidFile(homePath string) { + pidMu.Lock() + defer pidMu.Unlock() + + pidPath := pidFilePath(homePath) + // Only remove if the PID matches our own process (avoid deleting + // a file that belongs to a newer gateway instance). + if data, err := readPidFileUnlocked(pidPath); err == nil { + if data.PID != os.Getpid() { + return + } + } + + logger.Infof("remove pid file: %s", pidPath) + os.Remove(pidPath) +} + +// RemovePidFileIfPID deletes the PID file only when the recorded PID matches +// expectedPID. It returns true when the file is removed successfully. +func RemovePidFileIfPID(homePath string, expectedPID int) bool { + if expectedPID <= 0 { + return false + } + + pidMu.Lock() + defer pidMu.Unlock() + + pidPath := pidFilePath(homePath) + data, err := readPidFileUnlocked(pidPath) + if err != nil { + return false + } + if data.PID != expectedPID { + return false + } + if err := os.Remove(pidPath); err != nil { + return false + } + return true +} + +// readPidFileUnlocked reads the PID file without acquiring the lock. +// Caller must hold pidMu. +func readPidFileUnlocked(pidPath string) (*PidFileData, error) { + raw, err := os.ReadFile(pidPath) + if err != nil { + return nil, err + } + + var data PidFileData + if err := json.Unmarshal(raw, &data); err != nil { + return nil, fmt.Errorf("%w: %v", errInvalidPidFile, err) + } + + // Validate PID is a positive integer. + if data.PID <= 0 { + return nil, fmt.Errorf("%w: pid=%d", errInvalidPidFile, data.PID) + } + + return &data, nil +} diff --git a/pkg/pid/pidfile_test.go b/pkg/pid/pidfile_test.go new file mode 100644 index 000000000..2d3c11f63 --- /dev/null +++ b/pkg/pid/pidfile_test.go @@ -0,0 +1,343 @@ +package pid + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// tmpDir returns a clean temporary directory for a test. +func tmpDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "pidtest-*") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + return dir +} + +// TestGenerateToken verifies that generateToken produces a 32-character hex string. +func TestGenerateToken(t *testing.T) { + token := generateToken() + if len(token) != 32 { + t.Errorf("expected token length 32, got %d (token: %q)", len(token), token) + } + // Verify all characters are valid hex. + for _, c := range token { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("token contains non-hex character: %c", c) + } + } +} + +// TestGenerateTokenUniqueness checks that two consecutive tokens differ. +func TestGenerateTokenUniqueness(t *testing.T) { + a := generateToken() + b := generateToken() + if a == b { + t.Error("two consecutive tokens should not be equal") + } +} + +// TestPidFilePath returns the expected path. +func TestPidFilePath(t *testing.T) { + dir := tmpDir(t) + got := pidFilePath(dir) + want := filepath.Join(dir, pidFileName) + if got != want { + t.Errorf("pidFilePath(%q) = %q, want %q", dir, got, want) + } +} + +// TestWritePidFile creates a PID file and verifies its contents. +func TestWritePidFile(t *testing.T) { + dir := tmpDir(t) + data, err := WritePidFile(dir, "127.0.0.1", 18790) + if err != nil { + t.Fatalf("WritePidFile failed: %v", err) + } + + if data.PID != os.Getpid() { + t.Errorf("PID = %d, want %d", data.PID, os.Getpid()) + } + if data.Host != "127.0.0.1" { + t.Errorf("Host = %q, want %q", data.Host, "127.0.0.1") + } + if data.Port != 18790 { + t.Errorf("Port = %d, want %d", data.Port, 18790) + } + if len(data.Token) != 32 { + t.Errorf("Token length = %d, want 32", len(data.Token)) + } + + // Verify the file exists and can be unmarshalled. + raw, err := os.ReadFile(filepath.Join(dir, pidFileName)) + if err != nil { + t.Fatalf("failed to read pid file: %v", err) + } + + var fileData PidFileData + if err = json.Unmarshal(raw, &fileData); err != nil { + t.Fatalf("failed to unmarshal pid file: %v", err) + } + if fileData.PID != data.PID || fileData.Token != data.Token { + t.Error("file data mismatch") + } + + // Verify file permissions (owner-only read/write). + info, err := os.Stat(filepath.Join(dir, pidFileName)) + if err != nil { + t.Fatalf("failed to stat pid file: %v", err) + } + perm := info.Mode().Perm() + if perm != 0o600 { + t.Errorf("file permission = %o, want 0600", perm) + } +} + +// TestWritePidFileOverwrite writes twice and verifies the PID file is replaced. +func TestWritePidFileOverwrite(t *testing.T) { + dir := tmpDir(t) + + data1, err := WritePidFile(dir, "0.0.0.0", 18790) + if err != nil { + t.Fatalf("first WritePidFile failed: %v", err) + } + + // Second write should succeed because the PID matches our process. + data2, err := WritePidFile(dir, "0.0.0.0", 18800) + if err != nil { + t.Fatalf("second WritePidFile failed: %v", err) + } + + if data2.Token == data1.Token { + t.Error("token should change on re-write") + } + if data2.Port != 18800 { + t.Errorf("Port = %d, want 18800", data2.Port) + } +} + +// TestWritePidFileStalePID writes a PID file with a non-running PID, then +// verifies WritePidFile cleans it up and writes a new one. +func TestWritePidFileStalePID(t *testing.T) { + dir := tmpDir(t) + + // Write a PID file with a PID that almost certainly doesn't exist. + stale := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(stale, "", " ") + os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600) + + data, err := WritePidFile(dir, "127.0.0.1", 18790) + if err != nil { + t.Fatalf("WritePidFile with stale PID failed: %v", err) + } + if data.PID != os.Getpid() { + t.Errorf("PID = %d, want %d", data.PID, os.Getpid()) + } +} + +// TestReadPidFileWithCheck verifies reading a valid PID file for the current process. +func TestReadPidFileWithCheck(t *testing.T) { + dir := tmpDir(t) + + // Some sandboxed environments (e.g. macOS test runner) may restrict + // signal(0), causing isProcessRunning(getpid()) to return false. + if !isProcessRunning(os.Getpid()) { + t.Skip("skipping: isProcessRunning(getpid()) is false in this environment") + } + + written, err := WritePidFile(dir, "127.0.0.1", 18790) + if err != nil { + t.Fatalf("WritePidFile failed: %v", err) + } + + read := ReadPidFileWithCheck(dir) + if read == nil { + t.Fatal("ReadPidFileWithCheck returned nil for current process") + } + if read.PID != written.PID || read.Token != written.Token { + t.Error("read data doesn't match written data") + } +} + +// TestReadPidFileWithCheckNonexistent returns nil for missing file. +func TestReadPidFileWithCheckNonexistent(t *testing.T) { + dir := tmpDir(t) + data := ReadPidFileWithCheck(dir) + if data != nil { + t.Error("expected nil for nonexistent PID file") + } +} + +// TestReadPidFileWithCheckStalePID auto-cleans a PID file whose process is dead. +func TestReadPidFileWithCheckStalePID(t *testing.T) { + dir := tmpDir(t) + + stale := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(stale, "", " ") + os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600) + + data := ReadPidFileWithCheck(dir) + if data != nil { + t.Error("expected nil for stale PID") + } + + // File should be cleaned up. + if _, err := os.Stat(filepath.Join(dir, pidFileName)); !os.IsNotExist(err) { + t.Error("stale PID file should be removed") + } +} + +// TestReadPidFileWithCheckInvalidFile auto-cleans malformed PID file. +func TestReadPidFileWithCheckInvalidFile(t *testing.T) { + dir := tmpDir(t) + path := filepath.Join(dir, pidFileName) + os.WriteFile(path, []byte("not json"), 0o600) + + data := ReadPidFileWithCheck(dir) + if data != nil { + t.Error("expected nil for malformed pid file") + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("malformed PID file should be removed") + } +} + +// TestRemovePidFile removes the PID file for the current process. +func TestRemovePidFile(t *testing.T) { + dir := tmpDir(t) + + if _, err := WritePidFile(dir, "127.0.0.1", 18790); err != nil { + t.Fatalf("WritePidFile failed: %v", err) + } + + RemovePidFile(dir) + + if _, err := os.Stat(filepath.Join(dir, pidFileName)); !os.IsNotExist(err) { + t.Error("PID file should be removed") + } +} + +// TestRemovePidFileDifferentPID does not remove a PID file owned by another process. +func TestRemovePidFileDifferentPID(t *testing.T) { + dir := tmpDir(t) + + other := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(other, "", " ") + os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600) + + RemovePidFile(dir) + + if _, err := os.Stat(filepath.Join(dir, pidFileName)); os.IsNotExist(err) { + t.Error("PID file should NOT be removed (different PID)") + } +} + +// TestRemovePidFileNonexistent does not error on missing file. +func TestRemovePidFileNonexistent(t *testing.T) { + dir := tmpDir(t) + // Should not panic or error. + RemovePidFile(dir) +} + +func TestRemovePidFileIfPID(t *testing.T) { + dir := tmpDir(t) + + other := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(other, "", " ") + path := filepath.Join(dir, pidFileName) + os.WriteFile(path, raw, 0o600) + + removed := RemovePidFileIfPID(dir, 99999999) + if !removed { + t.Fatal("expected RemovePidFileIfPID to remove matching pid file") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("PID file should be removed for matching expected PID") + } +} + +func TestRemovePidFileIfPIDMismatch(t *testing.T) { + dir := tmpDir(t) + + other := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(other, "", " ") + path := filepath.Join(dir, pidFileName) + os.WriteFile(path, raw, 0o600) + + removed := RemovePidFileIfPID(dir, 88888888) + if removed { + t.Fatal("expected RemovePidFileIfPID to keep non-matching pid file") + } + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Error("PID file should NOT be removed for mismatching expected PID") + } +} + +// TestWritePidFileContainerPID1 verifies that a leftover PID file with PID 1 +// (typical container entrypoint) is treated as stale and overwritten. +func TestWritePidFileContainerPID1(t *testing.T) { + dir := tmpDir(t) + + stale := PidFileData{PID: 1, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(stale, "", " ") + os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600) + + data, err := WritePidFile(dir, "127.0.0.1", 18790) + if err != nil { + t.Fatalf("WritePidFile should treat PID 1 as stale, got error: %v", err) + } + if data.PID != os.Getpid() { + t.Errorf("PID = %d, want %d", data.PID, os.Getpid()) + } +} + +// TestReadPidFileWithCheckContainerPID1 verifies that a leftover PID file +// with PID 1 is treated as stale and cleaned up. +func TestReadPidFileWithCheckContainerPID1(t *testing.T) { + if os.Getpid() == 1 { + t.Skip("test not meaningful when running as PID 1") + } + dir := tmpDir(t) + + stale := PidFileData{PID: 1, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(stale, "", " ") + os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600) + + data := ReadPidFileWithCheck(dir) + if data != nil { + t.Error("expected nil for PID 1 leftover") + } + + if _, err := os.Stat(filepath.Join(dir, pidFileName)); !os.IsNotExist(err) { + t.Error("PID 1 leftover file should be removed") + } +} + +// TestReadPidFileUnlockedInvalidJSON returns error for malformed content. +func TestReadPidFileUnlockedInvalidJSON(t *testing.T) { + dir := tmpDir(t) + path := filepath.Join(dir, pidFileName) + os.WriteFile(path, []byte("not json"), 0o600) + + _, err := readPidFileUnlocked(path) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +// TestReadPidFileUnlockedInvalidPID returns error for non-positive PID. +func TestReadPidFileUnlockedInvalidPID(t *testing.T) { + dir := tmpDir(t) + path := filepath.Join(dir, pidFileName) + os.WriteFile(path, []byte(`{"pid": -1, "token": "a"}`), 0o600) + + _, err := readPidFileUnlocked(path) + if err == nil { + t.Error("expected error for invalid PID") + } +} diff --git a/pkg/pid/pidfile_unix.go b/pkg/pid/pidfile_unix.go new file mode 100644 index 000000000..7bc53b752 --- /dev/null +++ b/pkg/pid/pidfile_unix.go @@ -0,0 +1,29 @@ +//go:build !windows + +package pid + +import ( + "errors" + "os" + "syscall" +) + +// isProcessRunning checks whether a process with the given PID is alive +// on Unix-like systems using signal(0). +func isProcessRunning(pid int) bool { + if pid <= 0 { + return false + } + p, err := os.FindProcess(pid) + if err != nil { + return false + } + // Signal(nil) does not kill the process but checks existence on Unix. + err = p.Signal(syscall.Signal(0)) + if err == nil { + return true + } + var errno syscall.Errno + // EPERM means the process exists but we are not allowed to signal it. + return errors.As(err, &errno) && errno == syscall.EPERM +} diff --git a/pkg/pid/pidfile_windows.go b/pkg/pid/pidfile_windows.go new file mode 100644 index 000000000..6d8b79552 --- /dev/null +++ b/pkg/pid/pidfile_windows.go @@ -0,0 +1,42 @@ +//go:build windows + +package pid + +import ( + "syscall" + "unsafe" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procOpenProcess = kernel32.NewProc("OpenProcess") + procGetExitCodeProcess = kernel32.NewProc("GetExitCodeProcess") + procCloseHandle = kernel32.NewProc("CloseHandle") + processQueryLimitedInformation = uint32(0x1000) + stillActive = uint32(259) +) + +// isProcessRunning checks whether a process with the given PID is alive +// on Windows using OpenProcess + GetExitCodeProcess. +func isProcessRunning(pid int) bool { + if pid <= 0 { + return false + } + + handle, _, _ := procOpenProcess.Call( + uintptr(processQueryLimitedInformation), + 0, + uintptr(pid), + ) + if handle == 0 { + return false + } + defer procCloseHandle.Call(handle) + + var exitCode uint32 + ret, _, _ := procGetExitCodeProcess.Call(handle, uintptr(unsafe.Pointer(&exitCode))) + if ret == 0 { + return false + } + return exitCode == stillActive +} diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 242ded175..6f4aadb8b 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -10,6 +10,7 @@ import ( "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" + "github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -42,7 +43,7 @@ func NewProvider(token string) *Provider { } func NewProviderWithBaseURL(token, apiBase string) *Provider { - baseURL := normalizeBaseURL(apiBase) + baseURL := common.NormalizeBaseURL(apiBase, defaultBaseURL, false) client := anthropic.NewClient( option.WithAuthToken(token), option.WithBaseURL(baseURL), @@ -180,6 +181,10 @@ func buildParams( blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) } for _, tc := range msg.ToolCalls { + // Skip tool calls with empty names to avoid API errors + if tc.Name == "" { + continue + } args := tc.Arguments if args == nil && tc.Function != nil && tc.Function.Arguments != "" { if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { @@ -381,20 +386,3 @@ func parseResponse(resp *anthropic.Message) *LLMResponse { }, } } - -func normalizeBaseURL(apiBase string) string { - base := strings.TrimSpace(apiBase) - if base == "" { - return defaultBaseURL - } - - base = strings.TrimRight(base, "/") - if before, ok := strings.CutSuffix(base, "/v1"); ok { - base = before - } - if base == "" { - return defaultBaseURL - } - - return base -} diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go new file mode 100644 index 000000000..672fb9324 --- /dev/null +++ b/pkg/providers/anthropic_messages/provider.go @@ -0,0 +1,388 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package anthropicmessages + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/providers/common" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition +) + +const ( + defaultAPIVersion = "2023-06-01" + defaultBaseURL = "https://api.anthropic.com/v1" + defaultRequestTimeout = 120 * time.Second +) + +// Provider implements Anthropic Messages API via HTTP (without SDK). +// It supports custom endpoints that use Anthropic's native message format. +type Provider struct { + apiKey string + apiBase string + httpClient *http.Client + userAgent string +} + +// NewProvider creates a new Anthropic Messages API provider. +func NewProvider(apiKey, apiBase, userAgent string) *Provider { + return NewProviderWithTimeout(apiKey, apiBase, userAgent, 0) +} + +// NewProviderWithTimeout creates a provider with custom request timeout. +func NewProviderWithTimeout(apiKey, apiBase, userAgent string, timeoutSeconds int) *Provider { + baseURL := common.NormalizeBaseURL(apiBase, defaultBaseURL, true) + timeout := defaultRequestTimeout + if timeoutSeconds > 0 { + timeout = time.Duration(timeoutSeconds) * time.Second + } + + return &Provider{ + apiKey: apiKey, + apiBase: baseURL, + userAgent: userAgent, + httpClient: &http.Client{ + Timeout: timeout, + }, + } +} + +// Chat sends messages to the Anthropic Messages API and returns the response. +func (p *Provider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + if p.apiKey == "" { + return nil, fmt.Errorf("API key not configured") + } + + // Build request body + requestBody, err := buildRequestBody(messages, tools, model, options) + if err != nil { + return nil, fmt.Errorf("building request body: %w", err) + } + + // Serialize to JSON + jsonBody, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("serializing request body: %w", err) + } + + // Build request URL + endpointURL, err := url.JoinPath(p.apiBase, "messages") + if err != nil { + return nil, fmt.Errorf("building endpoint URL: %w", err) + } + + // Create HTTP request + req, err := http.NewRequestWithContext(ctx, "POST", endpointURL, bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("creating HTTP request: %w", err) + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name + req.Header.Set("Anthropic-Version", defaultAPIVersion) + if p.userAgent != "" { + req.Header.Set("User-Agent", p.userAgent) + } + + // Execute request + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("executing HTTP request: %w", err) + } + defer resp.Body.Close() + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + // Check for HTTP errors with detailed messages + switch resp.StatusCode { + case http.StatusUnauthorized: + return nil, fmt.Errorf("authentication failed (401): check your API key") + case http.StatusTooManyRequests: + return nil, fmt.Errorf("rate limited (429): %s", string(body)) + case http.StatusBadRequest: + return nil, fmt.Errorf("bad request (400): %s", string(body)) + case http.StatusNotFound: + return nil, fmt.Errorf("endpoint not found (404): %s", string(body)) + case http.StatusInternalServerError: + return nil, fmt.Errorf("internal server error (500): %s", string(body)) + case http.StatusServiceUnavailable: + return nil, fmt.Errorf("service unavailable (503): %s", string(body)) + default: + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + } + + // Parse response + return parseResponseBody(body) +} + +// GetDefaultModel returns the default model for this provider. +func (p *Provider) GetDefaultModel() string { + return "claude-sonnet-4.6" +} + +// buildRequestBody converts internal message format to Anthropic Messages API format. +func buildRequestBody( + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (map[string]any, error) { + // max_tokens is required and guaranteed by agent loop + maxTokens, ok := common.AsInt(options["max_tokens"]) + if !ok { + return nil, fmt.Errorf("max_tokens is required in options") + } + + result := map[string]any{ + "model": model, + "max_tokens": int64(maxTokens), + "messages": []any{}, + } + + // Set temperature from options + if temp, ok := common.AsFloat(options["temperature"]); ok { + result["temperature"] = temp + } + + // Process messages + var systemPrompt string + var apiMessages []any + + for _, msg := range messages { + switch msg.Role { + case "system": + // Accumulate system messages + if systemPrompt != "" { + systemPrompt += "\n\n" + msg.Content + } else { + systemPrompt = msg.Content + } + + case "user": + if msg.ToolCallID != "" { + // Tool result message — merge into previous user message if it contains tool_results + toolResultBlock := map[string]any{ + "type": "tool_result", + "tool_use_id": msg.ToolCallID, + "content": msg.Content, + } + if len(apiMessages) > 0 { + if prev, ok := apiMessages[len(apiMessages)-1].(map[string]any); ok && prev["role"] == "user" { + if content, ok := prev["content"].([]map[string]any); ok { + prev["content"] = append(content, toolResultBlock) + continue + } + } + } + apiMessages = append(apiMessages, map[string]any{ + "role": "user", + "content": []map[string]any{toolResultBlock}, + }) + } else { + // Regular user message + apiMessages = append(apiMessages, map[string]any{ + "role": "user", + "content": msg.Content, + }) + } + + case "assistant": + content := []any{} + + // Add text content if present + if msg.Content != "" { + content = append(content, map[string]any{ + "type": "text", + "text": msg.Content, + }) + } + + // Add tool_use blocks + for _, tc := range msg.ToolCalls { + if strings.TrimSpace(tc.Name) == "" { + continue + } + + // Handle nil Arguments (GLM-4 may return null input) + input := tc.Arguments + if input == nil { + input = map[string]any{} + } + + toolUse := map[string]any{ + "type": "tool_use", + "id": tc.ID, + "name": tc.Name, + "input": input, + } + content = append(content, toolUse) + } + + apiMessages = append(apiMessages, map[string]any{ + "role": "assistant", + "content": content, + }) + + case "tool": + // Tool result (alternative format) — merge into previous user message if it contains tool_results + toolResultBlock := map[string]any{ + "type": "tool_result", + "tool_use_id": msg.ToolCallID, + "content": msg.Content, + } + if len(apiMessages) > 0 { + if prev, ok := apiMessages[len(apiMessages)-1].(map[string]any); ok && prev["role"] == "user" { + if content, ok := prev["content"].([]map[string]any); ok { + prev["content"] = append(content, toolResultBlock) + continue + } + } + } + apiMessages = append(apiMessages, map[string]any{ + "role": "user", + "content": []map[string]any{toolResultBlock}, + }) + } + } + + result["messages"] = apiMessages + + // Set system prompt if present + if systemPrompt != "" { + result["system"] = systemPrompt + } + + // Add tools if present + if len(tools) > 0 { + result["tools"] = buildTools(tools) + } + + return result, nil +} + +// buildTools converts tool definitions to Anthropic format. +func buildTools(tools []ToolDefinition) []any { + result := make([]any, len(tools)) + for i, tool := range tools { + toolDef := map[string]any{ + "name": tool.Function.Name, + "description": tool.Function.Description, + "input_schema": tool.Function.Parameters, + } + result[i] = toolDef + } + return result +} + +// parseResponseBody parses Anthropic Messages API response. +func parseResponseBody(body []byte) (*LLMResponse, error) { + var resp anthropicMessageResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parsing JSON response: %w", err) + } + + // Extract content and tool calls + var content strings.Builder + toolCalls := make([]ToolCall, 0) // Initialize as empty slice (not nil) for consistent JSON serialization + + for _, block := range resp.Content { + switch block.Type { + case "text": + content.WriteString(block.Text) + case "tool_use": + argsJSON, _ := json.Marshal(block.Input) + toolCalls = append(toolCalls, ToolCall{ + ID: block.ID, + Name: block.Name, + Arguments: block.Input, + Function: &FunctionCall{ + Name: block.Name, + Arguments: string(argsJSON), + }, + }) + } + } + + // Map stop_reason + finishReason := "stop" + switch resp.StopReason { + case "tool_use": + finishReason = "tool_calls" + case "max_tokens": + finishReason = "length" + case "end_turn": + finishReason = "stop" + case "stop_sequence": + finishReason = "stop" + } + + return &LLMResponse{ + Content: content.String(), + ToolCalls: toolCalls, + FinishReason: finishReason, + Usage: &UsageInfo{ + PromptTokens: int(resp.Usage.InputTokens), + CompletionTokens: int(resp.Usage.OutputTokens), + TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens), + }, + }, nil +} + +// Anthropic API response structures + +type anthropicMessageResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Content []contentBlock `json:"content"` + StopReason string `json:"stop_reason"` + Model string `json:"model"` + Usage usageInfo `json:"usage"` +} + +type contentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input map[string]any `json:"input,omitempty"` +} + +type usageInfo struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` +} diff --git a/pkg/providers/anthropic_messages/provider_test.go b/pkg/providers/anthropic_messages/provider_test.go new file mode 100644 index 000000000..6401d84bd --- /dev/null +++ b/pkg/providers/anthropic_messages/provider_test.go @@ -0,0 +1,719 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package anthropicmessages + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestBuildRequestBody(t *testing.T) { + tests := []struct { + name string + messages []Message + tools []ToolDefinition + model string + options map[string]any + want map[string]any + wantErr bool + }{ + { + name: "basic user message", + messages: []Message{ + {Role: "user", Content: "Hello, world!"}, + }, + model: "test-model", + options: map[string]any{ + "max_tokens": 8192, + }, + want: map[string]any{ + "model": "test-model", + "max_tokens": int64(8192), + "messages": []any{ + map[string]any{ + "role": "user", + "content": "Hello, world!", + }, + }, + }, + }, + { + name: "user and assistant messages", + messages: []Message{ + {Role: "user", Content: "What is 2+2?"}, + {Role: "assistant", Content: "4"}, + }, + model: "test-model", + options: map[string]any{ + "max_tokens": 8192, + }, + want: map[string]any{ + "model": "test-model", + "max_tokens": int64(8192), + "messages": []any{ + map[string]any{ + "role": "user", + "content": "What is 2+2?", + }, + map[string]any{ + "role": "assistant", + "content": []any{ + map[string]any{ + "type": "text", + "text": "4", + }, + }, + }, + }, + }, + }, + { + name: "with system message", + messages: []Message{ + {Role: "system", Content: "You are a helpful assistant."}, + {Role: "user", Content: "Hello"}, + }, + model: "test-model", + options: map[string]any{ + "max_tokens": 8192, + }, + want: map[string]any{ + "model": "test-model", + "max_tokens": int64(8192), + "system": "You are a helpful assistant.", + "messages": []any{ + map[string]any{ + "role": "user", + "content": "Hello", + }, + }, + }, + }, + { + name: "with custom max_tokens and temperature", + messages: []Message{ + {Role: "user", Content: "Test"}, + }, + model: "test-model", + options: map[string]any{ + "max_tokens": 2048, + "temperature": 0.5, + }, + want: map[string]any{ + "model": "test-model", + "max_tokens": int64(2048), + "temperature": 0.5, + "messages": []any{ + map[string]any{ + "role": "user", + "content": "Test", + }, + }, + }, + }, + { + name: "missing max_tokens returns error", + messages: []Message{ + {Role: "user", Content: "Test"}, + }, + model: "test-model", + options: map[string]any{}, + want: nil, + wantErr: true, + }, + { + name: "with tools", + messages: []Message{ + {Role: "user", Content: "What's the weather?"}, + }, + tools: []ToolDefinition{ + { + Function: ToolFunctionDefinition{ + Name: "get_weather", + Description: "Get current weather", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "location": map[string]any{ + "type": "string", + "description": "City name", + }, + }, + }, + }, + }, + }, + model: "test-model", + options: map[string]any{ + "max_tokens": 8192, + }, + want: map[string]any{ + "model": "test-model", + "max_tokens": int64(8192), + "messages": []any{ + map[string]any{ + "role": "user", + "content": "What's the weather?", + }, + }, + "tools": []any{ + map[string]any{ + "name": "get_weather", + "description": "Get current weather", + "input_schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "location": map[string]any{ + "type": "string", + "description": "City name", + }, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequestBody(tt.messages, tt.tools, tt.model, tt.options) + if (err != nil) != tt.wantErr { + t.Errorf("buildRequestBody() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + gotJSON, _ := json.MarshalIndent(got, "", " ") + wantJSON, _ := json.MarshalIndent(tt.want, "", " ") + t.Errorf("buildRequestBody() mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON) + } + }) + } +} + +func TestParseResponseBody(t *testing.T) { + tests := []struct { + name string + body []byte + want *LLMResponse + wantErr bool + }{ + { + name: "basic text response", + body: []byte(`{ + "id": "msg-123", + "type": "message", + "role": "assistant", + "content": [ + {"type": "text", "text": "Hello, how can I help?"} + ], + "stop_reason": "end_turn", + "model": "test-model", + "usage": { + "input_tokens": 10, + "output_tokens": 5 + } + }`), + want: &LLMResponse{ + Content: "Hello, how can I help?", + ToolCalls: []ToolCall{}, + FinishReason: "stop", + Usage: &UsageInfo{ + PromptTokens: 10, + CompletionTokens: 5, + TotalTokens: 15, + }, + Reasoning: "", + ReasoningDetails: nil, + }, + wantErr: false, + }, + { + name: "response with tool use", + body: []byte(`{ + "id": "msg-456", + "type": "message", + "role": "assistant", + "content": [ + {"type": "text", "text": "I'll check the weather for you."}, + { + "type": "tool_use", + "id": "toolu-123", + "name": "get_weather", + "input": {"location": "Tokyo"} + } + ], + "stop_reason": "tool_use", + "model": "test-model", + "usage": { + "input_tokens": 20, + "output_tokens": 15 + } + }`), + want: &LLMResponse{ + Content: "I'll check the weather for you.", + ToolCalls: []ToolCall{ + { + ID: "toolu-123", + Name: "get_weather", + Arguments: map[string]any{ + "location": "Tokyo", + }, + Function: &FunctionCall{ + Name: "get_weather", + Arguments: `{"location":"Tokyo"}`, + }, + }, + }, + FinishReason: "tool_calls", + Usage: &UsageInfo{ + PromptTokens: 20, + CompletionTokens: 15, + TotalTokens: 35, + }, + Reasoning: "", + ReasoningDetails: nil, + }, + wantErr: false, + }, + { + name: "invalid JSON", + body: []byte(`invalid json`), + want: nil, + wantErr: true, + }, + { + name: "max_tokens stop reason", + body: []byte(`{ + "id": "msg-789", + "type": "message", + "role": "assistant", + "content": [ + {"type": "text", "text": "Partial response"} + ], + "stop_reason": "max_tokens", + "model": "test-model", + "usage": { + "input_tokens": 100, + "output_tokens": 4096 + } + }`), + want: &LLMResponse{ + Content: "Partial response", + ToolCalls: []ToolCall{}, + FinishReason: "length", + Usage: &UsageInfo{ + PromptTokens: 100, + CompletionTokens: 4096, + TotalTokens: 4196, + }, + Reasoning: "", + ReasoningDetails: nil, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseResponseBody(tt.body) + if (err != nil) != tt.wantErr { + t.Errorf("parseResponseBody() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil { + return + } + + // Compare individual fields + if got.Content != tt.want.Content { + t.Errorf("Content = %q, want %q", got.Content, tt.want.Content) + } + if got.FinishReason != tt.want.FinishReason { + t.Errorf("FinishReason = %q, want %q", got.FinishReason, tt.want.FinishReason) + } + if got.Usage == nil && tt.want.Usage != nil { + t.Errorf("Usage = nil, want non-nil") + } else if got.Usage != nil && tt.want.Usage == nil { + t.Errorf("Usage = non-nil, want nil") + } else if got.Usage != nil && tt.want.Usage != nil { + if got.Usage.PromptTokens != tt.want.Usage.PromptTokens { + t.Errorf("Usage.PromptTokens = %d, want %d", got.Usage.PromptTokens, tt.want.Usage.PromptTokens) + } + if got.Usage.CompletionTokens != tt.want.Usage.CompletionTokens { + t.Errorf("Usage.CompletionTokens = %d, want %d", + got.Usage.CompletionTokens, tt.want.Usage.CompletionTokens) + } + if got.Usage.TotalTokens != tt.want.Usage.TotalTokens { + t.Errorf("Usage.TotalTokens = %d, want %d", got.Usage.TotalTokens, tt.want.Usage.TotalTokens) + } + } + if len(got.ToolCalls) != len(tt.want.ToolCalls) { + t.Errorf("ToolCalls length = %d, want %d", len(got.ToolCalls), len(tt.want.ToolCalls)) + } else { + for i := range got.ToolCalls { + if got.ToolCalls[i].ID != tt.want.ToolCalls[i].ID { + t.Errorf("ToolCalls[%d].ID = %q, want %q", + i, got.ToolCalls[i].ID, tt.want.ToolCalls[i].ID) + } + if got.ToolCalls[i].Name != tt.want.ToolCalls[i].Name { + t.Errorf("ToolCalls[%d].Name = %q, want %q", + i, got.ToolCalls[i].Name, tt.want.ToolCalls[i].Name) + } + } + } + }) + } +} + +func TestNewProvider(t *testing.T) { + provider := NewProvider("test-key", "https://api.example.com", "") + if provider == nil { + t.Fatal("NewProvider() returned nil") + } + if provider.apiKey != "test-key" { + t.Errorf("provider.apiKey = %q, want %q", provider.apiKey, "test-key") + } + if provider.apiBase != "https://api.example.com/v1" { + t.Errorf("provider.apiBase = %q, want %q", provider.apiBase, "https://api.example.com/v1") + } +} + +func TestGetDefaultModel(t *testing.T) { + provider := NewProvider("test-key", "", "") + got := provider.GetDefaultModel() + expected := "claude-sonnet-4.6" + if got != expected { + t.Errorf("GetDefaultModel() = %q, want %q", got, expected) + } +} + +// TestBuildRequestBodyEdgeCases tests edge cases for buildRequestBody. +func TestBuildRequestBodyEdgeCases(t *testing.T) { + tests := []struct { + name string + messages []Message + tools []ToolDefinition + model string + options map[string]any + wantErr bool + }{ + { + name: "empty message list", + messages: []Message{}, + model: "test-model", + options: map[string]any{ + "max_tokens": 8192, + }, + wantErr: false, + }, + { + name: "very long system message", + messages: []Message{ + {Role: "system", Content: strings.Repeat("This is a very long system prompt. ", 1000)}, + {Role: "user", Content: "Hello"}, + }, + model: "test-model", + options: map[string]any{ + "max_tokens": 8192, + }, + wantErr: false, + }, + { + name: "multiple consecutive system messages", + messages: []Message{ + {Role: "system", Content: "First system message"}, + {Role: "system", Content: "Second system message"}, + {Role: "system", Content: "Third system message"}, + {Role: "user", Content: "Hello"}, + }, + model: "test-model", + options: map[string]any{ + "max_tokens": 8192, + }, + wantErr: false, + }, + { + name: "tool result without tool call", + messages: []Message{ + {Role: "user", Content: "Use a tool"}, + {Role: "assistant", Content: "", ToolCalls: []ToolCall{ + {ID: "tool-1", Name: "test_tool", Arguments: map[string]any{"arg": "value"}}, + }}, + {Role: "user", ToolCallID: "tool-1", Content: "Tool result"}, + }, + model: "test-model", + options: map[string]any{ + "max_tokens": 8192, + }, + wantErr: false, + }, + { + name: "skip tool calls with empty names", + messages: []Message{ + {Role: "assistant", Content: "Calling tool", ToolCalls: []ToolCall{ + {ID: "tool-empty", Name: "", Arguments: map[string]any{"ignored": true}}, + {ID: "tool-valid", Name: "test_tool", Arguments: map[string]any{"arg": "value"}}, + }}, + }, + model: "test-model", + options: map[string]any{ + "max_tokens": 8192, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequestBody(tt.messages, tt.tools, tt.model, tt.options) + if (err != nil) != tt.wantErr { + t.Errorf("buildRequestBody() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil { + return + } + + // Verify basic structure + if got == nil { + t.Error("buildRequestBody() returned nil") + return + } + if got["model"] != tt.model { + t.Errorf("model = %v, want %v", got["model"], tt.model) + } + + if tt.name == "skip tool calls with empty names" { + messages, ok := got["messages"].([]any) + if !ok || len(messages) != 1 { + t.Fatalf("messages = %#v, want single assistant message", got["messages"]) + } + + assistantMsg, ok := messages[0].(map[string]any) + if !ok { + t.Fatalf("assistant message = %#v, want map", messages[0]) + } + + content, ok := assistantMsg["content"].([]any) + if !ok { + t.Fatalf("assistant content = %#v, want []any", assistantMsg["content"]) + } + if len(content) != 2 { + t.Fatalf("assistant content length = %d, want 2", len(content)) + } + + toolUse, ok := content[1].(map[string]any) + if !ok { + t.Fatalf("tool_use block = %#v, want map", content[1]) + } + if gotName := toolUse["name"]; gotName != "test_tool" { + t.Fatalf("tool_use name = %v, want %q", gotName, "test_tool") + } + if gotID := toolUse["id"]; gotID != "tool-valid" { + t.Fatalf("tool_use id = %v, want %q", gotID, "tool-valid") + } + } + }) + } +} + +func TestBuildRequestBody_ConsecutiveToolResultsMerged(t *testing.T) { + // Consecutive tool results (role "tool") should be merged into a single "user" message + messages := []Message{ + {Role: "user", Content: "Use tools"}, + {Role: "assistant", Content: "", ToolCalls: []ToolCall{ + {ID: "t1", Name: "tool_a", Arguments: map[string]any{"x": 1}}, + {ID: "t2", Name: "tool_b", Arguments: map[string]any{"y": 2}}, + }}, + {Role: "tool", ToolCallID: "t1", Content: "result1"}, + {Role: "tool", ToolCallID: "t2", Content: "result2"}, + } + + got, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 8192}) + if err != nil { + t.Fatalf("buildRequestBody() error: %v", err) + } + + apiMessages, ok := got["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any") + } + + // Expect: user, assistant, user (merged tool results) + if len(apiMessages) != 3 { + for i, m := range apiMessages { + t.Logf("message[%d]: %+v", i, m) + } + t.Fatalf("expected 3 API messages, got %d", len(apiMessages)) + } + + // The third message should be a user message with 2 tool_result blocks + toolResultMsg, ok := apiMessages[2].(map[string]any) + if !ok { + t.Fatalf("tool result message is not map[string]any") + } + if toolResultMsg["role"] != "user" { + t.Errorf("expected role 'user', got %v", toolResultMsg["role"]) + } + content, ok := toolResultMsg["content"].([]map[string]any) + if !ok { + t.Fatalf("content is not []map[string]any: %T", toolResultMsg["content"]) + } + if len(content) != 2 { + t.Fatalf("expected 2 tool_result blocks, got %d", len(content)) + } + if content[0]["tool_use_id"] != "t1" { + t.Errorf("first tool_result tool_use_id = %v, want t1", content[0]["tool_use_id"]) + } + if content[1]["tool_use_id"] != "t2" { + t.Errorf("second tool_result tool_use_id = %v, want t2", content[1]["tool_use_id"]) + } +} + +func TestBuildRequestBody_UserToolResultsMerged(t *testing.T) { + // Consecutive tool results using role "user" with ToolCallID should also be merged + messages := []Message{ + {Role: "user", Content: "Use tools"}, + {Role: "assistant", Content: "", ToolCalls: []ToolCall{ + {ID: "t1", Name: "tool_a", Arguments: map[string]any{"x": 1}}, + {ID: "t2", Name: "tool_b", Arguments: map[string]any{"y": 2}}, + }}, + {Role: "user", ToolCallID: "t1", Content: "result1"}, + {Role: "user", ToolCallID: "t2", Content: "result2"}, + } + + got, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 8192}) + if err != nil { + t.Fatalf("buildRequestBody() error: %v", err) + } + + apiMessages, ok := got["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any") + } + + // Expect: user, assistant, user (merged tool results) + if len(apiMessages) != 3 { + t.Fatalf("expected 3 API messages, got %d", len(apiMessages)) + } + + toolResultMsg := apiMessages[2].(map[string]any) + content, ok := toolResultMsg["content"].([]map[string]any) + if !ok { + t.Fatalf("content is not []map[string]any: %T", toolResultMsg["content"]) + } + if len(content) != 2 { + t.Fatalf("expected 2 tool_result blocks, got %d", len(content)) + } +} + +// TestParseResponseBodyEdgeCases tests edge cases for parseResponseBody. +func TestParseResponseBodyEdgeCases(t *testing.T) { + tests := []struct { + name string + body []byte + wantErr bool + check func(*testing.T, *LLMResponse) + }{ + { + name: "empty content blocks", + body: []byte(`{ + "id": "msg-empty", + "type": "message", + "role": "assistant", + "content": [], + "stop_reason": "end_turn", + "model": "test-model", + "usage": {"input_tokens": 5, "output_tokens": 0} + }`), + wantErr: false, + check: func(t *testing.T, resp *LLMResponse) { + if resp.Content != "" { + t.Errorf("Content = %q, want empty string", resp.Content) + } + if len(resp.ToolCalls) != 0 { + t.Errorf("ToolCalls length = %d, want 0", len(resp.ToolCalls)) + } + }, + }, + { + name: "multiple tool use blocks", + body: []byte(`{ + "id": "msg-multi", + "type": "message", + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "tool-1", "name": "func1", "input": {"arg": "val1"}}, + {"type": "tool_use", "id": "tool-2", "name": "func2", "input": {"arg": "val2"}} + ], + "stop_reason": "tool_use", + "model": "test-model", + "usage": {"input_tokens": 10, "output_tokens": 20} + }`), + wantErr: false, + check: func(t *testing.T, resp *LLMResponse) { + if len(resp.ToolCalls) != 2 { + t.Errorf("ToolCalls length = %d, want 2", len(resp.ToolCalls)) + } + }, + }, + { + name: "malformed JSON response", + body: []byte(`{invalid json`), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseResponseBody(tt.body) + if (err != nil) != tt.wantErr { + t.Errorf("parseResponseBody() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.check != nil && err == nil { + tt.check(t, got) + } + }) + } +} + +// TestProviderChatErrors tests error handling in Chat. +// Note: apiBase check removed as it's dead code - normalizeBaseURL() always provides a default. +func TestProviderChatErrors(t *testing.T) { + tests := []struct { + name string + apiKey string + messages []Message + wantErrMsg string + }{ + { + name: "missing API key", + apiKey: "", + messages: []Message{{Role: "user", Content: "Test"}}, + wantErrMsg: "API key not configured", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create provider using constructor to ensure proper initialization + provider := NewProvider(tt.apiKey, "https://api.example.com", "") + + _, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil) + if err == nil { + t.Fatal("Chat() expected error, got nil") + } + if err.Error() != tt.wantErrMsg { + t.Errorf("Chat() error = %q, want %q", err.Error(), tt.wantErrMsg) + } + }) + } +} diff --git a/pkg/providers/antigravity_provider_test.go b/pkg/providers/antigravity_provider_test.go deleted file mode 100644 index 238765321..000000000 --- a/pkg/providers/antigravity_provider_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package providers - -import "testing" - -func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) { - p := &AntigravityProvider{} - - messages := []Message{ - { - Role: "assistant", - ToolCalls: []ToolCall{{ - ID: "call_read_file_123", - Function: &FunctionCall{ - Name: "read_file", - Arguments: `{"path":"README.md"}`, - }, - }}, - }, - { - Role: "tool", - ToolCallID: "call_read_file_123", - Content: "ok", - }, - } - - req := p.buildRequest(messages, nil, "", nil) - if len(req.Contents) != 2 { - t.Fatalf("expected 2 contents, got %d", len(req.Contents)) - } - - modelPart := req.Contents[0].Parts[0] - if modelPart.FunctionCall == nil { - t.Fatal("expected functionCall in assistant message") - } - if modelPart.FunctionCall.Name != "read_file" { - t.Fatalf("expected functionCall name read_file, got %q", modelPart.FunctionCall.Name) - } - if got := modelPart.FunctionCall.Args["path"]; got != "README.md" { - t.Fatalf("expected functionCall args[path] to be README.md, got %v", got) - } - - toolPart := req.Contents[1].Parts[0] - if toolPart.FunctionResponse == nil { - t.Fatal("expected functionResponse in tool message") - } - if toolPart.FunctionResponse.Name != "read_file" { - t.Fatalf("expected functionResponse name read_file, got %q", toolPart.FunctionResponse.Name) - } -} - -func TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) { - got := resolveToolResponseName("call_search_docs_999", map[string]string{}) - if got != "search_docs" { - t.Fatalf("expected inferred tool name search_docs, got %q", got) - } -} diff --git a/pkg/providers/azure/provider.go b/pkg/providers/azure/provider.go new file mode 100644 index 000000000..7de703248 --- /dev/null +++ b/pkg/providers/azure/provider.go @@ -0,0 +1,173 @@ +package azure + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/responses" + + "github.com/sipeed/picoclaw/pkg/providers/common" + orc "github.com/sipeed/picoclaw/pkg/providers/openai_responses_common" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + LLMResponse = protocoltypes.LLMResponse + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition +) + +const ( + defaultRequestTimeout = common.DefaultRequestTimeout + responsesAPIPath = "openai/v1/responses" +) + +// Provider implements the LLM provider interface for Azure OpenAI endpoints. +// It handles Azure-specific authentication (Bearer token), URL construction +// (Responses API), and request/response formatting. +type Provider struct { + apiKey string + apiBase string + httpClient *http.Client + userAgent string +} + +// Option configures the Azure Provider. +type Option func(*Provider) + +// WithRequestTimeout sets the HTTP request timeout. +func WithRequestTimeout(timeout time.Duration) Option { + return func(p *Provider) { + if timeout > 0 { + p.httpClient.Timeout = timeout + } + } +} + +// WithUserAgent sets the User-Agent header for requests. +func WithUserAgent(userAgent string) Option { + return func(p *Provider) { + p.userAgent = userAgent + } +} + +// NewProvider creates a new Azure OpenAI provider. +func NewProvider(apiKey, apiBase, proxy, userAgent string, opts ...Option) *Provider { + p := &Provider{ + apiKey: apiKey, + apiBase: strings.TrimRight(apiBase, "/"), + userAgent: userAgent, + httpClient: common.NewHTTPClient(proxy), + } + + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + + return p +} + +// NewProviderWithTimeout creates a new Azure OpenAI provider with a custom request timeout in seconds. +func NewProviderWithTimeout(apiKey, apiBase, proxy, userAgent string, requestTimeoutSeconds int) *Provider { + return NewProvider( + apiKey, apiBase, proxy, userAgent, + WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), + ) +} + +// Chat sends a request to the Azure OpenAI Responses API endpoint. +// The model parameter is passed in the request body. +func (p *Provider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + if p.apiBase == "" { + return nil, fmt.Errorf("Azure API base not configured") + } + + requestURL, err := url.JoinPath(p.apiBase, responsesAPIPath) + if err != nil { + return nil, fmt.Errorf("failed to build Azure request URL: %w", err) + } + + input, instructions := orc.TranslateMessages(messages) + + requestBody := responses.ResponseNewParams{ + Model: model, + Input: responses.ResponseNewParamsInputUnion{ + OfInputItemList: input, + }, + Store: openai.Opt(false), + } + + if instructions != "" { + requestBody.Instructions = openai.Opt(instructions) + } + + if len(tools) > 0 { + enableWebSearch, _ := options["native_search"].(bool) + requestBody.Tools = orc.TranslateTools(tools, enableWebSearch) + requestBody.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ + OfToolChoiceMode: openai.Opt(responses.ToolChoiceOptionsAuto), + } + } + + if maxTokens, ok := common.AsInt(options["max_tokens"]); ok { + requestBody.MaxOutputTokens = openai.Opt(int64(maxTokens)) + } + + if temperature, ok := common.AsFloat(options["temperature"]); ok { + requestBody.Temperature = openai.Opt(temperature) + } + + if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" { + requestBody.PromptCacheKey = openai.Opt(cacheKey) + } + + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", requestURL, bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if p.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+p.apiKey) + } + if p.userAgent != "" { + req.Header.Set("User-Agent", p.userAgent) + } + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, common.HandleErrorResponse(resp, p.apiBase) + } + + return orc.ParseResponseBody(resp.Body) +} + +// GetDefaultModel returns an empty string as Azure deployments are user-configured. +func (p *Provider) GetDefaultModel() string { + return "" +} diff --git a/pkg/providers/azure/provider_test.go b/pkg/providers/azure/provider_test.go new file mode 100644 index 000000000..816ae97dc --- /dev/null +++ b/pkg/providers/azure/provider_test.go @@ -0,0 +1,417 @@ +package azure + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +// writeValidResponse writes a minimal valid Responses API response. +func writeValidResponse(w http.ResponseWriter) { + resp := map[string]any{ + "id": "resp_test", + "object": "response", + "status": "completed", + "output": []map[string]any{ + { + "type": "message", + "content": []map[string]any{ + {"type": "output_text", "text": "ok"}, + }, + }, + }, + "usage": map[string]any{ + "input_tokens": 5, + "output_tokens": 2, + "total_tokens": 7, + "input_tokens_details": map[string]any{"cached_tokens": 0}, + "output_tokens_details": map[string]any{"reasoning_tokens": 0}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func TestProviderChat_AzureURLConstruction(t *testing.T) { + var capturedPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + writeValidResponse(w) + })) + defer server.Close() + + p := NewProvider("test-key", server.URL, "", "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-gpt5-deployment", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + wantPath := "/openai/v1/responses" + if capturedPath != wantPath { + t.Errorf("URL path = %q, want %q", capturedPath, wantPath) + } +} + +func TestProviderChat_AzureAuthHeader(t *testing.T) { + var capturedAuth string + var capturedAPIKey string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + capturedAPIKey = r.Header.Get("Api-Key") + writeValidResponse(w) + })) + defer server.Close() + + p := NewProvider("test-azure-key", server.URL, "", "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if capturedAuth != "Bearer test-azure-key" { + t.Errorf("Authorization header = %q, want %q", capturedAuth, "Bearer test-azure-key") + } + if capturedAPIKey != "" { + t.Errorf("Api-Key header should be empty, got %q", capturedAPIKey) + } +} + +func TestProviderChat_AzureRequestBodyContainsModel(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&requestBody) + writeValidResponse(w) + })) + defer server.Close() + + p := NewProvider("test-key", server.URL, "", "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-deployment", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if requestBody["model"] != "my-deployment" { + t.Errorf("model = %v, want %q", requestBody["model"], "my-deployment") + } +} + +func TestProviderChat_AzureUsesMaxOutputTokens(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&requestBody) + writeValidResponse(w) + })) + defer server.Close() + + p := NewProvider("test-key", server.URL, "", "") + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "deployment", + map[string]any{"max_tokens": 2048}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if requestBody["max_output_tokens"] == nil { + t.Error("request body should contain 'max_output_tokens'") + } + if _, exists := requestBody["max_tokens"]; exists { + t.Error("request body should not contain 'max_tokens'") + } + if _, exists := requestBody["max_completion_tokens"]; exists { + t.Error("request body should not contain 'max_completion_tokens'") + } +} + +func TestProviderChat_AzureStoreIsFalse(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&requestBody) + writeValidResponse(w) + })) + defer server.Close() + + p := NewProvider("test-key", server.URL, "", "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if requestBody["store"] != false { + t.Errorf("store = %v, want false", requestBody["store"]) + } +} + +func TestProviderChat_AzureHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + })) + defer server.Close() + + p := NewProvider("bad-key", server.URL, "", "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestProviderChat_AzureRateLimitError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte(`{"error":{"message":"Rate limit exceeded","type":"rate_limit_error"}}`)) + })) + defer server.Close() + + p := NewProvider("test-key", server.URL, "", "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) + if err == nil { + t.Fatal("expected error for 429, got nil") + } + if !strings.Contains(err.Error(), "429") { + t.Errorf("error should contain status code 429, got: %v", err) + } +} + +func TestProviderChat_AzureServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":{"message":"Internal server error","type":"server_error"}}`)) + })) + defer server.Close() + + p := NewProvider("test-key", server.URL, "", "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) + if err == nil { + t.Fatal("expected error for 500, got nil") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("error should contain status code 500, got: %v", err) + } +} + +func TestProviderChat_AzureParseTextOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "id": "resp_1", + "object": "response", + "status": "completed", + "output": []map[string]any{ + { + "type": "message", + "content": []map[string]any{ + {"type": "output_text", "text": "Hello there!"}, + }, + }, + }, + "usage": map[string]any{ + "input_tokens": 10, "output_tokens": 5, "total_tokens": 15, + "input_tokens_details": map[string]any{"cached_tokens": 0}, + "output_tokens_details": map[string]any{"reasoning_tokens": 0}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("test-key", server.URL, "", "") + out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if out.Content != "Hello there!" { + t.Errorf("Content = %q, want %q", out.Content, "Hello there!") + } + if out.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", out.FinishReason, "stop") + } + if out.Usage.TotalTokens != 15 { + t.Errorf("TotalTokens = %d, want 15", out.Usage.TotalTokens) + } +} + +func TestProviderChat_AzureParseToolCalls(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "id": "resp_2", + "object": "response", + "status": "completed", + "output": []map[string]any{ + { + "type": "function_call", + "call_id": "call_1", + "name": "get_weather", + "arguments": `{"city":"Seattle"}`, + }, + }, + "usage": map[string]any{ + "input_tokens": 10, "output_tokens": 8, "total_tokens": 18, + "input_tokens_details": map[string]any{"cached_tokens": 0}, + "output_tokens_details": map[string]any{"reasoning_tokens": 0}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("test-key", server.URL, "", "") + out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "deployment", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].Name != "get_weather" { + t.Errorf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather") + } + if out.FinishReason != "tool_calls" { + t.Errorf("FinishReason = %q, want %q", out.FinishReason, "tool_calls") + } +} + +func TestProvider_AzureEmptyAPIBase(t *testing.T) { + p := NewProvider("test-key", "", "", "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) + if err == nil { + t.Fatal("expected error for empty API base") + } +} + +func TestProvider_AzureRequestTimeoutDefault(t *testing.T) { + p := NewProvider("test-key", "https://example.com", "", "") + if p.httpClient.Timeout != defaultRequestTimeout { + t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) + } +} + +func TestProvider_AzureRequestTimeoutOverride(t *testing.T) { + p := NewProvider("test-key", "https://example.com", "", "", WithRequestTimeout(300*time.Second)) + if p.httpClient.Timeout != 300*time.Second { + t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second) + } +} + +func TestProvider_AzureNewProviderWithTimeout(t *testing.T) { + p := NewProviderWithTimeout("test-key", "https://example.com", "", "", 180) + if p.httpClient.Timeout != 180*time.Second { + t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 180*time.Second) + } +} + +func TestProviderChat_AzureNativeWebSearchInjection(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&requestBody) + writeValidResponse(w) + })) + defer server.Close() + + tools := []ToolDefinition{ + { + Type: "function", + Function: protocoltypes.ToolFunctionDefinition{ + Name: "web_search", + Description: "local web search", + Parameters: map[string]any{"type": "object"}, + }, + }, + { + Type: "function", + Function: protocoltypes.ToolFunctionDefinition{ + Name: "read_file", + Description: "read a file", + Parameters: map[string]any{"type": "object"}, + }, + }, + } + + p := NewProvider("test-key", server.URL, "", "") + + // With native_search=true: user-defined web_search should be replaced by built-in + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment", + map[string]any{"native_search": true}) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + toolsAny, ok := requestBody["tools"].([]any) + if !ok { + t.Fatal("request body should contain 'tools' array") + } + if len(toolsAny) != 2 { + t.Fatalf("len(tools) = %d, want 2 (read_file + web_search builtin)", len(toolsAny)) + } + + // First tool should be read_file (user-defined web_search was skipped) + firstTool, _ := toolsAny[0].(map[string]any) + if firstTool["name"] != "read_file" { + t.Errorf("first tool name = %v, want %q", firstTool["name"], "read_file") + } + + // Second tool should be built-in web_search + secondTool, _ := toolsAny[1].(map[string]any) + if secondTool["type"] != "web_search" { + t.Errorf("second tool type = %v, want %q", secondTool["type"], "web_search") + } +} + +func TestProviderChat_AzureNoNativeWebSearch(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&requestBody) + writeValidResponse(w) + })) + defer server.Close() + + tools := []ToolDefinition{ + { + Type: "function", + Function: protocoltypes.ToolFunctionDefinition{ + Name: "web_search", + Description: "local web search", + Parameters: map[string]any{"type": "object"}, + }, + }, + } + + p := NewProvider("test-key", server.URL, "", "") + + // Without native_search: user-defined web_search should be kept as-is + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + toolsAny, ok := requestBody["tools"].([]any) + if !ok { + t.Fatal("request body should contain 'tools' array") + } + if len(toolsAny) != 1 { + t.Fatalf("len(tools) = %d, want 1", len(toolsAny)) + } + + // Should be the user-defined function tool, not built-in + tool, _ := toolsAny[0].(map[string]any) + if tool["type"] != "function" { + t.Errorf("tool type = %v, want %q", tool["type"], "function") + } +} diff --git a/pkg/providers/bedrock/provider_bedrock.go b/pkg/providers/bedrock/provider_bedrock.go new file mode 100644 index 000000000..3798c5fd8 --- /dev/null +++ b/pkg/providers/bedrock/provider_bedrock.go @@ -0,0 +1,616 @@ +//go:build bedrock + +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +// Package bedrock implements the LLM provider interface for AWS Bedrock. +// It uses the Bedrock Runtime Converse API for unified access to multiple +// model families (Claude, Llama, Mistral, etc.) with tool/function calling support. +package bedrock + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "math" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/document" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types" + + "github.com/sipeed/picoclaw/pkg/providers/common" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition +) + +// Provider implements the LLM provider interface for AWS Bedrock. +type Provider struct { + client *bedrockruntime.Client + region string + requestTimeout time.Duration +} + +// Option configures the Bedrock Provider. +type Option func(*providerConfig) + +type providerConfig struct { + region string + profile string + baseEndpoint string + requestTimeout time.Duration +} + +// WithRegion sets the AWS region for Bedrock requests. +func WithRegion(region string) Option { + return func(c *providerConfig) { + c.region = region + } +} + +// WithProfile sets the AWS profile to use for credentials. +func WithProfile(profile string) Option { + return func(c *providerConfig) { + c.profile = profile + } +} + +// WithBaseEndpoint sets a custom Bedrock endpoint URL. +// Example: https://bedrock-runtime.us-east-1.amazonaws.com +func WithBaseEndpoint(endpoint string) Option { + return func(c *providerConfig) { + c.baseEndpoint = endpoint + } +} + +// WithRequestTimeout sets the timeout for Bedrock API requests. +func WithRequestTimeout(timeout time.Duration) Option { + return func(c *providerConfig) { + c.requestTimeout = timeout + } +} + +// NewProvider creates a new AWS Bedrock provider. +// It uses the default AWS credential chain (env vars, shared config, IAM roles, etc.). +func NewProvider(ctx context.Context, opts ...Option) (*Provider, error) { + pc := &providerConfig{} + for _, opt := range opts { + opt(pc) + } + + // Build AWS config options + var configOpts []func(*config.LoadOptions) error + + if pc.region != "" { + configOpts = append(configOpts, config.WithRegion(pc.region)) + } + + if pc.profile != "" { + configOpts = append(configOpts, config.WithSharedConfigProfile(pc.profile)) + } + + // Load AWS config with automatic credential discovery + cfg, err := config.LoadDefaultConfig(ctx, configOpts...) + if err != nil { + return nil, fmt.Errorf("loading AWS config: %w", err) + } + + // Validate region is set - required for Bedrock request signing + if cfg.Region == "" { + return nil, fmt.Errorf( + "AWS region not configured: set AWS_REGION, AWS_DEFAULT_REGION, or use WithRegion option", + ) + } + + // Build client options + var clientOpts []func(*bedrockruntime.Options) + if pc.baseEndpoint != "" { + clientOpts = append(clientOpts, func(o *bedrockruntime.Options) { + o.BaseEndpoint = aws.String(pc.baseEndpoint) + }) + } + + client := bedrockruntime.NewFromConfig(cfg, clientOpts...) + + return &Provider{ + client: client, + region: cfg.Region, + requestTimeout: pc.requestTimeout, + }, nil +} + +// Chat sends messages to AWS Bedrock using the Converse API. +func (p *Provider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + // Apply request timeout if context doesn't already have a deadline. + // Use explicit timeout if set, otherwise fall back to common default. + effectiveTimeout := p.requestTimeout + if effectiveTimeout <= 0 { + effectiveTimeout = common.DefaultRequestTimeout + } + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, effectiveTimeout) + defer cancel() + } + + // Build the Converse API input + input := &bedrockruntime.ConverseInput{ + ModelId: aws.String(model), + } + + // Convert messages to Bedrock format + bedrockMessages, systemPrompts := convertMessages(messages) + input.Messages = bedrockMessages + + // Set system prompts if any + if len(systemPrompts) > 0 { + input.System = systemPrompts + } + + // Set inference configuration only when options are provided + var inferenceConfig *types.InferenceConfiguration + + if maxTokens, ok := common.AsInt(options["max_tokens"]); ok && maxTokens > 0 { + if inferenceConfig == nil { + inferenceConfig = &types.InferenceConfiguration{} + } + // Clamp to int32 range to avoid overflow + if maxTokens > math.MaxInt32 { + maxTokens = math.MaxInt32 + } + inferenceConfig.MaxTokens = aws.Int32(int32(maxTokens)) + } + + if temp, ok := common.AsFloat(options["temperature"]); ok { + if inferenceConfig == nil { + inferenceConfig = &types.InferenceConfiguration{} + } + inferenceConfig.Temperature = aws.Float32(float32(temp)) + } + + if inferenceConfig != nil { + input.InferenceConfig = inferenceConfig + } + + // Convert tools to Bedrock format + // Only set ToolConfig if at least one valid tool was produced + if len(tools) > 0 { + toolConfig := convertTools(tools) + if len(toolConfig.Tools) > 0 { + input.ToolConfig = toolConfig + } + } + + // Call Bedrock Converse API + output, err := p.client.Converse(ctx, input) + if err != nil { + // Check for SSO token expiration errors and provide actionable guidance + if isSSOTokenError(err) { + return nil, fmt.Errorf( + "bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w", + err, + ) + } + return nil, fmt.Errorf("bedrock converse: %w", err) + } + + // Parse the response + return parseResponse(output) +} + +// GetDefaultModel returns an empty string as Bedrock models are user-configured. +func (p *Provider) GetDefaultModel() string { + return "" +} + +// Region returns the AWS region configured for this Provider. +func (p *Provider) Region() string { + return p.region +} + +// convertMessages converts internal messages to Bedrock Converse format. +// Returns the conversation messages and any system prompts separately. +// Note: Bedrock requires all tool results for a given assistant turn to be in a single +// user message with multiple ToolResultBlock content blocks. This function merges +// consecutive tool result messages accordingly. +func convertMessages(messages []Message) ([]types.Message, []types.SystemContentBlock) { + var bedrockMessages []types.Message + var systemPrompts []types.SystemContentBlock + + // Helper to check if a message is a tool result + isToolResult := func(msg Message) bool { + return (msg.Role == "tool" || (msg.Role == "user" && msg.ToolCallID != "")) && msg.ToolCallID != "" + } + + // Helper to create a tool result content block + makeToolResultBlock := func(msg Message) types.ContentBlock { + return &types.ContentBlockMemberToolResult{ + Value: types.ToolResultBlock{ + ToolUseId: aws.String(msg.ToolCallID), + Content: []types.ToolResultContentBlock{ + &types.ToolResultContentBlockMemberText{ + Value: msg.Content, + }, + }, + }, + } + } + + i := 0 + for i < len(messages) { + msg := messages[i] + + switch { + case msg.Role == "system": + // System messages go to the System field + systemPrompts = append(systemPrompts, &types.SystemContentBlockMemberText{ + Value: msg.Content, + }) + i++ + + case isToolResult(msg): + // Collect all consecutive tool results into a single user message + // Bedrock requires all tool results for a turn in one message + var toolResultBlocks []types.ContentBlock + for i < len(messages) && isToolResult(messages[i]) { + toolResultBlocks = append(toolResultBlocks, makeToolResultBlock(messages[i])) + i++ + } + bedrockMessages = append(bedrockMessages, types.Message{ + Role: types.ConversationRoleUser, + Content: toolResultBlocks, + }) + + case msg.Role == "user": + // Regular user message (no ToolCallID) + content := buildUserContent(msg) + bedrockMessages = append(bedrockMessages, types.Message{ + Role: types.ConversationRoleUser, + Content: content, + }) + i++ + + case msg.Role == "assistant": + content := buildAssistantContent(msg) + bedrockMessages = append(bedrockMessages, types.Message{ + Role: types.ConversationRoleAssistant, + Content: content, + }) + i++ + + case msg.Role == "tool" && msg.ToolCallID == "": + // Tool message without ToolCallID - treat as regular user message + content := buildUserContent(msg) + bedrockMessages = append(bedrockMessages, types.Message{ + Role: types.ConversationRoleUser, + Content: content, + }) + i++ + + default: + // Unknown role - skip + i++ + } + } + + return bedrockMessages, systemPrompts +} + +// buildUserContent builds Bedrock content blocks for a user message. +func buildUserContent(msg Message) []types.ContentBlock { + var content []types.ContentBlock + + // Add text content + if msg.Content != "" { + content = append(content, &types.ContentBlockMemberText{ + Value: msg.Content, + }) + } + + // Add images from Media field + for _, mediaURL := range msg.Media { + if strings.HasPrefix(mediaURL, "data:image/") { + // Parse data URL: data:image/jpeg;base64, + parts := strings.SplitN(mediaURL, ",", 2) + if len(parts) != 2 { + continue + } + + // Extract media type from "data:image/jpeg;base64" + mediaType := "" + header := parts[0] + if idx := strings.Index(header, "/"); idx != -1 { + end := strings.Index(header[idx:], ";") + if end == -1 { + end = len(header) - idx + } + mediaType = header[idx+1 : idx+end] + } + + // Verify this is base64 encoded + if !strings.Contains(header, ";base64") { + continue // Skip non-base64 encoded data + } + + // Map media type to Bedrock format + var format types.ImageFormat + switch mediaType { + case "jpeg", "jpg": + format = types.ImageFormatJpeg + case "png": + format = types.ImageFormatPng + case "gif": + format = types.ImageFormatGif + case "webp": + format = types.ImageFormatWebp + default: + continue // Skip unsupported formats + } + + // Check size before decoding to prevent excessive memory allocation + // Bedrock has a ~20MB request limit; cap decoded images at 10MB + const maxImageSize = 10 * 1024 * 1024 + decodedLen := base64.StdEncoding.DecodedLen(len(parts[1])) + if decodedLen > maxImageSize { + log.Printf("bedrock: skipping image exceeding size limit (%d bytes > %d)", decodedLen, maxImageSize) + continue + } + + // Decode base64 data + imageData, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + log.Printf("bedrock: failed to decode base64 image data: %v", err) + continue + } + + content = append(content, &types.ContentBlockMemberImage{ + Value: types.ImageBlock{ + Format: format, + Source: &types.ImageSourceMemberBytes{ + Value: imageData, + }, + }, + }) + } + } + + // Bedrock requires at least one content block; add empty text if needed + if len(content) == 0 { + content = append(content, &types.ContentBlockMemberText{Value: ""}) + } + + return content +} + +// buildAssistantContent builds Bedrock content blocks for an assistant message. +func buildAssistantContent(msg Message) []types.ContentBlock { + var content []types.ContentBlock + + // Add text content if present + if msg.Content != "" { + content = append(content, &types.ContentBlockMemberText{ + Value: msg.Content, + }) + } + + // Add tool use blocks + for _, tc := range msg.ToolCalls { + // Validate tool call ID - Bedrock requires non-empty ToolUseId + if strings.TrimSpace(tc.ID) == "" { + log.Printf("bedrock: skipping tool call with empty ID (name: %q)", tc.Name) + continue + } + + // Resolve tool name: prefer tc.Name, fallback to tc.Function.Name + // (tc.Name/tc.Arguments are json:"-" and may be empty when from JSON) + toolName := tc.Name + if toolName == "" && tc.Function != nil { + toolName = tc.Function.Name + } + if strings.TrimSpace(toolName) == "" { + continue + } + + // Resolve arguments: prefer tc.Arguments, fallback to parsing tc.Function.Arguments + args := tc.Arguments + if args == nil && tc.Function != nil && tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + log.Printf("bedrock: failed to parse Function.Arguments for tool %q: %v", toolName, err) + args = map[string]any{} + } + } + if args == nil { + args = map[string]any{} + } + + // Convert arguments to a Bedrock document using NewLazyDocument + inputDoc := document.NewLazyDocument(args) + + content = append(content, &types.ContentBlockMemberToolUse{ + Value: types.ToolUseBlock{ + ToolUseId: aws.String(tc.ID), + Name: aws.String(toolName), + Input: inputDoc, + }, + }) + } + + // Bedrock requires at least one content block; add empty text if needed + if len(content) == 0 { + content = append(content, &types.ContentBlockMemberText{Value: ""}) + } + + return content +} + +// convertTools converts tool definitions to Bedrock format. +func convertTools(tools []ToolDefinition) *types.ToolConfiguration { + bedrockTools := make([]types.Tool, 0, len(tools)) + + for _, tool := range tools { + // Skip tools with empty names + if strings.TrimSpace(tool.Function.Name) == "" { + continue + } + + // Ensure parameters is not nil - default to minimal object schema + params := tool.Function.Parameters + if params == nil { + params = map[string]any{ + "type": "object", + "properties": map[string]any{}, + } + } + + // Convert parameters schema to a Bedrock document + inputSchema := document.NewLazyDocument(params) + + bedrockTools = append(bedrockTools, &types.ToolMemberToolSpec{ + Value: types.ToolSpecification{ + Name: aws.String(tool.Function.Name), + Description: aws.String(tool.Function.Description), + InputSchema: &types.ToolInputSchemaMemberJson{ + Value: inputSchema, + }, + }, + }) + } + + return &types.ToolConfiguration{ + Tools: bedrockTools, + } +} + +// parseResponse converts Bedrock Converse output to LLMResponse. +func parseResponse(output *bedrockruntime.ConverseOutput) (*LLMResponse, error) { + var content strings.Builder + toolCalls := make([]ToolCall, 0) + + // Process output content blocks + if output.Output != nil { + if msgOutput, ok := output.Output.(*types.ConverseOutputMemberMessage); ok { + for _, block := range msgOutput.Value.Content { + switch b := block.(type) { + case *types.ContentBlockMemberText: + content.WriteString(b.Value) + + case *types.ContentBlockMemberToolUse: + // Unmarshal the document interface to a map + args := make(map[string]any) + if b.Value.Input != nil { + if err := b.Value.Input.UnmarshalSmithyDocument(&args); err != nil { + log.Printf("bedrock: failed to unmarshal tool input for tool %q (id %q): %v", + aws.ToString(b.Value.Name), + aws.ToString(b.Value.ToolUseId), + err, + ) + args = make(map[string]any) + } + } + + // Serialize arguments to JSON string for FunctionCall + argsJSON, err := json.Marshal(args) + if err != nil { + log.Printf("bedrock: failed to marshal tool arguments for tool %q (id %q): %v", + aws.ToString(b.Value.Name), + aws.ToString(b.Value.ToolUseId), + err, + ) + argsJSON = []byte("{}") + } + + toolCalls = append(toolCalls, ToolCall{ + ID: aws.ToString(b.Value.ToolUseId), + Name: aws.ToString(b.Value.Name), + Arguments: args, + Function: &FunctionCall{ + Name: aws.ToString(b.Value.Name), + Arguments: string(argsJSON), + }, + }) + } + } + } + } + + // Map stop reason + finishReason := "stop" + switch output.StopReason { + case types.StopReasonToolUse: + finishReason = "tool_calls" + case types.StopReasonMaxTokens: + finishReason = "length" + case types.StopReasonEndTurn: + finishReason = "stop" + case types.StopReasonStopSequence: + finishReason = "stop" + case types.StopReasonContentFiltered: + finishReason = "content_filter" + } + + // Build usage info + var usage *UsageInfo + if output.Usage != nil { + usage = &UsageInfo{ + PromptTokens: int(aws.ToInt32(output.Usage.InputTokens)), + CompletionTokens: int(aws.ToInt32(output.Usage.OutputTokens)), + TotalTokens: int(aws.ToInt32(output.Usage.InputTokens)) + int(aws.ToInt32(output.Usage.OutputTokens)), + } + } + + return &LLMResponse{ + Content: content.String(), + ToolCalls: toolCalls, + FinishReason: finishReason, + Usage: usage, + }, nil +} + +// isSSOTokenError checks if the error is related to expired or invalid AWS SSO tokens. +// This helps provide actionable guidance when SSO credentials need to be refreshed. +// Only matches SSO-specific error patterns to avoid misclassifying other AWS credential errors. +func isSSOTokenError(err error) bool { + if err == nil { + return false + } + lower := strings.ToLower(err.Error()) + + // Check for specific SSO token expiration/refresh-related error patterns (case-insensitive) + // Avoid matching generic patterns that could match non-SSO AWS errors (e.g., STS ExpiredToken) + if strings.Contains(lower, "refresh cached sso token") { + return true + } + if strings.Contains(lower, "read cached sso token") { + return true + } + if strings.Contains(lower, "sso oidc") { + return true + } + if strings.Contains(lower, "invalidgrantexception") { + return true + } + + return false +} diff --git a/pkg/providers/bedrock/provider_bedrock_test.go b/pkg/providers/bedrock/provider_bedrock_test.go new file mode 100644 index 000000000..38a5e26da --- /dev/null +++ b/pkg/providers/bedrock/provider_bedrock_test.go @@ -0,0 +1,607 @@ +//go:build bedrock + +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package bedrock + +import ( + "fmt" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/document" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +func TestConvertMessages_SystemPrompts(t *testing.T) { + messages := []Message{ + {Role: "system", Content: "You are a helpful assistant."}, + {Role: "user", Content: "Hello"}, + } + + bedrockMsgs, systemPrompts := convertMessages(messages) + + assert.Len(t, systemPrompts, 1) + assert.Len(t, bedrockMsgs, 1) + + // Check system prompt + textBlock, ok := systemPrompts[0].(*types.SystemContentBlockMemberText) + require.True(t, ok) + assert.Equal(t, "You are a helpful assistant.", textBlock.Value) + + // Check user message + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role) +} + +func TestConvertMessages_UserMessage(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "What is 2+2?"}, + } + + bedrockMsgs, systemPrompts := convertMessages(messages) + + assert.Empty(t, systemPrompts) + assert.Len(t, bedrockMsgs, 1) + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role) + + textBlock, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberText) + require.True(t, ok) + assert.Equal(t, "What is 2+2?", textBlock.Value) +} + +func TestConvertMessages_AssistantMessage(t *testing.T) { + messages := []Message{ + {Role: "assistant", Content: "The answer is 4."}, + } + + bedrockMsgs, _ := convertMessages(messages) + + assert.Len(t, bedrockMsgs, 1) + assert.Equal(t, types.ConversationRoleAssistant, bedrockMsgs[0].Role) + + textBlock, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberText) + require.True(t, ok) + assert.Equal(t, "The answer is 4.", textBlock.Value) +} + +func TestConvertMessages_ToolResult(t *testing.T) { + messages := []Message{ + {Role: "tool", Content: "Result from tool", ToolCallID: "call_123"}, + } + + bedrockMsgs, _ := convertMessages(messages) + + assert.Len(t, bedrockMsgs, 1) + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role) + + toolResult, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberToolResult) + require.True(t, ok) + assert.Equal(t, "call_123", aws.ToString(toolResult.Value.ToolUseId)) +} + +func TestConvertMessages_MultipleToolResultsMerged(t *testing.T) { + // When an assistant makes multiple tool calls, all tool results must be + // merged into a single user message for Bedrock + messages := []Message{ + {Role: "user", Content: "What's the weather in NYC and LA?"}, + { + Role: "assistant", + Content: "Let me check both cities.", + ToolCalls: []protocoltypes.ToolCall{ + {ID: "call_nyc", Name: "get_weather", Arguments: map[string]any{"city": "NYC"}}, + {ID: "call_la", Name: "get_weather", Arguments: map[string]any{"city": "LA"}}, + }, + }, + {Role: "tool", Content: "NYC: 72°F, sunny", ToolCallID: "call_nyc"}, + {Role: "tool", Content: "LA: 85°F, clear", ToolCallID: "call_la"}, + } + + bedrockMsgs, _ := convertMessages(messages) + + // Should be: user message, assistant message, merged tool results (single user message) + assert.Len(t, bedrockMsgs, 3) + + // First message: user + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role) + + // Second message: assistant with tool calls + assert.Equal(t, types.ConversationRoleAssistant, bedrockMsgs[1].Role) + + // Third message: merged tool results in single user message + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[2].Role) + assert.Len(t, bedrockMsgs[2].Content, 2) // Both tool results in one message + + // Verify both tool results are present + result1, ok := bedrockMsgs[2].Content[0].(*types.ContentBlockMemberToolResult) + require.True(t, ok) + assert.Equal(t, "call_nyc", aws.ToString(result1.Value.ToolUseId)) + + result2, ok := bedrockMsgs[2].Content[1].(*types.ContentBlockMemberToolResult) + require.True(t, ok) + assert.Equal(t, "call_la", aws.ToString(result2.Value.ToolUseId)) +} + +func TestConvertMessages_AssistantWithToolCalls(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + Content: "Let me calculate that.", + ToolCalls: []protocoltypes.ToolCall{ + { + ID: "call_456", + Name: "calculator", + Arguments: map[string]any{"expression": "2+2"}, + }, + }, + }, + } + + bedrockMsgs, _ := convertMessages(messages) + + assert.Len(t, bedrockMsgs, 1) + assert.Len(t, bedrockMsgs[0].Content, 2) // text + tool use + + // Check text content + textBlock, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberText) + require.True(t, ok) + assert.Equal(t, "Let me calculate that.", textBlock.Value) + + // Check tool use + toolUse, ok := bedrockMsgs[0].Content[1].(*types.ContentBlockMemberToolUse) + require.True(t, ok) + assert.Equal(t, "call_456", aws.ToString(toolUse.Value.ToolUseId)) + assert.Equal(t, "calculator", aws.ToString(toolUse.Value.Name)) +} + +func TestConvertTools_Basic(t *testing.T) { + tools := []ToolDefinition{ + { + Function: protocoltypes.ToolFunctionDefinition{ + Name: "get_weather", + Description: "Get the current weather", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "location": map[string]any{"type": "string"}, + }, + }, + }, + }, + } + + toolConfig := convertTools(tools) + + assert.NotNil(t, toolConfig) + assert.Len(t, toolConfig.Tools, 1) + + toolSpec, ok := toolConfig.Tools[0].(*types.ToolMemberToolSpec) + require.True(t, ok) + assert.Equal(t, "get_weather", aws.ToString(toolSpec.Value.Name)) + assert.Equal(t, "Get the current weather", aws.ToString(toolSpec.Value.Description)) +} + +func TestConvertTools_SkipsEmptyName(t *testing.T) { + tools := []ToolDefinition{ + { + Function: protocoltypes.ToolFunctionDefinition{ + Name: "", + Description: "Empty name tool", + }, + }, + { + Function: protocoltypes.ToolFunctionDefinition{ + Name: " ", + Description: "Whitespace name tool", + }, + }, + { + Function: protocoltypes.ToolFunctionDefinition{ + Name: "valid_tool", + Description: "Valid tool", + }, + }, + } + + toolConfig := convertTools(tools) + + assert.Len(t, toolConfig.Tools, 1) + toolSpec := toolConfig.Tools[0].(*types.ToolMemberToolSpec) + assert.Equal(t, "valid_tool", aws.ToString(toolSpec.Value.Name)) +} + +func TestConvertTools_NilParameters(t *testing.T) { + tools := []ToolDefinition{ + { + Function: protocoltypes.ToolFunctionDefinition{ + Name: "simple_tool", + Description: "A tool with no parameters", + Parameters: nil, + }, + }, + } + + toolConfig := convertTools(tools) + + assert.Len(t, toolConfig.Tools, 1) + // Should not panic and should create a valid tool +} + +func TestBuildUserContent_TextOnly(t *testing.T) { + msg := Message{Content: "Hello world"} + + content := buildUserContent(msg) + + assert.Len(t, content, 1) + textBlock, ok := content[0].(*types.ContentBlockMemberText) + require.True(t, ok) + assert.Equal(t, "Hello world", textBlock.Value) +} + +func TestBuildUserContent_WithImage(t *testing.T) { + // Base64-encoded 1x1 PNG (the provider doesn't validate image correctness, + // it just verifies the format and base64 decoding works) + b64Data := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADUlEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + + msg := Message{ + Content: "Look at this image", + Media: []string{"data:image/png;base64," + b64Data}, + } + + content := buildUserContent(msg) + + assert.Len(t, content, 2) + + // Check text + textBlock, ok := content[0].(*types.ContentBlockMemberText) + require.True(t, ok) + assert.Equal(t, "Look at this image", textBlock.Value) + + // Check image + imageBlock, ok := content[1].(*types.ContentBlockMemberImage) + require.True(t, ok) + assert.Equal(t, types.ImageFormatPng, imageBlock.Value.Format) +} + +func TestBuildUserContent_SkipsInvalidBase64(t *testing.T) { + msg := Message{ + Content: "Invalid image", + Media: []string{"data:image/png;base64,not-valid-base64!!!"}, + } + + content := buildUserContent(msg) + + // Should only have text, image should be skipped + assert.Len(t, content, 1) +} + +func TestBuildUserContent_SkipsNonBase64Data(t *testing.T) { + msg := Message{ + Content: "Non-base64 image", + Media: []string{"data:image/png,raw-data-here"}, + } + + content := buildUserContent(msg) + + // Should only have text, non-base64 image should be skipped + assert.Len(t, content, 1) +} + +func TestBuildAssistantContent_SkipsEmptyToolName(t *testing.T) { + msg := Message{ + Content: "Response", + ToolCalls: []protocoltypes.ToolCall{ + {ID: "1", Name: "", Arguments: map[string]any{}}, + {ID: "2", Name: " ", Arguments: map[string]any{}}, + {ID: "3", Name: "valid", Arguments: map[string]any{}}, + }, + } + + content := buildAssistantContent(msg) + + // Should have text + 1 valid tool + assert.Len(t, content, 2) +} + +func TestBuildAssistantContent_NilArguments(t *testing.T) { + msg := Message{ + ToolCalls: []protocoltypes.ToolCall{ + {ID: "1", Name: "tool", Arguments: nil}, + }, + } + + content := buildAssistantContent(msg) + + assert.Len(t, content, 1) + toolUse, ok := content[0].(*types.ContentBlockMemberToolUse) + require.True(t, ok) + assert.NotNil(t, toolUse.Value.Input) +} + +func TestBuildAssistantContent_FunctionFallback(t *testing.T) { + // When Name/Arguments are empty (json:"-"), should fallback to Function fields + msg := Message{ + ToolCalls: []protocoltypes.ToolCall{ + { + ID: "1", + Name: "", // empty, should fallback to Function.Name + Function: &protocoltypes.FunctionCall{ + Name: "fallback_tool", + Arguments: `{"key":"value"}`, + }, + }, + }, + } + + content := buildAssistantContent(msg) + + assert.Len(t, content, 1) + toolUse, ok := content[0].(*types.ContentBlockMemberToolUse) + require.True(t, ok) + assert.Equal(t, "fallback_tool", aws.ToString(toolUse.Value.Name)) +} + +func TestParseResponse_TextOnly(t *testing.T) { + output := &bedrockruntime.ConverseOutput{ + Output: &types.ConverseOutputMemberMessage{ + Value: types.Message{ + Role: types.ConversationRoleAssistant, + Content: []types.ContentBlock{ + &types.ContentBlockMemberText{Value: "Hello!"}, + }, + }, + }, + StopReason: types.StopReasonEndTurn, + Usage: &types.TokenUsage{ + InputTokens: aws.Int32(10), + OutputTokens: aws.Int32(5), + }, + } + + resp, err := parseResponse(output) + + require.NoError(t, err) + assert.Equal(t, "Hello!", resp.Content) + assert.Equal(t, "stop", resp.FinishReason) + assert.Empty(t, resp.ToolCalls) + assert.Equal(t, 10, resp.Usage.PromptTokens) + assert.Equal(t, 5, resp.Usage.CompletionTokens) +} + +func TestParseResponse_StopReasons(t *testing.T) { + tests := []struct { + stopReason types.StopReason + expectedFinish string + }{ + {types.StopReasonEndTurn, "stop"}, + {types.StopReasonToolUse, "tool_calls"}, + {types.StopReasonMaxTokens, "length"}, + {types.StopReasonStopSequence, "stop"}, + {types.StopReasonContentFiltered, "content_filter"}, + } + + for _, tt := range tests { + t.Run(string(tt.stopReason), func(t *testing.T) { + output := &bedrockruntime.ConverseOutput{ + Output: &types.ConverseOutputMemberMessage{ + Value: types.Message{ + Content: []types.ContentBlock{ + &types.ContentBlockMemberText{Value: "test"}, + }, + }, + }, + StopReason: tt.stopReason, + } + + resp, err := parseResponse(output) + + require.NoError(t, err) + assert.Equal(t, tt.expectedFinish, resp.FinishReason) + }) + } +} + +func TestParseResponse_WithToolCalls(t *testing.T) { + // Note: document.NewLazyDocument has limitations with UnmarshalSmithyDocument in tests, + // so we test the structure extraction and verify Arguments gets populated (even if empty + // due to SDK limitations). The actual unmarshal works correctly at runtime. + toolInput := document.NewLazyDocument(map[string]any{ + "location": "San Francisco", + "unit": "celsius", + }) + + output := &bedrockruntime.ConverseOutput{ + Output: &types.ConverseOutputMemberMessage{ + Value: types.Message{ + Role: types.ConversationRoleAssistant, + Content: []types.ContentBlock{ + &types.ContentBlockMemberText{Value: "Let me check the weather."}, + &types.ContentBlockMemberToolUse{ + Value: types.ToolUseBlock{ + ToolUseId: aws.String("call_weather_123"), + Name: aws.String("get_weather"), + Input: toolInput, + }, + }, + }, + }, + }, + StopReason: types.StopReasonToolUse, + Usage: &types.TokenUsage{ + InputTokens: aws.Int32(20), + OutputTokens: aws.Int32(15), + }, + } + + resp, err := parseResponse(output) + + require.NoError(t, err) + assert.Equal(t, "Let me check the weather.", resp.Content) + assert.Equal(t, "tool_calls", resp.FinishReason) + assert.Len(t, resp.ToolCalls, 1) + + // Verify tool call ID and Name are extracted correctly + tc := resp.ToolCalls[0] + assert.Equal(t, "call_weather_123", tc.ID) + assert.Equal(t, "get_weather", tc.Name) + + // Verify Function fields are also populated + require.NotNil(t, tc.Function) + assert.Equal(t, "get_weather", tc.Function.Name) + + // Verify Arguments is not nil (content may vary due to SDK limitations in tests) + assert.NotNil(t, tc.Arguments) + + // Verify usage + assert.Equal(t, 20, resp.Usage.PromptTokens) + assert.Equal(t, 15, resp.Usage.CompletionTokens) + assert.Equal(t, 35, resp.Usage.TotalTokens) +} + +func TestParseResponse_MultipleToolCalls(t *testing.T) { + output := &bedrockruntime.ConverseOutput{ + Output: &types.ConverseOutputMemberMessage{ + Value: types.Message{ + Role: types.ConversationRoleAssistant, + Content: []types.ContentBlock{ + &types.ContentBlockMemberToolUse{ + Value: types.ToolUseBlock{ + ToolUseId: aws.String("call_1"), + Name: aws.String("tool_a"), + Input: document.NewLazyDocument(map[string]any{"arg": "value1"}), + }, + }, + &types.ContentBlockMemberToolUse{ + Value: types.ToolUseBlock{ + ToolUseId: aws.String("call_2"), + Name: aws.String("tool_b"), + Input: document.NewLazyDocument(map[string]any{"arg": "value2"}), + }, + }, + }, + }, + }, + StopReason: types.StopReasonToolUse, + } + + resp, err := parseResponse(output) + + require.NoError(t, err) + assert.Equal(t, "tool_calls", resp.FinishReason) + assert.Len(t, resp.ToolCalls, 2) + + // Verify tool call structure + assert.Equal(t, "call_1", resp.ToolCalls[0].ID) + assert.Equal(t, "tool_a", resp.ToolCalls[0].Name) + assert.NotNil(t, resp.ToolCalls[0].Arguments) + assert.NotNil(t, resp.ToolCalls[0].Function) + assert.Equal(t, "tool_a", resp.ToolCalls[0].Function.Name) + + assert.Equal(t, "call_2", resp.ToolCalls[1].ID) + assert.Equal(t, "tool_b", resp.ToolCalls[1].Name) + assert.NotNil(t, resp.ToolCalls[1].Arguments) + assert.NotNil(t, resp.ToolCalls[1].Function) + assert.Equal(t, "tool_b", resp.ToolCalls[1].Function.Name) +} + +func TestParseResponse_ToolCallWithNilInput(t *testing.T) { + output := &bedrockruntime.ConverseOutput{ + Output: &types.ConverseOutputMemberMessage{ + Value: types.Message{ + Role: types.ConversationRoleAssistant, + Content: []types.ContentBlock{ + &types.ContentBlockMemberToolUse{ + Value: types.ToolUseBlock{ + ToolUseId: aws.String("call_nil"), + Name: aws.String("no_args_tool"), + Input: nil, + }, + }, + }, + }, + }, + StopReason: types.StopReasonToolUse, + } + + resp, err := parseResponse(output) + + require.NoError(t, err) + assert.Len(t, resp.ToolCalls, 1) + assert.Equal(t, "call_nil", resp.ToolCalls[0].ID) + assert.Equal(t, "no_args_tool", resp.ToolCalls[0].Name) + // Arguments should be empty map, not nil + assert.NotNil(t, resp.ToolCalls[0].Arguments) + assert.Empty(t, resp.ToolCalls[0].Arguments) +} + +func TestIsSSOTokenError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "generic error", + err: fmt.Errorf("connection refused"), + expected: false, + }, + { + name: "SSO config error not expiration", + err: fmt.Errorf("failed to load SSO profile: invalid SSO session"), + expected: false, + }, + { + name: "STS ExpiredToken error", + err: fmt.Errorf("ExpiredToken: The security token included in the request is expired"), + expected: false, + }, + { + name: "SSO token refresh error", + err: fmt.Errorf("refresh cached SSO token failed"), + expected: true, + }, + { + name: "InvalidGrantException", + err: fmt.Errorf("operation error SSO OIDC: CreateToken, InvalidGrantException"), + expected: true, + }, + { + name: "SSO OIDC error", + err: fmt.Errorf("operation error SSO OIDC: CreateToken, failed"), + expected: true, + }, + { + name: "full SSO error message", + err: fmt.Errorf( + "get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token", + ), + expected: true, + }, + { + name: "SSO token file missing", + err: fmt.Errorf( + "get identity: get credentials: failed to refresh cached credentials, failed to read cached SSO token file, open ~/.aws/sso/cache/abc123.json: no such file or directory", + ), + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isSSOTokenError(tt.err) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/providers/bedrock/provider_stub.go b/pkg/providers/bedrock/provider_stub.go new file mode 100644 index 000000000..894d9f2ca --- /dev/null +++ b/pkg/providers/bedrock/provider_stub.go @@ -0,0 +1,73 @@ +//go:build !bedrock + +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +// Package bedrock provides a stub implementation when built without the bedrock tag. +// To enable AWS Bedrock support, build with: go build -tags bedrock +package bedrock + +import ( + "context" + "fmt" + "time" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + LLMResponse = protocoltypes.LLMResponse + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition +) + +// Provider is a stub that returns an error when Bedrock support is not compiled in. +type Provider struct{} + +// Option is a no-op when Bedrock is not enabled. +type Option func(*providerConfig) + +type providerConfig struct{} + +// WithRegion is a no-op when Bedrock is not enabled. +func WithRegion(region string) Option { + return func(c *providerConfig) {} +} + +// WithProfile is a no-op when Bedrock is not enabled. +func WithProfile(profile string) Option { + return func(c *providerConfig) {} +} + +// WithBaseEndpoint is a no-op when Bedrock is not enabled. +func WithBaseEndpoint(endpoint string) Option { + return func(c *providerConfig) {} +} + +// WithRequestTimeout is a no-op when Bedrock is not enabled. +func WithRequestTimeout(timeout time.Duration) Option { + return func(c *providerConfig) {} +} + +// NewProvider returns an error indicating Bedrock support is not compiled in. +func NewProvider(ctx context.Context, opts ...Option) (*Provider, error) { + return nil, fmt.Errorf("bedrock provider not available: build with -tags bedrock to enable AWS Bedrock support") +} + +// Chat returns an error - this should never be called since NewProvider fails. +func (p *Provider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + return nil, fmt.Errorf("bedrock provider not available: build with -tags bedrock to enable AWS Bedrock support") +} + +// GetDefaultModel returns an empty string. +func (p *Provider) GetDefaultModel() string { + return "" +} diff --git a/pkg/providers/bedrock/provider_stub_test.go b/pkg/providers/bedrock/provider_stub_test.go new file mode 100644 index 000000000..50ec8340f --- /dev/null +++ b/pkg/providers/bedrock/provider_stub_test.go @@ -0,0 +1,35 @@ +//go:build !bedrock + +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package bedrock + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewProvider_ReturnsStubError(t *testing.T) { + provider, err := NewProvider(context.Background()) + + assert.Nil(t, provider) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "build with -tags bedrock"), + "error should mention build tag requirement, got: %s", err.Error()) +} + +func TestNewProvider_WithOptions_ReturnsStubError(t *testing.T) { + provider, err := NewProvider(context.Background(), WithRegion("us-west-2"), WithProfile("test")) + + assert.Nil(t, provider) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "build with -tags bedrock"), + "error should mention build tag requirement, got: %s", err.Error()) +} diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/cli/claude_cli_provider.go similarity index 89% rename from pkg/providers/claude_cli_provider.go rename to pkg/providers/cli/claude_cli_provider.go index 6c4f6a767..62851ca3a 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/cli/claude_cli_provider.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "bytes" @@ -7,6 +7,8 @@ import ( "fmt" "os/exec" "strings" + + "github.com/sipeed/picoclaw/pkg/isolation" ) // ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess. @@ -49,11 +51,21 @@ func (p *ClaudeCliProvider) Chat( cmd.Stdout = &stdout cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - if stderrStr := stderr.String(); stderrStr != "" { + // Execute the CLI through the shared isolation wrapper so external provider + // processes honor the configured isolation policy. + if err := isolation.Run(cmd); err != nil { + stderrStr := strings.TrimSpace(stderr.String()) + stdoutStr := strings.TrimSpace(stdout.String()) + switch { + case stderrStr != "" && stdoutStr != "": + return nil, fmt.Errorf("claude cli error: %w\nstderr: %s\nstdout: %s", err, stderrStr, stdoutStr) + case stderrStr != "": return nil, fmt.Errorf("claude cli error: %s", stderrStr) + case stdoutStr != "": + return nil, fmt.Errorf("claude cli error: %w\noutput: %s", err, stdoutStr) + default: + return nil, fmt.Errorf("claude cli error: %w", err) } - return nil, fmt.Errorf("claude cli error: %w", err) } return p.parseClaudeCliResponse(stdout.String()) diff --git a/pkg/providers/claude_cli_provider_integration_test.go b/pkg/providers/cli/claude_cli_provider_integration_test.go similarity index 99% rename from pkg/providers/claude_cli_provider_integration_test.go rename to pkg/providers/cli/claude_cli_provider_integration_test.go index f6e0d787a..cdfe7060e 100644 --- a/pkg/providers/claude_cli_provider_integration_test.go +++ b/pkg/providers/cli/claude_cli_provider_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package providers +package cliprovider import ( "context" diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/cli/claude_cli_provider_test.go similarity index 92% rename from pkg/providers/claude_cli_provider_test.go rename to pkg/providers/cli/claude_cli_provider_test.go index d4d648f5a..ddef84ffc 100644 --- a/pkg/providers/claude_cli_provider_test.go +++ b/pkg/providers/cli/claude_cli_provider_test.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "context" @@ -9,8 +9,6 @@ import ( "strings" "testing" "time" - - "github.com/sipeed/picoclaw/pkg/config" ) // --- Compile-time interface check --- @@ -409,83 +407,6 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) { } } -// --- CreateProvider factory tests --- - -func TestCreateProvider_ClaudeCli(t *testing.T) { - cfg := config.DefaultConfig() - cfg.ModelList = []config.ModelConfig{ - {ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"}, - } - cfg.Agents.Defaults.Model = "claude-sonnet-4.6" - - provider, _, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claude-cli) error = %v", err) - } - - cliProvider, ok := provider.(*ClaudeCliProvider) - if !ok { - t.Fatalf("CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider", provider) - } - if cliProvider.workspace != "/test/ws" { - t.Errorf("workspace = %q, want %q", cliProvider.workspace, "/test/ws") - } -} - -func TestCreateProvider_ClaudeCode(t *testing.T) { - cfg := config.DefaultConfig() - cfg.ModelList = []config.ModelConfig{ - {ModelName: "claude-code", Model: "claude-cli/claude-code"}, - } - cfg.Agents.Defaults.Model = "claude-code" - - provider, _, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claude-code) error = %v", err) - } - if _, ok := provider.(*ClaudeCliProvider); !ok { - t.Fatalf("CreateProvider(claude-code) returned %T, want *ClaudeCliProvider", provider) - } -} - -func TestCreateProvider_ClaudeCodec(t *testing.T) { - cfg := config.DefaultConfig() - cfg.ModelList = []config.ModelConfig{ - {ModelName: "claudecode", Model: "claude-cli/claudecode"}, - } - cfg.Agents.Defaults.Model = "claudecode" - - provider, _, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claudecode) error = %v", err) - } - if _, ok := provider.(*ClaudeCliProvider); !ok { - t.Fatalf("CreateProvider(claudecode) returned %T, want *ClaudeCliProvider", provider) - } -} - -func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) { - cfg := config.DefaultConfig() - cfg.ModelList = []config.ModelConfig{ - {ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"}, - } - cfg.Agents.Defaults.Model = "claude-cli" - cfg.Agents.Defaults.Workspace = "" - - provider, _, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider error = %v", err) - } - - cliProvider, ok := provider.(*ClaudeCliProvider) - if !ok { - t.Fatalf("returned %T, want *ClaudeCliProvider", provider) - } - if cliProvider.workspace != "." { - t.Errorf("workspace = %q, want %q (default)", cliProvider.workspace, ".") - } -} - // --- messagesToPrompt tests --- func TestMessagesToPrompt_SingleUser(t *testing.T) { diff --git a/pkg/providers/codex_cli_credentials.go b/pkg/providers/cli/codex_cli_credentials.go similarity index 89% rename from pkg/providers/codex_cli_credentials.go rename to pkg/providers/cli/codex_cli_credentials.go index 40f3ee2a1..95e289097 100644 --- a/pkg/providers/codex_cli_credentials.go +++ b/pkg/providers/cli/codex_cli_credentials.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "encoding/json" @@ -8,6 +8,11 @@ import ( "time" ) +// CodexHomeEnvVar is the environment variable that overrides the Codex CLI +// home directory when resolving the codex auth.json credentials file. +// Default: ~/.codex +const CodexHomeEnvVar = "CODEX_HOME" + // CodexCliAuth represents the ~/.codex/auth.json file structure. type CodexCliAuth struct { Tokens struct { @@ -69,7 +74,7 @@ func CreateCodexCliTokenSource() func() (string, string, error) { } func resolveCodexAuthPath() (string, error) { - codexHome := os.Getenv("CODEX_HOME") + codexHome := os.Getenv(CodexHomeEnvVar) if codexHome == "" { home, err := os.UserHomeDir() if err != nil { diff --git a/pkg/providers/codex_cli_credentials_test.go b/pkg/providers/cli/codex_cli_credentials_test.go similarity index 99% rename from pkg/providers/codex_cli_credentials_test.go rename to pkg/providers/cli/codex_cli_credentials_test.go index 1e88c1120..abad6e248 100644 --- a/pkg/providers/codex_cli_credentials_test.go +++ b/pkg/providers/cli/codex_cli_credentials_test.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "os" diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/cli/codex_cli_provider.go similarity index 96% rename from pkg/providers/codex_cli_provider.go rename to pkg/providers/cli/codex_cli_provider.go index 13f53ad9e..d1a23c329 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/cli/codex_cli_provider.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "bufio" @@ -8,6 +8,8 @@ import ( "fmt" "os/exec" "strings" + + "github.com/sipeed/picoclaw/pkg/isolation" ) // CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess. @@ -56,7 +58,9 @@ func (p *CodexCliProvider) Chat( cmd.Stdout = &stdout cmd.Stderr = &stderr - err := cmd.Run() + // Execute the CLI through the shared isolation wrapper so external provider + // processes honor the configured isolation policy. + err := isolation.Run(cmd) // Parse JSONL from stdout even if exit code is non-zero, // because codex writes diagnostic noise to stderr (e.g. rollout errors) diff --git a/pkg/providers/codex_cli_provider_integration_test.go b/pkg/providers/cli/codex_cli_provider_integration_test.go similarity index 99% rename from pkg/providers/codex_cli_provider_integration_test.go rename to pkg/providers/cli/codex_cli_provider_integration_test.go index 17a8305ad..af18b8c6d 100644 --- a/pkg/providers/codex_cli_provider_integration_test.go +++ b/pkg/providers/cli/codex_cli_provider_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package providers +package cliprovider import ( "context" diff --git a/pkg/providers/codex_cli_provider_test.go b/pkg/providers/cli/codex_cli_provider_test.go similarity index 97% rename from pkg/providers/codex_cli_provider_test.go rename to pkg/providers/cli/codex_cli_provider_test.go index 414e0844d..8338fbc91 100644 --- a/pkg/providers/codex_cli_provider_test.go +++ b/pkg/providers/cli/codex_cli_provider_test.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "context" @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -400,6 +401,9 @@ func TestCodexCliProvider_GetDefaultModel(t *testing.T) { func createMockCodexCLI(t *testing.T, events []string) string { t.Helper() + if runtime.GOOS == "windows" { + t.Skip("mock CLI scripts not supported on Windows") + } tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "codex") @@ -471,6 +475,9 @@ func TestCodexCliProvider_MockCLI_Error(t *testing.T) { } func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mock CLI scripts not supported on Windows") + } // Mock script that captures args to verify model flag is passed tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "codex") @@ -490,7 +497,7 @@ echo '{"type":"turn.completed"}'` } messages := []Message{{Role: "user", Content: "test"}} - _, err := p.Chat(context.Background(), messages, nil, "gpt-5.2-codex", nil) + _, err := p.Chat(context.Background(), messages, nil, "gpt-5.3-codex", nil) if err != nil { t.Fatalf("Chat() error: %v", err) } @@ -502,7 +509,7 @@ echo '{"type":"turn.completed"}'` } args := string(argsData) - if !strings.Contains(args, "-m gpt-5.2-codex") { + if !strings.Contains(args, "-m gpt-5.3-codex") { t.Errorf("args should contain model flag, got: %s", args) } if !strings.Contains(args, "-C /tmp/test-workspace") { @@ -517,6 +524,9 @@ echo '{"type":"turn.completed"}'` } func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mock CLI scripts not supported on Windows") + } // Script that sleeps forever tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "codex") diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/cli/github_copilot_provider.go similarity index 94% rename from pkg/providers/github_copilot_provider.go rename to pkg/providers/cli/github_copilot_provider.go index 6d642b2b5..d1d8a3e23 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/cli/github_copilot_provider.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "context" @@ -41,8 +41,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi } session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ - Model: model, - Hooks: &copilot.SessionHooks{}, + Model: model, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{}, }) if err != nil { client.Stop() diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/cli/tool_call_extract.go similarity index 98% rename from pkg/providers/tool_call_extract.go rename to pkg/providers/cli/tool_call_extract.go index 7ddea0e99..f1d1886ea 100644 --- a/pkg/providers/tool_call_extract.go +++ b/pkg/providers/cli/tool_call_extract.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "encoding/json" diff --git a/pkg/providers/toolcall_utils.go b/pkg/providers/cli/toolcall_utils.go similarity index 73% rename from pkg/providers/toolcall_utils.go rename to pkg/providers/cli/toolcall_utils.go index a33e1eb5c..1f58c9a26 100644 --- a/pkg/providers/toolcall_utils.go +++ b/pkg/providers/cli/toolcall_utils.go @@ -3,7 +3,7 @@ // // Copyright (c) 2026 PicoClaw contributors -package providers +package cliprovider import ( "encoding/json" @@ -23,6 +23,12 @@ func buildCLIToolsPrompt(tools []ToolDefinition) string { ) sb.WriteString("\n```\n\n") sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") + sb.WriteString("Escaping rules (what to type in `function.arguments`):\n") + sb.WriteString("- Use `\\n` to represent a real newline character.\n") + sb.WriteString("- Use `\\\\n` to represent a literal backslash+n sequence (`\\n`).\n") + sb.WriteString( + "- `function.arguments` is a JSON-encoded string, so quotes/backslashes must be escaped in the outer payload.\n\n", + ) sb.WriteString("### Tool Definitions:\n\n") for _, tool := range tools { @@ -49,6 +55,12 @@ func buildCLIToolsPrompt(tools []ToolDefinition) string { func NormalizeToolCall(tc ToolCall) ToolCall { normalized := tc + if normalized.ThoughtSignature == "" && + normalized.ExtraContent != nil && + normalized.ExtraContent.Google != nil { + normalized.ThoughtSignature = normalized.ExtraContent.Google.ThoughtSignature + } + // Ensure Name is populated from Function if not set if normalized.Name == "" && normalized.Function != nil { normalized.Name = normalized.Function.Name @@ -71,8 +83,9 @@ func NormalizeToolCall(tc ToolCall) ToolCall { argsJSON, _ := json.Marshal(normalized.Arguments) if normalized.Function == nil { normalized.Function = &FunctionCall{ - Name: normalized.Name, - Arguments: string(argsJSON), + Name: normalized.Name, + Arguments: string(argsJSON), + ThoughtSignature: normalized.ThoughtSignature, } } else { if normalized.Function.Name == "" { @@ -84,6 +97,12 @@ func NormalizeToolCall(tc ToolCall) ToolCall { if normalized.Function.Arguments == "" { normalized.Function.Arguments = string(argsJSON) } + if normalized.Function.ThoughtSignature == "" { + normalized.Function.ThoughtSignature = normalized.ThoughtSignature + } + if normalized.ThoughtSignature == "" { + normalized.ThoughtSignature = normalized.Function.ThoughtSignature + } } return normalized diff --git a/pkg/providers/cli/types.go b/pkg/providers/cli/types.go new file mode 100644 index 000000000..f15897adf --- /dev/null +++ b/pkg/providers/cli/types.go @@ -0,0 +1,28 @@ +package cliprovider + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition +) + +type LLMProvider interface { + Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + ) (*LLMResponse, error) + GetDefaultModel() string +} diff --git a/pkg/providers/cli_facade.go b/pkg/providers/cli_facade.go new file mode 100644 index 000000000..6580291bd --- /dev/null +++ b/pkg/providers/cli_facade.go @@ -0,0 +1,40 @@ +package providers + +import ( + "time" + + cliprovider "github.com/sipeed/picoclaw/pkg/providers/cli" +) + +type ( + ClaudeCliProvider = cliprovider.ClaudeCliProvider + CodexCliProvider = cliprovider.CodexCliProvider + CodexCliAuth = cliprovider.CodexCliAuth + GitHubCopilotProvider = cliprovider.GitHubCopilotProvider +) + +const CodexHomeEnvVar = cliprovider.CodexHomeEnvVar + +func NewClaudeCliProvider(workspace string) *ClaudeCliProvider { + return cliprovider.NewClaudeCliProvider(workspace) +} + +func NewCodexCliProvider(workspace string) *CodexCliProvider { + return cliprovider.NewCodexCliProvider(workspace) +} + +func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) { + return cliprovider.NewGitHubCopilotProvider(uri, connectMode, model) +} + +func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Time, err error) { + return cliprovider.ReadCodexCliCredentials() +} + +func CreateCodexCliTokenSource() func() (string, string, error) { + return cliprovider.CreateCodexCliTokenSource() +} + +func NormalizeToolCall(tc ToolCall) ToolCall { + return cliprovider.NormalizeToolCall(tc) +} diff --git a/pkg/providers/cli_factory_test.go b/pkg/providers/cli_factory_test.go new file mode 100644 index 000000000..b00eafb9f --- /dev/null +++ b/pkg/providers/cli_factory_test.go @@ -0,0 +1,99 @@ +package providers + +import ( + "reflect" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func testProviderWorkspace(t *testing.T, provider any) string { + t.Helper() + + v := reflect.ValueOf(provider) + if v.Kind() != reflect.Ptr || v.IsNil() { + t.Fatalf("provider = %T, want non-nil pointer", provider) + } + + field := v.Elem().FieldByName("workspace") + if !field.IsValid() || field.Kind() != reflect.String { + t.Fatalf("provider %T does not expose workspace field", provider) + } + + return field.String() +} + +func TestCreateProvider_ClaudeCli(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{ + {ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"}, + } + cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6" + + provider, _, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider(claude-cli) error = %v", err) + } + + cliProvider, ok := provider.(*ClaudeCliProvider) + if !ok { + t.Fatalf("CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider", provider) + } + if got := testProviderWorkspace(t, cliProvider); got != "/test/ws" { + t.Errorf("workspace = %q, want %q", got, "/test/ws") + } +} + +func TestCreateProvider_ClaudeCode(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{ + {ModelName: "claude-code", Model: "claude-cli/claude-code"}, + } + cfg.Agents.Defaults.ModelName = "claude-code" + + provider, _, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider(claude-code) error = %v", err) + } + if _, ok := provider.(*ClaudeCliProvider); !ok { + t.Fatalf("CreateProvider(claude-code) returned %T, want *ClaudeCliProvider", provider) + } +} + +func TestCreateProvider_ClaudeCodec(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{ + {ModelName: "claudecode", Model: "claude-cli/claudecode"}, + } + cfg.Agents.Defaults.ModelName = "claudecode" + + provider, _, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider(claudecode) error = %v", err) + } + if _, ok := provider.(*ClaudeCliProvider); !ok { + t.Fatalf("CreateProvider(claudecode) returned %T, want *ClaudeCliProvider", provider) + } +} + +func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{ + {ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"}, + } + cfg.Agents.Defaults.ModelName = "claude-cli" + cfg.Agents.Defaults.Workspace = "" + + provider, _, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider error = %v", err) + } + + cliProvider, ok := provider.(*ClaudeCliProvider) + if !ok { + t.Fatalf("returned %T, want *ClaudeCliProvider", provider) + } + if got := testProviderWorkspace(t, cliProvider); got != "." { + t.Errorf("workspace = %q, want %q (default)", got, ".") + } +} diff --git a/pkg/providers/common/anthropic_common.go b/pkg/providers/common/anthropic_common.go new file mode 100644 index 000000000..92dace9ac --- /dev/null +++ b/pkg/providers/common/anthropic_common.go @@ -0,0 +1,27 @@ +package common + +import "strings" + +// NormalizeBaseURL ensures the Anthropic base URL is properly formatted. +// It removes a trailing /v1 suffix if present (to avoid duplication), then +// re-appends /v1 when appendV1Suffix is true. An empty apiBase falls back to +// defaultBaseURL. +func NormalizeBaseURL(apiBase, defaultBaseURL string, appendV1Suffix bool) string { + base := strings.TrimSpace(apiBase) + if base == "" { + return defaultBaseURL + } + + base = strings.TrimRight(base, "/") + if before, ok := strings.CutSuffix(base, "/v1"); ok { + base = before + } + if base == "" { + return defaultBaseURL + } + + if appendV1Suffix { + return base + "/v1" + } + return base +} diff --git a/pkg/providers/common/anthropic_common_test.go b/pkg/providers/common/anthropic_common_test.go new file mode 100644 index 000000000..7563141b5 --- /dev/null +++ b/pkg/providers/common/anthropic_common_test.go @@ -0,0 +1,59 @@ +package common + +import "testing" + +func TestNormalizeAnthropicBaseURL(t *testing.T) { + const defaultURL = "https://api.anthropic.com" + const defaultURLWithV1 = "https://api.anthropic.com/v1" + + tests := []struct { + name string + apiBase string + defaultBase string + appendV1Suffix bool + expected string + }{ + {"empty with v1", "", defaultURLWithV1, true, defaultURLWithV1}, + {"empty without v1", "", defaultURL, false, defaultURL}, + { + "URL without v1 gets it appended", + "https://api.example.com/anthropic", defaultURLWithV1, + true, "https://api.example.com/anthropic/v1", + }, + { + "URL without v1 stays as-is", + "https://api.example.com/anthropic", defaultURL, + false, "https://api.example.com/anthropic", + }, + { + "URL with v1 remains unchanged when appending", + "https://api.example.com/v1", defaultURLWithV1, + true, "https://api.example.com/v1", + }, + { + "URL with v1 gets it stripped when not appending", + "https://api.example.com/v1", defaultURL, + false, "https://api.example.com", + }, + { + "trailing slash cleaned with v1", + "https://api.example.com/anthropic/", defaultURLWithV1, + true, "https://api.example.com/anthropic/v1", + }, + { + "trailing slash cleaned without v1", + "https://api.example.com/anthropic/", defaultURL, + false, "https://api.example.com/anthropic", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NormalizeBaseURL(tt.apiBase, tt.defaultBase, tt.appendV1Suffix) + if got != tt.expected { + t.Errorf("NormalizeAnthropicBaseURL(%q, %q, %v) = %q, want %q", + tt.apiBase, tt.defaultBase, tt.appendV1Suffix, got, tt.expected) + } + }) + } +} diff --git a/pkg/providers/common/common.go b/pkg/providers/common/common.go new file mode 100644 index 000000000..5e03bc0c2 --- /dev/null +++ b/pkg/providers/common/common.go @@ -0,0 +1,496 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +// Package common provides shared utilities used by multiple LLM provider +// implementations (openai_compat, azure, etc.). +package common + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +// Re-export protocol types used across providers. +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition + ExtraContent = protocoltypes.ExtraContent + GoogleExtra = protocoltypes.GoogleExtra + ReasoningDetail = protocoltypes.ReasoningDetail +) + +const DefaultRequestTimeout = 120 * time.Second + +// NewHTTPClient creates an *http.Client with an optional proxy and the default timeout. +func NewHTTPClient(proxy string) *http.Client { + client := &http.Client{ + Timeout: DefaultRequestTimeout, + } + if proxy != "" { + parsed, err := url.Parse(proxy) + if err == nil { + // Preserve http.DefaultTransport settings (TLS, HTTP/2, timeouts, etc.) + if base, ok := http.DefaultTransport.(*http.Transport); ok { + tr := base.Clone() + tr.Proxy = http.ProxyURL(parsed) + client.Transport = tr + } else { + // Fallback: minimal transport if DefaultTransport is not *http.Transport. + client.Transport = &http.Transport{ + Proxy: http.ProxyURL(parsed), + } + } + } else { + log.Printf("common: invalid proxy URL %q: %v", proxy, err) + } + } + return client +} + +// --- Message serialization --- + +// openaiMessage is the wire-format message for OpenAI-compatible APIs. +// It mirrors protocoltypes.Message but omits SystemParts, which is an +// internal field that would be unknown to third-party endpoints. +type openaiMessage struct { + Role string `json:"role"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type openaiToolCall struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + Function *openaiFunctionCall `json:"function,omitempty"` +} + +type openaiFunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + ThoughtSignature string `json:"thought_signature,omitempty"` +} + +// SerializeMessages converts internal Message structs to the OpenAI wire format. +// - Strips SystemParts (unknown to third-party endpoints) +// - Converts messages with Media to multipart content format (text + image_url parts) +// - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages +func SerializeMessages(messages []Message) []any { + out := make([]any, 0, len(messages)) + for _, m := range messages { + toolCalls := serializeToolCalls(m.ToolCalls) + if len(m.Media) == 0 { + out = append(out, openaiMessage{ + Role: m.Role, + Content: m.Content, + ReasoningContent: m.ReasoningContent, + ToolCalls: toolCalls, + ToolCallID: m.ToolCallID, + }) + continue + } + + // Multipart content format for messages with media + parts := make([]map[string]any, 0, 1+len(m.Media)) + if m.Content != "" { + parts = append(parts, map[string]any{ + "type": "text", + "text": m.Content, + }) + } + for _, mediaURL := range m.Media { + if strings.HasPrefix(mediaURL, "data:image/") { + parts = append(parts, map[string]any{ + "type": "image_url", + "image_url": map[string]any{ + "url": mediaURL, + }, + }) + continue + } + + if format, data, ok := ParseDataAudioURL(mediaURL); ok { + parts = append(parts, map[string]any{ + "type": "input_audio", + "input_audio": map[string]any{ + "data": data, + "format": format, + }, + }) + } + } + + msg := map[string]any{ + "role": m.Role, + "content": parts, + } + if m.ToolCallID != "" { + msg["tool_call_id"] = m.ToolCallID + } + if len(toolCalls) > 0 { + msg["tool_calls"] = toolCalls + } + if m.ReasoningContent != "" { + msg["reasoning_content"] = m.ReasoningContent + } + out = append(out, msg) + } + return out +} + +func serializeToolCalls(toolCalls []ToolCall) []openaiToolCall { + if len(toolCalls) == 0 { + return nil + } + + out := make([]openaiToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + wireCall := openaiToolCall{ + ID: tc.ID, + Type: tc.Type, + } + + if tc.Function != nil { + thoughtSignature := tc.Function.ThoughtSignature + if thoughtSignature == "" { + thoughtSignature = tc.ThoughtSignature + } + if thoughtSignature == "" && tc.ExtraContent != nil && tc.ExtraContent.Google != nil { + thoughtSignature = tc.ExtraContent.Google.ThoughtSignature + } + wireCall.Function = &openaiFunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + ThoughtSignature: thoughtSignature, + } + } else if tc.Name != "" || len(tc.Arguments) > 0 || tc.ThoughtSignature != "" { + thoughtSignature := tc.ThoughtSignature + if thoughtSignature == "" && tc.ExtraContent != nil && tc.ExtraContent.Google != nil { + thoughtSignature = tc.ExtraContent.Google.ThoughtSignature + } + argsJSON := "{}" + if len(tc.Arguments) > 0 { + if encoded, err := json.Marshal(tc.Arguments); err == nil { + argsJSON = string(encoded) + } + } + wireCall.Function = &openaiFunctionCall{ + Name: tc.Name, + Arguments: argsJSON, + ThoughtSignature: thoughtSignature, + } + } + + out = append(out, wireCall) + } + + return out +} + +// ParseDataAudioURL extracts the format and base64 data from a data:audio/... URL. +func ParseDataAudioURL(mediaURL string) (format, data string, ok bool) { + if !strings.HasPrefix(mediaURL, "data:audio/") { + return "", "", false + } + + payload := strings.TrimPrefix(mediaURL, "data:audio/") + meta, data, found := strings.Cut(payload, ",") + if !found { + return "", "", false + } + + format, _, _ = strings.Cut(meta, ";") + format = strings.TrimSpace(format) + data = strings.TrimSpace(data) + if format == "" || data == "" { + return "", "", false + } + return format, data, true +} + +// --- Response parsing --- + +// ParseResponse parses a JSON chat completion response body into an LLMResponse. +func ParseResponse(body io.Reader) (*LLMResponse, error) { + var apiResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` + Reasoning string `json:"reasoning"` + ReasoningDetails []ReasoningDetail `json:"reasoning_details"` + ToolCalls []struct { + ID string `json:"id"` + Type string `json:"type"` + Function *struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + ThoughtSignature string `json:"thought_signature"` + } `json:"function"` + ExtraContent *struct { + Google *struct { + ThoughtSignature string `json:"thought_signature"` + } `json:"google"` + ToolFeedbackExplanation string `json:"tool_feedback_explanation"` + } `json:"extra_content"` + } `json:"tool_calls"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage *UsageInfo `json:"usage"` + } + + if err := json.NewDecoder(body).Decode(&apiResponse); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if len(apiResponse.Choices) == 0 { + return &LLMResponse{ + Content: "", + FinishReason: "stop", + }, nil + } + + choice := apiResponse.Choices[0] + toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls)) + for _, tc := range choice.Message.ToolCalls { + arguments := make(map[string]any) + name := "" + + thoughtSignature := "" + if tc.Function != nil { + thoughtSignature = tc.Function.ThoughtSignature + } + if thoughtSignature == "" && tc.ExtraContent != nil && tc.ExtraContent.Google != nil { + thoughtSignature = tc.ExtraContent.Google.ThoughtSignature + } + + if tc.Function != nil { + name = tc.Function.Name + arguments = DecodeToolCallArguments(tc.Function.Arguments, name) + } + + toolCall := ToolCall{ + ID: tc.ID, + Name: name, + Arguments: arguments, + ThoughtSignature: thoughtSignature, + } + + if thoughtSignature != "" || tc.ExtraContent != nil { + extraContent := &ExtraContent{ + ToolFeedbackExplanation: "", + } + if tc.ExtraContent != nil { + extraContent.ToolFeedbackExplanation = tc.ExtraContent.ToolFeedbackExplanation + } + if thoughtSignature != "" { + extraContent.Google = &GoogleExtra{ + ThoughtSignature: thoughtSignature, + } + } + if extraContent.Google != nil || strings.TrimSpace(extraContent.ToolFeedbackExplanation) != "" { + toolCall.ExtraContent = extraContent + } + } + + toolCalls = append(toolCalls, toolCall) + } + + return &LLMResponse{ + Content: choice.Message.Content, + ReasoningContent: choice.Message.ReasoningContent, + Reasoning: choice.Message.Reasoning, + ReasoningDetails: choice.Message.ReasoningDetails, + ToolCalls: toolCalls, + FinishReason: normalizeFinishReason(choice.FinishReason), + Usage: apiResponse.Usage, + }, nil +} + +// normalizeFinishReason normalizes finish_reason values across providers. +// Converts "length" to "truncated" for consistent handling. +func normalizeFinishReason(reason string) string { + if reason == "length" { + return "truncated" + } + return reason +} + +// DecodeToolCallArguments decodes a tool call's arguments from raw JSON. +func DecodeToolCallArguments(raw json.RawMessage, name string) map[string]any { + arguments := make(map[string]any) + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return arguments + } + + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + log.Printf("common: failed to decode tool call arguments payload for %q: %v", name, err) + arguments["raw"] = string(raw) + return arguments + } + + switch v := decoded.(type) { + case string: + if strings.TrimSpace(v) == "" { + return arguments + } + if err := json.Unmarshal([]byte(v), &arguments); err != nil { + log.Printf("common: failed to decode tool call arguments for %q: %v", name, err) + arguments["raw"] = v + } + return arguments + case map[string]any: + return v + default: + log.Printf("common: unsupported tool call arguments type for %q: %T", name, decoded) + arguments["raw"] = string(raw) + return arguments + } +} + +// --- HTTP response helpers --- + +// HandleErrorResponse reads a non-200 response body and returns an appropriate error. +func HandleErrorResponse(resp *http.Response, apiBase string) error { + contentType := resp.Header.Get("Content-Type") + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256)) + if readErr != nil { + return fmt.Errorf("failed to read response: %w", readErr) + } + if LooksLikeHTML(body, contentType) { + return WrapHTMLResponseError(resp.StatusCode, body, contentType, apiBase) + } + return fmt.Errorf( + "API request failed:\n Status: %d\n Body: %s", + resp.StatusCode, + ResponsePreview(body, 128), + ) +} + +// ReadAndParseResponse peeks at the response body to detect HTML errors, +// then parses the JSON response into an LLMResponse. +func ReadAndParseResponse(resp *http.Response, apiBase string) (*LLMResponse, error) { + contentType := resp.Header.Get("Content-Type") + reader := bufio.NewReader(resp.Body) + prefix, err := reader.Peek(256) + if err != nil && err != io.EOF && err != bufio.ErrBufferFull { + return nil, fmt.Errorf("failed to inspect response: %w", err) + } + if LooksLikeHTML(prefix, contentType) { + return nil, WrapHTMLResponseError(resp.StatusCode, prefix, contentType, apiBase) + } + out, err := ParseResponse(reader) + if err != nil { + return nil, fmt.Errorf("failed to parse JSON response: %w", err) + } + return out, nil +} + +// LooksLikeHTML checks if the response body appears to be HTML. +func LooksLikeHTML(body []byte, contentType string) bool { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") { + return true + } + prefix := bytes.ToLower(leadingTrimmedPrefix(body, 128)) + return bytes.HasPrefix(prefix, []byte("" + } + if len(trimmed) <= maxLen { + return string(trimmed) + } + return string(trimmed[:maxLen]) + "..." +} + +func leadingTrimmedPrefix(body []byte, maxLen int) []byte { + i := 0 + for i < len(body) { + switch body[i] { + case ' ', '\t', '\n', '\r', '\f', '\v': + i++ + default: + end := i + maxLen + if end > len(body) { + end = len(body) + } + return body[i:end] + } + } + return nil +} + +// --- Numeric helpers --- + +// AsInt converts various numeric types to int. +func AsInt(v any) (int, bool) { + switch val := v.(type) { + case int: + return val, true + case int64: + return int(val), true + case float64: + return int(val), true + case float32: + return int(val), true + default: + return 0, false + } +} + +// AsFloat converts various numeric types to float64. +func AsFloat(v any) (float64, bool) { + switch val := v.(type) { + case float64: + return val, true + case float32: + return float64(val), true + case int: + return float64(val), true + case int64: + return float64(val), true + default: + return 0, false + } +} diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go new file mode 100644 index 000000000..3cf2f4285 --- /dev/null +++ b/pkg/providers/common/common_test.go @@ -0,0 +1,802 @@ +package common + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +// --- NewHTTPClient tests --- + +func TestNewHTTPClient_DefaultTimeout(t *testing.T) { + client := NewHTTPClient("") + if client.Timeout != DefaultRequestTimeout { + t.Errorf("timeout = %v, want %v", client.Timeout, DefaultRequestTimeout) + } +} + +func TestNewHTTPClient_WithProxy(t *testing.T) { + client := NewHTTPClient("http://127.0.0.1:8080") + transport, ok := client.Transport.(*http.Transport) + if !ok || transport == nil { + t.Fatalf("expected http.Transport with proxy, got %T", client.Transport) + } + req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}} + gotProxy, err := transport.Proxy(req) + if err != nil { + t.Fatalf("proxy function error: %v", err) + } + if gotProxy == nil || gotProxy.String() != "http://127.0.0.1:8080" { + t.Errorf("proxy = %v, want http://127.0.0.1:8080", gotProxy) + } +} + +func TestNewHTTPClient_NoProxy(t *testing.T) { + client := NewHTTPClient("") + if client.Transport != nil { + t.Errorf("expected nil transport without proxy, got %T", client.Transport) + } +} + +func TestNewHTTPClient_InvalidProxy(t *testing.T) { + // Should not panic, just log and return client without proxy + client := NewHTTPClient("://bad-url") + if client == nil { + t.Fatal("expected non-nil client even with invalid proxy") + } +} + +// --- SerializeMessages tests --- + +func TestSerializeMessages_PlainText(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi", ReasoningContent: "thinking..."}, + } + result := SerializeMessages(messages) + + data, _ := json.Marshal(result) + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + if msgs[0]["content"] != "hello" { + t.Errorf("expected plain string content, got %v", msgs[0]["content"]) + } + if msgs[1]["reasoning_content"] != "thinking..." { + t.Errorf("reasoning_content not preserved, got %v", msgs[1]["reasoning_content"]) + } +} + +func TestSerializeMessages_WithMedia(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}}, + } + result := SerializeMessages(messages) + + data, _ := json.Marshal(result) + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + content, ok := msgs[0]["content"].([]any) + if !ok { + t.Fatalf("expected array content for media message, got %T", msgs[0]["content"]) + } + if len(content) != 2 { + t.Fatalf("expected 2 content parts, got %d", len(content)) + } +} + +func TestSerializeMessages_WithAudioMedia(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "transcribe this", Media: []string{"data:audio/ogg;base64,abc123"}}, + } + result := SerializeMessages(messages) + + data, _ := json.Marshal(result) + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + content, ok := msgs[0]["content"].([]any) + if !ok { + t.Fatalf("expected array content for media message, got %T", msgs[0]["content"]) + } + if len(content) != 2 { + t.Fatalf("expected 2 content parts, got %d", len(content)) + } + + audioPart, ok := content[1].(map[string]any) + if !ok { + t.Fatalf("expected audio content part to be an object, got %T", content[1]) + } + if audioPart["type"] != "input_audio" { + t.Fatalf("audio part type = %v, want input_audio", audioPart["type"]) + } + + inputAudio, ok := audioPart["input_audio"].(map[string]any) + if !ok { + t.Fatalf("expected input_audio object, got %T", audioPart["input_audio"]) + } + if inputAudio["format"] != "ogg" { + t.Fatalf("audio format = %v, want ogg", inputAudio["format"]) + } + if inputAudio["data"] != "abc123" { + t.Fatalf("audio data = %v, want abc123", inputAudio["data"]) + } +} + +func TestSerializeMessages_MediaWithToolCallID(t *testing.T) { + messages := []Message{ + {Role: "tool", Content: "result", Media: []string{"data:image/png;base64,xyz"}, ToolCallID: "call_1"}, + } + result := SerializeMessages(messages) + + data, _ := json.Marshal(result) + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + if msgs[0]["tool_call_id"] != "call_1" { + t.Errorf("tool_call_id not preserved, got %v", msgs[0]["tool_call_id"]) + } +} + +func TestSerializeMessages_StripsSystemParts(t *testing.T) { + messages := []Message{ + { + Role: "system", + Content: "you are helpful", + SystemParts: []protocoltypes.ContentBlock{ + {Type: "text", Text: "you are helpful"}, + }, + }, + } + result := SerializeMessages(messages) + + data, _ := json.Marshal(result) + if strings.Contains(string(data), "system_parts") { + t.Error("system_parts should not appear in serialized output") + } +} + +func TestSerializeMessages_StripsInternalToolCallExtraContent(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + ThoughtSignature: "sig-1", + }, + ExtraContent: &ExtraContent{ + Google: &GoogleExtra{ + ThoughtSignature: "sig-ignored-here", + }, + ToolFeedbackExplanation: "Read README.md first.", + }, + }}, + }, + } + + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + payload := string(data) + if strings.Contains(payload, "extra_content") { + t.Fatalf("serialized payload should not include internal extra_content: %s", payload) + } + if !strings.Contains(payload, "thought_signature") { + t.Fatalf("serialized payload should preserve function thought_signature: %s", payload) + } +} + +func TestSerializeMessages_PreservesTopLevelThoughtSignature(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Type: "function", + ThoughtSignature: "sig-1", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + }}, + }, + } + + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + payload := string(data) + if !strings.Contains(payload, `"thought_signature":"sig-1"`) { + t.Fatalf("serialized payload should preserve top-level thought signature: %s", payload) + } +} + +func TestSerializeMessages_PreservesGoogleExtraThoughtSignature(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + ExtraContent: &ExtraContent{ + Google: &GoogleExtra{ThoughtSignature: "sig-1"}, + }, + }}, + }, + } + + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + payload := string(data) + if strings.Contains(payload, "extra_content") { + t.Fatalf("serialized payload should not include extra_content: %s", payload) + } + if !strings.Contains(payload, `"thought_signature":"sig-1"`) { + t.Fatalf("serialized payload should preserve google thought signature: %s", payload) + } +} + +// --- ParseResponse tests --- + +func TestParseResponse_BasicContent(t *testing.T) { + body := `{"choices":[{"message":{"content":"hello world"},"finish_reason":"stop"}]}` + out, err := ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if out.Content != "hello world" { + t.Errorf("Content = %q, want %q", out.Content, "hello world") + } + if out.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", out.FinishReason, "stop") + } +} + +func TestParseResponse_EmptyChoices(t *testing.T) { + body := `{"choices":[]}` + out, err := ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if out.Content != "" { + t.Errorf("Content = %q, want empty", out.Content) + } + if out.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", out.FinishReason, "stop") + } +} + +func TestParseResponse_WithToolCalls(t *testing.T) { + body := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"SF\"}"}}]},"finish_reason":"tool_calls"}]}` + out, err := ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].Name != "get_weather" { + t.Errorf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather") + } + if out.ToolCalls[0].Arguments["city"] != "SF" { + t.Errorf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"]) + } +} + +func TestParseResponse_WithUsage(t *testing.T) { + body := `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}` + out, err := ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if out.Usage == nil { + t.Fatal("Usage is nil") + } + if out.Usage.PromptTokens != 10 { + t.Errorf("PromptTokens = %d, want 10", out.Usage.PromptTokens) + } +} + +func TestParseResponse_WithReasoningContent(t *testing.T) { + body := `{"choices":[{"message":{"content":"2","reasoning_content":"Let me think... 1+1=2"},"finish_reason":"stop"}]}` + out, err := ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if out.ReasoningContent != "Let me think... 1+1=2" { + t.Errorf("ReasoningContent = %q, want %q", out.ReasoningContent, "Let me think... 1+1=2") + } +} + +func TestParseResponse_WithToolFeedbackExplanationExtraContent(t *testing.T) { + body := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"test_tool","arguments":"{}"},"extra_content":{"tool_feedback_explanation":"Check the current config before editing."}}]},"finish_reason":"tool_calls"}]}` + out, err := ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].ExtraContent == nil { + t.Fatal("ExtraContent is nil") + } + if out.ToolCalls[0].ExtraContent.ToolFeedbackExplanation != "Check the current config before editing." { + t.Fatalf( + "ToolFeedbackExplanation = %q, want %q", + out.ToolCalls[0].ExtraContent.ToolFeedbackExplanation, + "Check the current config before editing.", + ) + } +} + +func TestParseResponse_InvalidJSON(t *testing.T) { + _, err := ParseResponse(strings.NewReader("not json")) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +// --- DecodeToolCallArguments tests --- + +func TestDecodeToolCallArguments_ObjectJSON(t *testing.T) { + raw := json.RawMessage(`{"city":"Seattle","units":"metric"}`) + args := DecodeToolCallArguments(raw, "test") + if args["city"] != "Seattle" { + t.Errorf("city = %v, want Seattle", args["city"]) + } + if args["units"] != "metric" { + t.Errorf("units = %v, want metric", args["units"]) + } +} + +func TestDecodeToolCallArguments_ObjectJSON_NewlineEscape(t *testing.T) { + raw := json.RawMessage(`{"content":"line1\nline2"}`) + args := DecodeToolCallArguments(raw, "write_file") + if args["content"] != "line1\nline2" { + t.Errorf("content = %q, want newline-expanded string", args["content"]) + } +} + +func TestDecodeToolCallArguments_ObjectJSON_LiteralBackslashN(t *testing.T) { + raw := json.RawMessage(`{"content":"line1\\nline2"}`) + args := DecodeToolCallArguments(raw, "write_file") + if args["content"] != `line1\nline2` { + t.Errorf("content = %q, want literal backslash-n", args["content"]) + } +} + +func TestDecodeToolCallArguments_StringJSON(t *testing.T) { + raw := json.RawMessage(`"{\"city\":\"SF\"}"`) + args := DecodeToolCallArguments(raw, "test") + if args["city"] != "SF" { + t.Errorf("city = %v, want SF", args["city"]) + } +} + +func TestDecodeToolCallArguments_StringJSON_NewlineEscape(t *testing.T) { + raw := json.RawMessage(`"{\"content\":\"line1\\nline2\"}"`) + args := DecodeToolCallArguments(raw, "write_file") + if args["content"] != "line1\nline2" { + t.Errorf("content = %q, want newline-expanded string", args["content"]) + } +} + +func TestDecodeToolCallArguments_StringJSON_LiteralBackslashN(t *testing.T) { + raw := json.RawMessage(`"{\"content\":\"line1\\\\nline2\"}"`) + args := DecodeToolCallArguments(raw, "write_file") + if args["content"] != `line1\nline2` { + t.Errorf("content = %q, want literal backslash-n", args["content"]) + } +} + +func TestDecodeToolCallArguments_EmptyInput(t *testing.T) { + args := DecodeToolCallArguments(nil, "test") + if len(args) != 0 { + t.Errorf("expected empty map, got %v", args) + } +} + +func TestDecodeToolCallArguments_NullInput(t *testing.T) { + args := DecodeToolCallArguments(json.RawMessage(`null`), "test") + if len(args) != 0 { + t.Errorf("expected empty map, got %v", args) + } +} + +func TestDecodeToolCallArguments_InvalidJSON(t *testing.T) { + args := DecodeToolCallArguments(json.RawMessage(`not-json`), "test") + if _, ok := args["raw"]; !ok { + t.Error("expected 'raw' fallback key for invalid JSON") + } +} + +func TestDecodeToolCallArguments_EmptyStringJSON(t *testing.T) { + args := DecodeToolCallArguments(json.RawMessage(`" "`), "test") + if len(args) != 0 { + t.Errorf("expected empty map for whitespace string, got %v", args) + } +} + +// --- HandleErrorResponse tests --- + +func TestHandleErrorResponse_JSONError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"bad request"}`)) + })) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("http.Get() error = %v", err) + } + defer resp.Body.Close() + err = HandleErrorResponse(resp, server.URL) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "400") { + t.Errorf("error should contain status code, got %v", err) + } + if strings.Contains(err.Error(), "HTML") { + t.Errorf("should not mention HTML for JSON error, got %v", err) + } +} + +func TestHandleErrorResponse_HTMLError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte("bad gateway")) + })) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("http.Get() error = %v", err) + } + defer resp.Body.Close() + err = HandleErrorResponse(resp, server.URL) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "HTML instead of JSON") { + t.Errorf("expected HTML error message, got %v", err) + } +} + +// --- ReadAndParseResponse tests --- + +func TestReadAndParseResponse_ValidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("http.Get() error = %v", err) + } + defer resp.Body.Close() + out, err := ReadAndParseResponse(resp, server.URL) + if err != nil { + t.Fatalf("ReadAndParseResponse() error = %v", err) + } + if out.Content != "ok" { + t.Errorf("Content = %q, want %q", out.Content, "ok") + } +} + +func TestReadAndParseResponse_HTMLResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte("login page")) + })) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("http.Get() error = %v", err) + } + defer resp.Body.Close() + _, err = ReadAndParseResponse(resp, server.URL) + if err == nil { + t.Fatal("expected error for HTML response") + } + if !strings.Contains(err.Error(), "HTML instead of JSON") { + t.Errorf("expected HTML error, got %v", err) + } +} + +// --- LooksLikeHTML tests --- + +func TestLooksLikeHTML_ContentTypeHTML(t *testing.T) { + if !LooksLikeHTML(nil, "text/html; charset=utf-8") { + t.Error("expected true for text/html content type") + } +} + +func TestLooksLikeHTML_ContentTypeXHTML(t *testing.T) { + if !LooksLikeHTML(nil, "application/xhtml+xml") { + t.Error("expected true for xhtml content type") + } +} + +func TestLooksLikeHTML_BodyPrefix(t *testing.T) { + tests := []struct { + name string + body string + }{ + {"doctype", ""}, + {"html tag", ""}, + {"head tag", ""}, + {"body tag", "<body>content"}, + {"whitespace before", " \n\t<!DOCTYPE html>"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !LooksLikeHTML([]byte(tt.body), "application/json") { + t.Errorf("expected true for body %q", tt.body) + } + }) + } +} + +func TestLooksLikeHTML_NotHTML(t *testing.T) { + if LooksLikeHTML([]byte(`{"error":"bad"}`), "application/json") { + t.Error("expected false for JSON body") + } +} + +// --- ResponsePreview tests --- + +func TestResponsePreview_Short(t *testing.T) { + got := ResponsePreview([]byte("hello"), 128) + if got != "hello" { + t.Errorf("got %q, want %q", got, "hello") + } +} + +func TestResponsePreview_Truncated(t *testing.T) { + body := strings.Repeat("a", 200) + got := ResponsePreview([]byte(body), 128) + if len(got) != 131 { // 128 + "..." + t.Errorf("len = %d, want 131", len(got)) + } + if !strings.HasSuffix(got, "...") { + t.Error("expected ... suffix") + } +} + +func TestResponsePreview_Empty(t *testing.T) { + got := ResponsePreview([]byte(""), 128) + if got != "<empty>" { + t.Errorf("got %q, want %q", got, "<empty>") + } +} + +func TestResponsePreview_Whitespace(t *testing.T) { + got := ResponsePreview([]byte(" \n\t "), 128) + if got != "<empty>" { + t.Errorf("got %q, want %q for whitespace-only body", got, "<empty>") + } +} + +// --- AsInt tests --- + +func TestAsInt(t *testing.T) { + tests := []struct { + name string + val any + want int + ok bool + }{ + {"int", 42, 42, true}, + {"int64", int64(99), 99, true}, + {"float64", float64(512), 512, true}, + {"float32", float32(256), 256, true}, + {"string", "nope", 0, false}, + {"nil", nil, 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := AsInt(tt.val) + if ok != tt.ok || got != tt.want { + t.Errorf("AsInt(%v) = (%d, %v), want (%d, %v)", tt.val, got, ok, tt.want, tt.ok) + } + }) + } +} + +// --- AsFloat tests --- + +func TestAsFloat(t *testing.T) { + tests := []struct { + name string + val any + want float64 + ok bool + }{ + {"float64", float64(0.7), 0.7, true}, + {"float32", float32(0.5), float64(float32(0.5)), true}, + {"int", 1, 1.0, true}, + {"int64", int64(100), 100.0, true}, + {"string", "nope", 0, false}, + {"nil", nil, 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := AsFloat(tt.val) + if ok != tt.ok || got != tt.want { + t.Errorf("AsFloat(%v) = (%f, %v), want (%f, %v)", tt.val, got, ok, tt.want, tt.ok) + } + }) + } +} + +// --- ParseDataAudioURL tests --- + +func TestParseDataAudioURL(t *testing.T) { + tests := []struct { + name string + mediaURL string + wantFormat string + wantData string + wantOK bool + }{ + {"valid mp3", "data:audio/mp3;base64,SGVsbG8=", "mp3", "SGVsbG8=", true}, + {"valid wav", "data:audio/wav;base64,AAAA", "wav", "AAAA", true}, + {"not audio", "data:image/png;base64,abc", "", "", false}, + {"no comma", "data:audio/mp3;base64", "", "", false}, + {"empty data", "data:audio/mp3;base64,", "", "", false}, + {"empty string", "", "", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + format, data, ok := ParseDataAudioURL(tt.mediaURL) + if ok != tt.wantOK || format != tt.wantFormat || data != tt.wantData { + t.Errorf( + "ParseDataAudioURL(%q) = (%q, %q, %v), want (%q, %q, %v)", + tt.mediaURL, format, data, ok, + tt.wantFormat, tt.wantData, tt.wantOK, + ) + } + }) + } +} + +// --- WrapHTMLResponseError tests --- + +func TestWrapHTMLResponseError(t *testing.T) { + err := WrapHTMLResponseError(502, []byte("<html>bad</html>"), "text/html", "https://api.example.com") + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "502") { + t.Errorf("expected status code in error, got %v", msg) + } + if !strings.Contains(msg, "https://api.example.com") { + t.Errorf("expected api base in error, got %v", msg) + } + if !strings.Contains(msg, "HTML instead of JSON") { + t.Errorf("expected HTML mention in error, got %v", msg) + } +} + +// --- HandleErrorResponse with read failure --- + +func TestHandleErrorResponse_EmptyBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + // empty body + })) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("http.Get() error = %v", err) + } + defer resp.Body.Close() + err = HandleErrorResponse(resp, server.URL) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("expected status code, got %v", err) + } +} + +// --- ReadAndParseResponse with invalid JSON --- + +func TestReadAndParseResponse_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("not valid json")) + })) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("http.Get() error = %v", err) + } + defer resp.Body.Close() + _, err = ReadAndParseResponse(resp, server.URL) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +// --- ParseResponse with thought_signature (Google/Gemini) --- + +func TestParseResponse_WithThoughtSignature(t *testing.T) { + body := `{"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"}]}` + out, err := ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].ThoughtSignature != "sig123" { + t.Errorf("ThoughtSignature = %q, want %q", out.ToolCalls[0].ThoughtSignature, "sig123") + } + if out.ToolCalls[0].ExtraContent == nil || out.ToolCalls[0].ExtraContent.Google == nil { + t.Fatal("ExtraContent.Google is nil") + } + if out.ToolCalls[0].ExtraContent.Google.ThoughtSignature != "sig123" { + t.Errorf("ExtraContent.Google.ThoughtSignature = %q, want %q", + out.ToolCalls[0].ExtraContent.Google.ThoughtSignature, "sig123") + } +} + +func TestParseResponse_WithFunctionThoughtSignature(t *testing.T) { + body := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"test_tool","arguments":"{}","thought_signature":"sig456"}}]},"finish_reason":"tool_calls"}]}` + out, err := ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].ThoughtSignature != "sig456" { + t.Fatalf("ThoughtSignature = %q, want %q", out.ToolCalls[0].ThoughtSignature, "sig456") + } + if out.ToolCalls[0].ExtraContent == nil || out.ToolCalls[0].ExtraContent.Google == nil { + t.Fatal("ExtraContent.Google is nil") + } + if out.ToolCalls[0].ExtraContent.Google.ThoughtSignature != "sig456" { + t.Fatalf( + "ExtraContent.Google.ThoughtSignature = %q, want %q", + out.ToolCalls[0].ExtraContent.Google.ThoughtSignature, + "sig456", + ) + } +} diff --git a/pkg/providers/common/google_common.go b/pkg/providers/common/google_common.go new file mode 100644 index 000000000..954c0c802 --- /dev/null +++ b/pkg/providers/common/google_common.go @@ -0,0 +1,70 @@ +package common + +import ( + "encoding/json" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +// NormalizeStoredToolCall extracts the tool name, arguments, and thought signature +// from a stored ToolCall. It handles both the top-level fields and the nested +// Function struct used by different API formats. +func NormalizeStoredToolCall(tc protocoltypes.ToolCall) (string, map[string]any, string) { + name := tc.Name + args := tc.Arguments + thoughtSignature := "" + + if name == "" && tc.Function != nil { + name = tc.Function.Name + thoughtSignature = tc.Function.ThoughtSignature + } else if tc.Function != nil { + thoughtSignature = tc.Function.ThoughtSignature + } + + if args == nil { + args = map[string]any{} + } + + if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" { + var parsed map[string]any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil { + args = parsed + } + } + + return name, args, thoughtSignature +} + +// ResolveToolResponseName returns the tool name for a given tool call ID. +// It first checks the provided name map, then falls back to inferring the +// name from the call ID format. +func ResolveToolResponseName(toolCallID string, toolCallNames map[string]string) string { + if toolCallID == "" { + return "" + } + + if name, ok := toolCallNames[toolCallID]; ok && name != "" { + return name + } + + return InferToolNameFromCallID(toolCallID) +} + +// InferToolNameFromCallID extracts a tool name from a call ID in the format +// "call_<name>_<suffix>". Returns the original ID if it doesn't match. +func InferToolNameFromCallID(toolCallID string) string { + if !strings.HasPrefix(toolCallID, "call_") { + return toolCallID + } + + rest := strings.TrimPrefix(toolCallID, "call_") + if idx := strings.LastIndex(rest, "_"); idx > 0 { + candidate := rest[:idx] + if candidate != "" { + return candidate + } + } + + return toolCallID +} diff --git a/pkg/providers/common/google_common_test.go b/pkg/providers/common/google_common_test.go new file mode 100644 index 000000000..cc013dcd1 --- /dev/null +++ b/pkg/providers/common/google_common_test.go @@ -0,0 +1,146 @@ +package common + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +func TestNormalizeStoredToolCall_TopLevelFields(t *testing.T) { + tc := protocoltypes.ToolCall{ + Name: "search", + Arguments: map[string]any{"q": "hello"}, + } + name, args, sig := NormalizeStoredToolCall(tc) + if name != "search" { + t.Errorf("name = %q, want %q", name, "search") + } + if args["q"] != "hello" { + t.Errorf("args[q] = %v, want %q", args["q"], "hello") + } + if sig != "" { + t.Errorf("thoughtSignature = %q, want empty", sig) + } +} + +func TestNormalizeStoredToolCall_FallsBackToFunction(t *testing.T) { + tc := protocoltypes.ToolCall{ + Function: &protocoltypes.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"/tmp"}`, + ThoughtSignature: "sig123", + }, + } + name, args, sig := NormalizeStoredToolCall(tc) + if name != "read_file" { + t.Errorf("name = %q, want %q", name, "read_file") + } + if args["path"] != "/tmp" { + t.Errorf("args[path] = %v, want %q", args["path"], "/tmp") + } + if sig != "sig123" { + t.Errorf("thoughtSignature = %q, want %q", sig, "sig123") + } +} + +func TestNormalizeStoredToolCall_TopLevelNameWithFunctionSig(t *testing.T) { + tc := protocoltypes.ToolCall{ + Name: "search", + Arguments: map[string]any{"q": "hi"}, + Function: &protocoltypes.FunctionCall{ + ThoughtSignature: "thought1", + }, + } + name, _, sig := NormalizeStoredToolCall(tc) + if name != "search" { + t.Errorf("name = %q, want %q", name, "search") + } + if sig != "thought1" { + t.Errorf("thoughtSignature = %q, want %q", sig, "thought1") + } +} + +func TestNormalizeStoredToolCall_NilArgs(t *testing.T) { + tc := protocoltypes.ToolCall{Name: "test"} + _, args, _ := NormalizeStoredToolCall(tc) + if args == nil { + t.Fatal("args should not be nil") + } + if len(args) != 0 { + t.Errorf("args should be empty, got %v", args) + } +} + +func TestNormalizeStoredToolCall_EmptyArgsParseFromFunction(t *testing.T) { + tc := protocoltypes.ToolCall{ + Name: "tool", + Arguments: map[string]any{}, + Function: &protocoltypes.FunctionCall{ + Arguments: `{"key":"val"}`, + }, + } + _, args, _ := NormalizeStoredToolCall(tc) + if args["key"] != "val" { + t.Errorf("args[key] = %v, want %q", args["key"], "val") + } +} + +func TestNormalizeStoredToolCall_InvalidFunctionJSON(t *testing.T) { + tc := protocoltypes.ToolCall{ + Name: "tool", + Function: &protocoltypes.FunctionCall{ + Arguments: `not-json`, + }, + } + _, args, _ := NormalizeStoredToolCall(tc) + if len(args) != 0 { + t.Errorf("args should be empty for invalid JSON, got %v", args) + } +} + +func TestResolveToolResponseName_FromMap(t *testing.T) { + names := map[string]string{"call_1": "search"} + got := ResolveToolResponseName("call_1", names) + if got != "search" { + t.Errorf("got %q, want %q", got, "search") + } +} + +func TestResolveToolResponseName_EmptyID(t *testing.T) { + got := ResolveToolResponseName("", map[string]string{"x": "y"}) + if got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestResolveToolResponseName_FallsBackToInfer(t *testing.T) { + got := ResolveToolResponseName("call_search_docs_999", map[string]string{}) + if got != "search_docs" { + t.Errorf("got %q, want %q", got, "search_docs") + } +} + +func TestInferToolNameFromCallID(t *testing.T) { + tests := []struct { + name string + id string + want string + }{ + {"standard format", "call_search_docs_999", "search_docs"}, + {"single name", "call_read_123", "read"}, + {"no call prefix", "some_id", "some_id"}, + {"call prefix no underscore suffix", "call_onlyname", "call_onlyname"}, + {"empty string", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := InferToolNameFromCallID(tt.id) + if got != tt.want { + t.Errorf( + "InferToolNameFromCallID(%q) = %q, want %q", + tt.id, got, tt.want, + ) + } + }) + } +} diff --git a/pkg/providers/common/google_schema.go b/pkg/providers/common/google_schema.go new file mode 100644 index 000000000..f7b2a337b --- /dev/null +++ b/pkg/providers/common/google_schema.go @@ -0,0 +1,642 @@ +package common + +import ( + "strconv" + "strings" +) + +const maxGeminiSchemaDepth = 64 + +var geminiSupportedTypes = map[string]bool{ + "array": true, + "boolean": true, + "integer": true, + "number": true, + "object": true, + "string": true, +} + +// SanitizeSchemaForGoogle reduces a JSON Schema to the conservative subset +// accepted by Google/Gemini-style function declarations. It resolves local +// refs, collapses composition keywords like anyOf/oneOf/allOf, and strips +// advanced keywords that Gemini-compatible backends often reject. +func SanitizeSchemaForGoogle(schema map[string]any) map[string]any { + if schema == nil { + return nil + } + + sanitizer := geminiSchemaSanitizer{root: schema} + result := sanitizer.sanitizeNode(schema, nil, 0) + if len(result) == 0 { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + } + } + if _, hasProps := result["properties"]; hasProps { + result["type"] = "object" + } + return result +} + +// SanitizeSchemaForGemini is kept as a compatibility alias for the original +// Google/Gemini sanitizer name. +func SanitizeSchemaForGemini(schema map[string]any) map[string]any { + return SanitizeSchemaForGoogle(schema) +} + +type geminiSchemaSanitizer struct { + root map[string]any +} + +func (s geminiSchemaSanitizer) sanitizeNode( + node map[string]any, + refTrail map[string]struct{}, + depth int, +) map[string]any { + if node == nil || depth > maxGeminiSchemaDepth { + return map[string]any{} + } + + normalized := s.normalizeNode(node, refTrail, depth) + if len(normalized) == 0 { + return map[string]any{} + } + + result := make(map[string]any) + + if desc, ok := normalized["description"].(string); ok && strings.TrimSpace(desc) != "" { + result["description"] = desc + } + + if schemaType := sanitizeGeminiSchemaType(normalized["type"]); schemaType != "" { + result["type"] = schemaType + } + + if enumValues := sanitizeGeminiEnum(normalized["enum"]); len(enumValues) > 0 { + result["enum"] = enumValues + } + + if propsRaw, ok := normalized["properties"].(map[string]any); ok { + props := make(map[string]any, len(propsRaw)) + for name, rawProp := range propsRaw { + propSchema, ok := rawProp.(map[string]any) + if !ok { + continue + } + sanitizedProp := s.sanitizeNode(propSchema, refTrail, depth+1) + if len(sanitizedProp) == 0 { + sanitizedProp = map[string]any{} + } + props[name] = sanitizedProp + } + result["properties"] = props + result["type"] = "object" + if required := sanitizeGeminiRequired(normalized["required"], props); len(required) > 0 { + result["required"] = required + } + } + + if itemsRaw, ok := normalized["items"].(map[string]any); ok { + items := s.sanitizeNode(itemsRaw, refTrail, depth+1) + if len(items) == 0 { + items = map[string]any{} + } + result["items"] = items + if _, hasType := result["type"]; !hasType { + result["type"] = "array" + } + } + + return result +} + +func (s geminiSchemaSanitizer) normalizeNode( + node map[string]any, + refTrail map[string]struct{}, + depth int, +) map[string]any { + if node == nil || depth > maxGeminiSchemaDepth { + return map[string]any{} + } + + normalized := cloneGeminiSchemaMap(node) + + if ref, ok := normalized["$ref"].(string); ok { + delete(normalized, "$ref") + if _, seen := refTrail[ref]; !seen { + if target, ok := s.resolveLocalSchemaRef(ref); ok { + nextTrail := cloneRefTrail(refTrail) + nextTrail[ref] = struct{}{} + normalized = mergeGeminiSchemaMaps( + s.normalizeNode(target, nextTrail, depth+1), + normalized, + ) + } + } + } + + if rawAllOf, ok := normalized["allOf"]; ok { + delete(normalized, "allOf") + for _, part := range schemaSlice(rawAllOf) { + normalized = mergeGeminiSchemaMaps( + normalized, + s.normalizeNode(part, refTrail, depth+1), + ) + } + } + + if rawAnyOf, ok := normalized["anyOf"]; ok { + delete(normalized, "anyOf") + normalized = mergeGeminiSchemaMaps( + s.mergeUnionBranches(schemaSlice(rawAnyOf), refTrail, depth+1), + normalized, + ) + } + + if rawOneOf, ok := normalized["oneOf"]; ok { + delete(normalized, "oneOf") + normalized = mergeGeminiSchemaMaps( + s.mergeUnionBranches(schemaSlice(rawOneOf), refTrail, depth+1), + normalized, + ) + } + + return normalized +} + +func (s geminiSchemaSanitizer) mergeUnionBranches( + branches []map[string]any, + refTrail map[string]struct{}, + depth int, +) map[string]any { + if len(branches) == 0 { + return map[string]any{} + } + + objectBranches := make([]map[string]any, 0, len(branches)) + arrayBranches := make([]map[string]any, 0, len(branches)) + nonNullBranches := make([]map[string]any, 0, len(branches)) + sameType := "" + sameTypeConsistent := true + + for _, branch := range branches { + normalized := s.normalizeNode(branch, refTrail, depth+1) + if len(normalized) == 0 { + continue + } + + branchType := geminiSchemaBranchType(normalized["type"]) + if branchType == "null" { + continue + } + nonNullBranches = append(nonNullBranches, normalized) + + if sameType == "" { + sameType = branchType + } else if branchType != "" && branchType != sameType { + sameTypeConsistent = false + } + + if branchType == "object" || hasSchemaProperties(normalized) { + objectBranches = append(objectBranches, normalized) + continue + } + if branchType == "array" || hasSchemaItems(normalized) { + arrayBranches = append(arrayBranches, normalized) + } + } + + if len(nonNullBranches) == 0 { + return map[string]any{} + } + if len(objectBranches) > 0 { + return mergeUnionObjectSchemas(objectBranches) + } + if len(arrayBranches) == len(nonNullBranches) && len(arrayBranches) > 0 { + return mergeUnionArraySchemas(arrayBranches) + } + if sameTypeConsistent && sameType != "" { + merged := map[string]any{} + for _, branch := range nonNullBranches { + merged = mergeGeminiSchemaMaps(merged, branch) + } + return merged + } + + best := nonNullBranches[0] + bestScore := geminiUnionBranchScore(best) + for _, branch := range nonNullBranches[1:] { + if score := geminiUnionBranchScore(branch); score > bestScore { + best = branch + bestScore = score + } + } + return cloneGeminiSchemaMap(best) +} + +func (s geminiSchemaSanitizer) resolveLocalSchemaRef(ref string) (map[string]any, bool) { + if ref == "#" { + return s.root, true + } + if !strings.HasPrefix(ref, "#/") { + return nil, false + } + + var current any = s.root + for _, rawToken := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { + token := strings.ReplaceAll(strings.ReplaceAll(rawToken, "~1", "/"), "~0", "~") + switch value := current.(type) { + case map[string]any: + next, ok := value[token] + if !ok { + return nil, false + } + current = next + case []any: + index, err := strconv.Atoi(token) + if err != nil || index < 0 || index >= len(value) { + return nil, false + } + current = value[index] + default: + return nil, false + } + } + + resolved, ok := current.(map[string]any) + return resolved, ok +} + +func mergeUnionObjectSchemas(branches []map[string]any) map[string]any { + merged := map[string]any{ + "type": "object", + "properties": map[string]any{}, + } + + var commonRequired map[string]struct{} + var requiredOrder []string + + for i, branch := range branches { + merged = mergeGeminiSchemaMaps(merged, branch) + + required := requiredStrings(branch["required"]) + if i == 0 { + commonRequired = make(map[string]struct{}, len(required)) + requiredOrder = append(requiredOrder, required...) + for _, name := range required { + commonRequired[name] = struct{}{} + } + continue + } + + current := make(map[string]struct{}, len(required)) + for _, name := range required { + current[name] = struct{}{} + } + for name := range commonRequired { + if _, ok := current[name]; !ok { + delete(commonRequired, name) + } + } + } + + if len(commonRequired) > 0 { + filtered := make([]string, 0, len(commonRequired)) + for _, name := range requiredOrder { + if _, ok := commonRequired[name]; ok { + filtered = append(filtered, name) + } + } + if len(filtered) > 0 { + merged["required"] = filtered + } + } else { + delete(merged, "required") + } + + return merged +} + +func mergeUnionArraySchemas(branches []map[string]any) map[string]any { + merged := map[string]any{ + "type": "array", + } + for _, branch := range branches { + merged = mergeGeminiSchemaMaps(merged, branch) + } + return merged +} + +func mergeGeminiSchemaMaps(base map[string]any, overlay map[string]any) map[string]any { + if len(base) == 0 { + return cloneGeminiSchemaMap(overlay) + } + if len(overlay) == 0 { + return cloneGeminiSchemaMap(base) + } + + result := cloneGeminiSchemaMap(base) + for key, value := range overlay { + switch key { + case "properties": + overlayProps, ok := value.(map[string]any) + if !ok { + continue + } + existing, _ := result["properties"].(map[string]any) + mergedProps := cloneGeminiSchemaMap(existing) + if mergedProps == nil { + mergedProps = make(map[string]any, len(overlayProps)) + } + for name, rawProp := range overlayProps { + propSchema, ok := rawProp.(map[string]any) + if !ok { + continue + } + if existingProp, ok := mergedProps[name].(map[string]any); ok { + mergedProps[name] = mergeGeminiSchemaMaps(existingProp, propSchema) + } else { + mergedProps[name] = cloneGeminiSchemaMap(propSchema) + } + } + result["properties"] = mergedProps + case "items": + overlayItems, ok := value.(map[string]any) + if !ok { + continue + } + if existingItems, ok := result["items"].(map[string]any); ok { + result["items"] = mergeGeminiSchemaMaps(existingItems, overlayItems) + } else { + result["items"] = cloneGeminiSchemaMap(overlayItems) + } + case "required": + if merged := mergeRequiredLists(result["required"], value); len(merged) > 0 { + result["required"] = merged + } + case "type": + if mergedType := mergeGeminiSchemaTypes(result["type"], value); mergedType != "" { + result["type"] = mergedType + } else { + delete(result, "type") + } + case "description": + desc, ok := value.(string) + if ok && strings.TrimSpace(desc) != "" { + result["description"] = desc + } + default: + result[key] = cloneGeminiSchemaValue(value) + } + } + + return result +} + +func mergeGeminiSchemaTypes(left any, right any) string { + leftType := geminiSchemaBranchType(left) + rightType := geminiSchemaBranchType(right) + + switch { + case leftType == "": + return rightType + case rightType == "": + return leftType + case leftType == rightType: + return leftType + case leftType == "null": + return rightType + case rightType == "null": + return leftType + default: + return "" + } +} + +func sanitizeGeminiSchemaType(raw any) string { + typeName := geminiSchemaBranchType(raw) + if typeName == "null" { + return "" + } + return typeName +} + +func geminiSchemaBranchType(raw any) string { + switch value := raw.(type) { + case string: + if value == "null" { + return value + } + if geminiSupportedTypes[value] { + return value + } + return "" + case []string: + return geminiSchemaBranchType(stringSliceToAny(value)) + case []any: + candidate := "" + sawNull := false + for _, item := range value { + typeName, ok := item.(string) + if !ok { + continue + } + if typeName == "null" { + sawNull = true + continue + } + if !geminiSupportedTypes[typeName] { + continue + } + if candidate == "" { + candidate = typeName + continue + } + if candidate != typeName { + return "" + } + } + if candidate == "" && sawNull { + return "null" + } + return candidate + default: + return "" + } +} + +func sanitizeGeminiEnum(raw any) []any { + values, ok := raw.([]any) + if !ok { + if stringValues, ok := raw.([]string); ok { + return stringSliceToAny(stringValues) + } + return nil + } + + sanitized := make([]any, 0, len(values)) + for _, value := range values { + switch value.(type) { + case string, bool, float64, int, int32, int64: + sanitized = append(sanitized, value) + } + } + if len(sanitized) == 0 { + return nil + } + return sanitized +} + +func sanitizeGeminiRequired(raw any, properties map[string]any) []string { + required := requiredStrings(raw) + if len(required) == 0 { + return nil + } + + filtered := make([]string, 0, len(required)) + seen := make(map[string]struct{}, len(required)) + for _, name := range required { + if _, ok := properties[name]; !ok { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + filtered = append(filtered, name) + } + if len(filtered) == 0 { + return nil + } + return filtered +} + +func requiredStrings(raw any) []string { + switch value := raw.(type) { + case []string: + return append([]string(nil), value...) + case []any: + required := make([]string, 0, len(value)) + for _, item := range value { + name, ok := item.(string) + if ok { + required = append(required, name) + } + } + return required + default: + return nil + } +} + +func mergeRequiredLists(left any, right any) []string { + merged := make([]string, 0) + seen := map[string]struct{}{} + + for _, name := range append(requiredStrings(left), requiredStrings(right)...) { + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + merged = append(merged, name) + } + + return merged +} + +func geminiUnionBranchScore(schema map[string]any) int { + score := 0 + if hasSchemaProperties(schema) { + score += 20 + } + if hasSchemaItems(schema) { + score += 10 + } + if _, ok := schema["enum"]; ok { + score += 5 + } + if _, ok := schema["description"]; ok { + score += 2 + } + score += len(schema) + return score +} + +func hasSchemaProperties(schema map[string]any) bool { + props, ok := schema["properties"].(map[string]any) + return ok && len(props) > 0 +} + +func hasSchemaItems(schema map[string]any) bool { + _, ok := schema["items"].(map[string]any) + return ok +} + +func schemaSlice(raw any) []map[string]any { + switch value := raw.(type) { + case []map[string]any: + return append([]map[string]any(nil), value...) + case []any: + schemas := make([]map[string]any, 0, len(value)) + for _, item := range value { + schema, ok := item.(map[string]any) + if ok { + schemas = append(schemas, schema) + } + } + return schemas + default: + return nil + } +} + +func cloneGeminiSchemaMap(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + out := make(map[string]any, len(in)) + for key, value := range in { + out[key] = cloneGeminiSchemaValue(value) + } + return out +} + +func cloneGeminiSchemaValue(value any) any { + switch typed := value.(type) { + case map[string]any: + return cloneGeminiSchemaMap(typed) + case []any: + out := make([]any, len(typed)) + for i, item := range typed { + out[i] = cloneGeminiSchemaValue(item) + } + return out + case []string: + return append([]string(nil), typed...) + default: + return typed + } +} + +func cloneRefTrail(in map[string]struct{}) map[string]struct{} { + if len(in) == 0 { + return make(map[string]struct{}) + } + out := make(map[string]struct{}, len(in)) + for key := range in { + out[key] = struct{}{} + } + return out +} + +func stringSliceToAny(values []string) []any { + if len(values) == 0 { + return nil + } + result := make([]any, len(values)) + for i, value := range values { + result[i] = value + } + return result +} diff --git a/pkg/providers/common/google_schema_test.go b/pkg/providers/common/google_schema_test.go new file mode 100644 index 000000000..23aadbf98 --- /dev/null +++ b/pkg/providers/common/google_schema_test.go @@ -0,0 +1,254 @@ +package common + +import "testing" + +func TestSanitizeSchemaForGemini_DereferencesRefsAndFlattensUnions(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "parent": map[string]any{ + "anyOf": []any{ + map[string]any{"$ref": "#/$defs/pageParent"}, + map[string]any{"$ref": "#/$defs/databaseParent"}, + }, + }, + "icon": map[string]any{ + "anyOf": []any{ + map[string]any{"$ref": "#/$defs/emoji"}, + map[string]any{"type": "null"}, + }, + }, + "data": map[string]any{ + "$ref": "#/$defs/dataPayload", + }, + }, + "required": []any{"parent", "icon", "missing"}, + "$defs": map[string]any{ + "pageParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "page_id": map[string]any{ + "type": "string", + }, + }, + "required": []any{"page_id"}, + }, + "databaseParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "database_id": map[string]any{ + "type": "string", + }, + }, + "required": []any{"database_id"}, + }, + "emoji": map[string]any{ + "type": "string", + "pattern": "^:[a-z_]+:$", + }, + "dataPayload": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + "minLength": 1, + }, + "count": map[string]any{ + "type": "integer", + "minimum": 1, + }, + }, + "required": []any{"name"}, + }, + }, + } + + got := SanitizeSchemaForGemini(schema) + assertSchemaKeyAbsent(t, got, "$defs") + assertSchemaKeyAbsent(t, got, "$ref") + assertSchemaKeyAbsent(t, got, "anyOf") + assertSchemaKeyAbsent(t, got, "oneOf") + assertSchemaKeyAbsent(t, got, "allOf") + assertSchemaKeyAbsent(t, got, "additionalProperties") + assertSchemaKeyAbsent(t, got, "pattern") + assertSchemaKeyAbsent(t, got, "minLength") + assertSchemaKeyAbsent(t, got, "minimum") + + if got["type"] != "object" { + t.Fatalf("top-level type = %#v, want object", got["type"]) + } + + props, ok := got["properties"].(map[string]any) + if !ok { + t.Fatalf("properties = %#v, want map", got["properties"]) + } + + parent, ok := props["parent"].(map[string]any) + if !ok { + t.Fatalf("parent schema = %#v, want map", props["parent"]) + } + if parent["type"] != "object" { + t.Fatalf("parent.type = %#v, want object", parent["type"]) + } + parentProps, ok := parent["properties"].(map[string]any) + if !ok { + t.Fatalf("parent.properties = %#v, want map", parent["properties"]) + } + if _, found := parentProps["page_id"]; !found { + t.Fatalf("parent.properties missing page_id: %#v", parentProps) + } + if _, found := parentProps["database_id"]; !found { + t.Fatalf("parent.properties missing database_id: %#v", parentProps) + } + if _, hasRequired := parent["required"]; hasRequired { + t.Fatalf("parent.required = %#v, want omitted for merged anyOf branches", parent["required"]) + } + + icon, ok := props["icon"].(map[string]any) + if !ok { + t.Fatalf("icon schema = %#v, want map", props["icon"]) + } + if icon["type"] != "string" { + t.Fatalf("icon.type = %#v, want string", icon["type"]) + } + + data, ok := props["data"].(map[string]any) + if !ok { + t.Fatalf("data schema = %#v, want map", props["data"]) + } + if data["type"] != "object" { + t.Fatalf("data.type = %#v, want object", data["type"]) + } + dataProps, ok := data["properties"].(map[string]any) + if !ok { + t.Fatalf("data.properties = %#v, want map", data["properties"]) + } + if _, found := dataProps["name"]; !found { + t.Fatalf("data.properties missing name: %#v", dataProps) + } + if _, found := dataProps["count"]; !found { + t.Fatalf("data.properties missing count: %#v", dataProps) + } + + required, ok := got["required"].([]string) + if !ok { + t.Fatalf("required = %#v, want []string", got["required"]) + } + if len(required) != 2 || required[0] != "parent" || required[1] != "icon" { + t.Fatalf("required = %#v, want [parent icon]", required) + } +} + +func TestSanitizeSchemaForGemini_MergesAllOfAndFiltersRequired(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "payload": map[string]any{ + "allOf": []any{ + map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{ + "type": "string", + }, + }, + "required": []any{"id"}, + }, + map[string]any{ + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + }, + "count": map[string]any{ + "type": "integer", + "minimum": 1, + }, + }, + "required": []any{"name", "missing"}, + }, + }, + }, + }, + } + + got := SanitizeSchemaForGemini(schema) + props := got["properties"].(map[string]any) + payload := props["payload"].(map[string]any) + + if payload["type"] != "object" { + t.Fatalf("payload.type = %#v, want object", payload["type"]) + } + payloadProps, ok := payload["properties"].(map[string]any) + if !ok { + t.Fatalf("payload.properties = %#v, want map", payload["properties"]) + } + for _, key := range []string{"id", "name", "count"} { + if _, found := payloadProps[key]; !found { + t.Fatalf("payload.properties missing %q: %#v", key, payloadProps) + } + } + + required, ok := payload["required"].([]string) + if !ok { + t.Fatalf("payload.required = %#v, want []string", payload["required"]) + } + if len(required) != 2 || required[0] != "id" || required[1] != "name" { + t.Fatalf("payload.required = %#v, want [id name]", required) + } + + assertSchemaKeyAbsent(t, payload, "allOf") + assertSchemaKeyAbsent(t, payload, "minimum") +} + +func TestSanitizeSchemaForGemini_HandlesRecursiveRefs(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "tree": map[string]any{ + "$ref": "#/$defs/node", + }, + }, + "$defs": map[string]any{ + "node": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + }, + "child": map[string]any{ + "$ref": "#/$defs/node", + }, + }, + }, + }, + } + + got := SanitizeSchemaForGemini(schema) + props := got["properties"].(map[string]any) + tree := props["tree"].(map[string]any) + if tree["type"] != "object" { + t.Fatalf("tree.type = %#v, want object", tree["type"]) + } + assertSchemaKeyAbsent(t, tree, "$ref") +} + +func assertSchemaKeyAbsent(t *testing.T, value any, key string) { + t.Helper() + + switch typed := value.(type) { + case map[string]any: + if _, found := typed[key]; found { + t.Fatalf("schema still contains key %q: %#v", key, typed) + } + for _, nested := range typed { + assertSchemaKeyAbsent(t, nested, key) + } + case []any: + for _, nested := range typed { + assertSchemaKeyAbsent(t, nested, key) + } + case []string: + return + } +} diff --git a/pkg/providers/common/tool_schema_transform.go b/pkg/providers/common/tool_schema_transform.go new file mode 100644 index 000000000..10e96d056 --- /dev/null +++ b/pkg/providers/common/tool_schema_transform.go @@ -0,0 +1,59 @@ +package common + +import ( + "fmt" + "strings" +) + +const ( + ToolSchemaTransformOff = "" + ToolSchemaTransformSimple = "simple" +) + +// NormalizeToolSchemaTransform resolves user-facing aliases to a canonical +// transform mode. Empty values and explicit "off"-style values disable schema +// transformation. +func NormalizeToolSchemaTransform(raw string) (string, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "off", "none", "native": + return ToolSchemaTransformOff, nil + case "simple", "basic", "strict", "flat": + return ToolSchemaTransformSimple, nil + default: + return "", fmt.Errorf("unsupported tool_schema_transform %q (supported: off, simple)", raw) + } +} + +// TransformToolDefinitions clones tool definitions and applies the configured +// schema transform to function parameter schemas. When the transform is off, the +// original slice is returned unchanged. +func TransformToolDefinitions(tools []ToolDefinition, transform string) ([]ToolDefinition, error) { + transform, err := NormalizeToolSchemaTransform(transform) + if err != nil { + return nil, err + } + if transform == ToolSchemaTransformOff || len(tools) == 0 { + return tools, nil + } + + out := make([]ToolDefinition, len(tools)) + for i, tool := range tools { + out[i] = tool + if tool.Type != "function" { + continue + } + out[i].Function = tool.Function + out[i].Function.Parameters = transformToolSchema(tool.Function.Parameters, transform) + } + + return out, nil +} + +func transformToolSchema(schema map[string]any, transform string) map[string]any { + switch transform { + case ToolSchemaTransformSimple: + return SanitizeSchemaForGoogle(schema) + default: + return cloneGeminiSchemaMap(schema) + } +} diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go index fd9bf1e81..88c92a47d 100644 --- a/pkg/providers/error_classifier.go +++ b/pkg/providers/error_classifier.go @@ -2,8 +2,12 @@ package providers import ( "context" + "errors" + "io" + "net" "regexp" "strings" + "syscall" ) // Common patterns in Go HTTP error messages @@ -50,6 +54,30 @@ var ( substr("context deadline exceeded"), } + networkPatterns = []errorPattern{ + substr("connection reset"), + substr("reset by peer"), + substr("connection refused"), + substr("connection aborted"), + substr("broken pipe"), + substr("use of closed network connection"), + substr("network is unreachable"), + substr("host is unreachable"), + substr("no such host"), + substr("temporary failure in name resolution"), + substr("server misbehaving"), + substr("read tcp"), + substr("write tcp"), + substr("dial tcp"), + substr("tls:"), + substr("x509:"), + substr("certificate"), + substr("handshake"), + substr("unexpected eof"), + substr("read: eof"), + substr("write: eof"), + } + billingPatterns = []errorPattern{ rxp(`\b402\b`), substr("payment required"), @@ -84,6 +112,15 @@ var ( substr("messages.1.content.1.tool_use.id"), substr("invalid request format"), } + contextOverflowPatterns = []errorPattern{ + rxp(`context[_ ]?length[_ ]?exceeded`), + rxp(`context[_ ]?window[_ ]?exceeded`), + substr("maximum context length"), + substr("token limit"), + substr("too many tokens"), + substr("prompt is too long"), + substr("request too large"), + } imageDimensionPatterns = []errorPattern{ rxp(`image dimensions exceed max`), @@ -125,6 +162,17 @@ func ClassifyError(err error, provider, model string) *FailoverError { msg := strings.ToLower(err.Error()) + // Concrete transport errors should continue the fallback chain even when + // providers do not expose a structured HTTP status. + if reason := classifyByErrorType(err); reason != "" { + return &FailoverError{ + Reason: reason, + Provider: provider, + Model: model, + Wrapped: err, + } + } + // Image dimension/size errors: non-retriable, non-fallback. if IsImageDimensionError(msg) || IsImageSizeError(msg) { return &FailoverError{ @@ -161,6 +209,41 @@ func ClassifyError(err error, provider, model string) *FailoverError { return nil } +// classifyByErrorType maps concrete transport-layer error types to a retryable +// fallback reason before message heuristics are applied. +func classifyByErrorType(err error) FailoverReason { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return FailoverNetwork + } + + for _, transportErr := range []error{ + syscall.ECONNRESET, + syscall.ECONNABORTED, + syscall.ECONNREFUSED, + syscall.ETIMEDOUT, + syscall.EHOSTUNREACH, + syscall.ENETUNREACH, + syscall.EPIPE, + } { + if errors.Is(err, transportErr) { + if transportErr == syscall.ETIMEDOUT { + return FailoverTimeout + } + return FailoverNetwork + } + } + + var netErr net.Error + if errors.As(err, &netErr) { + if netErr.Timeout() { + return FailoverTimeout + } + return FailoverNetwork + } + + return "" +} + // classifyByStatus maps HTTP status codes to FailoverReason. func classifyByStatus(status int) FailoverReason { switch { @@ -195,12 +278,18 @@ func classifyByMessage(msg string) FailoverReason { if matchesAny(msg, timeoutPatterns) { return FailoverTimeout } + if matchesAny(msg, networkPatterns) { + return FailoverNetwork + } if matchesAny(msg, authPatterns) { return FailoverAuth } if matchesAny(msg, formatPatterns) { return FailoverFormat } + if matchesAny(msg, contextOverflowPatterns) { + return FailoverContextOverflow + } return "" } diff --git a/pkg/providers/error_classifier_test.go b/pkg/providers/error_classifier_test.go index 67d9af62b..571fb3882 100644 --- a/pkg/providers/error_classifier_test.go +++ b/pkg/providers/error_classifier_test.go @@ -4,9 +4,22 @@ import ( "context" "errors" "fmt" + "io" + "net" + "net/url" + "syscall" "testing" ) +type stubNetError struct { + msg string + timeout bool +} + +func (e stubNetError) Error() string { return e.msg } +func (e stubNetError) Timeout() bool { return e.timeout } +func (e stubNetError) Temporary() bool { return false } + func TestClassifyError_Nil(t *testing.T) { result := ClassifyError(nil, "openai", "gpt-4") if result != nil { @@ -154,6 +167,129 @@ func TestClassifyError_TimeoutPatterns(t *testing.T) { } } +func TestClassifyError_NetworkPatterns(t *testing.T) { + patterns := []string{ + `failed to send request: Post "https://example.com": tls: bad record MAC`, + "read tcp 10.20.0.1:61279->172.65.90.20:443: read: connection reset by peer", + "failed to send request: dial tcp 203.0.113.10:443: connect: connection refused", + "tls handshake failure", + "x509: certificate has expired or is not yet valid", + "read tcp 127.0.0.1:443: read: unexpected EOF", + "lookup api.example.com: no such host", + } + + for _, msg := range patterns { + err := errors.New(msg) + result := ClassifyError(err, "openai", "gpt-4") + if result == nil { + t.Errorf("pattern %q: expected non-nil", msg) + continue + } + if result.Reason != FailoverNetwork { + t.Errorf("pattern %q: reason = %q, want network", msg, result.Reason) + } + } +} + +func TestClassifyError_NetworkTypes(t *testing.T) { + tests := []struct { + name string + err error + }{ + { + name: "wrapped EOF", + err: &url.Error{ + Op: "Post", + URL: "https://example.com", + Err: io.EOF, + }, + }, + { + name: "dns error", + err: &net.DNSError{ + Err: "no such host", + Name: "api.example.com", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ClassifyError(tt.err, "openai", "gpt-4") + if result == nil { + t.Fatal("expected non-nil") + } + if result.Reason != FailoverNetwork { + t.Fatalf("reason = %q, want network", result.Reason) + } + }) + } +} + +func TestClassifyError_TimeoutNetworkTypes(t *testing.T) { + tests := []struct { + name string + err error + }{ + { + name: "wrapped syscall timeout", + err: fmt.Errorf("dial tcp: %w", syscall.ETIMEDOUT), + }, + { + name: "net error timeout", + err: &url.Error{ + Op: "Post", + URL: "https://example.com", + Err: stubNetError{msg: "i/o timeout", timeout: true}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ClassifyError(tt.err, "openai", "gpt-4") + if result == nil { + t.Fatal("expected non-nil") + } + if result.Reason != FailoverTimeout { + t.Fatalf("reason = %q, want timeout", result.Reason) + } + }) + } +} + +func TestClassifyError_TimeoutPatternsWinOverNetworkContext(t *testing.T) { + patterns := []string{ + `failed to send request: Post "https://example.com": dial tcp 203.0.113.10:443: i/o timeout`, + `read tcp 10.20.0.1:61279->172.65.90.20:443: i/o timeout`, + } + + for _, msg := range patterns { + err := errors.New(msg) + result := ClassifyError(err, "openai", "gpt-4") + if result == nil { + t.Errorf("pattern %q: expected non-nil", msg) + continue + } + if result.Reason != FailoverTimeout { + t.Errorf("pattern %q: reason = %q, want timeout", msg, result.Reason) + } + } +} + +func TestClassifyError_NetworkPatternsWinOverAuthExpired(t *testing.T) { + err := errors.New( + `Post "https://example.com": tls: failed to verify certificate: x509: certificate has expired or is not yet valid`, + ) + result := ClassifyError(err, "openai", "gpt-4") + if result == nil { + t.Fatal("expected non-nil") + } + if result.Reason != FailoverNetwork { + t.Fatalf("reason = %q, want network", result.Reason) + } +} + func TestClassifyError_AuthPatterns(t *testing.T) { patterns := []string{ "invalid api key", @@ -221,6 +357,30 @@ func TestClassifyError_ImageDimensionError(t *testing.T) { } } +func TestClassifyError_ContextOverflowPatterns(t *testing.T) { + patterns := []string{ + "context_length_exceeded", + "context_window_exceeded", + "maximum context length", + "token limit", + "too many tokens", + "prompt is too long", + "request too large", + } + + for _, msg := range patterns { + err := errors.New(msg) + result := ClassifyError(err, "openai", "gpt-4") + if result == nil { + t.Errorf("pattern %q: expected non-nil", msg) + continue + } + if result.Reason != FailoverContextOverflow { + t.Errorf("pattern %q: reason = %q, want context_overflow", msg, result.Reason) + } + } +} + func TestClassifyError_ImageSizeError(t *testing.T) { err := errors.New("image exceeds 20 mb limit") result := ClassifyError(err, "openai", "gpt-4o") @@ -262,9 +422,11 @@ func TestFailoverError_IsRetriable(t *testing.T) { {FailoverAuth, true}, {FailoverRateLimit, true}, {FailoverBilling, true}, + {FailoverNetwork, true}, {FailoverTimeout, true}, {FailoverOverloaded, true}, {FailoverFormat, false}, + {FailoverContextOverflow, false}, {FailoverUnknown, true}, } diff --git a/pkg/providers/facade_compat_test.go b/pkg/providers/facade_compat_test.go new file mode 100644 index 000000000..024c36abf --- /dev/null +++ b/pkg/providers/facade_compat_test.go @@ -0,0 +1,44 @@ +package providers + +import ( + "testing" + + cliprovider "github.com/sipeed/picoclaw/pkg/providers/cli" + oauthprovider "github.com/sipeed/picoclaw/pkg/providers/oauth" +) + +func TestNormalizeToolCallFacadeMatchesCLIProvider(t *testing.T) { + input := ToolCall{ + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + } + + got := NormalizeToolCall(input) + want := cliprovider.NormalizeToolCall(input) + + if got.Name != want.Name { + t.Fatalf("Name = %q, want %q", got.Name, want.Name) + } + if got.Function == nil || want.Function == nil { + t.Fatalf("Function should not be nil: got=%v want=%v", got.Function, want.Function) + } + if got.Function.Name != want.Function.Name { + t.Fatalf("Function.Name = %q, want %q", got.Function.Name, want.Function.Name) + } + if got.Function.Arguments != want.Function.Arguments { + t.Fatalf("Function.Arguments = %q, want %q", got.Function.Arguments, want.Function.Arguments) + } + if got.Arguments["path"] != want.Arguments["path"] { + t.Fatalf("Arguments[path] = %v, want %v", got.Arguments["path"], want.Arguments["path"]) + } +} + +func TestAntigravityFacadeSignaturesRemainAvailable(t *testing.T) { + var _ func(string) (string, error) = FetchAntigravityProjectID + var _ func(string, string) ([]AntigravityModelInfo, error) = FetchAntigravityModels + var _ AntigravityModelInfo = oauthprovider.AntigravityModelInfo{} +} diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index 25916ad03..354acafcb 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -1,364 +1,7 @@ package providers import ( - "fmt" - "strings" - "github.com/sipeed/picoclaw/pkg/auth" - "github.com/sipeed/picoclaw/pkg/config" ) -const defaultAnthropicAPIBase = "https://api.anthropic.com/v1" - var getCredential = auth.GetCredential - -type providerType int - -const ( - providerTypeHTTPCompat providerType = iota - providerTypeClaudeAuth - providerTypeCodexAuth - providerTypeCodexCLIToken - providerTypeClaudeCLI - providerTypeCodexCLI - providerTypeGitHubCopilot -) - -type providerSelection struct { - providerType providerType - apiKey string - apiBase string - proxy string - model string - workspace string - connectMode string - enableWebSearch bool -} - -func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { - model := cfg.Agents.Defaults.GetModelName() - providerName := strings.ToLower(cfg.Agents.Defaults.Provider) - lowerModel := strings.ToLower(model) - - sel := providerSelection{ - providerType: providerTypeHTTPCompat, - model: model, - } - - // First, prefer explicit provider configuration. - if providerName != "" { - switch providerName { - case "groq": - if cfg.Providers.Groq.APIKey != "" { - sel.apiKey = cfg.Providers.Groq.APIKey - sel.apiBase = cfg.Providers.Groq.APIBase - sel.proxy = cfg.Providers.Groq.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.groq.com/openai/v1" - } - } - case "openai", "gpt": - if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" { - sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch - if cfg.Providers.OpenAI.AuthMethod == "codex-cli" { - sel.providerType = providerTypeCodexCLIToken - return sel, nil - } - if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - sel.providerType = providerTypeCodexAuth - return sel, nil - } - sel.apiKey = cfg.Providers.OpenAI.APIKey - sel.apiBase = cfg.Providers.OpenAI.APIBase - sel.proxy = cfg.Providers.OpenAI.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.openai.com/v1" - } - } - case "anthropic", "claude": - if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { - sel.apiBase = cfg.Providers.Anthropic.APIBase - if sel.apiBase == "" { - sel.apiBase = defaultAnthropicAPIBase - } - sel.providerType = providerTypeClaudeAuth - return sel, nil - } - sel.apiKey = cfg.Providers.Anthropic.APIKey - sel.apiBase = cfg.Providers.Anthropic.APIBase - sel.proxy = cfg.Providers.Anthropic.Proxy - if sel.apiBase == "" { - sel.apiBase = defaultAnthropicAPIBase - } - } - case "openrouter": - if cfg.Providers.OpenRouter.APIKey != "" { - sel.apiKey = cfg.Providers.OpenRouter.APIKey - sel.proxy = cfg.Providers.OpenRouter.Proxy - if cfg.Providers.OpenRouter.APIBase != "" { - sel.apiBase = cfg.Providers.OpenRouter.APIBase - } else { - sel.apiBase = "https://openrouter.ai/api/v1" - } - } - case "litellm": - if cfg.Providers.LiteLLM.APIKey != "" || cfg.Providers.LiteLLM.APIBase != "" { - sel.apiKey = cfg.Providers.LiteLLM.APIKey - sel.apiBase = cfg.Providers.LiteLLM.APIBase - sel.proxy = cfg.Providers.LiteLLM.Proxy - if sel.apiBase == "" { - sel.apiBase = "http://localhost:4000/v1" - } - } - case "zhipu", "glm": - if cfg.Providers.Zhipu.APIKey != "" { - sel.apiKey = cfg.Providers.Zhipu.APIKey - sel.apiBase = cfg.Providers.Zhipu.APIBase - sel.proxy = cfg.Providers.Zhipu.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://open.bigmodel.cn/api/paas/v4" - } - } - case "gemini", "google": - if cfg.Providers.Gemini.APIKey != "" { - sel.apiKey = cfg.Providers.Gemini.APIKey - sel.apiBase = cfg.Providers.Gemini.APIBase - sel.proxy = cfg.Providers.Gemini.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://generativelanguage.googleapis.com/v1beta" - } - } - case "vllm": - if cfg.Providers.VLLM.APIBase != "" { - sel.apiKey = cfg.Providers.VLLM.APIKey - sel.apiBase = cfg.Providers.VLLM.APIBase - sel.proxy = cfg.Providers.VLLM.Proxy - } - case "shengsuanyun": - if cfg.Providers.ShengSuanYun.APIKey != "" { - sel.apiKey = cfg.Providers.ShengSuanYun.APIKey - sel.apiBase = cfg.Providers.ShengSuanYun.APIBase - sel.proxy = cfg.Providers.ShengSuanYun.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://router.shengsuanyun.com/api/v1" - } - } - case "nvidia": - if cfg.Providers.Nvidia.APIKey != "" { - sel.apiKey = cfg.Providers.Nvidia.APIKey - sel.apiBase = cfg.Providers.Nvidia.APIBase - sel.proxy = cfg.Providers.Nvidia.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://integrate.api.nvidia.com/v1" - } - } - case "vivgrid": - if cfg.Providers.Vivgrid.APIKey != "" { - sel.apiKey = cfg.Providers.Vivgrid.APIKey - sel.apiBase = cfg.Providers.Vivgrid.APIBase - sel.proxy = cfg.Providers.Vivgrid.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.vivgrid.com/v1" - } - } - case "claude-cli", "claude-code", "claudecode": - workspace := cfg.WorkspacePath() - if workspace == "" { - workspace = "." - } - sel.providerType = providerTypeClaudeCLI - sel.workspace = workspace - return sel, nil - case "codex-cli", "codex-code": - workspace := cfg.WorkspacePath() - if workspace == "" { - workspace = "." - } - sel.providerType = providerTypeCodexCLI - sel.workspace = workspace - return sel, nil - case "deepseek": - if cfg.Providers.DeepSeek.APIKey != "" { - sel.apiKey = cfg.Providers.DeepSeek.APIKey - sel.apiBase = cfg.Providers.DeepSeek.APIBase - sel.proxy = cfg.Providers.DeepSeek.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.deepseek.com/v1" - } - if model != "deepseek-chat" && model != "deepseek-reasoner" { - sel.model = "deepseek-chat" - } - } - case "avian": - if cfg.Providers.Avian.APIKey != "" { - sel.apiKey = cfg.Providers.Avian.APIKey - sel.apiBase = cfg.Providers.Avian.APIBase - sel.proxy = cfg.Providers.Avian.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.avian.io/v1" - } - } - case "mistral": - if cfg.Providers.Mistral.APIKey != "" { - sel.apiKey = cfg.Providers.Mistral.APIKey - sel.apiBase = cfg.Providers.Mistral.APIBase - sel.proxy = cfg.Providers.Mistral.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.mistral.ai/v1" - } - } - case "github_copilot", "copilot": - sel.providerType = providerTypeGitHubCopilot - if cfg.Providers.GitHubCopilot.APIBase != "" { - sel.apiBase = cfg.Providers.GitHubCopilot.APIBase - } else { - sel.apiBase = "localhost:4321" - } - sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode - return sel, nil - } - } - - // Fallback: infer provider from model and configured keys. - if sel.apiKey == "" && sel.apiBase == "" { - switch { - case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "": - sel.apiKey = cfg.Providers.Moonshot.APIKey - sel.apiBase = cfg.Providers.Moonshot.APIBase - sel.proxy = cfg.Providers.Moonshot.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.moonshot.cn/v1" - } - case strings.HasPrefix(model, "openrouter/") || - strings.HasPrefix(model, "anthropic/") || - strings.HasPrefix(model, "openai/") || - strings.HasPrefix(model, "meta-llama/") || - strings.HasPrefix(model, "deepseek/") || - strings.HasPrefix(model, "google/"): - sel.apiKey = cfg.Providers.OpenRouter.APIKey - sel.proxy = cfg.Providers.OpenRouter.Proxy - if cfg.Providers.OpenRouter.APIBase != "" { - sel.apiBase = cfg.Providers.OpenRouter.APIBase - } else { - sel.apiBase = "https://openrouter.ai/api/v1" - } - case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && - (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { - sel.apiBase = cfg.Providers.Anthropic.APIBase - if sel.apiBase == "" { - sel.apiBase = defaultAnthropicAPIBase - } - sel.providerType = providerTypeClaudeAuth - return sel, nil - } - sel.apiKey = cfg.Providers.Anthropic.APIKey - sel.apiBase = cfg.Providers.Anthropic.APIBase - sel.proxy = cfg.Providers.Anthropic.Proxy - if sel.apiBase == "" { - sel.apiBase = defaultAnthropicAPIBase - } - case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && - (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""): - sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch - if cfg.Providers.OpenAI.AuthMethod == "codex-cli" { - sel.providerType = providerTypeCodexCLIToken - return sel, nil - } - if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - sel.providerType = providerTypeCodexAuth - return sel, nil - } - sel.apiKey = cfg.Providers.OpenAI.APIKey - sel.apiBase = cfg.Providers.OpenAI.APIBase - sel.proxy = cfg.Providers.OpenAI.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.openai.com/v1" - } - case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "": - sel.apiKey = cfg.Providers.Gemini.APIKey - sel.apiBase = cfg.Providers.Gemini.APIBase - sel.proxy = cfg.Providers.Gemini.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://generativelanguage.googleapis.com/v1beta" - } - case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "": - sel.apiKey = cfg.Providers.Zhipu.APIKey - sel.apiBase = cfg.Providers.Zhipu.APIBase - sel.proxy = cfg.Providers.Zhipu.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://open.bigmodel.cn/api/paas/v4" - } - case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "": - sel.apiKey = cfg.Providers.Groq.APIKey - sel.apiBase = cfg.Providers.Groq.APIBase - sel.proxy = cfg.Providers.Groq.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.groq.com/openai/v1" - } - case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "": - sel.apiKey = cfg.Providers.Nvidia.APIKey - sel.apiBase = cfg.Providers.Nvidia.APIBase - sel.proxy = cfg.Providers.Nvidia.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://integrate.api.nvidia.com/v1" - } - case strings.HasPrefix(model, "vivgrid/") && cfg.Providers.Vivgrid.APIKey != "": - sel.apiKey = cfg.Providers.Vivgrid.APIKey - sel.apiBase = cfg.Providers.Vivgrid.APIBase - sel.proxy = cfg.Providers.Vivgrid.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.vivgrid.com/v1" - } - case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "": - sel.apiKey = cfg.Providers.Ollama.APIKey - sel.apiBase = cfg.Providers.Ollama.APIBase - sel.proxy = cfg.Providers.Ollama.Proxy - if sel.apiBase == "" { - sel.apiBase = "http://localhost:11434/v1" - } - case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "": - sel.apiKey = cfg.Providers.Mistral.APIKey - sel.apiBase = cfg.Providers.Mistral.APIBase - sel.proxy = cfg.Providers.Mistral.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.mistral.ai/v1" - } - case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "": - sel.apiKey = cfg.Providers.Avian.APIKey - sel.apiBase = cfg.Providers.Avian.APIBase - sel.proxy = cfg.Providers.Avian.Proxy - if sel.apiBase == "" { - sel.apiBase = "https://api.avian.io/v1" - } - case cfg.Providers.VLLM.APIBase != "": - sel.apiKey = cfg.Providers.VLLM.APIKey - sel.apiBase = cfg.Providers.VLLM.APIBase - sel.proxy = cfg.Providers.VLLM.Proxy - default: - if cfg.Providers.OpenRouter.APIKey != "" { - sel.apiKey = cfg.Providers.OpenRouter.APIKey - sel.proxy = cfg.Providers.OpenRouter.Proxy - if cfg.Providers.OpenRouter.APIBase != "" { - sel.apiBase = cfg.Providers.OpenRouter.APIBase - } else { - sel.apiBase = "https://openrouter.ai/api/v1" - } - } else { - return providerSelection{}, fmt.Errorf("no API key configured for model: %s", model) - } - } - } - - if sel.providerType == providerTypeHTTPCompat { - if sel.apiKey == "" && !strings.HasPrefix(model, "bedrock/") { - return providerSelection{}, fmt.Errorf("no API key configured for provider (model: %s)", model) - } - if sel.apiBase == "" { - return providerSelection{}, fmt.Errorf("no API base configured for provider (model: %s)", model) - } - } - - return sel, nil -} diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 941985964..aa99d6d38 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -6,12 +6,62 @@ package providers import ( + "context" "fmt" "strings" + "time" "github.com/sipeed/picoclaw/pkg/config" + anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages" + "github.com/sipeed/picoclaw/pkg/providers/azure" + "github.com/sipeed/picoclaw/pkg/providers/bedrock" ) +type protocolMeta struct { + defaultAPIBase string + emptyAPIKeyAllowed bool +} + +var protocolMetaByName = map[string]protocolMeta{ + "openai": {defaultAPIBase: "https://api.openai.com/v1"}, + "venice": {defaultAPIBase: "https://api.venice.ai/api/v1"}, + "openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"}, + "litellm": {defaultAPIBase: "http://localhost:4000/v1"}, + "lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true}, + "novita": {defaultAPIBase: "https://api.novita.ai/openai"}, + "groq": {defaultAPIBase: "https://api.groq.com/openai/v1"}, + "zhipu": {defaultAPIBase: "https://open.bigmodel.cn/api/paas/v4"}, + "gemini": {defaultAPIBase: "https://generativelanguage.googleapis.com/v1beta"}, + "nvidia": {defaultAPIBase: "https://integrate.api.nvidia.com/v1"}, + "ollama": {defaultAPIBase: "http://localhost:11434/v1", emptyAPIKeyAllowed: true}, + "moonshot": {defaultAPIBase: "https://api.moonshot.cn/v1"}, + "shengsuanyun": {defaultAPIBase: "https://router.shengsuanyun.com/api/v1"}, + "deepseek": {defaultAPIBase: "https://api.deepseek.com/v1"}, + "cerebras": {defaultAPIBase: "https://api.cerebras.ai/v1"}, + "vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"}, + "volcengine": {defaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3"}, + "qwen": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"}, + "qwen-portal": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"}, + "qwen-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, + "qwen-international": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, + "dashscope-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, + "qwen-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"}, + "dashscope-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"}, + "coding-plan": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, + "alibaba-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, + "qwen-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, + "coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"}, + "alibaba-coding-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"}, + "zai": {defaultAPIBase: "https://api.z.ai/api/coding/paas/v4"}, + "vllm": {defaultAPIBase: "http://localhost:8000/v1", emptyAPIKeyAllowed: true}, + "mistral": {defaultAPIBase: "https://api.mistral.ai/v1"}, + "avian": {defaultAPIBase: "https://api.avian.io/v1"}, + "minimax": {defaultAPIBase: "https://api.minimaxi.com/v1"}, + "longcat": {defaultAPIBase: "https://api.longcat.chat/openai"}, + "modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"}, + "mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"}, +} + // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. func createClaudeAuthProvider() (LLMProvider, error) { cred, err := getCredential("anthropic") @@ -36,25 +86,52 @@ func createCodexAuthProvider() (LLMProvider, error) { return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil } -// ExtractProtocol extracts the protocol prefix and model identifier from a model string. -// If no prefix is specified, it defaults to "openai". +// ExtractProtocol extracts the effective protocol and model identifier from a +// model configuration. +// +// The explicit Provider field takes precedence. When Provider is empty, the +// protocol is inferred from Model. Plain model names default to "openai". +// Provider-prefixed models strip the first slash-separated segment from the +// returned model ID. +// +// The returned protocol is normalized to the provider's canonical spelling. // Examples: -// - "openai/gpt-4o" -> ("openai", "gpt-4o") -// - "anthropic/claude-sonnet-4.6" -> ("anthropic", "claude-sonnet-4.6") -// - "gpt-4o" -> ("openai", "gpt-4o") // default protocol -func ExtractProtocol(model string) (protocol, modelID string) { - model = strings.TrimSpace(model) - protocol, modelID, found := strings.Cut(model, "/") - if !found { - return "openai", model +// - Model "openai/gpt-4o" -> ("openai", "gpt-4o") +// - Model "nvidia/z-ai/glm-5.1" -> ("nvidia", "z-ai/glm-5.1") +// - Provider "nvidia", Model "z-ai/glm-5.1" -> ("nvidia", "z-ai/glm-5.1") +// - Provider "openai", Model "openai/gpt-4o" -> ("openai", "openai/gpt-4o") +// - Model "gpt-4o" -> ("openai", "gpt-4o") +func ExtractProtocol(cfg *config.ModelConfig) (protocol, modelID string) { + if cfg == nil { + return "", "" } - return protocol, modelID + + model := strings.TrimSpace(cfg.Model) + if provider := strings.TrimSpace(cfg.Provider); provider != "" { + return NormalizeProvider(provider), model + } + return SplitModelProviderAndID(model, "openai") +} + +// ResolveAPIBase returns the configured API base, or the protocol default when +// the model uses an HTTP-based provider family with a known default endpoint. +func ResolveAPIBase(cfg *config.ModelConfig) string { + if cfg == nil { + return "" + } + if apiBase := strings.TrimSpace(cfg.APIBase); apiBase != "" { + return strings.TrimRight(apiBase, "/") + } + protocol, _ := ExtractProtocol(cfg) + return strings.TrimRight(getDefaultAPIBase(protocol), "/") } // CreateProviderFromConfig creates a provider based on the ModelConfig. -// It uses the protocol prefix in the Model field to determine which provider to create. -// Supported protocols: openai, litellm, anthropic, antigravity, claude-cli, codex-cli, github-copilot -// Returns the provider, the model ID (without protocol prefix), and any error. +// It uses ExtractProtocol to determine which provider to create. +// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq), +// Azure OpenAI, Amazon Bedrock, Anthropic (including messages), and various CLI/compatibility shims. +// See the switch on protocol in this function for the authoritative list. +// Returns the provider, the effective model ID from ExtractProtocol, and any error. func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) { if cfg == nil { return nil, "", fmt.Errorf("config is nil") @@ -64,94 +141,251 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return nil, "", fmt.Errorf("model is required") } - protocol, modelID := ExtractProtocol(cfg.Model) + protocol, modelID := ExtractProtocol(cfg) + authMethod := strings.ToLower(strings.TrimSpace(cfg.AuthMethod)) + + userAgent := cfg.UserAgent + if userAgent == "" { + userAgent = fmt.Sprintf("PicoClaw/%s", config.Version) + } switch protocol { case "openai": // OpenAI with OAuth/token auth (Codex-style) - if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { + if authMethod == "oauth" || authMethod == "token" { provider, err := createCodexAuthProvider() if err != nil { return nil, "", err } - return provider, modelID, nil + return finalizeProviderFromConfig(provider, modelID, cfg) } // OpenAI with API key - if cfg.APIKey == "" && cfg.APIBase == "" { + if cfg.APIKey() == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) } apiBase := cfg.APIBase if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - cfg.APIKey, + provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, - ), modelID, nil + cfg.ExtraBody, + cfg.CustomHeaders, + ) + provider.SetProviderName(protocol) + return finalizeProviderFromConfig(provider, modelID, cfg) - case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", + case "azure", "azure-openai": + // Azure OpenAI uses deployment-based URLs, api-key header auth, + // and always sends max_completion_tokens. + if cfg.APIKey() == "" { + return nil, "", fmt.Errorf("api_key is required for azure protocol") + } + if cfg.APIBase == "" { + return nil, "", fmt.Errorf( + "api_base is required for azure protocol (e.g., https://your-resource.openai.azure.com)", + ) + } + return finalizeProviderFromConfig(azure.NewProviderWithTimeout( + cfg.APIKey(), + cfg.APIBase, + cfg.Proxy, + userAgent, + cfg.RequestTimeout, + ), modelID, cfg) + + case "bedrock": + // AWS Bedrock uses AWS SDK credentials (env vars, profiles, IAM roles, etc.) + // api_base can be: + // - A full endpoint URL: https://bedrock-runtime.us-east-1.amazonaws.com + // - A region name: us-east-1 (AWS SDK resolves endpoint automatically) + var opts []bedrock.Option + if cfg.APIBase != "" { + if !strings.Contains(cfg.APIBase, "://") { + // Treat as region: let AWS SDK resolve the correct endpoint + // (supports all AWS partitions: aws, aws-cn, aws-us-gov, etc.) + opts = append(opts, bedrock.WithRegion(cfg.APIBase)) + } else { + // Full endpoint URL provided (for custom endpoints or testing) + opts = append(opts, bedrock.WithBaseEndpoint(cfg.APIBase)) + } + } + // Use a separate timeout for AWS config loading (credential resolution can block) + initTimeout := 30 * time.Second + if cfg.RequestTimeout > 0 { + reqTimeout := time.Duration(cfg.RequestTimeout) * time.Second + // Set request timeout for API calls + opts = append(opts, bedrock.WithRequestTimeout(reqTimeout)) + // Ensure init timeout is at least as large as request timeout + if reqTimeout > initTimeout { + initTimeout = reqTimeout + } + } + ctx, cancel := context.WithTimeout(context.Background(), initTimeout) + defer cancel() + // Note: AWS_PROFILE env var is automatically used by AWS SDK + provider, err := bedrock.NewProvider(ctx, opts...) + if err != nil { + return nil, "", fmt.Errorf("creating bedrock provider: %w", err) + } + return finalizeProviderFromConfig(provider, modelID, cfg) + + case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian": + "vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", + "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", + "coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo": // All other OpenAI-compatible HTTP providers - if cfg.APIKey == "" && cfg.APIBase == "" { + if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) } apiBase := cfg.APIBase if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - cfg.APIKey, + provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, - ), modelID, nil + cfg.ExtraBody, + cfg.CustomHeaders, + ) + provider.SetProviderName(protocol) + return finalizeProviderFromConfig(provider, modelID, cfg) + + case "gemini": + if cfg.APIKey() == "" && cfg.APIBase == "" { + return nil, "", fmt.Errorf("api_key or api_base is required for gemini protocol (model: %s)", cfg.Model) + } + apiBase := cfg.APIBase + if apiBase == "" { + apiBase = getDefaultAPIBase(protocol) + } + return finalizeProviderFromConfig(NewGeminiProvider( + cfg.APIKey(), + apiBase, + cfg.Proxy, + userAgent, + cfg.RequestTimeout, + cfg.ExtraBody, + cfg.CustomHeaders, + ), modelID, cfg) + + case "minimax": + // Minimax requires reasoning_split: true in the request body + if cfg.APIKey() == "" && cfg.APIBase == "" { + return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) + } + apiBase := cfg.APIBase + if apiBase == "" { + apiBase = getDefaultAPIBase(protocol) + } + extraBody := cfg.ExtraBody + if extraBody == nil { + extraBody = make(map[string]any) + } + if _, ok := extraBody["reasoning_split"]; !ok { + extraBody["reasoning_split"] = true + } + provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey(), + apiBase, + cfg.Proxy, + cfg.MaxTokensField, + userAgent, + cfg.RequestTimeout, + extraBody, + cfg.CustomHeaders, + ) + provider.SetProviderName(protocol) + return finalizeProviderFromConfig(provider, modelID, cfg) case "anthropic": - if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { + if authMethod == "oauth" || authMethod == "token" { // Use OAuth credentials from auth store provider, err := createClaudeAuthProvider() if err != nil { return nil, "", err } - return provider, modelID, nil + return finalizeProviderFromConfig(provider, modelID, cfg) } // Use API key with HTTP API apiBase := cfg.APIBase if apiBase == "" { apiBase = "https://api.anthropic.com/v1" } - if cfg.APIKey == "" { + if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model) } - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - cfg.APIKey, + provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, - ), modelID, nil + cfg.ExtraBody, + cfg.CustomHeaders, + ) + provider.SetProviderName(protocol) + return finalizeProviderFromConfig(provider, modelID, cfg) + + case "anthropic-messages": + // Anthropic Messages API with native format (HTTP-based, no SDK) + apiBase := cfg.APIBase + if apiBase == "" { + apiBase = "https://api.anthropic.com/v1" + } + if cfg.APIKey() == "" { + return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model) + } + return finalizeProviderFromConfig(anthropicmessages.NewProviderWithTimeout( + cfg.APIKey(), + apiBase, + userAgent, + cfg.RequestTimeout, + ), modelID, cfg) + + case "coding-plan-anthropic", "alibaba-coding-anthropic": + // Alibaba Coding Plan with Anthropic-compatible API + apiBase := cfg.APIBase + if apiBase == "" { + apiBase = getDefaultAPIBase(protocol) + } + if cfg.APIKey() == "" { + return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model) + } + return finalizeProviderFromConfig(anthropicmessages.NewProviderWithTimeout( + cfg.APIKey(), + apiBase, + userAgent, + cfg.RequestTimeout, + ), modelID, cfg) case "antigravity": - return NewAntigravityProvider(), modelID, nil + return finalizeProviderFromConfig(NewAntigravityProvider(), modelID, cfg) case "claude-cli", "claudecli": workspace := cfg.Workspace if workspace == "" { workspace = "." } - return NewClaudeCliProvider(workspace), modelID, nil + return finalizeProviderFromConfig(NewClaudeCliProvider(workspace), modelID, cfg) case "codex-cli", "codexcli": workspace := cfg.Workspace if workspace == "" { workspace = "." } - return NewCodexCliProvider(workspace), modelID, nil + return finalizeProviderFromConfig(NewCodexCliProvider(workspace), modelID, cfg) case "github-copilot", "copilot": apiBase := cfg.APIBase @@ -166,53 +400,59 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if err != nil { return nil, "", err } - return provider, modelID, nil + return finalizeProviderFromConfig(provider, modelID, cfg) default: return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model) } } +func finalizeProviderFromConfig( + provider LLMProvider, + modelID string, + cfg *config.ModelConfig, +) (LLMProvider, string, error) { + wrapped, err := wrapProviderWithToolSchemaTransform(provider, cfg.ToolSchemaTransform) + if err != nil { + return nil, "", err + } + return wrapped, modelID, nil +} + +func isEmptyAPIKeyAllowed(protocol string) bool { + meta, ok := protocolMetaForName(protocol) + return ok && meta.emptyAPIKeyAllowed +} + +// IsEmptyAPIKeyAllowedForProtocol reports whether a protocol allows requests +// without api_key when using its default local endpoint. +func IsEmptyAPIKeyAllowedForProtocol(protocol string) bool { + protocol = strings.ToLower(strings.TrimSpace(protocol)) + return isEmptyAPIKeyAllowed(protocol) +} + +// DefaultAPIBaseForProtocol returns the configured default API base for a protocol. +// It returns empty string if the protocol has no default base. +func DefaultAPIBaseForProtocol(protocol string) string { + protocol = strings.ToLower(strings.TrimSpace(protocol)) + return getDefaultAPIBase(protocol) +} + // getDefaultAPIBase returns the default API base URL for a given protocol. func getDefaultAPIBase(protocol string) string { - switch protocol { - case "openai": - return "https://api.openai.com/v1" - case "openrouter": - return "https://openrouter.ai/api/v1" - case "litellm": - return "http://localhost:4000/v1" - case "groq": - return "https://api.groq.com/openai/v1" - case "zhipu": - return "https://open.bigmodel.cn/api/paas/v4" - case "gemini": - return "https://generativelanguage.googleapis.com/v1beta" - case "nvidia": - return "https://integrate.api.nvidia.com/v1" - case "ollama": - return "http://localhost:11434/v1" - case "moonshot": - return "https://api.moonshot.cn/v1" - case "shengsuanyun": - return "https://router.shengsuanyun.com/api/v1" - case "deepseek": - return "https://api.deepseek.com/v1" - case "cerebras": - return "https://api.cerebras.ai/v1" - case "vivgrid": - return "https://api.vivgrid.com/v1" - case "volcengine": - return "https://ark.cn-beijing.volces.com/api/v3" - case "qwen": - return "https://dashscope.aliyuncs.com/compatible-mode/v1" - case "vllm": - return "http://localhost:8000/v1" - case "mistral": - return "https://api.mistral.ai/v1" - case "avian": - return "https://api.avian.io/v1" - default: + meta, ok := protocolMetaForName(protocol) + if !ok { return "" } + return meta.defaultAPIBase +} + +func protocolMetaForName(protocol string) (protocolMeta, bool) { + if meta, ok := protocolMetaByName[protocol]; ok { + return meta, true + } + if meta, ok := attachedModelProviderMetaByName[protocol]; ok { + return meta.protocolMeta, true + } + return protocolMeta{}, false } diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 17bc55d25..eb9b3d600 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -6,74 +6,123 @@ package providers import ( + "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" + "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) func TestExtractProtocol(t *testing.T) { tests := []struct { name string - model string + config *config.ModelConfig wantProtocol string wantModelID string }{ { name: "openai with prefix", - model: "openai/gpt-4o", + config: &config.ModelConfig{Model: "openai/gpt-4o"}, wantProtocol: "openai", wantModelID: "gpt-4o", }, { name: "anthropic with prefix", - model: "anthropic/claude-sonnet-4.6", + config: &config.ModelConfig{Model: "anthropic/claude-sonnet-4.6"}, wantProtocol: "anthropic", wantModelID: "claude-sonnet-4.6", }, { name: "no prefix - defaults to openai", - model: "gpt-4o", + config: &config.ModelConfig{Model: "gpt-4o"}, wantProtocol: "openai", wantModelID: "gpt-4o", }, { name: "groq with prefix", - model: "groq/llama-3.1-70b", + config: &config.ModelConfig{Model: "groq/llama-3.1-70b"}, wantProtocol: "groq", wantModelID: "llama-3.1-70b", }, { name: "empty string", - model: "", - wantProtocol: "openai", + config: &config.ModelConfig{Model: ""}, + wantProtocol: "", wantModelID: "", }, { name: "with whitespace", - model: " openai/gpt-4 ", + config: &config.ModelConfig{Model: " openai/gpt-4 "}, wantProtocol: "openai", wantModelID: "gpt-4", }, { name: "multiple slashes", - model: "nvidia/meta/llama-3.1-8b", + config: &config.ModelConfig{Model: "nvidia/meta/llama-3.1-8b"}, wantProtocol: "nvidia", wantModelID: "meta/llama-3.1-8b", }, + { + name: "normalizes provider", + config: &config.ModelConfig{Model: "z.ai/glm-5.1"}, + wantProtocol: "zai", + wantModelID: "glm-5.1", + }, + { + name: "azure with prefix", + config: &config.ModelConfig{Model: "azure/my-gpt5-deployment"}, + wantProtocol: "azure", + wantModelID: "my-gpt5-deployment", + }, + { + name: "explicit provider keeps model", + config: &config.ModelConfig{Provider: "nvidia", Model: "z-ai/glm-5.1"}, + wantProtocol: "nvidia", + wantModelID: "z-ai/glm-5.1", + }, + { + name: "explicit provider preserves matching prefix", + config: &config.ModelConfig{Provider: "openai", Model: "openai/gpt-4o"}, + wantProtocol: "openai", + wantModelID: "openai/gpt-4o", + }, + { + name: "explicit provider preserves aliased prefix", + config: &config.ModelConfig{Provider: "qwen", Model: "qwen/qwen-plus"}, + wantProtocol: "qwen-portal", + wantModelID: "qwen/qwen-plus", + }, + { + name: "empty provider segment", + config: &config.ModelConfig{Model: "/gpt-4o"}, + wantProtocol: "", + wantModelID: "gpt-4o", + }, + { + name: "unknown prefix falls back to openai", + config: &config.ModelConfig{Model: "meta-llama/Llama-3.1-8B-Instruct"}, + wantProtocol: "openai", + wantModelID: "meta-llama/Llama-3.1-8B-Instruct", + }, + { + name: "nil config", + wantProtocol: "", + wantModelID: "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - protocol, modelID := ExtractProtocol(tt.model) + protocol, modelID := ExtractProtocol(tt.config) if protocol != tt.wantProtocol { - t.Errorf("ExtractProtocol(%q) protocol = %q, want %q", tt.model, protocol, tt.wantProtocol) + t.Errorf("ExtractProtocol() protocol = %q, want %q", protocol, tt.wantProtocol) } if modelID != tt.wantModelID { - t.Errorf("ExtractProtocol(%q) modelID = %q, want %q", tt.model, modelID, tt.wantModelID) + t.Errorf("ExtractProtocol() modelID = %q, want %q", modelID, tt.wantModelID) } }) } @@ -83,9 +132,9 @@ func TestCreateProviderFromConfig_OpenAI(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-openai", Model: "openai/gpt-4o", - APIKey: "test-key", APIBase: "https://api.example.com/v1", } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -99,13 +148,59 @@ func TestCreateProviderFromConfig_OpenAI(t *testing.T) { } } +func TestCreateProviderFromConfig_UsesExplicitProvider(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-explicit-provider", + Model: "z-ai/glm-5.1", + Provider: "nvidia", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "z-ai/glm-5.1" { + t.Fatalf("modelID = %q, want z-ai/glm-5.1", modelID) + } + if got := ResolveAPIBase(cfg); got != "https://integrate.api.nvidia.com/v1" { + t.Fatalf("ResolveAPIBase() = %q, want NVIDIA default API base", got) + } +} + +func TestCreateProviderFromConfig_PreservesExplicitProviderPrefixedModel(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-openai", + Provider: "openai", + Model: "openai/gpt-4o", + APIBase: "https://api.example.com/v1", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "openai/gpt-4o" { + t.Fatalf("modelID = %q, want %q", modelID, "openai/gpt-4o") + } +} + func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { tests := []struct { name string protocol string }{ {"openai", "openai"}, + {"venice", "venice"}, {"groq", "groq"}, + {"novita", "novita"}, {"openrouter", "openrouter"}, {"cerebras", "cerebras"}, {"vivgrid", "vivgrid"}, @@ -113,6 +208,10 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { {"vllm", "vllm"}, {"deepseek", "deepseek"}, {"ollama", "ollama"}, + {"lmstudio", "lmstudio"}, + {"longcat", "longcat"}, + {"modelscope", "modelscope"}, + {"mimo", "mimo"}, } for _, tt := range tests { @@ -120,8 +219,8 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-" + tt.protocol, Model: tt.protocol + "/test-model", - APIKey: "test-key", } + cfg.SetAPIKey("test-key") provider, _, err := CreateProviderFromConfig(cfg) if err != nil { @@ -142,13 +241,25 @@ func TestGetDefaultAPIBase_LiteLLM(t *testing.T) { } } +func TestGetDefaultAPIBase_LMStudio(t *testing.T) { + if got := getDefaultAPIBase("lmstudio"); got != "http://localhost:1234/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "lmstudio", got, "http://localhost:1234/v1") + } +} + +func TestGetDefaultAPIBase_Venice(t *testing.T) { + if got := getDefaultAPIBase("venice"); got != "https://api.venice.ai/api/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "venice", got, "https://api.venice.ai/api/v1") + } +} + func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-litellm", Model: "litellm/my-proxy-alias", - APIKey: "test-key", APIBase: "http://localhost:4000/v1", } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -162,12 +273,222 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { } } +func TestCreateProviderFromConfig_LocalProviders(t *testing.T) { + tests := []struct { + name string + modelName string + model string + apiKey string + wantModelID string + }{ + { + name: "LMStudio with API key", + modelName: "test-lmstudio", + model: "lmstudio/openai/gpt-oss-20b", + apiKey: "test-key", + wantModelID: "openai/gpt-oss-20b", + }, + { + name: "LMStudio without API key", + modelName: "test-lmstudio", + model: "lmstudio/openai/gpt-oss-20b", + apiKey: "", + wantModelID: "openai/gpt-oss-20b", + }, + { + name: "Ollama with API key", + modelName: "test-ollama", + model: "ollama/llama3.1:8b", + apiKey: "test-key", + wantModelID: "llama3.1:8b", + }, + { + name: "Ollama without API key", + modelName: "test-ollama", + model: "ollama/llama3.1:8b", + apiKey: "", + wantModelID: "llama3.1:8b", + }, + { + name: "VLLM with API key", + modelName: "test-vllm", + model: "vllm/Qwen/Qwen3-8B", + apiKey: "test-key", + wantModelID: "Qwen/Qwen3-8B", + }, + { + name: "VLLM without API key", + modelName: "test-vllm", + model: "vllm/Qwen/Qwen3-8B", + apiKey: "", + wantModelID: "Qwen/Qwen3-8B", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: tt.modelName, + Model: tt.model, + } + if tt.apiKey != "" { + cfg.SetAPIKey(tt.apiKey) + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != tt.wantModelID { + t.Errorf("modelID = %q, want %q", modelID, tt.wantModelID) + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } + }) + } +} + +func TestCreateProviderFromConfig_LongCat(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-longcat", + Model: "longcat/LongCat-Flash-Thinking", + APIBase: "https://api.longcat.chat/openai", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "LongCat-Flash-Thinking" { + t.Errorf("modelID = %q, want %q", modelID, "LongCat-Flash-Thinking") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + +func TestCreateProviderFromConfig_ModelScope(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-modelscope", + Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507", + APIBase: "https://api-inference.modelscope.cn/v1", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "Qwen/Qwen3-235B-A22B-Instruct-2507" { + t.Errorf("modelID = %q, want %q", modelID, "Qwen/Qwen3-235B-A22B-Instruct-2507") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + +func TestGetDefaultAPIBase_ModelScope(t *testing.T) { + if got := getDefaultAPIBase("modelscope"); got != "https://api-inference.modelscope.cn/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "modelscope", got, "https://api-inference.modelscope.cn/v1") + } +} + +func TestCreateProviderFromConfig_Novita(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-novita", + Model: "novita/deepseek/deepseek-v3.2", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "deepseek/deepseek-v3.2" { + t.Errorf("modelID = %q, want %q", modelID, "deepseek/deepseek-v3.2") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + +func TestGetDefaultAPIBase_Novita(t *testing.T) { + if got := getDefaultAPIBase("novita"); got != "https://api.novita.ai/openai" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "novita", got, "https://api.novita.ai/openai") + } +} + +func TestCreateProviderFromConfig_Mimo(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-mimo", + Model: "mimo/mimo-v2-pro", + APIBase: "https://api.xiaomimimo.com/v1", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "mimo-v2-pro" { + t.Errorf("modelID = %q, want %q", modelID, "mimo-v2-pro") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + +func TestCreateProviderFromConfig_Venice(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-venice", + Model: "venice/venice-uncensored", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "venice-uncensored" { + t.Errorf("modelID = %q, want %q", modelID, "venice-uncensored") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + +func TestGetDefaultAPIBase_Mimo(t *testing.T) { + if got := getDefaultAPIBase("mimo"); got != "https://api.xiaomimimo.com/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "mimo", got, "https://api.xiaomimimo.com/v1") + } +} + func TestCreateProviderFromConfig_Anthropic(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-anthropic", Model: "anthropic/claude-sonnet-4.6", - APIKey: "test-key", } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -199,6 +520,62 @@ func TestCreateProviderFromConfig_Antigravity(t *testing.T) { } } +func TestCreateProviderFromConfig_Gemini(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-gemini", + Model: "gemini/gemini-2.5-flash", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "gemini-2.5-flash" { + t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash") + } + if _, ok := provider.(*GeminiProvider); !ok { + t.Fatalf("expected *GeminiProvider, got %T", provider) + } +} + +func TestCreateProviderFromConfig_GeminiMissingAPIKey(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-gemini-no-key", + Model: "gemini/gemini-2.5-flash", + } + + _, _, err := CreateProviderFromConfig(cfg) + if err == nil { + t.Fatal("CreateProviderFromConfig() expected error for missing gemini API key") + } +} + +func TestCreateProviderFromConfig_GeminiCustomAPIBaseWithoutKey(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-gemini-custom-base", + Model: "gemini/gemini-2.5-flash", + APIBase: "https://proxy.example.com/v1beta", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "gemini-2.5-flash" { + t.Errorf("modelID = %q, want %q", modelID, "gemini-2.5-flash") + } + if _, ok := provider.(*GeminiProvider); !ok { + t.Fatalf("expected *GeminiProvider, got %T", provider) + } +} + func TestCreateProviderFromConfig_ClaudeCLI(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-claude-cli", @@ -235,6 +612,41 @@ func TestCreateProviderFromConfig_CodexCLI(t *testing.T) { } } +func TestCreateProviderFromConfig_OpenAIMixedCaseAuthMethodUsesOAuthBranch(t *testing.T) { + origGetCredential := getCredential + getCredential = func(provider string) (*auth.AuthCredential, error) { + if provider != "openai" { + t.Fatalf("provider = %q, want %q", provider, "openai") + } + return &auth.AuthCredential{ + AccessToken: "test-token", + AccountID: "acct-test", + Provider: "openai", + AuthMethod: "oauth", + }, nil + } + t.Cleanup(func() { + getCredential = origGetCredential + }) + + cfg := &config.ModelConfig{ + ModelName: "test-openai-oauth", + Model: "openai/gpt-5.4", + AuthMethod: "OAuth", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "gpt-5.4" { + t.Errorf("modelID = %q, want %q", modelID, "gpt-5.4") + } +} + func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-no-key", @@ -249,10 +661,11 @@ func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) { func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { cfg := &config.ModelConfig{ - ModelName: "test-unknown", - Model: "unknown-protocol/model", - APIKey: "test-key", + ModelName: "test-unknown-provider", + Provider: "unknown-protocol", + Model: "model", } + cfg.SetAPIKey("test-key") _, _, err := CreateProviderFromConfig(cfg) if err == nil { @@ -260,6 +673,26 @@ func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { } } +func TestCreateProviderFromConfig_UnknownModelPrefixDefaultsToOpenAI(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-unknown-model-prefix", + Model: "meta-llama/Llama-3.1-8B-Instruct", + APIBase: "https://api.example.com/v1", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("modelID = %q, want full model ID", modelID) + } +} + func TestCreateProviderFromConfig_NilConfig(t *testing.T) { _, _, err := CreateProviderFromConfig(nil) if err == nil { @@ -293,6 +726,7 @@ func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) { APIBase: server.URL, RequestTimeout: 1, } + cfg.SetAPIKey("test-key") provider, modelID, err := CreateProviderFromConfig(cfg) if err != nil { @@ -317,3 +751,621 @@ func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) { t.Fatalf("Chat() error = %q, want timeout-related error", errMsg) } } + +func TestCreateProviderFromConfig_Azure(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "azure-gpt5", + Model: "azure/my-gpt5-deployment", + APIBase: "https://my-resource.openai.azure.com", + } + cfg.SetAPIKey("test-azure-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "my-gpt5-deployment" { + t.Errorf("modelID = %q, want %q", modelID, "my-gpt5-deployment") + } +} + +func TestCreateProviderFromConfig_AzureOpenAIAlias(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "azure-gpt4", + Model: "azure-openai/my-deployment", + APIBase: "https://my-resource.openai.azure.com", + } + cfg.SetAPIKey("test-azure-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "my-deployment" { + t.Errorf("modelID = %q, want %q", modelID, "my-deployment") + } +} + +func TestCreateProviderFromConfig_AzureMissingAPIKey(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "azure-gpt5", + Model: "azure/my-gpt5-deployment", + APIBase: "https://my-resource.openai.azure.com", + } + + _, _, err := CreateProviderFromConfig(cfg) + if err == nil { + t.Fatal("CreateProviderFromConfig() expected error for missing API key") + } +} + +func TestCreateProviderFromConfig_AzureMissingAPIBase(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "azure-gpt5", + Model: "azure/my-gpt5-deployment", + } + cfg.SetAPIKey("test-azure-key") + + _, _, err := CreateProviderFromConfig(cfg) + if err == nil { + t.Fatal("CreateProviderFromConfig() expected error for missing API base") + } +} + +func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) { + tests := []struct { + name string + protocol string + }{ + {"qwen-international", "qwen-international"}, + {"dashscope-intl", "dashscope-intl"}, + {"qwen-intl", "qwen-intl"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-" + tt.protocol, + Model: tt.protocol + "/qwen-max", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + wantModelID := "qwen-max" + if modelID != wantModelID { + t.Errorf("modelID = %q, want %q", modelID, wantModelID) + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } + }) + } +} + +func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) { + tests := []struct { + name string + protocol string + }{ + {"qwen-us", "qwen-us"}, + {"dashscope-us", "dashscope-us"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-" + tt.protocol, + Model: tt.protocol + "/qwen-max", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + wantModelID := "qwen-max" + if modelID != wantModelID { + t.Errorf("modelID = %q, want %q", modelID, wantModelID) + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } + }) + } +} + +func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) { + tests := []struct { + name string + protocol string + }{ + {"coding-plan-anthropic", "coding-plan-anthropic"}, + {"alibaba-coding-anthropic", "alibaba-coding-anthropic"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-" + tt.protocol, + Model: tt.protocol + "/claude-sonnet-4-20250514", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + wantModelID := "claude-sonnet-4-20250514" + if modelID != wantModelID { + t.Errorf("modelID = %q, want %q", modelID, wantModelID) + } + // coding-plan-anthropic uses Anthropic Messages provider + // Verify it's the anthropic messages provider by checking interface + var _ LLMProvider = provider + }) + } +} + +func TestGetDefaultAPIBase_CodingPlanAnthropic(t *testing.T) { + expectedURL := "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" + if got := getDefaultAPIBase("coding-plan-anthropic"); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "coding-plan-anthropic", got, expectedURL) + } + if got := getDefaultAPIBase("alibaba-coding-anthropic"); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "alibaba-coding-anthropic", got, expectedURL) + } +} + +func TestGetDefaultAPIBase_QwenIntlAliases(t *testing.T) { + expectedURL := "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + for _, protocol := range []string{"qwen-intl", "qwen-international", "dashscope-intl"} { + if got := getDefaultAPIBase(protocol); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", protocol, got, expectedURL) + } + } +} + +func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) { + expectedURL := "https://dashscope-us.aliyuncs.com/compatible-mode/v1" + for _, protocol := range []string{"qwen-us", "dashscope-us"} { + if got := getDefaultAPIBase(protocol); got != expectedURL { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", protocol, got, expectedURL) + } + } +} + +func TestModelProviderOptions(t *testing.T) { + options := ModelProviderOptions() + if len(options) == 0 { + t.Fatal("ModelProviderOptions() returned no options") + } + + seen := make(map[string]ModelProviderOption, len(options)) + for _, option := range options { + seen[option.ID] = option + } + + if _, ok := seen["openai"]; !ok { + t.Fatal("openai option missing") + } + if option, ok := seen["openai"]; ok && !option.CreateAllowed { + t.Fatal("openai should be creatable") + } + if option, ok := seen["lmstudio"]; !ok { + t.Fatal("lmstudio option missing") + } else if !option.EmptyAPIKeyAllowed { + t.Fatal("lmstudio should allow empty API keys") + } + if option, ok := seen["anthropic"]; !ok { + t.Fatal("anthropic option missing") + } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" { + t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1") + } + if _, ok := seen["azure"]; !ok { + t.Fatal("azure option missing") + } + if option, ok := seen["bedrock"]; !ok { + t.Fatal("bedrock option missing") + } else if !option.CreateAllowed { + t.Fatal("bedrock should be creatable and defer credential/build errors to runtime") + } + if option, ok := seen["elevenlabs"]; !ok { + t.Fatal("elevenlabs option missing") + } else { + if option.DefaultAPIBase != "https://api.elevenlabs.io" { + t.Fatalf("elevenlabs default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.elevenlabs.io") + } + if option.DefaultModelAllowed { + t.Fatal("elevenlabs should be ASR-only and therefore not allowed as a default chat model") + } + } + if option, ok := seen["antigravity"]; !ok { + t.Fatal("antigravity option missing") + } else { + if !option.CreateAllowed { + t.Fatal("antigravity should be creatable") + } + if option.DefaultAuthMethod != "oauth" { + t.Fatalf("antigravity default_auth_method = %q, want %q", option.DefaultAuthMethod, "oauth") + } + if !option.AuthMethodLocked { + t.Fatal("antigravity auth method should be locked") + } + } + if option, ok := seen["github-copilot"]; !ok { + t.Fatal("github-copilot option missing") + } else if option.DefaultAPIBase != "localhost:4321" { + t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") + } +} + +func TestCreateProviderFromConfig_MinimaxInjectsReasoningSplit(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + cfg := &config.ModelConfig{ + ModelName: "test-minimax", + Model: "minimax/MiniMax-M2.5", + APIBase: server.URL, + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "MiniMax-M2.5" { + t.Errorf("modelID = %q, want %q", modelID, "MiniMax-M2.5") + } + + _, err = provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + modelID, + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // Verify reasoning_split is automatically injected + if got, ok := requestBody["reasoning_split"]; !ok || got != true { + t.Fatalf("reasoning_split = %v, want true", got) + } +} + +func TestCreateProviderFromConfig_MinimaxPreservesUserExtraBody(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + cfg := &config.ModelConfig{ + ModelName: "test-minimax-custom", + Model: "minimax/MiniMax-M2.5", + APIBase: server.URL, + ExtraBody: map[string]any{"custom_field": "test"}, + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + + _, err = provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + modelID, + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // Verify reasoning_split is automatically injected + if got, ok := requestBody["reasoning_split"]; !ok || got != true { + t.Fatalf("reasoning_split = %v, want true", got) + } + // Verify user's custom field is preserved + if got, ok := requestBody["custom_field"]; !ok || got != "test" { + t.Fatalf("custom_field = %v, want test", got) + } +} + +func TestCreateProviderFromConfig_CustomHeaders(t *testing.T) { + var gotSource, gotAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotSource = r.Header.Get("X-Source") + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer server.Close() + + cfg := &config.ModelConfig{ + ModelName: "test-headers", + Model: "openai/gpt-4o", + APIBase: server.URL, + CustomHeaders: map[string]string{"X-Source": "coding-plan", "Authorization": "Token config-auth"}, + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + + _, err = provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + modelID, + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if gotSource != "coding-plan" { + t.Fatalf("X-Source = %q, want %q", gotSource, "coding-plan") + } + if gotAuth != "Token config-auth" { + t.Fatalf("Authorization = %q, want %q", gotAuth, "Token config-auth") + } +} + +// openaiCompatResponse is the JSON response used by OpenAI-compatible providers. +const openaiCompatResponse = `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}` + +// anthropicResponse is the JSON response used by Anthropic providers. +const anthropicResponse = `{"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","model":"claude-sonnet-4-20250514","usage":{"input_tokens":10,"output_tokens":5}}` + +func TestCreateProviderFromConfig_UserAgent(t *testing.T) { + defaultUA := "PicoClaw/" + config.Version + + tests := []struct { + name string + model string + userAgent string + apiKey string + response string + wantUA string + chatOpts map[string]any + }{ + { + name: "openai default user agent", + model: "openai/gpt-4o", + apiKey: "test-key", + response: openaiCompatResponse, + wantUA: defaultUA, + }, + { + name: "openai custom user agent", + model: "openai/gpt-4o", + apiKey: "test-key", + userAgent: "MyAgent/1.2.3", + response: openaiCompatResponse, + wantUA: "MyAgent/1.2.3", + }, + { + name: "anthropic default user agent", + model: "anthropic/claude-sonnet-4-20250514", + apiKey: "test-key", + response: anthropicResponse, + wantUA: defaultUA, + }, + { + name: "anthropic-messages default user agent", + model: "anthropic-messages/claude-sonnet-4-20250514", + apiKey: "test-key", + response: anthropicResponse, + wantUA: defaultUA, + chatOpts: map[string]any{"max_tokens": 1024}, + }, + { + name: "azure default user agent", + model: "azure/my-deployment", + apiKey: "test-azure-key", + response: openaiCompatResponse, + wantUA: defaultUA, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var receivedUA string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedUA = r.Header.Get("User-Agent") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tt.response)) + })) + defer server.Close() + + cfg := &config.ModelConfig{ + ModelName: "test-ua-" + tt.name, + Model: tt.model, + APIBase: server.URL, + UserAgent: tt.userAgent, + } + cfg.SetAPIKey(tt.apiKey) + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + + _, err = provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + modelID, + tt.chatOpts, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if receivedUA != tt.wantUA { + t.Errorf("User-Agent = %q, want %q", receivedUA, tt.wantUA) + } + }) + } +} + +func TestCreateProviderFromConfig_Bedrock(t *testing.T) { + // Set dummy AWS env vars to make test deterministic + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + // Clear profile-related env vars to avoid loading shared config + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_PROFILE", "") + t.Setenv("AWS_SDK_LOAD_CONFIG", "") + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "") + + cfg := &config.ModelConfig{ + ModelName: "bedrock-claude", + Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", + APIBase: "us-west-2", // Region (also sets AWS region) + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err == nil { + // Provider created successfully (built with -tags bedrock) + if provider == nil { + t.Error("provider is nil on success") + } + if modelID != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Errorf("modelID = %q, want %q", modelID, "us.anthropic.claude-sonnet-4-20250514-v1:0") + } + return + } + errMsg := err.Error() + // When built without -tags bedrock, expect stub error + if strings.Contains(errMsg, "build with -tags bedrock") { + return // Expected stub error + } + // Unexpected error - fail the test + t.Errorf("unexpected error from bedrock provider: %v", err) +} + +func TestCreateProviderFromConfig_BedrockWithEndpointURL(t *testing.T) { + // Set dummy AWS env vars to make test deterministic + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") + t.Setenv("AWS_REGION", "us-east-1") // Required when using endpoint URL + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + // Clear profile-related env vars to avoid loading shared config + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_DEFAULT_PROFILE", "") + t.Setenv("AWS_SDK_LOAD_CONFIG", "") + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "") + + cfg := &config.ModelConfig{ + ModelName: "bedrock-claude", + Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", + APIBase: "https://bedrock-runtime.us-east-1.amazonaws.com", // Full endpoint URL + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err == nil { + // Provider created successfully (built with -tags bedrock) + if provider == nil { + t.Error("provider is nil on success") + } + if modelID != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Errorf("modelID = %q, want %q", modelID, "us.anthropic.claude-sonnet-4-20250514-v1:0") + } + return + } + errMsg := err.Error() + // When built without -tags bedrock, expect stub error + if strings.Contains(errMsg, "build with -tags bedrock") { + return // Expected stub error + } + // Unexpected error - fail the test + t.Errorf("unexpected error from bedrock provider: %v", err) +} + +func TestCreateProviderFromConfig_ToolSchemaTransformWrapsProvider(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "claude-cli-test", + Provider: "claude-cli", + Model: "claude-sonnet-4.6", + Workspace: t.TempDir(), + ToolSchemaTransform: "simple", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if modelID != "claude-sonnet-4.6" { + t.Fatalf("modelID = %q, want %q", modelID, "claude-sonnet-4.6") + } + if _, ok := provider.(*toolSchemaTransformProvider); !ok { + t.Fatalf("provider = %T, want *toolSchemaTransformProvider", provider) + } +} + +func TestCreateProviderFromConfig_InvalidToolSchemaTransform(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "claude-cli-test", + Provider: "claude-cli", + Model: "claude-sonnet-4.6", + Workspace: t.TempDir(), + ToolSchemaTransform: "invalid", + } + + _, _, err := CreateProviderFromConfig(cfg) + if err == nil { + t.Fatal("CreateProviderFromConfig() expected error for invalid tool_schema_transform") + } + if !strings.Contains(err.Error(), "tool_schema_transform") { + t.Fatalf("error = %v, want mention tool_schema_transform", err) + } +} diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go index 36ccda4a1..b99f5baf9 100644 --- a/pkg/providers/factory_test.go +++ b/pkg/providers/factory_test.go @@ -1,242 +1,22 @@ package providers import ( - "strings" "testing" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) -func TestResolveProviderSelection(t *testing.T) { - tests := []struct { - name string - setup func(*config.Config) - wantType providerType - wantAPIBase string - wantProxy string - wantErrSubstr string - }{ - { - name: "explicit litellm provider uses configured base", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "litellm" - cfg.Providers.LiteLLM.APIKey = "litellm-key" - cfg.Providers.LiteLLM.APIBase = "http://localhost:4000/v1" - cfg.Providers.LiteLLM.Proxy = "http://127.0.0.1:7890" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "http://localhost:4000/v1", - wantProxy: "http://127.0.0.1:7890", - }, - { - name: "explicit litellm provider defaults base when only key is configured", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "litellm" - cfg.Providers.LiteLLM.APIKey = "litellm-key" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "http://localhost:4000/v1", - }, - { - name: "explicit claude-cli provider routes to cli provider type", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "claude-cli" - cfg.Agents.Defaults.Workspace = "/tmp/ws" - }, - wantType: providerTypeClaudeCLI, - }, - { - name: "explicit copilot provider routes to github copilot type", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "copilot" - }, - wantType: providerTypeGitHubCopilot, - wantAPIBase: "localhost:4321", - }, - { - name: "explicit deepseek provider uses deepseek defaults", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "deepseek" - cfg.Agents.Defaults.Model = "deepseek/deepseek-chat" - cfg.Providers.DeepSeek.APIKey = "deepseek-key" - cfg.Providers.DeepSeek.Proxy = "http://127.0.0.1:7890" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://api.deepseek.com/v1", - wantProxy: "http://127.0.0.1:7890", - }, - { - name: "explicit shengsuanyun provider uses defaults", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "shengsuanyun" - cfg.Providers.ShengSuanYun.APIKey = "ssy-key" - cfg.Providers.ShengSuanYun.Proxy = "http://127.0.0.1:7890" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://router.shengsuanyun.com/api/v1", - wantProxy: "http://127.0.0.1:7890", - }, - { - name: "explicit nvidia provider uses defaults", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "nvidia" - cfg.Providers.Nvidia.APIKey = "nvapi-test" - cfg.Providers.Nvidia.Proxy = "http://127.0.0.1:7890" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://integrate.api.nvidia.com/v1", - wantProxy: "http://127.0.0.1:7890", - }, - { - name: "explicit vivgrid provider uses defaults", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "vivgrid" - cfg.Providers.Vivgrid.APIKey = "vivgrid-key" - cfg.Providers.Vivgrid.Proxy = "http://127.0.0.1:7890" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://api.vivgrid.com/v1", - wantProxy: "http://127.0.0.1:7890", - }, - { - name: "openrouter model uses openrouter defaults", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "openrouter/auto" - cfg.Providers.OpenRouter.APIKey = "sk-or-test" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://openrouter.ai/api/v1", - }, - { - name: "anthropic oauth routes to claude auth provider", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "claude-sonnet-4.6" - cfg.Providers.Anthropic.AuthMethod = "oauth" - }, - wantType: providerTypeClaudeAuth, - }, - { - name: "openai oauth routes to codex auth provider", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "gpt-4o" - cfg.Providers.OpenAI.AuthMethod = "oauth" - }, - wantType: providerTypeCodexAuth, - }, - { - name: "openai codex-cli auth routes to codex cli token provider", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "gpt-4o" - cfg.Providers.OpenAI.AuthMethod = "codex-cli" - }, - wantType: providerTypeCodexCLIToken, - }, - { - name: "explicit codex-code provider routes to codex cli provider type", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Provider = "codex-code" - cfg.Agents.Defaults.Workspace = "/tmp/ws" - }, - wantType: providerTypeCodexCLI, - }, - { - name: "zhipu model uses zhipu base default", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "glm-4.7" - cfg.Providers.Zhipu.APIKey = "zhipu-key" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://open.bigmodel.cn/api/paas/v4", - }, - { - name: "groq model uses groq base default", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "groq/llama-3.3-70b" - cfg.Providers.Groq.APIKey = "gsk-key" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://api.groq.com/openai/v1", - }, - { - name: "ollama model uses ollama base default", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "ollama/qwen2.5:14b" - cfg.Providers.Ollama.APIKey = "ollama-key" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "http://localhost:11434/v1", - }, - { - name: "moonshot model keeps proxy and default base", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "moonshot/kimi-k2.5" - cfg.Providers.Moonshot.APIKey = "moonshot-key" - cfg.Providers.Moonshot.Proxy = "http://127.0.0.1:7890" - }, - wantType: providerTypeHTTPCompat, - wantAPIBase: "https://api.moonshot.cn/v1", - wantProxy: "http://127.0.0.1:7890", - }, - { - name: "missing keys returns model config error", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "custom-model" - }, - wantErrSubstr: "no API key configured for model", - }, - { - name: "openrouter prefix without key returns provider key error", - setup: func(cfg *config.Config) { - cfg.Agents.Defaults.Model = "openrouter/auto" - }, - wantErrSubstr: "no API key configured for provider", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := config.DefaultConfig() - tt.setup(cfg) - - got, err := resolveProviderSelection(cfg) - if tt.wantErrSubstr != "" { - if err == nil { - t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr) - } - if !strings.Contains(err.Error(), tt.wantErrSubstr) { - t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErrSubstr) - } - return - } - - if err != nil { - t.Fatalf("resolveProviderSelection() error = %v", err) - } - if got.providerType != tt.wantType { - t.Fatalf("providerType = %v, want %v", got.providerType, tt.wantType) - } - if tt.wantAPIBase != "" && got.apiBase != tt.wantAPIBase { - t.Fatalf("apiBase = %q, want %q", got.apiBase, tt.wantAPIBase) - } - if tt.wantProxy != "" && got.proxy != tt.wantProxy { - t.Fatalf("proxy = %q, want %q", got.proxy, tt.wantProxy) - } - }) - } -} - func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) { cfg := config.DefaultConfig() - cfg.Agents.Defaults.Model = "test-openrouter" - cfg.ModelList = []config.ModelConfig{ - { - ModelName: "test-openrouter", - Model: "openrouter/auto", - APIKey: "sk-or-test", - APIBase: "https://openrouter.ai/api/v1", - }, + cfg.Agents.Defaults.ModelName = "test-openrouter" + modelCfg := &config.ModelConfig{ + ModelName: "test-openrouter", + Model: "openrouter/auto", + APIBase: "https://openrouter.ai/api/v1", } + modelCfg.SetAPIKey("sk-or-test") + cfg.ModelList = []*config.ModelConfig{modelCfg} provider, _, err := CreateProvider(cfg) if err != nil { @@ -250,8 +30,8 @@ func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) { func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) { cfg := config.DefaultConfig() - cfg.Agents.Defaults.Model = "test-codex" - cfg.ModelList = []config.ModelConfig{ + cfg.Agents.Defaults.ModelName = "test-codex" + cfg.ModelList = []*config.ModelConfig{ { ModelName: "test-codex", Model: "codex-cli/codex-model", @@ -271,8 +51,8 @@ func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) { func TestCreateProviderReturnsClaudeCliProviderForClaudeCli(t *testing.T) { cfg := config.DefaultConfig() - cfg.Agents.Defaults.Model = "test-claude-cli" - cfg.ModelList = []config.ModelConfig{ + cfg.Agents.Defaults.ModelName = "test-claude-cli" + cfg.ModelList = []*config.ModelConfig{ { ModelName: "test-claude-cli", Model: "claude-cli/claude-sonnet", @@ -304,8 +84,8 @@ func TestCreateProviderReturnsClaudeProviderForAnthropicOAuth(t *testing.T) { } cfg := config.DefaultConfig() - cfg.Agents.Defaults.Model = "test-claude-oauth" - cfg.ModelList = []config.ModelConfig{ + cfg.Agents.Defaults.ModelName = "test-claude-oauth" + cfg.ModelList = []*config.ModelConfig{ { ModelName: "test-claude-oauth", Model: "anthropic/claude-sonnet-4.6", diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go index 7ba563b66..36092105b 100644 --- a/pkg/providers/fallback.go +++ b/pkg/providers/fallback.go @@ -10,12 +10,24 @@ import ( // FallbackChain orchestrates model fallback across multiple candidates. type FallbackChain struct { cooldown *CooldownTracker + rl *RateLimiterRegistry } // FallbackCandidate represents one model/provider to try. type FallbackCandidate struct { - Provider string - Model string + Provider string + Model string + RPM int // requests per minute; 0 means unrestricted + IdentityKey string // optional stable config identity for cooldown/rate limiting +} + +// StableKey returns the candidate's config-level identity when available, +// otherwise it falls back to the runtime provider/model key. +func (c FallbackCandidate) StableKey() string { + if key := strings.TrimSpace(c.IdentityKey); key != "" { + return key + } + return ModelKey(c.Provider, c.Model) } // FallbackResult contains the successful response and metadata about all attempts. @@ -36,9 +48,10 @@ type FallbackAttempt struct { Skipped bool // true if skipped due to cooldown } -// NewFallbackChain creates a new fallback chain with the given cooldown tracker. -func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain { - return &FallbackChain{cooldown: cooldown} +// NewFallbackChain creates a new fallback chain with the given cooldown tracker +// and rate limiter registry. +func NewFallbackChain(cooldown *CooldownTracker, rl *RateLimiterRegistry) *FallbackChain { + return &FallbackChain{cooldown: cooldown, rl: rl} } // ResolveCandidates parses model config into a deduplicated candidate list. @@ -117,23 +130,52 @@ func (fc *FallbackChain) Execute( return nil, context.Canceled } - // Check cooldown. - if !fc.cooldown.IsAvailable(candidate.Provider) { - remaining := fc.cooldown.CooldownRemaining(candidate.Provider) + // Check cooldown per stable candidate identity, not just provider/model. + // This allows aliases and multi-key configs to fail over independently. + cooldownKey := candidate.StableKey() + if !fc.cooldown.IsAvailable(cooldownKey) { + remaining := fc.cooldown.CooldownRemaining(cooldownKey) result.Attempts = append(result.Attempts, FallbackAttempt{ Provider: candidate.Provider, Model: candidate.Model, Skipped: true, Reason: FailoverRateLimit, Error: fmt.Errorf( - "provider %s in cooldown (%s remaining)", - candidate.Provider, + "%s in cooldown (%s remaining)", + cooldownKey, remaining.Round(time.Second), ), }) continue } + // Enforce per-candidate rate limit before calling the provider. + // If this candidate is locally saturated, try other candidates first. + if fc.rl != nil { + if !fc.rl.TryAcquire(cooldownKey) { + if i < len(candidates)-1 { + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Skipped: true, + Reason: FailoverRateLimit, + Error: fmt.Errorf("%s waiting for local rate limit token", cooldownKey), + }) + continue + } + if waitErr := fc.rl.Wait(ctx, cooldownKey); waitErr != nil { + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Skipped: true, + Reason: FailoverRateLimit, + Error: waitErr, + }) + return nil, waitErr + } + } + } + // Execute the run function. start := time.Now() resp, err := run(ctx, candidate.Provider, candidate.Model) @@ -141,7 +183,7 @@ func (fc *FallbackChain) Execute( if err == nil { // Success. - fc.cooldown.MarkSuccess(candidate.Provider) + fc.cooldown.MarkSuccess(cooldownKey) result.Response = resp result.Provider = candidate.Provider result.Model = candidate.Model @@ -187,7 +229,7 @@ func (fc *FallbackChain) Execute( } // Retriable error: mark failure and continue to next candidate. - fc.cooldown.MarkFailure(candidate.Provider, failErr.Reason) + fc.cooldown.MarkFailure(cooldownKey, failErr.Reason) result.Attempts = append(result.Attempts, FallbackAttempt{ Provider: candidate.Provider, Model: candidate.Model, @@ -227,6 +269,34 @@ func (fc *FallbackChain) ExecuteImage( return nil, context.Canceled } + // Enforce per-candidate rate limit before calling the provider. + // If this candidate is locally saturated, try other candidates first. + imageKey := candidate.StableKey() + if fc.rl != nil { + if !fc.rl.TryAcquire(imageKey) { + if i < len(candidates)-1 { + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Skipped: true, + Reason: FailoverRateLimit, + Error: fmt.Errorf("%s waiting for local rate limit token", imageKey), + }) + continue + } + if waitErr := fc.rl.Wait(ctx, imageKey); waitErr != nil { + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Skipped: true, + Reason: FailoverRateLimit, + Error: waitErr, + }) + return nil, waitErr + } + } + } + start := time.Now() resp, err := run(ctx, candidate.Provider, candidate.Model) elapsed := time.Since(start) diff --git a/pkg/providers/fallback_multikey_test.go b/pkg/providers/fallback_multikey_test.go new file mode 100644 index 000000000..10481ec61 --- /dev/null +++ b/pkg/providers/fallback_multikey_test.go @@ -0,0 +1,384 @@ +package providers + +import ( + "context" + "errors" + "testing" +) + +// TestMultiKeyFailover tests the complete failover flow with multiple API keys. +// This simulates the config expansion scenario where api_keys: ["key1", "key2", "key3"] +// is expanded into primary + fallbacks. +func TestMultiKeyFailover(t *testing.T) { + // Simulate expanded config: primary with 2 fallbacks + // This is what ExpandMultiKeyModels would produce for api_keys: ["key1", "key2", "key3"] + cfg := ModelConfig{ + Primary: "glm-4.7", + Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"}, + } + + candidates := ResolveCandidates(cfg, "zhipu") + + if len(candidates) != 3 { + t.Fatalf("expected 3 candidates, got %d: %v", len(candidates), candidates) + } + + // Create fallback chain + cooldown := NewCooldownTracker() + chain := NewFallbackChain(cooldown, nil) + + // Mock run function: first call fails with 429, second succeeds + callCount := 0 + mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + callCount++ + if callCount == 1 { + // First call: simulate rate limit + return nil, errors.New("http error: status 429 - rate limit exceeded") + } + // Second call: success + return &LLMResponse{ + Content: "Hello from key2!", + }, nil + } + + // Execute fallback chain + result, err := chain.Execute(context.Background(), candidates, mockRun) + if err != nil { + t.Fatalf("expected success after failover, got error: %v", err) + } + + if result == nil { + t.Fatal("expected result, got nil") + } + + if result.Response.Content != "Hello from key2!" { + t.Errorf("expected response from key2, got: %s", result.Response.Content) + } + + if callCount != 2 { + t.Errorf("expected 2 calls (1 fail + 1 success), got %d", callCount) + } + + // Verify first attempt was recorded + if len(result.Attempts) != 1 { + t.Errorf("expected 1 failed attempt recorded, got %d", len(result.Attempts)) + } + + if result.Attempts[0].Reason != FailoverRateLimit { + t.Errorf( + "expected first attempt reason to be rate_limit, got: %s", + result.Attempts[0].Reason, + ) + } +} + +// TestMultiKeyFailoverAllFail tests when all keys hit rate limit +func TestMultiKeyFailoverAllFail(t *testing.T) { + cfg := ModelConfig{ + Primary: "glm-4.7", + Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"}, + } + + candidates := ResolveCandidates(cfg, "zhipu") + + cooldown := NewCooldownTracker() + chain := NewFallbackChain(cooldown, nil) + + // Mock run function: all calls fail with rate limit + callCount := 0 + mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + callCount++ + return nil, errors.New("status: 429 - too many requests") + } + + // Execute fallback chain + result, err := chain.Execute(context.Background(), candidates, mockRun) + + if err == nil { + t.Fatal("expected error when all keys fail, got nil") + } + + if result != nil { + t.Errorf("expected nil result on failure, got: %v", result) + } + + if callCount != 3 { + t.Errorf("expected 3 calls (all fail), got %d", callCount) + } + + // Verify error type + var exhausted *FallbackExhaustedError + if !errors.As(err, &exhausted) { + t.Errorf("expected FallbackExhaustedError, got: %T - %v", err, err) + } + + if len(exhausted.Attempts) != 3 { + t.Errorf("expected 3 attempts in exhausted error, got %d", len(exhausted.Attempts)) + } +} + +// TestMultiKeyFailoverCooldown tests that a key in cooldown is skipped +func TestMultiKeyFailoverCooldown(t *testing.T) { + cfg := ModelConfig{ + Primary: "glm-4.7", + Fallbacks: []string{"glm-4.7__key_1"}, + } + + candidates := ResolveCandidates(cfg, "zhipu") + + cooldown := NewCooldownTracker() + chain := NewFallbackChain(cooldown, nil) + + // Put the first model in cooldown (using ModelKey now, not just provider) + cooldownKey := ModelKey(candidates[0].Provider, candidates[0].Model) + cooldown.MarkFailure(cooldownKey, FailoverRateLimit) + + // Verify it's not available + if cooldown.IsAvailable(cooldownKey) { + t.Fatal("expected first model to be in cooldown") + } + + // Mock run function: only second should be called + callCount := 0 + calledProviders := []string{} + mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + callCount++ + calledProviders = append(calledProviders, provider+"/"+model) + return &LLMResponse{Content: "success"}, nil + } + + result, err := chain.Execute(context.Background(), candidates, mockRun) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + + // First provider should have been skipped + if callCount != 1 { + t.Errorf("expected 1 call (first skipped due to cooldown), got %d", callCount) + } + + // Should have called the second provider/model + if len(calledProviders) != 1 || + calledProviders[0] != candidates[1].Provider+"/"+candidates[1].Model { + t.Errorf("expected second model to be called, got: %v", calledProviders) + } + + // Verify first attempt was recorded as skipped + if len(result.Attempts) != 1 { + t.Fatalf("expected 1 attempt (skipped), got %d", len(result.Attempts)) + } + + if !result.Attempts[0].Skipped { + t.Error("expected first attempt to be marked as skipped") + } +} + +// TestMultiKeyFailoverWithFormatError tests that format errors are non-retriable +func TestMultiKeyFailoverWithFormatError(t *testing.T) { + cfg := ModelConfig{ + Primary: "glm-4.7", + Fallbacks: []string{"glm-4.7__key_1"}, + } + + candidates := ResolveCandidates(cfg, "zhipu") + + cooldown := NewCooldownTracker() + chain := NewFallbackChain(cooldown, nil) + + // Mock run function: first call fails with format error (bad request) + callCount := 0 + mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + callCount++ + return nil, errors.New("invalid request format: tool_use.id missing") + } + + // Execute fallback chain + result, err := chain.Execute(context.Background(), candidates, mockRun) + + if err == nil { + t.Fatal("expected error for format failure, got nil") + } + + // Format errors should NOT trigger failover (non-retriable) + // So we should only have 1 call + if callCount != 1 { + t.Errorf("expected 1 call (format error is non-retriable), got %d", callCount) + } + + // Verify the error is a FailoverError with format reason + var failoverErr *FailoverError + if !errors.As(err, &failoverErr) { + t.Errorf("expected FailoverError, got: %T - %v", err, err) + } + + if failoverErr.Reason != FailoverFormat { + t.Errorf("expected FailoverFormat reason, got: %s", failoverErr.Reason) + } + + _ = result // result should be nil +} + +// TestMultiKeyWithModelFallback tests multi-key failover combined with model fallback. +// This simulates the scenario: api_keys: ["k1", "k2"] + fallbacks: ["minimax"] +// Expected failover order: glm-4.7 (k1) → glm-4.7__key_1 (k2) → minimax +func TestMultiKeyWithModelFallback(t *testing.T) { + // Simulate expanded config from: + // { "model_name": "glm-4.7", "api_keys": ["k1", "k2"], "fallbacks": ["minimax"] } + // After ExpandMultiKeyModels, primaryEntry.Fallbacks = ["glm-4.7__key_1", "minimax"] + // Note: In production, "minimax" would be resolved via model lookup to "minimax/minimax" + // In this test, we use the full format to avoid needing a lookup function. + cfg := ModelConfig{ + Primary: "glm-4.7", + Fallbacks: []string{"glm-4.7__key_1", "minimax/minimax"}, + } + + candidates := ResolveCandidates(cfg, "zhipu") + + // Should have 3 candidates: glm-4.7 (zhipu), glm-4.7__key_1 (zhipu), minimax (minimax) + if len(candidates) != 3 { + t.Fatalf("expected 3 candidates, got %d: %v", len(candidates), candidates) + } + + // Verify candidate order + if candidates[0].Model != "glm-4.7" || candidates[0].Provider != "zhipu" { + t.Errorf( + "expected first candidate to be zhipu/glm-4.7, got: %s/%s", + candidates[0].Provider, + candidates[0].Model, + ) + } + if candidates[1].Model != "glm-4.7__key_1" || candidates[1].Provider != "zhipu" { + t.Errorf( + "expected second candidate to be zhipu/glm-4.7__key_1, got: %s/%s", + candidates[1].Provider, + candidates[1].Model, + ) + } + if candidates[2].Model != "minimax" || candidates[2].Provider != "minimax" { + t.Errorf( + "expected third candidate to be minimax/minimax, got: %s/%s", + candidates[2].Provider, + candidates[2].Model, + ) + } + + cooldown := NewCooldownTracker() + chain := NewFallbackChain(cooldown, nil) + + // Mock run function: first two fail, third succeeds (model fallback) + callCount := 0 + calledModels := []string{} + mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + callCount++ + calledModels = append(calledModels, provider+"/"+model) + + switch callCount { + case 1: + // k1: rate limit + return nil, errors.New("status: 429 - rate limit") + case 2: + // k2: also rate limit (all zhipu keys exhausted) + return nil, errors.New("status: 429 - rate limit") + case 3: + // minimax: success + return &LLMResponse{Content: "success from minimax"}, nil + default: + return nil, errors.New("unexpected call") + } + } + + result, err := chain.Execute(context.Background(), candidates, mockRun) + if err != nil { + t.Fatalf("expected success after failover to model fallback, got error: %v", err) + } + + if callCount != 3 { + t.Errorf("expected 3 calls (k1 fail + k2 fail + minimax success), got %d", callCount) + } + + if result.Response.Content != "success from minimax" { + t.Errorf("expected response from minimax, got: %s", result.Response.Content) + } + + // Verify call order + if len(calledModels) != 3 { + t.Fatalf("expected 3 called models, got %d", len(calledModels)) + } + if calledModels[0] != "zhipu/glm-4.7" { + t.Errorf("expected first call to zhipu/glm-4.7, got: %s", calledModels[0]) + } + if calledModels[1] != "zhipu/glm-4.7__key_1" { + t.Errorf("expected second call to zhipu/glm-4.7__key_1, got: %s", calledModels[1]) + } + if calledModels[2] != "minimax/minimax" { + t.Errorf("expected third call to minimax/minimax, got: %s", calledModels[2]) + } + + // Verify 2 failed attempts recorded + if len(result.Attempts) != 2 { + t.Errorf("expected 2 failed attempts, got %d", len(result.Attempts)) + } + + // Both should be rate limit + for i, attempt := range result.Attempts { + if attempt.Reason != FailoverRateLimit { + t.Errorf("expected attempt %d to be rate_limit, got: %s", i, attempt.Reason) + } + } +} + +// TestMultiKeyFailoverMixedErrors tests failover with different error types +func TestMultiKeyFailoverMixedErrors(t *testing.T) { + cfg := ModelConfig{ + Primary: "glm-4.7", + Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"}, + } + + candidates := ResolveCandidates(cfg, "zhipu") + + cooldown := NewCooldownTracker() + chain := NewFallbackChain(cooldown, nil) + + // Mock run function: different errors for each key + callCount := 0 + mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + callCount++ + switch callCount { + case 1: + // First: rate limit (retriable) + return nil, errors.New("status: 429 - rate limit") + case 2: + // Second: timeout (retriable) + return nil, errors.New("context deadline exceeded") + case 3: + // Third: success + return &LLMResponse{Content: "success from key3"}, nil + default: + return nil, errors.New("unexpected call") + } + } + + result, err := chain.Execute(context.Background(), candidates, mockRun) + if err != nil { + t.Fatalf("expected success after 2 failovers, got error: %v", err) + } + + if callCount != 3 { + t.Errorf("expected 3 calls, got %d", callCount) + } + + // Verify both failed attempts were recorded + if len(result.Attempts) != 2 { + t.Errorf("expected 2 failed attempts, got %d", len(result.Attempts)) + } + + // First should be rate limit + if result.Attempts[0].Reason != FailoverRateLimit { + t.Errorf("expected first attempt to be rate_limit, got: %s", result.Attempts[0].Reason) + } + + // Second should be timeout + if result.Attempts[1].Reason != FailoverTimeout { + t.Errorf("expected second attempt to be timeout, got: %s", result.Attempts[1].Reason) + } +} diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go index 1783ebcb5..07cc01baa 100644 --- a/pkg/providers/fallback_test.go +++ b/pkg/providers/fallback_test.go @@ -19,7 +19,7 @@ func successRun(content string) func(ctx context.Context, provider, model string func TestFallback_SingleCandidate_Success(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} result, err := fc.Execute(context.Background(), candidates, successRun("hello")) @@ -36,7 +36,7 @@ func TestFallback_SingleCandidate_Success(t *testing.T) { func TestFallback_SecondCandidateSuccess(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), @@ -69,7 +69,7 @@ func TestFallback_SecondCandidateSuccess(t *testing.T) { func TestFallback_AllFail(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), @@ -96,7 +96,7 @@ func TestFallback_AllFail(t *testing.T) { func TestFallback_ContextCanceled(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) ctx, cancel := context.WithCancel(context.Background()) candidates := []FallbackCandidate{ @@ -123,7 +123,7 @@ func TestFallback_ContextCanceled(t *testing.T) { func TestFallback_NonRetriableError(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), @@ -155,10 +155,10 @@ func TestFallback_NonRetriableError(t *testing.T) { func TestFallback_CooldownSkip(t *testing.T) { now := time.Now() ct, _ := newTestTracker(now) - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) - // Put openai in cooldown - ct.MarkFailure("openai", FailoverRateLimit) + // Put openai/gpt-4 in cooldown (using ModelKey now) + ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), @@ -193,11 +193,11 @@ func TestFallback_CooldownSkip(t *testing.T) { func TestFallback_AllInCooldown(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) - // Put all providers in cooldown - ct.MarkFailure("openai", FailoverRateLimit) - ct.MarkFailure("anthropic", FailoverBilling) + // Put all models in cooldown (using ModelKey now) + ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit) + ct.MarkFailure(ModelKey("anthropic", "claude"), FailoverBilling) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), @@ -221,7 +221,7 @@ func TestFallback_AllInCooldown(t *testing.T) { func TestFallback_NoCandidates(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) _, err := fc.Execute(context.Background(), nil, successRun("ok")) if err == nil { @@ -232,7 +232,7 @@ func TestFallback_NoCandidates(t *testing.T) { func TestFallback_EmptyFallbacks(t *testing.T) { // Single primary, no fallbacks: should work like direct call ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} result, err := fc.Execute(context.Background(), candidates, successRun("ok")) @@ -246,7 +246,7 @@ func TestFallback_EmptyFallbacks(t *testing.T) { func TestFallback_UnclassifiedError(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), @@ -268,17 +268,87 @@ func TestFallback_UnclassifiedError(t *testing.T) { } } -func TestFallback_SuccessResetsCooldown(t *testing.T) { - ct := NewCooldownTracker() - fc := NewFallbackChain(ct) +func assertFallbackErrorFallsBack( + t *testing.T, + primaryProvider string, + primaryModel string, + initialErr error, + successContent string, + expectedReason FailoverReason, +) { + t.Helper() - candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} + ct := NewCooldownTracker() + fc := NewFallbackChain(ct, nil) + + candidates := []FallbackCandidate{ + makeCandidate(primaryProvider, primaryModel), + makeCandidate("anthropic", "claude"), + } attempt := 0 run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { attempt++ if attempt == 1 { - ct.MarkFailure("openai", FailoverRateLimit) // simulate failure tracked elsewhere + return nil, initialErr + } + return &LLMResponse{Content: successContent, FinishReason: "stop"}, nil + } + + result, err := fc.Execute(context.Background(), candidates, run) + if err != nil { + t.Fatalf("expected fallback success, got error: %v", err) + } + if attempt != 2 { + t.Fatalf("attempt = %d, want 2", attempt) + } + if result.Provider != "anthropic" || result.Model != "claude" { + t.Fatalf("result = %s/%s, want anthropic/claude", result.Provider, result.Model) + } + if len(result.Attempts) != 1 { + t.Fatalf("attempts = %d, want 1 failed attempt recorded", len(result.Attempts)) + } + if result.Attempts[0].Reason != expectedReason { + t.Fatalf("attempt reason = %q, want %s", result.Attempts[0].Reason, expectedReason) + } +} + +func TestFallback_NetworkErrorFallsBack(t *testing.T) { + assertFallbackErrorFallsBack( + t, + "minimax", + "minimax-m2.7", + errors.New( + `failed to send request: Post "https://opencode.ai/zen/go/v1/chat/completions": tls: bad record MAC`, + ), + "fallback ok", + FailoverNetwork, + ) +} + +func TestFallback_TimeoutErrorFallsBack(t *testing.T) { + assertFallbackErrorFallsBack( + t, + "openai", + "gpt-4", + errors.New("failed to send request: Post \"https://example.com\": i/o timeout"), + "timeout fallback ok", + FailoverTimeout, + ) +} + +func TestFallback_SuccessResetsCooldown(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct, nil) + + candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} + modelKey := ModelKey("openai", "gpt-4") + + attempt := 0 + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + attempt++ + if attempt == 1 { + ct.MarkFailure(modelKey, FailoverRateLimit) // simulate failure tracked elsewhere } return &LLMResponse{Content: "ok", FinishReason: "stop"}, nil } @@ -287,16 +357,83 @@ func TestFallback_SuccessResetsCooldown(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !ct.IsAvailable("openai") { + if !ct.IsAvailable(modelKey) { t.Error("success should reset cooldown") } } +func assertLocalRateLimitSkipsToHealthyFallback( + t *testing.T, + primaryKey string, + fallbackKey string, + fallbackProvider string, + fallbackModel string, + execute func(context.Context, *FallbackChain, []FallbackCandidate, + func(context.Context, string, string) (*LLMResponse, error), + ) (*FallbackResult, error), + responseContent string, +) { + t.Helper() + + ct := NewCooldownTracker() + rl := NewRateLimiterRegistry() + rl.Register(primaryKey, 1) + if err := rl.Wait(context.Background(), primaryKey); err != nil { + t.Fatalf("failed to pre-drain primary limiter: %v", err) + } + + fc := NewFallbackChain(ct, rl) + candidates := []FallbackCandidate{ + {Provider: "openai", Model: "gpt-4o", IdentityKey: primaryKey}, + {Provider: fallbackProvider, Model: fallbackModel, IdentityKey: fallbackKey}, + } + + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + if provider != fallbackProvider || model != fallbackModel { + t.Fatalf("expected fallback candidate to run, got %s/%s", provider, model) + } + return &LLMResponse{Content: responseContent, FinishReason: "stop"}, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + + result, err := execute(ctx, fc, candidates, run) + if err != nil { + t.Fatalf("expected fallback success, got error: %v", err) + } + if result.Provider != fallbackProvider || result.Model != fallbackModel { + t.Fatalf("result = %s/%s, want %s/%s", result.Provider, result.Model, fallbackProvider, fallbackModel) + } + if len(result.Attempts) != 1 || !result.Attempts[0].Skipped { + t.Fatalf("expected one skipped primary attempt, got %+v", result.Attempts) + } +} + +func TestFallback_LocalRateLimitSkipsToHealthyFallback(t *testing.T) { + assertLocalRateLimitSkipsToHealthyFallback( + t, + "model_name:primary", + "model_name:fallback", + "anthropic", + "claude", + func( + ctx context.Context, + fc *FallbackChain, + candidates []FallbackCandidate, + run func(context.Context, string, string) (*LLMResponse, error), + ) (*FallbackResult, error) { + return fc.Execute(ctx, candidates, run) + }, + "fallback ok", + ) +} + // --- Image Fallback Tests --- func TestImageFallback_Success(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4o")} result, err := fc.ExecuteImage(context.Background(), candidates, successRun("image result")) @@ -310,7 +447,7 @@ func TestImageFallback_Success(t *testing.T) { func TestImageFallback_DimensionError(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4o"), @@ -334,7 +471,7 @@ func TestImageFallback_DimensionError(t *testing.T) { func TestImageFallback_SizeError(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4o"), @@ -358,7 +495,7 @@ func TestImageFallback_SizeError(t *testing.T) { func TestImageFallback_RetryOnOtherErrors(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4o"), @@ -383,9 +520,28 @@ func TestImageFallback_RetryOnOtherErrors(t *testing.T) { } } +func TestImageFallback_LocalRateLimitSkipsToHealthyFallback(t *testing.T) { + assertLocalRateLimitSkipsToHealthyFallback( + t, + "model_name:primary-image", + "model_name:fallback-image", + "anthropic", + "claude-sonnet", + func( + ctx context.Context, + fc *FallbackChain, + candidates []FallbackCandidate, + run func(context.Context, string, string) (*LLMResponse, error), + ) (*FallbackResult, error) { + return fc.ExecuteImage(ctx, candidates, run) + }, + "image fallback ok", + ) +} + func TestImageFallback_NoCandidates(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) _, err := fc.ExecuteImage(context.Background(), nil, successRun("ok")) if err == nil { diff --git a/pkg/providers/httpapi/gemini_helpers.go b/pkg/providers/httpapi/gemini_helpers.go new file mode 100644 index 000000000..87cc4c084 --- /dev/null +++ b/pkg/providers/httpapi/gemini_helpers.go @@ -0,0 +1,22 @@ +package httpapi + +import "strings" + +func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake string) string { + if thoughtSignature != "" { + return thoughtSignature + } + if thoughtSignatureSnake != "" { + return thoughtSignatureSnake + } + return "" +} + +func extractProtocol(model string) (protocol, modelID string) { + model = strings.TrimSpace(model) + protocol, modelID, found := strings.Cut(model, "/") + if !found { + return "openai", model + } + return protocol, modelID +} diff --git a/pkg/providers/httpapi/gemini_provider.go b/pkg/providers/httpapi/gemini_provider.go new file mode 100644 index 000000000..395c555d1 --- /dev/null +++ b/pkg/providers/httpapi/gemini_provider.go @@ -0,0 +1,796 @@ +package httpapi + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/providers/common" +) + +const ( + geminiDefaultAPIBase = "https://generativelanguage.googleapis.com/v1beta" + geminiDefaultModel = "gemini-2.0-flash" +) + +type GeminiProvider struct { + apiKey string + apiBase string + httpClient *http.Client + extraBody map[string]any + customHeaders map[string]string + userAgent string +} + +func NewGeminiProvider( + apiKey string, + apiBase string, + proxy string, + userAgent string, + requestTimeoutSeconds int, + extraBody map[string]any, + customHeaders map[string]string, +) *GeminiProvider { + if strings.TrimSpace(apiBase) == "" { + apiBase = geminiDefaultAPIBase + } + client := common.NewHTTPClient(proxy) + if requestTimeoutSeconds > 0 { + client.Timeout = time.Duration(requestTimeoutSeconds) * time.Second + } + + return &GeminiProvider{ + apiKey: strings.TrimSpace(apiKey), + apiBase: strings.TrimRight(strings.TrimSpace(apiBase), "/"), + httpClient: client, + extraBody: cloneAnyMap(extraBody), + customHeaders: cloneStringMap(customHeaders), + userAgent: strings.TrimSpace(userAgent), + } +} + +func (p *GeminiProvider) GetDefaultModel() string { + return geminiDefaultModel +} + +func (p *GeminiProvider) SupportsThinking() bool { + return true +} + +func (p *GeminiProvider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + if p.apiBase == "" { + return nil, fmt.Errorf("API base not configured") + } + + model = normalizeGeminiModel(model) + requestBody := p.buildRequestBody(messages, tools, model, options) + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/models/%s:generateContent", p.apiBase, model) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + p.applyHeaders(req) + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, common.HandleErrorResponse(resp, p.apiBase) + } + + var apiResp geminiGenerateContentResponse + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return parseGeminiResponse(&apiResp), nil +} + +func (p *GeminiProvider) ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), +) (*LLMResponse, error) { + if p.apiBase == "" { + return nil, fmt.Errorf("API base not configured") + } + + model = normalizeGeminiModel(model) + requestBody := p.buildRequestBody(messages, tools, model, options) + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/models/%s:streamGenerateContent?alt=sse", p.apiBase, model) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + p.applyHeaders(req) + req.Header.Set("Accept", "text/event-stream") + + // Streaming should not use a whole-request timeout; context cancellation is the guard. + streamClient := &http.Client{Transport: p.httpClient.Transport} + resp, err := streamClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, common.HandleErrorResponse(resp, p.apiBase) + } + + return parseGeminiStreamResponse(ctx, resp.Body, onChunk) +} + +func (p *GeminiProvider) applyHeaders(req *http.Request) { + req.Header.Set("Content-Type", "application/json") + if p.apiKey != "" { + req.Header.Set("X-Goog-Api-Key", p.apiKey) + } + if p.userAgent != "" { + req.Header.Set("User-Agent", p.userAgent) + } + for k, v := range p.customHeaders { + if strings.TrimSpace(k) == "" { + continue + } + req.Header.Set(k, v) + } +} + +func (p *GeminiProvider) buildRequestBody( + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) map[string]any { + contents := make([]geminiContent, 0, len(messages)) + toolCallNames := make(map[string]string) + systemPrompts := make([]string, 0, 1) + + for _, msg := range messages { + switch msg.Role { + case "system": + if strings.TrimSpace(msg.Content) != "" { + systemPrompts = append(systemPrompts, msg.Content) + } + + case "user": + if msg.ToolCallID != "" { + toolName := common.ResolveToolResponseName(msg.ToolCallID, toolCallNames) + contents = append(contents, geminiContent{ + Role: "user", + Parts: []geminiPart{{ + FunctionResponse: buildGeminiFunctionResponse(toolName, msg.ToolCallID, msg.Content, msg.Media), + }}, + }) + continue + } + + parts := make([]geminiPart, 0, 1+len(msg.Media)) + if strings.TrimSpace(msg.Content) != "" { + parts = append(parts, geminiPart{Text: msg.Content}) + } + parts = append(parts, buildInlineMediaParts(msg.Media)...) + if len(parts) > 0 { + contents = append(contents, geminiContent{Role: "user", Parts: parts}) + } + + case "assistant": + content := geminiContent{Role: "model"} + if strings.TrimSpace(msg.Content) != "" { + content.Parts = append(content.Parts, geminiPart{Text: msg.Content}) + } + for _, tc := range msg.ToolCalls { + toolName, toolArgs, thoughtSignature := common.NormalizeStoredToolCall(tc) + if toolName == "" { + continue + } + if tc.ID != "" { + toolCallNames[tc.ID] = toolName + } + part := geminiPart{ + FunctionCall: &geminiFunctionCall{ + Name: toolName, + Args: toolArgs, + ID: tc.ID, + }, + } + if thoughtSignature != "" { + part.ThoughtSignature = thoughtSignature + } + content.Parts = append(content.Parts, part) + } + if len(content.Parts) > 0 { + contents = append(contents, content) + } + + case "tool": + toolName := common.ResolveToolResponseName(msg.ToolCallID, toolCallNames) + contents = append(contents, geminiContent{ + Role: "user", + Parts: []geminiPart{{ + FunctionResponse: buildGeminiFunctionResponse(toolName, msg.ToolCallID, msg.Content, msg.Media), + }}, + }) + } + } + + body := map[string]any{ + "contents": contents, + } + if len(systemPrompts) > 0 { + systemParts := make([]geminiPart, 0, len(systemPrompts)) + for _, prompt := range systemPrompts { + systemParts = append(systemParts, geminiPart{Text: prompt}) + } + body["systemInstruction"] = &geminiContent{Parts: systemParts} + } + + if len(tools) > 0 { + funcDecls := make([]geminiFunctionDeclaration, 0, len(tools)) + for _, t := range tools { + if t.Type != "function" { + continue + } + funcDecls = append(funcDecls, geminiFunctionDeclaration{ + Name: t.Function.Name, + Description: t.Function.Description, + Parameters: t.Function.Parameters, + }) + } + if len(funcDecls) > 0 { + body["tools"] = []geminiTool{{FunctionDeclarations: funcDecls}} + } + } + + generationConfig := make(map[string]any) + if val, ok := options["max_tokens"]; ok { + if maxTokens, ok := val.(int); ok && maxTokens > 0 { + generationConfig["maxOutputTokens"] = maxTokens + } else if maxTokens, ok := val.(float64); ok && maxTokens > 0 { + generationConfig["maxOutputTokens"] = int(maxTokens) + } + } + if temp, ok := options["temperature"].(float64); ok { + generationConfig["temperature"] = temp + } + + if thinkingConfig := buildGeminiThinkingConfig(model, options); len(thinkingConfig) > 0 { + generationConfig["thinkingConfig"] = thinkingConfig + } + + if len(generationConfig) > 0 { + body["generationConfig"] = generationConfig + } + + for k, v := range p.extraBody { + body[k] = v + } + + return body +} + +func normalizeGeminiModel(model string) string { + model = strings.TrimSpace(model) + model = strings.TrimPrefix(model, "models/") + if strings.Contains(model, "/") { + _, modelID := extractProtocol(model) + if modelID != "" { + return modelID + } + } + if model == "" { + return geminiDefaultModel + } + return model +} + +func mapGeminiThinkingLevel(level string) string { + switch strings.ToLower(strings.TrimSpace(level)) { + case "minimal", "off": + return "minimal" + case "low": + return "low" + case "medium": + return "medium" + case "high", "xhigh", "adaptive": + return "high" + default: + return "" + } +} + +func buildGeminiThinkingConfig(model string, options map[string]any) map[string]any { + if !geminiModelSupportsThinkingConfig(model) { + return nil + } + + config := map[string]any{} + rawLevel, _ := options["thinking_level"].(string) + rawLevel = strings.ToLower(strings.TrimSpace(rawLevel)) + if rawLevel == "" { + // Align with agent-level default: unset means ThinkingOff. + rawLevel = "off" + } + + includeThoughts := rawLevel != "off" && rawLevel != "minimal" + config["includeThoughts"] = includeThoughts + + if isGemini25Model(model) { + if isGemini25ProModel(model) && (rawLevel == "off" || rawLevel == "minimal") { + // Gemini 2.5 Pro cannot disable thinking; keep model-default thinking. + return config + } + if budget, ok := mapGeminiThinkingBudget(rawLevel); ok { + config["thinkingBudget"] = budget + } + return config + } + + if isGemini3ProModel(model) && (rawLevel == "off" || rawLevel == "minimal") { + // Gemini 3.x Pro does not support minimal thinking level. + return config + } + + if thinkingLevel := mapGeminiThinkingLevel(rawLevel); thinkingLevel != "" { + config["thinkingLevel"] = thinkingLevel + } + return config +} + +func geminiModelSupportsThinkingConfig(model string) bool { + lowerModel := strings.ToLower(strings.TrimSpace(model)) + return strings.Contains(lowerModel, "gemini-3") || isGemini25Model(lowerModel) +} + +func isGemini25Model(model string) bool { + lowerModel := strings.ToLower(strings.TrimSpace(model)) + return strings.Contains(lowerModel, "gemini-2.5") || strings.Contains(lowerModel, "gemini-25") +} + +func isGemini25ProModel(model string) bool { + lowerModel := strings.ToLower(strings.TrimSpace(model)) + return isGemini25Model(lowerModel) && strings.Contains(lowerModel, "pro") +} + +func isGemini3ProModel(model string) bool { + lowerModel := strings.ToLower(strings.TrimSpace(model)) + return strings.Contains(lowerModel, "gemini-3") && strings.Contains(lowerModel, "pro") +} + +func mapGeminiThinkingBudget(level string) (int, bool) { + level = strings.ToLower(strings.TrimSpace(level)) + if level == "" { + return 0, false + } + + switch level { + case "adaptive": + return -1, true + case "minimal": + return 0, true + case "off": + return 0, true + case "low": + return 1024, true + case "medium": + return 4096, true + case "high": + return 8192, true + case "xhigh": + return 16384, true + default: + return 0, false + } +} + +func parseGeminiResponse(resp *geminiGenerateContentResponse) *LLMResponse { + contentParts := make([]string, 0) + reasoningParts := make([]string, 0) + toolCalls := make([]ToolCall, 0) + finishReason := "" + + for _, candidate := range resp.Candidates { + for _, part := range candidate.Content.Parts { + if part.Text != "" { + if part.Thought { + reasoningParts = append(reasoningParts, part.Text) + } else { + contentParts = append(contentParts, part.Text) + } + } + if part.FunctionCall != nil { + toolCalls = append(toolCalls, buildGeminiToolCall(part)) + } + } + if candidate.FinishReason != "" { + finishReason = candidate.FinishReason + } + } + + var usage *UsageInfo + if resp.UsageMetadata.TotalTokenCount > 0 { + usage = &UsageInfo{ + PromptTokens: resp.UsageMetadata.PromptTokenCount, + CompletionTokens: resp.UsageMetadata.CandidatesTokenCount, + TotalTokens: resp.UsageMetadata.TotalTokenCount, + } + } + + return &LLMResponse{ + Content: strings.Join(contentParts, ""), + ReasoningContent: strings.Join(reasoningParts, ""), + ToolCalls: toolCalls, + FinishReason: normalizeGeminiFinishReason(finishReason, len(toolCalls)), + Usage: usage, + } +} + +func parseGeminiStreamResponse( + ctx context.Context, + reader io.Reader, + onChunk func(accumulated string), +) (*LLMResponse, error) { + var contentBuilder strings.Builder + var reasoningBuilder strings.Builder + var finishReason string + var usage *UsageInfo + + toolCallsByID := make(map[string]ToolCall) + toolCallOrder := make([]string, 0) + fallbackIndex := 0 + + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) + for scanner.Scan() { + if err := ctx.Err(); err != nil { + return nil, err + } + + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data: ")) + if data == "" { + continue + } + if data == "[DONE]" { + break + } + + var chunk geminiGenerateContentResponse + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + return nil, fmt.Errorf("invalid gemini stream chunk: %w", err) + } + + for _, candidate := range chunk.Candidates { + for _, part := range candidate.Content.Parts { + if part.Text != "" { + if part.Thought { + reasoningBuilder.WriteString(part.Text) + } else { + contentBuilder.WriteString(part.Text) + if onChunk != nil { + onChunk(contentBuilder.String()) + } + } + } + if part.FunctionCall != nil { + tc := buildGeminiToolCall(part) + if strings.TrimSpace(tc.Name) == "" { + continue + } + + key := strings.TrimSpace(part.FunctionCall.ID) + if key == "" { + if len(toolCallOrder) > 0 { + lastKey := toolCallOrder[len(toolCallOrder)-1] + if lastTC, exists := toolCallsByID[lastKey]; exists && lastTC.Name == tc.Name { + key = lastKey + } + } + if key == "" { + fallbackIndex++ + key = fmt.Sprintf("%s#%d", tc.Name, fallbackIndex) + } + } + + tc.ID = key + if _, exists := toolCallsByID[key]; !exists { + toolCallOrder = append(toolCallOrder, key) + } + toolCallsByID[key] = tc + } + } + if candidate.FinishReason != "" { + finishReason = candidate.FinishReason + } + } + + if chunk.UsageMetadata.TotalTokenCount > 0 { + usage = &UsageInfo{ + PromptTokens: chunk.UsageMetadata.PromptTokenCount, + CompletionTokens: chunk.UsageMetadata.CandidatesTokenCount, + TotalTokens: chunk.UsageMetadata.TotalTokenCount, + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("streaming read error: %w", err) + } + + toolCalls := make([]ToolCall, 0, len(toolCallOrder)) + for _, key := range toolCallOrder { + toolCalls = append(toolCalls, toolCallsByID[key]) + } + + return &LLMResponse{ + Content: contentBuilder.String(), + ReasoningContent: reasoningBuilder.String(), + ToolCalls: toolCalls, + FinishReason: normalizeGeminiFinishReason(finishReason, len(toolCalls)), + Usage: usage, + }, nil +} + +func normalizeGeminiFinishReason(reason string, toolCalls int) string { + if toolCalls > 0 { + return "tool_calls" + } + + switch strings.ToUpper(strings.TrimSpace(reason)) { + case "MAX_TOKENS": + return "length" + case "", "STOP": + return "stop" + default: + return strings.ToLower(strings.TrimSpace(reason)) + } +} + +func buildGeminiToolCall(part geminiPart) ToolCall { + if part.FunctionCall == nil { + return ToolCall{} + } + + args := part.FunctionCall.Args + if args == nil { + args = make(map[string]any) + } + argsJSON, _ := json.Marshal(args) + thoughtSignature := extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake) + + toolCall := ToolCall{ + ID: part.FunctionCall.ID, + Name: part.FunctionCall.Name, + Arguments: args, + ThoughtSignature: thoughtSignature, + Function: &FunctionCall{ + Name: part.FunctionCall.Name, + Arguments: string(argsJSON), + ThoughtSignature: thoughtSignature, + }, + } + + if thoughtSignature != "" { + toolCall.ExtraContent = &ExtraContent{ + Google: &GoogleExtra{ThoughtSignature: thoughtSignature}, + } + } + if strings.TrimSpace(toolCall.ID) == "" { + toolCall.ID = fmt.Sprintf("call_%s_%d", toolCall.Name, time.Now().UnixNano()) + } + + return toolCall +} + +func buildInlineMediaParts(media []string) []geminiPart { + parts := make([]geminiPart, 0, len(media)) + for _, mediaURL := range media { + mimeType, data, ok := parseBase64DataURL(mediaURL) + if !ok { + continue + } + parts = append(parts, geminiPart{ + InlineData: &geminiInlineData{ + MIMEType: mimeType, + Data: data, + }, + }) + } + return parts +} + +func buildGeminiFunctionResponse( + toolName string, + toolCallID string, + result string, + media []string, +) *geminiFunctionResponse { + response := &geminiFunctionResponse{ + ID: toolCallID, + Name: toolName, + Response: map[string]any{ + "result": result, + }, + } + + if parts := buildFunctionResponseMediaParts(media); len(parts) > 0 { + response.Parts = parts + } + + return response +} + +func buildFunctionResponseMediaParts(media []string) []geminiFunctionResponsePart { + parts := make([]geminiFunctionResponsePart, 0, len(media)) + for i, mediaURL := range media { + mimeType, data, ok := parseBase64DataURL(mediaURL) + if !ok { + continue + } + parts = append(parts, geminiFunctionResponsePart{ + InlineData: &geminiInlineData{ + MIMEType: mimeType, + Data: data, + DisplayName: defaultFunctionResponseDisplayName(mimeType, i+1), + }, + }) + } + return parts +} + +func defaultFunctionResponseDisplayName(mimeType string, index int) string { + suffix := "bin" + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "image/png": + suffix = "png" + case "image/jpeg": + suffix = "jpg" + case "image/webp": + suffix = "webp" + case "application/pdf": + suffix = "pdf" + case "text/plain": + suffix = "txt" + } + return fmt.Sprintf("attachment-%d.%s", index, suffix) +} + +func parseBase64DataURL(mediaURL string) (mimeType string, data string, ok bool) { + if !strings.HasPrefix(mediaURL, "data:") { + return "", "", false + } + + payload := strings.TrimPrefix(mediaURL, "data:") + header, data, found := strings.Cut(payload, ",") + if !found { + return "", "", false + } + mimeType, params, _ := strings.Cut(header, ";") + mimeType = strings.TrimSpace(mimeType) + data = strings.TrimSpace(data) + if mimeType == "" || data == "" { + return "", "", false + } + if !strings.Contains(strings.ToLower(params), "base64") { + return "", "", false + } + return mimeType, data, true +} + +func cloneAnyMap(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneStringMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +type geminiGenerateContentResponse struct { + Candidates []struct { + Content struct { + Role string `json:"role"` + Parts []geminiPart `json:"parts"` + } `json:"content"` + FinishReason string `json:"finishReason"` + } `json:"candidates"` + UsageMetadata struct { + PromptTokenCount int `json:"promptTokenCount"` + CandidatesTokenCount int `json:"candidatesTokenCount"` + TotalTokenCount int `json:"totalTokenCount"` + } `json:"usageMetadata"` +} + +type geminiContent struct { + Role string `json:"role,omitempty"` + Parts []geminiPart `json:"parts"` +} + +type geminiPart struct { + Text string `json:"text,omitempty"` + Thought bool `json:"thought,omitempty"` + ThoughtSignature string `json:"thoughtSignature,omitempty"` + ThoughtSignatureSnake string `json:"thought_signature,omitempty"` + InlineData *geminiInlineData `json:"inlineData,omitempty"` + FunctionCall *geminiFunctionCall `json:"functionCall,omitempty"` + FunctionResponse *geminiFunctionResponse `json:"functionResponse,omitempty"` +} + +type geminiInlineData struct { + MIMEType string `json:"mimeType"` + Data string `json:"data"` + DisplayName string `json:"displayName,omitempty"` +} + +type geminiFunctionCall struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Args map[string]any `json:"args,omitempty"` +} + +type geminiFunctionResponse struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Response map[string]any `json:"response"` + Parts []geminiFunctionResponsePart `json:"parts,omitempty"` +} + +type geminiFunctionResponsePart struct { + InlineData *geminiInlineData `json:"inlineData,omitempty"` +} + +type geminiTool struct { + FunctionDeclarations []geminiFunctionDeclaration `json:"functionDeclarations"` +} + +type geminiFunctionDeclaration struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters any `json:"parameters,omitempty"` +} diff --git a/pkg/providers/httpapi/gemini_provider_test.go b/pkg/providers/httpapi/gemini_provider_test.go new file mode 100644 index 000000000..b455357c0 --- /dev/null +++ b/pkg/providers/httpapi/gemini_provider_test.go @@ -0,0 +1,821 @@ +package httpapi + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGeminiProvider_ChatSeparatesThoughtAndToolCall(t *testing.T) { + var capturedBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if !strings.Contains(r.URL.Path, ":generateContent") { + t.Fatalf("path = %s, expected generateContent endpoint", r.URL.Path) + } + if got := r.Header.Get("X-Goog-Api-Key"); got != "test-key" { + t.Fatalf("X-Goog-Api-Key = %q, want %q", got, "test-key") + } + if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "candidates": []any{ + map[string]any{ + "content": map[string]any{ + "role": "model", + "parts": []any{ + map[string]any{"text": "hidden", "thought": true}, + map[string]any{"text": "visible"}, + map[string]any{ + "functionCall": map[string]any{ + "id": "call_1", + "name": "search", + "args": map[string]any{"q": "hi"}, + }, + "thoughtSignature": "sig-1", + }, + }, + }, + "finishReason": "STOP", + }, + }, + "usageMetadata": map[string]any{ + "promptTokenCount": 2, + "candidatesTokenCount": 3, + "totalTokenCount": 5, + }, + }) + })) + defer server.Close() + + provider := NewGeminiProvider("test-key", server.URL, "", "picoclaw-test", 0, nil, nil) + resp, err := provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-3-flash-preview", + map[string]any{"thinking_level": "high"}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if resp.Content != "visible" { + t.Fatalf("Content = %q, want %q", resp.Content, "visible") + } + if resp.ReasoningContent != "hidden" { + t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "hidden") + } + if resp.FinishReason != "tool_calls" { + t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") + } + if resp.Usage == nil || resp.Usage.TotalTokens != 5 { + t.Fatalf("Usage = %#v, expected total tokens = 5", resp.Usage) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls)) + } + if resp.ToolCalls[0].ID != "call_1" { + t.Fatalf("ToolCall ID = %q, want %q", resp.ToolCalls[0].ID, "call_1") + } + if resp.ToolCalls[0].Name != "search" { + t.Fatalf("ToolCall Name = %q, want %q", resp.ToolCalls[0].Name, "search") + } + if resp.ToolCalls[0].ThoughtSignature != "sig-1" { + t.Fatalf("ToolCall ThoughtSignature = %q, want %q", resp.ToolCalls[0].ThoughtSignature, "sig-1") + } + if resp.ToolCalls[0].Function == nil || !strings.Contains(resp.ToolCalls[0].Function.Arguments, `"q":"hi"`) { + t.Fatalf("ToolCall Function arguments = %#v, want q=hi", resp.ToolCalls[0].Function) + } + + generationConfig, ok := capturedBody["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("request missing generationConfig: %#v", capturedBody) + } + thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any) + if !ok { + t.Fatalf("request missing thinkingConfig: %#v", generationConfig) + } + if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || !includeThoughts { + t.Fatalf("thinkingConfig.includeThoughts = %#v, want true", thinkingConfig["includeThoughts"]) + } + if got := thinkingConfig["thinkingLevel"]; got != "high" { + t.Fatalf("thinkingConfig.thinkingLevel = %#v, want %q", got, "high") + } +} + +func TestGeminiProvider_ChatStreamParsesThoughtTextAndToolCalls(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, ":streamGenerateContent") { + t.Fatalf("path = %s, expected streamGenerateContent endpoint", r.URL.Path) + } + if got := r.URL.Query().Get("alt"); got != "sse" { + t.Fatalf("alt query = %q, want %q", got, "sse") + } + + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("response writer is not flushable") + } + + chunks := []map[string]any{ + { + "candidates": []any{map[string]any{ + "content": map[string]any{ + "parts": []any{ + map[string]any{"text": "think ", "thought": true}, + map[string]any{"text": "Hello "}, + }, + }, + }}, + }, + { + "candidates": []any{map[string]any{ + "content": map[string]any{ + "parts": []any{ + map[string]any{"text": "World"}, + map[string]any{ + "functionCall": map[string]any{ + "id": "call_stream", + "name": "search", + "args": map[string]any{"q": "stream"}, + }, + }, + }, + }, + "finishReason": "STOP", + }}, + "usageMetadata": map[string]any{ + "promptTokenCount": 1, + "candidatesTokenCount": 2, + "totalTokenCount": 3, + }, + }, + } + + for _, chunk := range chunks { + raw, err := json.Marshal(chunk) + if err != nil { + t.Fatalf("marshal chunk: %v", err) + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil { + t.Fatalf("write chunk: %v", err) + } + flusher.Flush() + } + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + flusher.Flush() + })) + defer server.Close() + + provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil) + updates := make([]string, 0) + resp, err := provider.ChatStream( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + nil, + func(accumulated string) { + updates = append(updates, accumulated) + }, + ) + if err != nil { + t.Fatalf("ChatStream() error = %v", err) + } + if resp.Content != "Hello World" { + t.Fatalf("Content = %q, want %q", resp.Content, "Hello World") + } + if resp.ReasoningContent != "think " { + t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "think ") + } + if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].ID != "call_stream" { + t.Fatalf("ToolCalls = %#v, want single call_stream", resp.ToolCalls) + } + if resp.FinishReason != "tool_calls" { + t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") + } + if resp.Usage == nil || resp.Usage.TotalTokens != 3 { + t.Fatalf("Usage = %#v, expected total tokens = 3", resp.Usage) + } + if len(updates) < 2 || updates[len(updates)-1] != "Hello World" { + t.Fatalf("stream updates = %#v, expected final accumulated text", updates) + } +} + +func TestGeminiProvider_ChatStreamSkipsEmptyDataFrames(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("response writer is not flushable") + } + + _, _ = fmt.Fprint(w, "data: \n\n") + flusher.Flush() + + chunk := map[string]any{ + "candidates": []any{map[string]any{ + "content": map[string]any{ + "parts": []any{map[string]any{"text": "ok"}}, + }, + "finishReason": "STOP", + }}, + } + raw, err := json.Marshal(chunk) + if err != nil { + t.Fatalf("marshal chunk: %v", err) + } + _, _ = fmt.Fprintf(w, "data: %s\n\n", raw) + flusher.Flush() + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + flusher.Flush() + })) + defer server.Close() + + provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil) + resp, err := provider.ChatStream( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + nil, + nil, + ) + if err != nil { + t.Fatalf("ChatStream() error = %v", err) + } + if resp.Content != "ok" { + t.Fatalf("Content = %q, want %q", resp.Content, "ok") + } +} + +func TestGeminiProvider_BuildRequestBody_PreservesComplexToolSchemasByDefault(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "parent": map[string]any{ + "anyOf": []any{ + map[string]any{"$ref": "#/$defs/pageParent"}, + map[string]any{"$ref": "#/$defs/databaseParent"}, + }, + }, + }, + "$defs": map[string]any{ + "pageParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "page_id": map[string]any{"type": "string"}, + }, + "required": []any{"page_id"}, + }, + "databaseParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "database_id": map[string]any{"type": "string"}, + }, + "required": []any{"database_id"}, + }, + }, + } + + body := provider.buildRequestBody( + []Message{{Role: "user", Content: "hello"}}, + []ToolDefinition{{ + Type: "function", + Function: ToolFunctionDefinition{ + Name: "mcp_notion_create", + Description: "Create a Notion object", + Parameters: schema, + }, + }}, + "gemini-3-flash-preview", + nil, + ) + + tools, ok := body["tools"].([]geminiTool) + if !ok || len(tools) != 1 { + t.Fatalf("tools = %#v, want one geminiTool", body["tools"]) + } + got, ok := tools[0].FunctionDeclarations[0].Parameters.(map[string]any) + if !ok { + t.Fatalf("parameters = %#v, want map", tools[0].FunctionDeclarations[0].Parameters) + } + + if got["$defs"] == nil { + t.Fatalf("parameters = %#v, want raw schema with $defs preserved by default", got) + } +} + +func TestGeminiProvider_ChatStreamReturnsErrorOnInvalidDataFrame(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("response writer is not flushable") + } + + _, _ = fmt.Fprint(w, "data: {invalid-json}\n\n") + flusher.Flush() + })) + defer server.Close() + + provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil) + _, err := provider.ChatStream( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + nil, + nil, + ) + if err == nil { + t.Fatal("ChatStream() expected error for invalid SSE data frame") + } + if !strings.Contains(err.Error(), "invalid gemini stream chunk") { + t.Fatalf("error = %v, want contains %q", err, "invalid gemini stream chunk") + } +} + +func TestGeminiProvider_BuildRequestBody_UsesCamelCaseThoughtSignatureOnly(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + + body := provider.buildRequestBody( + []Message{{ + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Name: "search", + Arguments: map[string]any{"q": "hello"}, + Function: &FunctionCall{ + Name: "search", + Arguments: `{"q":"hello"}`, + ThoughtSignature: "sig-1", + }, + }}, + }}, + nil, + "gemini-2.5-flash", + nil, + ) + + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request body: %v", err) + } + jsonBody := string(raw) + + if !strings.Contains(jsonBody, `"thoughtSignature":"sig-1"`) { + t.Fatalf("request body = %s, expected camelCase thoughtSignature", jsonBody) + } + if strings.Contains(jsonBody, `"thought_signature"`) { + t.Fatalf("request body = %s, unexpected snake_case thought_signature", jsonBody) + } +} + +func TestGeminiProvider_ChatStreamCoalescesToolCallWithoutWireID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("response writer is not flushable") + } + + chunks := []map[string]any{ + { + "candidates": []any{map[string]any{ + "content": map[string]any{ + "parts": []any{ + map[string]any{ + "functionCall": map[string]any{ + "name": "search", + "args": map[string]any{"q": "first"}, + }, + }, + }, + }, + }}, + }, + { + "candidates": []any{map[string]any{ + "content": map[string]any{ + "parts": []any{ + map[string]any{ + "functionCall": map[string]any{ + "name": "search", + "args": map[string]any{"q": "second"}, + }, + }, + }, + }, + "finishReason": "STOP", + }}, + }, + } + + for _, chunk := range chunks { + raw, err := json.Marshal(chunk) + if err != nil { + t.Fatalf("marshal chunk: %v", err) + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil { + t.Fatalf("write chunk: %v", err) + } + flusher.Flush() + } + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + flusher.Flush() + })) + defer server.Close() + + provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil) + resp, err := provider.ChatStream( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + nil, + nil, + ) + if err != nil { + t.Fatalf("ChatStream() error = %v", err) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls)) + } + tc := resp.ToolCalls[0] + if tc.ID != "search#1" { + t.Fatalf("ToolCall ID = %q, want %q", tc.ID, "search#1") + } + if tc.Name != "search" { + t.Fatalf("ToolCall Name = %q, want %q", tc.Name, "search") + } + if argQ, ok := tc.Arguments["q"].(string); !ok || argQ != "second" { + t.Fatalf("ToolCall Arguments = %#v, want q=second", tc.Arguments) + } + if resp.FinishReason != "tool_calls" { + t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") + } +} + +func TestGeminiProvider_BuildRequestBodyIncludesMediaAndThinkingConfig(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + + body := provider.buildRequestBody( + []Message{{ + Role: "user", + Content: "analyze attachments", + Media: []string{ + "data:application/pdf;base64,UEZERGF0YQ==", + "data:image/png;base64,aW1hZ2VEYXRh", + }, + }}, + nil, + "gemini-3-flash-preview", + map[string]any{ + "thinking_level": "low", + "max_tokens": 128, + "temperature": 0.2, + }, + ) + + contents, ok := body["contents"].([]geminiContent) + if !ok || len(contents) != 1 { + t.Fatalf("contents = %#v, want one gemini content", body["contents"]) + } + parts := contents[0].Parts + mimeSet := map[string]bool{} + for _, part := range parts { + if part.InlineData != nil { + mimeSet[part.InlineData.MIMEType] = true + } + } + if !mimeSet["application/pdf"] { + t.Fatalf("inline media missing application/pdf: %#v", parts) + } + if !mimeSet["image/png"] { + t.Fatalf("inline media missing image/png: %#v", parts) + } + + generationConfig, ok := body["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("generationConfig = %#v, want map", body["generationConfig"]) + } + if got := generationConfig["maxOutputTokens"]; got != 128 { + t.Fatalf("maxOutputTokens = %#v, want 128", got) + } + if got := generationConfig["temperature"]; got != 0.2 { + t.Fatalf("temperature = %#v, want 0.2", got) + } + thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any) + if !ok { + t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"]) + } + if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || !includeThoughts { + t.Fatalf("includeThoughts = %#v, want true", thinkingConfig["includeThoughts"]) + } + if got := thinkingConfig["thinkingLevel"]; got != "low" { + t.Fatalf("thinkingLevel = %#v, want %q", got, "low") + } +} + +func TestGeminiProvider_BuildRequestBody_UsesThinkingBudgetForGemini25(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + map[string]any{"thinking_level": "medium"}, + ) + + generationConfig, ok := body["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("generationConfig = %#v, want map", body["generationConfig"]) + } + thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any) + if !ok { + t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"]) + } + if got := thinkingConfig["thinkingBudget"]; got != 4096 { + t.Fatalf("thinkingBudget = %#v, want 4096", got) + } + if _, hasLevel := thinkingConfig["thinkingLevel"]; hasLevel { + t.Fatalf("thinkingLevel should not be set for Gemini 2.5: %#v", thinkingConfig) + } +} + +func TestGeminiProvider_BuildRequestBody_OmitsThinkingConfigForGemini20(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.0-flash-exp", + map[string]any{"thinking_level": "high"}, + ) + + if _, ok := body["generationConfig"]; ok { + t.Fatalf("generationConfig should be omitted for Gemini 2.0 when only thinking_level is set: %#v", body) + } +} + +func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini25(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + nil, + ) + + generationConfig, ok := body["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("generationConfig = %#v, want map", body["generationConfig"]) + } + thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any) + if !ok { + t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"]) + } + if got := thinkingConfig["thinkingBudget"]; got != 0 { + t.Fatalf("thinkingBudget = %#v, want 0 for default/off", got) + } + if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts { + t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"]) + } +} + +func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini3(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-3-flash-preview", + nil, + ) + + generationConfig, ok := body["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("generationConfig = %#v, want map", body["generationConfig"]) + } + thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any) + if !ok { + t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"]) + } + if got := thinkingConfig["thinkingLevel"]; got != "minimal" { + t.Fatalf("thinkingLevel = %#v, want minimal for default/off", got) + } + if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts { + t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"]) + } +} + +func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini25Pro(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-pro", + nil, + ) + + generationConfig, ok := body["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("generationConfig = %#v, want map", body["generationConfig"]) + } + thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any) + if !ok { + t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"]) + } + if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts { + t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"]) + } + if _, hasBudget := thinkingConfig["thinkingBudget"]; hasBudget { + t.Fatalf("thinkingBudget should be omitted for Gemini 2.5 Pro default/off: %#v", thinkingConfig) + } +} + +func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini31Pro(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-3.1-pro", + nil, + ) + + generationConfig, ok := body["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("generationConfig = %#v, want map", body["generationConfig"]) + } + thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any) + if !ok { + t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"]) + } + if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts { + t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"]) + } + if _, hasLevel := thinkingConfig["thinkingLevel"]; hasLevel { + t.Fatalf("thinkingLevel should be omitted for Gemini 3.1 Pro default/off: %#v", thinkingConfig) + } +} + +func TestGeminiProvider_BuildRequestBody_PreservesMultipleSystemMessages(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{ + {Role: "system", Content: "You are helpful."}, + {Role: "system", Content: "Be concise."}, + {Role: "user", Content: "hello"}, + }, + nil, + "gemini-3-flash-preview", + nil, + ) + + systemInstruction, ok := body["systemInstruction"].(*geminiContent) + if !ok || systemInstruction == nil { + t.Fatalf("systemInstruction = %#v, want *geminiContent", body["systemInstruction"]) + } + if len(systemInstruction.Parts) != 2 { + t.Fatalf("systemInstruction.Parts len = %d, want 2", len(systemInstruction.Parts)) + } + if systemInstruction.Parts[0].Text != "You are helpful." || systemInstruction.Parts[1].Text != "Be concise." { + t.Fatalf("systemInstruction.Parts = %#v, want ordered system prompts", systemInstruction.Parts) + } +} + +func TestGeminiProvider_BuildRequestBody_PreservesToolResponseMedia(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + body := provider.buildRequestBody( + []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Name: "load_image", + Arguments: map[string]any{"path": "demo.png"}, + }}, + }, + { + Role: "tool", + ToolCallID: "call_1", + Content: "tool result", + Media: []string{ + "data:image/png;base64,aW1hZ2VEYXRh", + "data:application/pdf;base64,UEZERGF0YQ==", + }, + }, + }, + nil, + "gemini-3-flash-preview", + nil, + ) + + contents, ok := body["contents"].([]geminiContent) + if !ok || len(contents) != 2 { + t.Fatalf("contents = %#v, want two content entries", body["contents"]) + } + parts := contents[1].Parts + if len(parts) != 1 || parts[0].FunctionResponse == nil { + t.Fatalf("tool response part = %#v, want functionResponse", parts) + } + response := parts[0].FunctionResponse + if response.Name != "load_image" { + t.Fatalf("functionResponse.Name = %q, want %q", response.Name, "load_image") + } + if response.Response["result"] != "tool result" { + t.Fatalf("functionResponse.Response = %#v, want result=tool result", response.Response) + } + if len(response.Parts) != 2 { + t.Fatalf("functionResponse.Parts len = %d, want 2", len(response.Parts)) + } +} + +func TestGeminiProvider_ChatAllowsCustomAuthHeaderWithoutAPIKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Fatalf("Authorization = %q, want %q", got, "Bearer test-token") + } + if got := r.Header.Get("X-Goog-Api-Key"); got != "" { + t.Fatalf("X-Goog-Api-Key = %q, want empty", got) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "candidates": []any{ + map[string]any{ + "content": map[string]any{ + "parts": []any{map[string]any{"text": "ok"}}, + }, + "finishReason": "STOP", + }, + }, + }) + })) + defer server.Close() + + provider := NewGeminiProvider( + "", + server.URL, + "", + "", + 0, + nil, + map[string]string{"Authorization": "Bearer test-token"}, + ) + + resp, err := provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if resp.Content != "ok" { + t.Fatalf("Content = %q, want %q", resp.Content, "ok") + } +} + +func TestGeminiProvider_ChatAllowsMissingAPIKeyForCustomAPIBase(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Goog-Api-Key"); got != "" { + t.Fatalf("X-Goog-Api-Key = %q, want empty", got) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "candidates": []any{ + map[string]any{ + "content": map[string]any{"parts": []any{map[string]any{"text": "ok"}}}, + "finishReason": "STOP", + }, + }, + }) + })) + defer server.Close() + + provider := NewGeminiProvider("", server.URL, "", "", 0, nil, nil) + resp, err := provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hello"}}, + nil, + "gemini-2.5-flash", + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if resp.Content != "ok" { + t.Fatalf("Content = %q, want %q", resp.Content, "ok") + } +} diff --git a/pkg/providers/http_provider.go b/pkg/providers/httpapi/http_provider.go similarity index 56% rename from pkg/providers/http_provider.go rename to pkg/providers/httpapi/http_provider.go index 5c328f418..90f389cc8 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/httpapi/http_provider.go @@ -4,7 +4,7 @@ // // Copyright (c) 2026 PicoClaw contributors -package providers +package httpapi import ( "context" @@ -24,12 +24,14 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { } func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider { - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0) + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, "", 0, nil, nil) } func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - apiKey, apiBase, proxy, maxTokensField string, + apiKey, apiBase, proxy, maxTokensField, userAgent string, requestTimeoutSeconds int, + extraBody map[string]any, + customHeaders map[string]string, ) *HTTPProvider { return &HTTPProvider{ delegate: openai_compat.NewProvider( @@ -38,6 +40,9 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( proxy, openai_compat.WithMaxTokensField(maxTokensField), openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), + openai_compat.WithExtraBody(extraBody), + openai_compat.WithCustomHeaders(customHeaders), + openai_compat.WithUserAgent(userAgent), ), } } @@ -52,6 +57,30 @@ func (p *HTTPProvider) Chat( return p.delegate.Chat(ctx, messages, tools, model, options) } +// ChatStream implements providers.StreamingProvider by delegating to the +// OpenAI-compatible streaming endpoint (SSE with stream: true). +func (p *HTTPProvider) ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), +) (*LLMResponse, error) { + return p.delegate.ChatStream(ctx, messages, tools, model, options, onChunk) +} + func (p *HTTPProvider) GetDefaultModel() string { return "" } + +func (p *HTTPProvider) SupportsNativeSearch() bool { + return p.delegate.SupportsNativeSearch() +} + +func (p *HTTPProvider) SetProviderName(providerName string) { + if p == nil || p.delegate == nil { + return + } + p.delegate.SetProviderName(providerName) +} diff --git a/pkg/providers/httpapi/types.go b/pkg/providers/httpapi/types.go new file mode 100644 index 000000000..c8bcdc0dc --- /dev/null +++ b/pkg/providers/httpapi/types.go @@ -0,0 +1,43 @@ +package httpapi + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition + ExtraContent = protocoltypes.ExtraContent + GoogleExtra = protocoltypes.GoogleExtra + ContentBlock = protocoltypes.ContentBlock + CacheControl = protocoltypes.CacheControl +) + +type LLMProvider interface { + Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + ) (*LLMResponse, error) + GetDefaultModel() string +} + +type StreamingProvider interface { + ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), + ) (*LLMResponse, error) +} diff --git a/pkg/providers/httpapi_facade.go b/pkg/providers/httpapi_facade.go new file mode 100644 index 000000000..fea92dc43 --- /dev/null +++ b/pkg/providers/httpapi_facade.go @@ -0,0 +1,46 @@ +package providers + +import httpapi "github.com/sipeed/picoclaw/pkg/providers/httpapi" + +type ( + GeminiProvider = httpapi.GeminiProvider + HTTPProvider = httpapi.HTTPProvider +) + +func NewGeminiProvider( + apiKey string, + apiBase string, + proxy string, + userAgent string, + requestTimeoutSeconds int, + extraBody map[string]any, + customHeaders map[string]string, +) *GeminiProvider { + return httpapi.NewGeminiProvider(apiKey, apiBase, proxy, userAgent, requestTimeoutSeconds, extraBody, customHeaders) +} + +func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { + return httpapi.NewHTTPProvider(apiKey, apiBase, proxy) +} + +func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider { + return httpapi.NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField) +} + +func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + apiKey, apiBase, proxy, maxTokensField, userAgent string, + requestTimeoutSeconds int, + extraBody map[string]any, + customHeaders map[string]string, +) *HTTPProvider { + return httpapi.NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + apiKey, + apiBase, + proxy, + maxTokensField, + userAgent, + requestTimeoutSeconds, + extraBody, + customHeaders, + ) +} diff --git a/pkg/providers/legacy_provider.go b/pkg/providers/legacy_provider.go index 26905159f..4b0815dd4 100644 --- a/pkg/providers/legacy_provider.go +++ b/pkg/providers/legacy_provider.go @@ -18,23 +18,6 @@ import ( func CreateProvider(cfg *config.Config) (LLMProvider, string, error) { model := cfg.Agents.Defaults.GetModelName() - // Ensure model_list is populated from providers config if needed - // This handles two cases: - // 1. ModelList is empty - convert all providers - // 2. ModelList has some entries but not all providers - merge missing ones - if cfg.HasProvidersConfig() { - providerModels := config.ConvertProvidersToModelList(cfg) - existingModelNames := make(map[string]bool) - for _, m := range cfg.ModelList { - existingModelNames[m.ModelName] = true - } - for _, pm := range providerModels { - if !existingModelNames[pm.ModelName] { - cfg.ModelList = append(cfg.ModelList, pm) - } - } - } - // Must have model_list at this point if len(cfg.ModelList) == 0 { return nil, "", fmt.Errorf("no providers configured. Please add entries to model_list in your config") diff --git a/pkg/providers/messageutil/messageutil.go b/pkg/providers/messageutil/messageutil.go new file mode 100644 index 000000000..c4382d894 --- /dev/null +++ b/pkg/providers/messageutil/messageutil.go @@ -0,0 +1,38 @@ +package messageutil + +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +// IsTransientAssistantThoughtMessage reports whether msg is an invalid +// reasoning-only assistant history record. These "hanging" thought messages +// are not a canonical persisted format and should be discarded instead of +// replayed or reconstructed. +func IsTransientAssistantThoughtMessage(msg protocoltypes.Message) bool { + return msg.Role == "assistant" && + strings.TrimSpace(msg.Content) == "" && + strings.TrimSpace(msg.ReasoningContent) != "" && + len(msg.ToolCalls) == 0 && + len(msg.Media) == 0 && + len(msg.Attachments) == 0 && + strings.TrimSpace(msg.ToolCallID) == "" +} + +// FilterInvalidHistoryMessages removes invalid persisted history records such +// as transient assistant thought-only messages. +func FilterInvalidHistoryMessages(history []protocoltypes.Message) []protocoltypes.Message { + if len(history) == 0 { + return []protocoltypes.Message{} + } + + filtered := make([]protocoltypes.Message, 0, len(history)) + for _, msg := range history { + if IsTransientAssistantThoughtMessage(msg) { + continue + } + filtered = append(filtered, msg) + } + return filtered +} diff --git a/pkg/providers/model_ref.go b/pkg/providers/model_ref.go index 0d1b02d16..48e3fb4cb 100644 --- a/pkg/providers/model_ref.go +++ b/pkg/providers/model_ref.go @@ -17,18 +17,13 @@ func ParseModelRef(raw string, defaultProvider string) *ModelRef { return nil } - if idx := strings.Index(raw, "/"); idx > 0 { - provider := NormalizeProvider(raw[:idx]) - model := strings.TrimSpace(raw[idx+1:]) - if model == "" { - return nil - } - return &ModelRef{Provider: provider, Model: model} + provider, model := SplitModelProviderAndID(raw, defaultProvider) + if model == "" { + return nil } - return &ModelRef{ - Provider: NormalizeProvider(defaultProvider), - Model: raw, + Provider: provider, + Model: model, } } @@ -53,6 +48,24 @@ func NormalizeProvider(provider string) string { return "zhipu" case "google": return "gemini" + case "google-antigravity": + return "antigravity" + case "alibaba-coding", "qwen-coding": + return "coding-plan" + case "alibaba-coding-anthropic": + return "coding-plan-anthropic" + case "qwen-international", "dashscope-intl": + return "qwen-intl" + case "dashscope-us": + return "qwen-us" + case "azure-openai": + return "azure" + case "claudecli": + return "claude-cli" + case "codexcli": + return "codex-cli" + case "copilot": + return "github-copilot" } return p diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go index 6dd25167f..9a164bf48 100644 --- a/pkg/providers/model_ref_test.go +++ b/pkg/providers/model_ref_test.go @@ -72,7 +72,20 @@ func TestNormalizeProvider(t *testing.T) { {"claude", "anthropic"}, {"glm", "zhipu"}, {"google", "gemini"}, + {"google-antigravity", "antigravity"}, {"groq", "groq"}, + {"azure-openai", "azure"}, + {"claudecli", "claude-cli"}, + {"codexcli", "codex-cli"}, + {"copilot", "github-copilot"}, + // Alibaba Coding Plan aliases + {"alibaba-coding", "coding-plan"}, + {"qwen-coding", "coding-plan"}, + {"alibaba-coding-anthropic", "coding-plan-anthropic"}, + // Qwen international aliases + {"qwen-international", "qwen-intl"}, + {"dashscope-intl", "qwen-intl"}, + {"dashscope-us", "qwen-us"}, {"", ""}, } @@ -123,3 +136,42 @@ func TestParseModelRef_DefaultProviderNormalization(t *testing.T) { t.Errorf("provider = %q, want openai (normalized from GPT)", ref.Provider) } } + +func TestParseModelRef_UnknownPrefixFallsBackToDefaultProvider(t *testing.T) { + ref := ParseModelRef("meta-llama/Llama-3.1-8B-Instruct", "openai") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "openai" { + t.Fatalf("provider = %q, want openai", ref.Provider) + } + if ref.Model != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("model = %q, want full original model ID", ref.Model) + } +} + +func TestParseModelRef_UnknownPrefixPreservesEmptyDefaultProvider(t *testing.T) { + ref := ParseModelRef("meta-llama/Llama-3.1-8B-Instruct", "") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "" { + t.Fatalf("provider = %q, want empty", ref.Provider) + } + if ref.Model != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("model = %q, want full original model ID", ref.Model) + } +} + +func TestParseModelRef_KnownNonSelectableProvider(t *testing.T) { + ref := ParseModelRef("bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", "openai") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "bedrock" { + t.Fatalf("provider = %q, want bedrock", ref.Provider) + } + if ref.Model != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want preserved bedrock model ID", ref.Model) + } +} diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/oauth/antigravity_provider.go similarity index 85% rename from pkg/providers/antigravity_provider.go rename to pkg/providers/oauth/antigravity_provider.go index 8a1890212..abf1e4bd6 100644 --- a/pkg/providers/antigravity_provider.go +++ b/pkg/providers/oauth/antigravity_provider.go @@ -1,4 +1,4 @@ -package providers +package oauthprovider import ( "bufio" @@ -14,6 +14,7 @@ import ( "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers/common" ) const ( @@ -221,7 +222,7 @@ func (p *AntigravityProvider) buildRequest( } case "user": if msg.ToolCallID != "" { - toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames) + toolName := common.ResolveToolResponseName(msg.ToolCallID, toolCallNames) // Tool result req.Contents = append(req.Contents, antigravityContent{ Role: "user", @@ -248,7 +249,7 @@ func (p *AntigravityProvider) buildRequest( content.Parts = append(content.Parts, antigravityPart{Text: msg.Content}) } for _, tc := range msg.ToolCalls { - toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc) + toolName, toolArgs, thoughtSignature := common.NormalizeStoredToolCall(tc) if toolName == "" { logger.WarnCF( "provider.antigravity", @@ -275,7 +276,7 @@ func (p *AntigravityProvider) buildRequest( req.Contents = append(req.Contents, content) } case "tool": - toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames) + toolName := common.ResolveToolResponseName(msg.ToolCallID, toolCallNames) req.Contents = append(req.Contents, antigravityContent{ Role: "user", Parts: []antigravityPart{{ @@ -290,18 +291,17 @@ func (p *AntigravityProvider) buildRequest( } } - // Build tools (sanitize schemas for Gemini compatibility) + // Build tools if len(tools) > 0 { var funcDecls []antigravityFuncDecl for _, t := range tools { if t.Type != "function" { continue } - params := sanitizeSchemaForGemini(t.Function.Parameters) funcDecls = append(funcDecls, antigravityFuncDecl{ Name: t.Function.Name, Description: t.Function.Description, - Parameters: params, + Parameters: t.Function.Parameters, }) } if len(funcDecls) > 0 { @@ -328,60 +328,6 @@ func (p *AntigravityProvider) buildRequest( return req } -func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) { - name := tc.Name - args := tc.Arguments - thoughtSignature := "" - - if name == "" && tc.Function != nil { - name = tc.Function.Name - thoughtSignature = tc.Function.ThoughtSignature - } else if tc.Function != nil { - thoughtSignature = tc.Function.ThoughtSignature - } - - if args == nil { - args = map[string]any{} - } - - if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" { - var parsed map[string]any - if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil { - args = parsed - } - } - - return name, args, thoughtSignature -} - -func resolveToolResponseName(toolCallID string, toolCallNames map[string]string) string { - if toolCallID == "" { - return "" - } - - if name, ok := toolCallNames[toolCallID]; ok && name != "" { - return name - } - - return inferToolNameFromCallID(toolCallID) -} - -func inferToolNameFromCallID(toolCallID string) string { - if !strings.HasPrefix(toolCallID, "call_") { - return toolCallID - } - - rest := strings.TrimPrefix(toolCallID, "call_") - if idx := strings.LastIndex(rest, "_"); idx > 0 { - candidate := rest[:idx] - if candidate != "" { - return candidate - } - } - - return toolCallID -} - // --- Response parsing --- type antigravityJSONResponse struct { @@ -389,6 +335,7 @@ type antigravityJSONResponse struct { Content struct { Parts []struct { Text string `json:"text,omitempty"` + Thought bool `json:"thought,omitempty"` ThoughtSignature string `json:"thoughtSignature,omitempty"` ThoughtSignatureSnake string `json:"thought_signature,omitempty"` FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"` @@ -406,6 +353,7 @@ type antigravityJSONResponse struct { func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) { var contentParts []string + var reasoningParts []string var toolCalls []ToolCall var usage *UsageInfo var finishReason string @@ -433,7 +381,11 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error for _, candidate := range resp.Candidates { for _, part := range candidate.Content.Parts { if part.Text != "" { - contentParts = append(contentParts, part.Text) + if part.Thought { + reasoningParts = append(reasoningParts, part.Text) + } else { + contentParts = append(contentParts, part.Text) + } } if part.FunctionCall != nil { argumentsJSON, _ := json.Marshal(part.FunctionCall.Args) @@ -475,10 +427,11 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error } return &LLMResponse{ - Content: strings.Join(contentParts, ""), - ToolCalls: toolCalls, - FinishReason: mappedFinish, - Usage: usage, + Content: strings.Join(contentParts, ""), + ReasoningContent: strings.Join(reasoningParts, ""), + ToolCalls: toolCalls, + FinishReason: mappedFinish, + Usage: usage, }, nil } @@ -492,71 +445,6 @@ func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake return "" } -// --- Schema sanitization --- - -// Google/Gemini doesn't support many JSON Schema keywords that other providers accept. -var geminiUnsupportedKeywords = map[string]bool{ - "patternProperties": true, - "additionalProperties": true, - "$schema": true, - "$id": true, - "$ref": true, - "$defs": true, - "definitions": true, - "examples": true, - "minLength": true, - "maxLength": true, - "minimum": true, - "maximum": true, - "multipleOf": true, - "pattern": true, - "format": true, - "minItems": true, - "maxItems": true, - "uniqueItems": true, - "minProperties": true, - "maxProperties": true, -} - -func sanitizeSchemaForGemini(schema map[string]any) map[string]any { - if schema == nil { - return nil - } - - result := make(map[string]any) - for k, v := range schema { - if geminiUnsupportedKeywords[k] { - continue - } - // Recursively sanitize nested objects - switch val := v.(type) { - case map[string]any: - result[k] = sanitizeSchemaForGemini(val) - case []any: - sanitized := make([]any, len(val)) - for i, item := range val { - if m, ok := item.(map[string]any); ok { - sanitized[i] = sanitizeSchemaForGemini(m) - } else { - sanitized[i] = item - } - } - result[k] = sanitized - default: - result[k] = v - } - } - - // Ensure top-level has type: "object" if properties are present - if _, hasProps := result["properties"]; hasProps { - if _, hasType := result["type"]; !hasType { - result["type"] = "object" - } - } - - return result -} - // --- Token source --- func createAntigravityTokenSource() func() (string, string, error) { diff --git a/pkg/providers/oauth/antigravity_provider_test.go b/pkg/providers/oauth/antigravity_provider_test.go new file mode 100644 index 000000000..d85e47dfa --- /dev/null +++ b/pkg/providers/oauth/antigravity_provider_test.go @@ -0,0 +1,142 @@ +package oauthprovider + +import ( + "testing" +) + +func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) { + p := &AntigravityProvider{} + + messages := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_read_file_123", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + }}, + }, + { + Role: "tool", + ToolCallID: "call_read_file_123", + Content: "ok", + }, + } + + req := p.buildRequest(messages, nil, "", nil) + if len(req.Contents) != 2 { + t.Fatalf("expected 2 contents, got %d", len(req.Contents)) + } + + modelPart := req.Contents[0].Parts[0] + if modelPart.FunctionCall == nil { + t.Fatal("expected functionCall in assistant message") + } + if modelPart.FunctionCall.Name != "read_file" { + t.Fatalf("expected functionCall name read_file, got %q", modelPart.FunctionCall.Name) + } + if got := modelPart.FunctionCall.Args["path"]; got != "README.md" { + t.Fatalf("expected functionCall args[path] to be README.md, got %v", got) + } + + toolPart := req.Contents[1].Parts[0] + if toolPart.FunctionResponse == nil { + t.Fatal("expected functionResponse in tool message") + } + if toolPart.FunctionResponse.Name != "read_file" { + t.Fatalf("expected functionResponse name read_file, got %q", toolPart.FunctionResponse.Name) + } +} + +func TestParseSSEResponse_SplitsThoughtAndVisibleContent(t *testing.T) { + p := &AntigravityProvider{} + body := "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hidden reasoning\",\"thought\":true},{\"text\":\"visible answer\"}],\"role\":\"model\"},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":17,\"totalTokenCount\":216}}}\n" + + "data: [DONE]\n" + + resp, err := p.parseSSEResponse(body) + if err != nil { + t.Fatalf("parseSSEResponse() error = %v", err) + } + + if resp.Content != "visible answer" { + t.Fatalf("Content = %q, want %q", resp.Content, "visible answer") + } + if resp.ReasoningContent != "hidden reasoning" { + t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "hidden reasoning") + } + if resp.FinishReason != "stop" { + t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } + if resp.Usage == nil || resp.Usage.TotalTokens != 216 { + t.Fatalf("Usage.TotalTokens = %v, want %d", resp.Usage, 216) + } +} + +func TestBuildRequest_PreservesComplexToolSchemasByDefault(t *testing.T) { + p := &AntigravityProvider{} + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "parent": map[string]any{ + "anyOf": []any{ + map[string]any{"$ref": "#/$defs/pageParent"}, + map[string]any{"$ref": "#/$defs/databaseParent"}, + }, + }, + "icon": map[string]any{ + "anyOf": []any{ + map[string]any{"type": "null"}, + map[string]any{"$ref": "#/$defs/emoji"}, + }, + }, + }, + "$defs": map[string]any{ + "pageParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "page_id": map[string]any{"type": "string"}, + }, + "required": []any{"page_id"}, + }, + "databaseParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "database_id": map[string]any{"type": "string"}, + }, + "required": []any{"database_id"}, + }, + "emoji": map[string]any{ + "type": "string", + "pattern": "^:[a-z_]+:$", + }, + }, + } + + req := p.buildRequest( + []Message{{Role: "user", Content: "hello"}}, + []ToolDefinition{{ + Type: "function", + Function: ToolFunctionDefinition{ + Name: "mcp_notion_create", + Description: "Create a Notion object", + Parameters: schema, + }, + }}, + "gemini-3-flash", + nil, + ) + + if len(req.Tools) != 1 || len(req.Tools[0].FunctionDeclarations) != 1 { + t.Fatalf("request tools = %#v, want one function declaration", req.Tools) + } + + got, ok := req.Tools[0].FunctionDeclarations[0].Parameters.(map[string]any) + if !ok { + t.Fatalf("parameters = %#v, want map", req.Tools[0].FunctionDeclarations[0].Parameters) + } + if got["$defs"] == nil { + t.Fatalf("parameters = %#v, want raw schema with $defs preserved by default", got) + } +} diff --git a/pkg/providers/claude_provider.go b/pkg/providers/oauth/claude_provider.go similarity index 91% rename from pkg/providers/claude_provider.go rename to pkg/providers/oauth/claude_provider.go index 60639ca18..cf0052acd 100644 --- a/pkg/providers/claude_provider.go +++ b/pkg/providers/oauth/claude_provider.go @@ -1,9 +1,10 @@ -package providers +package oauthprovider import ( "context" "fmt" + "github.com/sipeed/picoclaw/pkg/auth" anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic" ) @@ -55,7 +56,7 @@ func (p *ClaudeProvider) GetDefaultModel() string { return p.delegate.GetDefaultModel() } -func createClaudeTokenSource() func() (string, error) { +func CreateClaudeTokenSource(getCredential func(string) (*auth.AuthCredential, error)) func() (string, error) { return func() (string, error) { cred, err := getCredential("anthropic") if err != nil { diff --git a/pkg/providers/claude_provider_test.go b/pkg/providers/oauth/claude_provider_test.go similarity index 99% rename from pkg/providers/claude_provider_test.go rename to pkg/providers/oauth/claude_provider_test.go index 98e07bb80..eea5423c3 100644 --- a/pkg/providers/claude_provider_test.go +++ b/pkg/providers/oauth/claude_provider_test.go @@ -1,4 +1,4 @@ -package providers +package oauthprovider import ( "encoding/json" diff --git a/pkg/providers/codex_provider.go b/pkg/providers/oauth/codex_provider.go similarity index 55% rename from pkg/providers/codex_provider.go rename to pkg/providers/oauth/codex_provider.go index 47618300a..0b125997b 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/oauth/codex_provider.go @@ -1,8 +1,7 @@ -package providers +package oauthprovider import ( "context" - "encoding/json" "errors" "fmt" "strings" @@ -13,10 +12,11 @@ import ( "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/logger" + orc "github.com/sipeed/picoclaw/pkg/providers/openai_responses_common" ) const ( - codexDefaultModel = "gpt-5.2" + codexDefaultModel = "gpt-5.3-codex" codexDefaultInstructions = "You are Codex, a coding assistant." ) @@ -95,7 +95,10 @@ func (p *CodexProvider) Chat( ) } - params := buildCodexParams(messages, tools, resolvedModel, options, p.enableWebSearch) + // Respect tools.web.prefer_native: only inject native search when the agent + // loop passes options["native_search"]=true, so prefer_native=false means no injection. + useNativeSearch := p.enableWebSearch && (options["native_search"] == true) + params := buildCodexParams(messages, tools, resolvedModel, options, useNativeSearch) stream := p.client.Responses.NewStreaming(ctx, params, opts...) defer stream.Close() @@ -150,13 +153,17 @@ func (p *CodexProvider) Chat( return nil, fmt.Errorf("codex API call: stream ended without completed response") } - return parseCodexResponse(resp), nil + return orc.ParseResponseFromStruct(resp), nil } func (p *CodexProvider) GetDefaultModel() string { return codexDefaultModel } +func (p *CodexProvider) SupportsNativeSearch() bool { + return p.enableWebSearch +} + func resolveCodexModel(model string) (string, string) { m := strings.ToLower(strings.TrimSpace(model)) if m == "" { @@ -202,89 +209,14 @@ func resolveCodexModel(model string) (string, string) { func buildCodexParams( messages []Message, tools []ToolDefinition, model string, options map[string]any, enableWebSearch bool, ) responses.ResponseNewParams { - var inputItems responses.ResponseInputParam - var instructions string - - for _, msg := range messages { - switch msg.Role { - case "system": - // Use the full concatenated system prompt (static + dynamic + summary) - // as instructions. This keeps behavior consistent with Anthropic and - // OpenAI-compat adapters where the complete system context lives in - // one place. Prefix caching is handled by prompt_cache_key below, - // not by splitting content across instructions vs input messages. - instructions = msg.Content - case "user": - if msg.ToolCallID != "" { - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ - CallID: msg.ToolCallID, - Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{ - OfString: openai.Opt(msg.Content), - }, - }, - }) - } else { - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfMessage: &responses.EasyInputMessageParam{ - Role: responses.EasyInputMessageRoleUser, - Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, - }, - }) - } - case "assistant": - if len(msg.ToolCalls) > 0 { - if msg.Content != "" { - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfMessage: &responses.EasyInputMessageParam{ - Role: responses.EasyInputMessageRoleAssistant, - Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, - }, - }) - } - for _, tc := range msg.ToolCalls { - name, args, ok := resolveCodexToolCall(tc) - if !ok { - logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]any{ - "call_id": tc.ID, - }) - continue - } - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfFunctionCall: &responses.ResponseFunctionToolCallParam{ - CallID: tc.ID, - Name: name, - Arguments: args, - }, - }) - } - } else { - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfMessage: &responses.EasyInputMessageParam{ - Role: responses.EasyInputMessageRoleAssistant, - Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, - }, - }) - } - case "tool": - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ - CallID: msg.ToolCallID, - Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{ - OfString: openai.Opt(msg.Content), - }, - }, - }) - } - } + inputItems, instructions := orc.TranslateMessages(messages) params := responses.ResponseNewParams{ Model: model, Input: responses.ResponseNewParamsInputUnion{ OfInputItemList: inputItems, }, - Instructions: openai.Opt(instructions), - Store: openai.Opt(false), + Store: openai.Opt(false), } if instructions != "" { @@ -302,116 +234,13 @@ func buildCodexParams( } if len(tools) > 0 || enableWebSearch { - params.Tools = translateToolsForCodex(tools, enableWebSearch) + params.Tools = orc.TranslateTools(tools, enableWebSearch) } return params } -func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) { - name = tc.Name - if name == "" && tc.Function != nil { - name = tc.Function.Name - } - if name == "" { - return "", "", false - } - - if len(tc.Arguments) > 0 { - argsJSON, err := json.Marshal(tc.Arguments) - if err != nil { - return "", "", false - } - return name, string(argsJSON), true - } - - if tc.Function != nil && tc.Function.Arguments != "" { - return name, tc.Function.Arguments, true - } - - return name, "{}", true -} - -func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam { - capHint := len(tools) - if enableWebSearch { - capHint++ - } - result := make([]responses.ToolUnionParam, 0, capHint) - for _, t := range tools { - if t.Type != "function" { - continue - } - if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") { - continue - } - ft := responses.FunctionToolParam{ - Name: t.Function.Name, - Parameters: t.Function.Parameters, - Strict: openai.Opt(false), - } - if t.Function.Description != "" { - ft.Description = openai.Opt(t.Function.Description) - } - result = append(result, responses.ToolUnionParam{OfFunction: &ft}) - } - if enableWebSearch { - result = append(result, responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch)) - } - return result -} - -func parseCodexResponse(resp *responses.Response) *LLMResponse { - var content strings.Builder - var toolCalls []ToolCall - - for _, item := range resp.Output { - switch item.Type { - case "message": - for _, c := range item.Content { - if c.Type == "output_text" { - content.WriteString(c.Text) - } - } - case "function_call": - var args map[string]any - if err := json.Unmarshal([]byte(item.Arguments), &args); err != nil { - args = map[string]any{"raw": item.Arguments} - } - toolCalls = append(toolCalls, ToolCall{ - ID: item.CallID, - Name: item.Name, - Arguments: args, - }) - } - } - - finishReason := "stop" - if len(toolCalls) > 0 { - finishReason = "tool_calls" - } - if resp.Status == "incomplete" { - finishReason = "length" - } - - var usage *UsageInfo - if resp.Usage.TotalTokens > 0 { - usage = &UsageInfo{ - PromptTokens: int(resp.Usage.InputTokens), - CompletionTokens: int(resp.Usage.OutputTokens), - TotalTokens: int(resp.Usage.TotalTokens), - } - } - - return &LLMResponse{ - Content: content.String(), - ToolCalls: toolCalls, - FinishReason: finishReason, - Usage: usage, - } -} - -func createCodexTokenSource() func() (string, string, error) { +func CreateCodexTokenSource() func() (string, string, error) { return func() (string, string, error) { cred, err := auth.GetCredential("openai") if err != nil { diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/oauth/codex_provider_test.go similarity index 97% rename from pkg/providers/codex_provider_test.go rename to pkg/providers/oauth/codex_provider_test.go index 4157e53e9..aeeb18360 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/oauth/codex_provider_test.go @@ -1,4 +1,4 @@ -package providers +package oauthprovider import ( "encoding/json" @@ -10,6 +10,8 @@ import ( "github.com/openai/openai-go/v3" openaiopt "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/responses" + + orc "github.com/sipeed/picoclaw/pkg/providers/openai_responses_common" ) func TestBuildCodexParams_BasicMessage(t *testing.T) { @@ -225,7 +227,7 @@ func TestParseCodexResponse_TextOutput(t *testing.T) { t.Fatalf("unmarshal: %v", err) } - result := parseCodexResponse(&resp) + result := orc.ParseResponseFromStruct(&resp) if result.Content != "Hello there!" { t.Errorf("Content = %q, want %q", result.Content, "Hello there!") } @@ -266,7 +268,7 @@ func TestParseCodexResponse_FunctionCall(t *testing.T) { t.Fatalf("unmarshal: %v", err) } - result := parseCodexResponse(&resp) + result := orc.ParseResponseFromStruct(&resp) if len(result.ToolCalls) != 1 { t.Fatalf("len(ToolCalls) = %d, want 1", len(result.ToolCalls)) } @@ -355,7 +357,9 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]any{"max_tokens": 1024}) + // Pass native_search so Codex injects built-in web search (mirrors agent loop when prefer_native is true). + opts := map[string]any{"max_tokens": 1024, "native_search": true} + resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", opts) if err != nil { t.Fatalf("Chat() error: %v", err) } @@ -568,7 +572,7 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T) provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.2", nil) + resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.3-codex", nil) if err != nil { t.Fatalf("Chat() error: %v", err) } @@ -599,7 +603,7 @@ func TestResolveCodexModel(t *testing.T) { wantFallback: true, }, {name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true}, - {name: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false}, + {name: "openai prefix", input: "openai/gpt-5.3-codex", wantModel: "gpt-5.3-codex", wantFallback: false}, {name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false}, } diff --git a/pkg/providers/oauth/types.go b/pkg/providers/oauth/types.go new file mode 100644 index 000000000..02ea4a21c --- /dev/null +++ b/pkg/providers/oauth/types.go @@ -0,0 +1,32 @@ +package oauthprovider + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition + ExtraContent = protocoltypes.ExtraContent + GoogleExtra = protocoltypes.GoogleExtra + ContentBlock = protocoltypes.ContentBlock + CacheControl = protocoltypes.CacheControl +) + +type LLMProvider interface { + Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + ) (*LLMResponse, error) + GetDefaultModel() string +} diff --git a/pkg/providers/oauth_facade.go b/pkg/providers/oauth_facade.go new file mode 100644 index 000000000..c14117773 --- /dev/null +++ b/pkg/providers/oauth_facade.go @@ -0,0 +1,60 @@ +package providers + +import ( + oauthprovider "github.com/sipeed/picoclaw/pkg/providers/oauth" +) + +type ( + AntigravityProvider = oauthprovider.AntigravityProvider + AntigravityModelInfo = oauthprovider.AntigravityModelInfo + ClaudeProvider = oauthprovider.ClaudeProvider + CodexProvider = oauthprovider.CodexProvider +) + +func NewAntigravityProvider() *AntigravityProvider { + return oauthprovider.NewAntigravityProvider() +} + +func NewClaudeProvider(token string) *ClaudeProvider { + return oauthprovider.NewClaudeProvider(token) +} + +func NewClaudeProviderWithBaseURL(token, apiBase string) *ClaudeProvider { + return oauthprovider.NewClaudeProviderWithBaseURL(token, apiBase) +} + +func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider { + return oauthprovider.NewClaudeProviderWithTokenSource(token, tokenSource) +} + +func NewClaudeProviderWithTokenSourceAndBaseURL( + token string, tokenSource func() (string, error), apiBase string, +) *ClaudeProvider { + return oauthprovider.NewClaudeProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase) +} + +func NewCodexProvider(token, accountID string) *CodexProvider { + return oauthprovider.NewCodexProvider(token, accountID) +} + +func NewCodexProviderWithTokenSource( + token, accountID string, tokenSource func() (string, string, error), +) *CodexProvider { + return oauthprovider.NewCodexProviderWithTokenSource(token, accountID, tokenSource) +} + +func FetchAntigravityProjectID(accessToken string) (string, error) { + return oauthprovider.FetchAntigravityProjectID(accessToken) +} + +func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) { + return oauthprovider.FetchAntigravityModels(accessToken, projectID) +} + +func createClaudeTokenSource() func() (string, error) { + return oauthprovider.CreateClaudeTokenSource(getCredential) +} + +func createCodexTokenSource() func() (string, string, error) { + return oauthprovider.CreateCodexTokenSource() +} diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 5c868626a..be3e77a43 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -8,11 +8,14 @@ import ( "fmt" "io" "log" + "maps" "net/http" "net/url" "strings" "time" + "github.com/sipeed/picoclaw/pkg/providers/common" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -32,13 +35,35 @@ type ( type Provider struct { apiKey string apiBase string + providerName string maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) httpClient *http.Client + extraBody map[string]any // Additional fields to inject into request body + customHeaders map[string]string + userAgent string } type Option func(*Provider) -const defaultRequestTimeout = 120 * time.Second +const defaultRequestTimeout = common.DefaultRequestTimeout + +var stripModelPrefixProviders = map[string]struct{}{ + "litellm": {}, + "venice": {}, + "moonshot": {}, + "nvidia": {}, + "groq": {}, + "ollama": {}, + "deepseek": {}, + "google": {}, + "openrouter": {}, + "zhipu": {}, + "mistral": {}, + "vivgrid": {}, + "minimax": {}, + "novita": {}, + "lmstudio": {}, +} func WithMaxTokensField(maxTokensField string) Option { return func(p *Provider) { @@ -46,6 +71,12 @@ func WithMaxTokensField(maxTokensField string) Option { } } +func WithUserAgent(userAgent string) Option { + return func(p *Provider) { + p.userAgent = userAgent + } +} + func WithRequestTimeout(timeout time.Duration) Option { return func(p *Provider) { if timeout > 0 { @@ -54,26 +85,29 @@ func WithRequestTimeout(timeout time.Duration) Option { } } +func WithExtraBody(extraBody map[string]any) Option { + return func(p *Provider) { + p.extraBody = extraBody + } +} + +func WithCustomHeaders(customHeaders map[string]string) Option { + return func(p *Provider) { + p.customHeaders = customHeaders + } +} + +func WithProviderName(providerName string) Option { + return func(p *Provider) { + p.providerName = strings.ToLower(strings.TrimSpace(providerName)) + } +} + func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { - client := &http.Client{ - Timeout: defaultRequestTimeout, - } - - if proxy != "" { - parsed, err := url.Parse(proxy) - if err == nil { - client.Transport = &http.Transport{ - Proxy: http.ProxyURL(parsed), - } - } else { - log.Printf("openai_compat: invalid proxy URL %q: %v", proxy, err) - } - } - p := &Provider{ apiKey: apiKey, apiBase: strings.TrimRight(apiBase, "/"), - httpClient: client, + httpClient: common.NewHTTPClient(proxy), } for _, opt := range opts { @@ -102,34 +136,28 @@ func NewProviderWithMaxTokensFieldAndTimeout( ) } -func (p *Provider) Chat( - ctx context.Context, - messages []Message, - tools []ToolDefinition, - model string, - options map[string]any, -) (*LLMResponse, error) { - if p.apiBase == "" { - return nil, fmt.Errorf("API base not configured") - } - +// buildRequestBody constructs the common request body for Chat and ChatStream. +func (p *Provider) buildRequestBody( + messages []Message, tools []ToolDefinition, model string, options map[string]any, +) map[string]any { model = normalizeModel(model, p.apiBase) requestBody := map[string]any{ "model": model, - "messages": serializeMessages(messages), + "messages": common.SerializeMessages(p.prepareMessagesForRequest(messages)), } - if len(tools) > 0 { - requestBody["tools"] = tools + // When fallback uses a different provider (e.g. DeepSeek), that provider must not inject web_search_preview. + nativeSearch, _ := options["native_search"].(bool) + nativeSearch = nativeSearch && isNativeSearchHost(p.apiBase) + if len(tools) > 0 || nativeSearch { + requestBody["tools"] = buildToolsList(tools, nativeSearch) requestBody["tool_choice"] = "auto" } - if maxTokens, ok := asInt(options["max_tokens"]); ok { - // Use configured maxTokensField if specified, otherwise fallback to model-based detection + if maxTokens, ok := common.AsInt(options["max_tokens"]); ok { fieldName := p.maxTokensField if fieldName == "" { - // Fallback: detect from model name for backward compatibility lowerModel := strings.ToLower(model) if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || strings.Contains(lowerModel, "gpt-5") { @@ -141,9 +169,8 @@ func (p *Provider) Chat( requestBody[fieldName] = maxTokens } - if temperature, ok := asFloat(options["temperature"]); ok { + if temperature, ok := common.AsFloat(options["temperature"]); ok { lowerModel := strings.ToLower(model) - // Kimi k2 models only support temperature=1. if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") { requestBody["temperature"] = 1.0 } else { @@ -153,16 +180,152 @@ func (p *Provider) Chat( // Prompt caching: pass a stable cache key so OpenAI can bucket requests // with the same key and reuse prefix KV cache across calls. - // The key is typically the agent ID — stable per agent, shared across requests. - // See: https://platform.openai.com/docs/guides/prompt-caching // Prompt caching is only supported by OpenAI-native endpoints. - // Gemini and other providers reject unknown fields, so skip for non-OpenAI APIs. + // Non-OpenAI providers reject unknown fields with 422 errors. if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" { - if !strings.Contains(p.apiBase, "generativelanguage.googleapis.com") { + if supportsPromptCacheKey(p.apiBase) { requestBody["prompt_cache_key"] = cacheKey } } + // Merge extra body fields configured per-provider/model. + // These are injected last so they take precedence over defaults. + maps.Copy(requestBody, p.extraBody) + + return requestBody +} + +func (p *Provider) applyCustomHeaders(req *http.Request) { + for k, v := range p.customHeaders { + if strings.TrimSpace(k) == "" { + continue + } + req.Header.Set(k, v) + } +} + +func (p *Provider) SetProviderName(providerName string) { + p.providerName = strings.ToLower(strings.TrimSpace(providerName)) +} + +func (p *Provider) prepareMessagesForRequest(messages []Message) []Message { + if len(messages) == 0 { + return nil + } + + if p.isDeepSeekReasoningProvider() { + return filterDeepSeekReasoningMessages(messages) + } + return stripReasoningMessages(messages) +} + +func (p *Provider) isDeepSeekReasoningProvider() bool { + return p.providerName == "deepseek" || isDeepSeekHost(p.apiBase) +} + +func isDeepSeekHost(apiBase string) bool { + parsed, err := url.Parse(strings.TrimSpace(apiBase)) + if err != nil { + return false + } + host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) + return host == "deepseek.com" || strings.HasSuffix(host, ".deepseek.com") +} + +func filterDeepSeekReasoningMessages(messages []Message) []Message { + out := make([]Message, 0, len(messages)) + start := 0 + + flush := func(end int) { + if end <= start { + return + } + out = append(out, filterDeepSeekReasoningTurn(messages[start:end])...) + start = end + } + + for i := 1; i < len(messages); i++ { + if messages[i].Role == "user" { + flush(i) + } + } + flush(len(messages)) + + return out +} + +func filterDeepSeekReasoningTurn(messages []Message) []Message { + hasToolInteraction := false + for _, msg := range messages { + if msg.Role == "tool" || (msg.Role == "assistant" && len(msg.ToolCalls) > 0) { + hasToolInteraction = true + break + } + } + + out := make([]Message, 0, len(messages)) + for _, msg := range messages { + if messageutil.IsTransientAssistantThoughtMessage(msg) { + continue + } + + cloned := msg + // DeepSeek thinking-mode replay only requires reasoning_content for + // turns that participate in a tool interaction round. For plain + // assistant turns between two user messages, the docs say the API will + // ignore reasoning_content on replay, so we strip it here. + if cloned.Role == "assistant" && strings.TrimSpace(cloned.ReasoningContent) != "" && !hasToolInteraction { + cloned.ReasoningContent = "" + } + if assistantMessageEmpty(cloned) { + continue + } + out = append(out, cloned) + } + + return out +} + +func stripReasoningMessages(messages []Message) []Message { + out := make([]Message, 0, len(messages)) + for _, msg := range messages { + if messageutil.IsTransientAssistantThoughtMessage(msg) { + continue + } + + cloned := msg + cloned.ReasoningContent = "" + if assistantMessageEmpty(cloned) { + continue + } + out = append(out, cloned) + } + return out +} + +func assistantMessageEmpty(msg Message) bool { + return msg.Role == "assistant" && + strings.TrimSpace(msg.Content) == "" && + strings.TrimSpace(msg.ReasoningContent) == "" && + len(msg.ToolCalls) == 0 && + len(msg.Media) == 0 && + len(msg.Attachments) == 0 && + strings.TrimSpace(msg.ToolCallID) == "" +} + +func (p *Provider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + if p.apiBase == "" { + return nil, fmt.Errorf("API base not configured") + } + + requestBody := p.buildRequestBody(messages, tools, model, options) + jsonData, err := json.Marshal(requestBody) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) @@ -174,9 +337,13 @@ func (p *Provider) Chat( } req.Header.Set("Content-Type", "application/json") + if p.userAgent != "" { + req.Header.Set("User-Agent", p.userAgent) + } if p.apiKey != "" { req.Header.Set("Authorization", "Bearer "+p.apiKey) } + p.applyCustomHeaders(req) resp, err := p.httpClient.Do(req) if err != nil { @@ -184,249 +351,206 @@ func (p *Provider) Chat( } defer resp.Body.Close() - contentType := resp.Header.Get("Content-Type") - - // Non-200: read a prefix to tell HTML error page apart from JSON error body. if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256)) - if readErr != nil { - return nil, fmt.Errorf("failed to read response: %w", readErr) - } - if looksLikeHTML(body, contentType) { - return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, p.apiBase) - } - return nil, fmt.Errorf( - "API request failed:\n Status: %d\n Body: %s", - resp.StatusCode, - responsePreview(body, 128), - ) + return nil, common.HandleErrorResponse(resp, p.apiBase) } - // Peek without consuming so the full stream reaches the JSON decoder. - reader := bufio.NewReader(resp.Body) - prefix, err := reader.Peek(256) // io.EOF/ErrBufferFull are normal; only real errors abort - if err != nil && err != io.EOF && err != bufio.ErrBufferFull { - return nil, fmt.Errorf("failed to inspect response: %w", err) - } - if looksLikeHTML(prefix, contentType) { - return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, p.apiBase) + return common.ReadAndParseResponse(resp, p.apiBase) +} + +// ChatStream implements streaming via OpenAI-compatible SSE (stream: true). +// onChunk receives the accumulated text so far on each text delta. +func (p *Provider) ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), +) (*LLMResponse, error) { + if p.apiBase == "" { + return nil, fmt.Errorf("API base not configured") } - out, err := parseResponse(reader) + requestBody := p.buildRequestBody(messages, tools, model, options) + requestBody["stream"] = true + + jsonData, err := json.Marshal(requestBody) if err != nil { - return nil, fmt.Errorf("failed to parse JSON response: %w", err) + return nil, fmt.Errorf("failed to marshal request: %w", err) } - return out, nil -} - -func wrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error { - respPreview := responsePreview(body, 128) - return fmt.Errorf( - "API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s", - apiBase, - contentType, - statusCode, - respPreview, - ) -} - -func looksLikeHTML(body []byte, contentType string) bool { - contentType = strings.ToLower(strings.TrimSpace(contentType)) - if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") { - return true + req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) } - prefix := bytes.ToLower(leadingTrimmedPrefix(body, 128)) - return bytes.HasPrefix(prefix, []byte("<!doctype html")) || - bytes.HasPrefix(prefix, []byte("<html")) || - bytes.HasPrefix(prefix, []byte("<head")) || - bytes.HasPrefix(prefix, []byte("<body")) + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + if p.userAgent != "" { + req.Header.Set("User-Agent", p.userAgent) + } + if p.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+p.apiKey) + } + p.applyCustomHeaders(req) + + // Use a client without Timeout for streaming — the http.Client.Timeout covers + // the entire request lifecycle including body reads, which would kill long streams. + // Context cancellation still provides the safety net. + streamClient := &http.Client{Transport: p.httpClient.Transport} + resp, err := streamClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, common.HandleErrorResponse(resp, p.apiBase) + } + + return parseStreamResponse(ctx, resp.Body, onChunk) } -func leadingTrimmedPrefix(body []byte, maxLen int) []byte { - i := 0 - for i < len(body) { - switch body[i] { - case ' ', '\t', '\n', '\r', '\f', '\v': - i++ - default: - end := i + maxLen - if end > len(body) { - end = len(body) +// parseStreamResponse parses an OpenAI-compatible SSE stream. +func parseStreamResponse( + ctx context.Context, + reader io.Reader, + onChunk func(accumulated string), +) (*LLMResponse, error) { + var textContent strings.Builder + var finishReason string + var usage *UsageInfo + + // Tool call assembly: OpenAI streams tool calls as incremental deltas + type toolAccum struct { + id string + name string + argsJSON strings.Builder + } + activeTools := map[int]*toolAccum{} + + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) // 1MB initial, 10MB max + for scanner.Scan() { + // Check for context cancellation between chunks + if err := ctx.Err(); err != nil { + return nil, err + } + + line := scanner.Text() + + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + ToolCalls []struct { + Index int `json:"index"` + ID string `json:"id"` + Function *struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` + Usage *UsageInfo `json:"usage"` + } + + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue // skip malformed chunks + } + + if chunk.Usage != nil { + usage = chunk.Usage + } + + if len(chunk.Choices) == 0 { + continue + } + + choice := chunk.Choices[0] + + // Accumulate text content + if choice.Delta.Content != "" { + textContent.WriteString(choice.Delta.Content) + if onChunk != nil { + onChunk(textContent.String()) } - return body[i:end] - } - } - return nil -} - -func responsePreview(body []byte, maxLen int) string { - trimmed := bytes.TrimSpace(body) - if len(trimmed) == 0 { - return "<empty>" - } - if len(trimmed) <= maxLen { - return string(trimmed) - } - return string(trimmed[:maxLen]) + "..." -} - -func parseResponse(body io.Reader) (*LLMResponse, error) { - var apiResponse struct { - Choices []struct { - Message struct { - Content string `json:"content"` - ReasoningContent string `json:"reasoning_content"` - Reasoning string `json:"reasoning"` - ReasoningDetails []ReasoningDetail `json:"reasoning_details"` - ToolCalls []struct { - ID string `json:"id"` - Type string `json:"type"` - Function *struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` - ExtraContent *struct { - Google *struct { - ThoughtSignature string `json:"thought_signature"` - } `json:"google"` - } `json:"extra_content"` - } `json:"tool_calls"` - } `json:"message"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage *UsageInfo `json:"usage"` - } - - if err := json.NewDecoder(body).Decode(&apiResponse); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - if len(apiResponse.Choices) == 0 { - return &LLMResponse{ - Content: "", - FinishReason: "stop", - }, nil - } - - choice := apiResponse.Choices[0] - toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls)) - for _, tc := range choice.Message.ToolCalls { - arguments := make(map[string]any) - name := "" - - // Extract thought_signature from Gemini/Google-specific extra content - thoughtSignature := "" - if tc.ExtraContent != nil && tc.ExtraContent.Google != nil { - thoughtSignature = tc.ExtraContent.Google.ThoughtSignature } - if tc.Function != nil { - name = tc.Function.Name - if tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { - log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err) - arguments["raw"] = tc.Function.Arguments + // Accumulate tool call deltas + for _, tc := range choice.Delta.ToolCalls { + acc, ok := activeTools[tc.Index] + if !ok { + acc = &toolAccum{} + activeTools[tc.Index] = acc + } + if tc.ID != "" { + acc.id = tc.ID + } + if tc.Function != nil { + if tc.Function.Name != "" { + acc.name = tc.Function.Name + } + if tc.Function.Arguments != "" { + acc.argsJSON.WriteString(tc.Function.Arguments) } } } - // Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence - toolCall := ToolCall{ - ID: tc.ID, - Name: name, - Arguments: arguments, - ThoughtSignature: thoughtSignature, + if choice.FinishReason != nil { + finishReason = *choice.FinishReason } + } - if thoughtSignature != "" { - toolCall.ExtraContent = &ExtraContent{ - Google: &GoogleExtra{ - ThoughtSignature: thoughtSignature, - }, + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("streaming read error: %w", err) + } + + // Assemble tool calls from accumulated deltas + var toolCalls []ToolCall + for i := 0; i < len(activeTools); i++ { + acc, ok := activeTools[i] + if !ok { + continue + } + args := make(map[string]any) + raw := acc.argsJSON.String() + if raw != "" { + if err := json.Unmarshal([]byte(raw), &args); err != nil { + log.Printf("openai_compat stream: failed to decode tool call arguments for %q: %v", acc.name, err) + args["raw"] = raw } } + toolCalls = append(toolCalls, ToolCall{ + ID: acc.id, + Name: acc.name, + Arguments: args, + }) + } - toolCalls = append(toolCalls, toolCall) + if finishReason == "" { + finishReason = "stop" } return &LLMResponse{ - Content: choice.Message.Content, - ReasoningContent: choice.Message.ReasoningContent, - Reasoning: choice.Message.Reasoning, - ReasoningDetails: choice.Message.ReasoningDetails, - ToolCalls: toolCalls, - FinishReason: choice.FinishReason, - Usage: apiResponse.Usage, + Content: textContent.String(), + ToolCalls: toolCalls, + FinishReason: finishReason, + Usage: usage, }, nil } -// openaiMessage is the wire-format message for OpenAI-compatible APIs. -// It mirrors protocoltypes.Message but omits SystemParts, which is an -// internal field that would be unknown to third-party endpoints. -type openaiMessage struct { - Role string `json:"role"` - Content string `json:"content"` - ReasoningContent string `json:"reasoning_content,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` -} - -// serializeMessages converts internal Message structs to the OpenAI wire format. -// - Strips SystemParts (unknown to third-party endpoints) -// - Converts messages with Media to multipart content format (text + image_url parts) -// - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages -func serializeMessages(messages []Message) []any { - out := make([]any, 0, len(messages)) - for _, m := range messages { - if len(m.Media) == 0 { - out = append(out, openaiMessage{ - Role: m.Role, - Content: m.Content, - ReasoningContent: m.ReasoningContent, - ToolCalls: m.ToolCalls, - ToolCallID: m.ToolCallID, - }) - continue - } - - // Multipart content format for messages with media - parts := make([]map[string]any, 0, 1+len(m.Media)) - if m.Content != "" { - parts = append(parts, map[string]any{ - "type": "text", - "text": m.Content, - }) - } - for _, mediaURL := range m.Media { - if strings.HasPrefix(mediaURL, "data:image/") { - parts = append(parts, map[string]any{ - "type": "image_url", - "image_url": map[string]any{ - "url": mediaURL, - }, - }) - } - } - - msg := map[string]any{ - "role": m.Role, - "content": parts, - } - if m.ToolCallID != "" { - msg["tool_call_id"] = m.ToolCallID - } - if len(m.ToolCalls) > 0 { - msg["tool_calls"] = m.ToolCalls - } - if m.ReasoningContent != "" { - msg["reasoning_content"] = m.ReasoningContent - } - out = append(out, msg) - } - return out -} - func normalizeModel(model, apiBase string) string { before, after, ok := strings.Cut(model, "/") if !ok { @@ -438,41 +562,50 @@ func normalizeModel(model, apiBase string) string { } prefix := strings.ToLower(before) - switch prefix { - case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", - "openrouter", "zhipu", "mistral", "vivgrid": + if _, ok := stripModelPrefixProviders[prefix]; ok { return after - default: - return model } + + return model } -func asInt(v any) (int, bool) { - switch val := v.(type) { - case int: - return val, true - case int64: - return int(val), true - case float64: - return int(val), true - case float32: - return int(val), true - default: - return 0, false +func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any { + result := make([]any, 0, len(tools)+1) + for _, t := range tools { + if nativeSearch && strings.EqualFold(t.Function.Name, "web_search") { + continue + } + result = append(result, t) } + if nativeSearch { + result = append(result, map[string]any{"type": "web_search_preview"}) + } + return result } -func asFloat(v any) (float64, bool) { - switch val := v.(type) { - case float64: - return val, true - case float32: - return float64(val), true - case int: - return float64(val), true - case int64: - return float64(val), true - default: - return 0, false - } +func (p *Provider) SupportsNativeSearch() bool { + return isNativeSearchHost(p.apiBase) +} + +// isNativeOpenAIOrAzureEndpoint reports whether the given API base points to +// OpenAI's own API or an Azure OpenAI deployment. +func isNativeOpenAIOrAzureEndpoint(apiBase string) bool { + u, err := url.Parse(apiBase) + if err != nil { + return false + } + host := u.Hostname() + return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com") +} + +func isNativeSearchHost(apiBase string) bool { + return isNativeOpenAIOrAzureEndpoint(apiBase) +} + +// supportsPromptCacheKey reports whether the given API base is known to +// support the prompt_cache_key request field. Currently only OpenAI's own +// API and Azure OpenAI support this. All other OpenAI-compatible providers +// (Mistral, Gemini, DeepSeek, Groq, etc.) reject unknown fields with 422 errors. +func supportsPromptCacheKey(apiBase string) bool { + return isNativeOpenAIOrAzureEndpoint(apiBase) } diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 9a3a7acc5..4f68fb393 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -108,6 +109,55 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) { } } +func TestProviderChat_ParsesToolCallsWithObjectArguments(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{ + "content": "", + "tool_calls": []map[string]any{ + { + "id": "call_1", + "type": "function", + "function": map[string]any{ + "name": "get_weather", + "arguments": map[string]any{ + "city": "SF", + "metric": true, + }, + }, + }, + }, + }, + "finish_reason": "tool_calls", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].Name != "get_weather" { + t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather") + } + if out.ToolCalls[0].Arguments["city"] != "SF" { + t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"]) + } + if out.ToolCalls[0].Arguments["metric"] != true { + t.Fatalf("ToolCalls[0].Arguments[metric] = %v, want true", out.ToolCalls[0].Arguments["metric"]) + } +} + func TestProviderChat_ParsesReasoningContent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ @@ -152,7 +202,7 @@ func TestProviderChat_ParsesReasoningContent(t *testing.T) { } } -func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { +func TestProviderChat_StripsReasoningContentForNonDeepSeekHistory(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -175,8 +225,6 @@ func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { p := NewProvider("key", server.URL, "") - // Simulate a multi-turn conversation where the assistant's previous - // reply included reasoning_content (e.g. from kimi-k2.5). messages := []Message{ {Role: "user", Content: "What is 1+1?"}, {Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"}, @@ -188,7 +236,6 @@ func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { t.Fatalf("Chat() error = %v", err) } - // Verify reasoning_content is preserved in the serialized request. reqMessages, ok := requestBody["messages"].([]any) if !ok { t.Fatalf("messages is not []any: %T", requestBody["messages"]) @@ -197,8 +244,391 @@ func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { if !ok { t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1]) } - if assistantMsg["reasoning_content"] != "Let me think... 1+1=2" { - t.Errorf("reasoning_content not preserved in request, got %v", assistantMsg["reasoning_content"]) + if _, exists := assistantMsg["reasoning_content"]; exists { + t.Fatalf( + "reasoning_content should be stripped for non-DeepSeek providers, got %v", + assistantMsg["reasoning_content"], + ) + } +} + +func TestProviderChat_DeepSeekOmitsReasoningContentForNonToolTurnHistory(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + p.apiBase = "https://api.deepseek.com/v1" + p.httpClient = &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + r.URL, _ = url.Parse(server.URL + r.URL.Path) + return http.DefaultTransport.RoundTrip(r) + }), + } + + messages := []Message{ + {Role: "user", Content: "What is 1+1?"}, + {Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"}, + {Role: "user", Content: "What about 2+2?"}, + } + + _, err := p.Chat(t.Context(), messages, nil, "deepseek-v4-flash", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + reqMessages, ok := requestBody["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any: %T", requestBody["messages"]) + } + assistantMsg, ok := reqMessages[1].(map[string]any) + if !ok { + t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1]) + } + if _, exists := assistantMsg["reasoning_content"]; exists { + t.Fatalf( + "reasoning_content should be omitted for DeepSeek non-tool turns, got %v", + assistantMsg["reasoning_content"], + ) + } +} + +func TestProviderChat_DeepSeekPreservesReasoningContentForToolTurnHistory(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + p.SetProviderName("deepseek") + + messages := []Message{ + {Role: "user", Content: "How's the weather tomorrow?"}, + { + Role: "assistant", + Content: "Let me check the date first.", + ReasoningContent: "I need tomorrow's date before checking the weather.", + ToolCalls: []ToolCall{{ + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "get_date", + Arguments: "{}", + }, + }}, + }, + {Role: "tool", ToolCallID: "call_1", Content: "2026-04-24"}, + { + Role: "assistant", + Content: "Tomorrow is 2026-04-25.", + ReasoningContent: "Now I can share the final answer.", + }, + {Role: "user", Content: "What about Guangzhou?"}, + } + + _, err := p.Chat(t.Context(), messages, nil, "deepseek-v4-flash", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + reqMessages, ok := requestBody["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any: %T", requestBody["messages"]) + } + if len(reqMessages) != len(messages) { + t.Fatalf("len(messages) = %d, want %d", len(reqMessages), len(messages)) + } + + firstAssistant, ok := reqMessages[1].(map[string]any) + if !ok { + t.Fatalf("first assistant message is not map[string]any: %T", reqMessages[1]) + } + if firstAssistant["reasoning_content"] != "I need tomorrow's date before checking the weather." { + t.Fatalf("first assistant reasoning_content = %v, want preserved", firstAssistant["reasoning_content"]) + } + + finalAssistant, ok := reqMessages[3].(map[string]any) + if !ok { + t.Fatalf("final assistant message is not map[string]any: %T", reqMessages[3]) + } + if finalAssistant["reasoning_content"] != "Now I can share the final answer." { + t.Fatalf("final assistant reasoning_content = %v, want preserved", finalAssistant["reasoning_content"]) + } +} + +func TestProviderChat_HistoryCanonicalizationMatrix(t *testing.T) { + baseMessages := []Message{ + {Role: "user", Content: "turn1"}, + {Role: "assistant", Content: "plain visible", ReasoningContent: "plain thought"}, + {Role: "user", Content: "turn2"}, + { + Role: "assistant", + Content: "", + ReasoningContent: "tool thought", + ToolCalls: []ToolCall{{ + ID: "call_read_file", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_read_file", Content: "file content"}, + {Role: "user", Content: "turn3"}, + { + Role: "assistant", + Content: "tool visible only", + ToolCalls: []ToolCall{{ + ID: "call_list_dir", + Type: "function", + Function: &FunctionCall{ + Name: "list_dir", + Arguments: `{"path":"."}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_list_dir", Content: "dir listing"}, + {Role: "user", Content: "turn4"}, + { + Role: "assistant", + Content: "tool visible and thought", + ReasoningContent: "tool mixed thought", + ToolCalls: []ToolCall{{ + ID: "call_exec", + Type: "function", + Function: &FunctionCall{ + Name: "exec", + Arguments: `{"command":"pwd"}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_exec", Content: "pwd output"}, + {Role: "user", Content: "current turn"}, + } + + captureRequestMessages := func(t *testing.T, providerName string) []map[string]any { + t.Helper() + + var requestBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + if providerName != "" { + p.SetProviderName(providerName) + } + + _, err := p.Chat(t.Context(), baseMessages, nil, "test-model", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + rawMessages, ok := requestBody["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any: %T", requestBody["messages"]) + } + + out := make([]map[string]any, 0, len(rawMessages)) + for i, raw := range rawMessages { + msg, ok := raw.(map[string]any) + if !ok { + t.Fatalf("messages[%d] is %T, want map[string]any", i, raw) + } + out = append(out, msg) + } + return out + } + + t.Run("deepseek", func(t *testing.T) { + msgs := captureRequestMessages(t, "deepseek") + if len(msgs) != len(baseMessages) { + t.Fatalf("len(messages) = %d, want %d", len(msgs), len(baseMessages)) + } + + if _, ok := msgs[1]["reasoning_content"]; ok { + t.Fatalf( + "turn1 reasoning_content should be stripped for DeepSeek non-tool turn, got %v", + msgs[1]["reasoning_content"], + ) + } + if msgs[3]["reasoning_content"] != "tool thought" { + t.Fatalf("turn2 reasoning_content = %v, want preserved", msgs[3]["reasoning_content"]) + } + if _, ok := msgs[6]["reasoning_content"]; ok { + t.Fatalf("turn3 reasoning_content should be absent, got %v", msgs[6]["reasoning_content"]) + } + if msgs[9]["reasoning_content"] != "tool mixed thought" { + t.Fatalf("turn4 reasoning_content = %v, want preserved", msgs[9]["reasoning_content"]) + } + if msgs[9]["content"] != "tool visible and thought" { + t.Fatalf("turn4 content = %v, want preserved", msgs[9]["content"]) + } + }) + + t.Run("non-deepseek", func(t *testing.T) { + msgs := captureRequestMessages(t, "") + for i, msg := range msgs { + if _, ok := msg["reasoning_content"]; ok { + t.Fatalf( + "messages[%d] reasoning_content should be stripped for non-DeepSeek providers, got %v", + i, + msg["reasoning_content"], + ) + } + } + }) +} + +func TestProviderChat_DeepSeekDocsReplayRequirements(t *testing.T) { + // DeepSeek's thinking-mode and multi-round chat docs distinguish two cases: + // - for a plain assistant turn between two user messages without tool calls, + // reasoning_content does not need to be replayed and the API ignores it if sent; + // - for a turn that participates in a tool-interaction round, assistant + // reasoning_content must be replayed on subsequent requests. + // + // Keep this behavior explicit here so future changes do not "fix" the + // non-tool stripping based on issue reports that are broader than the + // vendor documentation. + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + p.SetProviderName("deepseek") + + messages := []Message{ + {Role: "user", Content: "Who wrote The Hobbit?"}, + {Role: "assistant", Content: "J.R.R. Tolkien.", ReasoningContent: "I know this from general knowledge."}, + {Role: "user", Content: "What's the weather tomorrow?"}, + { + Role: "assistant", + Content: "Let me check the date first.", + ReasoningContent: "I need tomorrow's date before checking the weather.", + ToolCalls: []ToolCall{{ + ID: "call_date", + Type: "function", + Function: &FunctionCall{ + Name: "get_date", + Arguments: "{}", + }, + }}, + }, + {Role: "tool", ToolCallID: "call_date", Content: "2026-04-29"}, + { + Role: "assistant", + Content: "Tomorrow is 2026-04-30.", + ReasoningContent: "Now I can continue with the weather request.", + }, + {Role: "user", Content: "What about Guangzhou?"}, + } + + _, err := p.Chat(t.Context(), messages, nil, "deepseek-v4-flash", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + reqMessages, ok := requestBody["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any: %T", requestBody["messages"]) + } + if len(reqMessages) != len(messages) { + t.Fatalf("len(messages) = %d, want %d", len(reqMessages), len(messages)) + } + + plainAssistant, ok := reqMessages[1].(map[string]any) + if !ok { + t.Fatalf("plain assistant message is not map[string]any: %T", reqMessages[1]) + } + if _, exists := plainAssistant["reasoning_content"]; exists { + t.Fatalf( + "plain DeepSeek turn should omit reasoning_content on replay, got %v", + plainAssistant["reasoning_content"], + ) + } + + toolAssistant, ok := reqMessages[3].(map[string]any) + if !ok { + t.Fatalf("tool assistant message is not map[string]any: %T", reqMessages[3]) + } + if toolAssistant["reasoning_content"] != "I need tomorrow's date before checking the weather." { + t.Fatalf( + "tool assistant reasoning_content = %v, want preserved", + toolAssistant["reasoning_content"], + ) + } + + finalAssistant, ok := reqMessages[5].(map[string]any) + if !ok { + t.Fatalf("final assistant message is not map[string]any: %T", reqMessages[5]) + } + if finalAssistant["reasoning_content"] != "Now I can continue with the weather request." { + t.Fatalf( + "final assistant reasoning_content = %v, want preserved", + finalAssistant["reasoning_content"], + ) } } @@ -382,7 +812,28 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin } } -func TestProviderChat_StripsGroqOllamaDeepseekVivgridPrefixes(t *testing.T) { +func TestProviderChat_StripsKnownProviderPrefixes(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") tests := []struct { name string input string @@ -403,6 +854,16 @@ func TestProviderChat_StripsGroqOllamaDeepseekVivgridPrefixes(t *testing.T) { input: "ollama/qwen2.5:14b", wantModel: "qwen2.5:14b", }, + { + name: "strips lmstudio prefix and keeps nested model", + input: "lmstudio/openai/gpt-oss-20b", + wantModel: "openai/gpt-oss-20b", + }, + { + name: "strips venice prefix", + input: "venice/venice-uncensored", + wantModel: "venice-uncensored", + }, { name: "strips deepseek prefix", input: "deepseek/deepseek-chat", @@ -413,31 +874,25 @@ func TestProviderChat_StripsGroqOllamaDeepseekVivgridPrefixes(t *testing.T) { input: "vivgrid/auto", wantModel: "auto", }, + { + name: "strips novita prefix deepseek model", + input: "novita/deepseek/deepseek-v3.2", + wantModel: "deepseek/deepseek-v3.2", + }, + { + name: "strips novita prefix zai model", + input: "novita/zai-org/glm-5", + wantModel: "zai-org/glm-5", + }, + { + name: "strips novita prefix minimax model", + input: "novita/minimax/minimax-m2.5", + wantModel: "minimax/minimax-m2.5", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var requestBody map[string]any - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - resp := map[string]any{ - "choices": []map[string]any{ - { - "message": map[string]any{"content": "ok"}, - "finish_reason": "stop", - }, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - p := NewProvider("key", server.URL, "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, tt.input, nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -514,6 +969,12 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) { if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" { t.Fatalf("normalizeModel(deepseek) = %q, want %q", got, "deepseek-chat") } + if got := normalizeModel("lmstudio/openai/gpt-oss-20b", "http://localhost:1234/v1"); got != "openai/gpt-oss-20b" { + t.Fatalf("normalizeModel(lmstudio) = %q, want %q", got, "openai/gpt-oss-20b") + } + if got := normalizeModel("venice/venice-uncensored", "https://api.venice.ai/api/v1"); got != "venice-uncensored" { + t.Fatalf("normalizeModel(venice) = %q, want %q", got, "venice-uncensored") + } if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" { t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto") } @@ -523,6 +984,12 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) { if got := normalizeModel("vivgrid/auto", "https://api.vivgrid.com/v1"); got != "auto" { t.Fatalf("normalizeModel(vivgrid auto) = %q, want %q", got, "auto") } + if got := normalizeModel( + "novita/deepseek/deepseek-v3.2", + "https://api.novita.ai/openai", + ); got != "deepseek/deepseek-v3.2" { + t.Fatalf("normalizeModel(novita) = %q, want %q", got, "deepseek/deepseek-v3.2") + } } func TestProvider_RequestTimeoutDefault(t *testing.T) { @@ -539,6 +1006,195 @@ func TestProvider_RequestTimeoutOverride(t *testing.T) { } } +func TestProviderChat_ExtraBodyInjected(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + extraBody := map[string]any{"reasoning_split": true, "custom_field": "test"} + p := NewProvider("key", server.URL, "", WithExtraBody(extraBody)) + + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "minimax/abab7", + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if got, ok := requestBody["reasoning_split"]; !ok || got != true { + t.Fatalf("reasoning_split = %v, want true", got) + } + if got, ok := requestBody["custom_field"]; !ok || got != "test" { + t.Fatalf("custom_field = %v, want test", got) + } +} + +func TestProviderChat_ExtraBodyOverridesOptions(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + extraBody := map[string]any{"temperature": 0.9} + p := NewProvider("key", server.URL, "", WithExtraBody(extraBody)) + + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "gpt-4o", + map[string]any{"temperature": 0.5}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // ExtraBody takes precedence over options since it is merged last. + if got := requestBody["temperature"]; got != float64(0.9) { + t.Fatalf("temperature = %v, want 0.9 (from extraBody, overriding options)", got) + } +} + +func TestProviderChat_CustomHeadersInjected(t *testing.T) { + var gotSource, gotAuth, gotUserAgent string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotSource = r.Header.Get("X-Source") + gotAuth = r.Header.Get("Authorization") + gotUserAgent = r.Header.Get("User-Agent") + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider( + "key", + server.URL, + "", + WithUserAgent("PicoClaw/Test"), + WithCustomHeaders(map[string]string{ + "X-Source": "coding-plan", + "Authorization": "Token custom-auth", + "User-Agent": "Custom-UA/1.0", + }), + ) + + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "gpt-4o", + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if gotSource != "coding-plan" { + t.Fatalf("X-Source = %q, want %q", gotSource, "coding-plan") + } + if gotAuth != "Token custom-auth" { + t.Fatalf("Authorization = %q, want %q", gotAuth, "Token custom-auth") + } + if gotUserAgent != "Custom-UA/1.0" { + t.Fatalf("User-Agent = %q, want %q", gotUserAgent, "Custom-UA/1.0") + } +} + +func TestProviderChatStream_CustomHeadersInjected(t *testing.T) { + var gotSource, gotAuth, gotUserAgent string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotSource = r.Header.Get("X-Source") + gotAuth = r.Header.Get("Authorization") + gotUserAgent = r.Header.Get("User-Agent") + + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n")) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer server.Close() + + p := NewProvider( + "key", + server.URL, + "", + WithUserAgent("PicoClaw/Test"), + WithCustomHeaders(map[string]string{ + "X-Source": "coding-plan", + "Authorization": "Token stream-auth", + "User-Agent": "Custom-UA/Stream", + }), + ) + + out, err := p.ChatStream( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "gpt-4o", + nil, + nil, + ) + if err != nil { + t.Fatalf("ChatStream() error = %v", err) + } + if out.Content != "ok" { + t.Fatalf("Content = %q, want %q", out.Content, "ok") + } + if gotSource != "coding-plan" { + t.Fatalf("X-Source = %q, want %q", gotSource, "coding-plan") + } + if gotAuth != "Token stream-auth" { + t.Fatalf("Authorization = %q, want %q", gotAuth, "Token stream-auth") + } + if gotUserAgent != "Custom-UA/Stream" { + t.Fatalf("User-Agent = %q, want %q", gotUserAgent, "Custom-UA/Stream") + } +} + type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { @@ -599,7 +1255,7 @@ func TestSerializeMessages_PlainText(t *testing.T) { {Role: "user", Content: "hello"}, {Role: "assistant", Content: "hi", ReasoningContent: "thinking..."}, } - result := serializeMessages(messages) + result := common.SerializeMessages(messages) data, err := json.Marshal(result) if err != nil { @@ -621,7 +1277,7 @@ func TestSerializeMessages_WithMedia(t *testing.T) { messages := []protocoltypes.Message{ {Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}}, } - result := serializeMessages(messages) + result := common.SerializeMessages(messages) data, _ := json.Marshal(result) var msgs []map[string]any @@ -654,7 +1310,7 @@ func TestSerializeMessages_MediaWithToolCallID(t *testing.T) { messages := []protocoltypes.Message{ {Role: "tool", Content: "image result", Media: []string{"data:image/png;base64,xyz"}, ToolCallID: "call_1"}, } - result := serializeMessages(messages) + result := common.SerializeMessages(messages) data, _ := json.Marshal(result) var msgs []map[string]any @@ -669,6 +1325,337 @@ func TestSerializeMessages_MediaWithToolCallID(t *testing.T) { } } +// chatWithCacheKey sets up a test server, sends a Chat request with prompt_cache_key, +// and returns the decoded request body for assertion. +func chatWithCacheKey(t *testing.T, apiBase string) map[string]any { + t.Helper() + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + p.apiBase = apiBase + p.httpClient = &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + r.URL, _ = url.Parse(server.URL + r.URL.Path) + return http.DefaultTransport.RoundTrip(r) + }), + } + + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "test-model", + map[string]any{"prompt_cache_key": "agent-main"}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + return requestBody +} + +func TestProviderChat_PromptCacheKeySentToOpenAI(t *testing.T) { + body := chatWithCacheKey(t, "https://api.openai.com/v1") + if body["prompt_cache_key"] != "agent-main" { + t.Fatalf("prompt_cache_key = %v, want %q", body["prompt_cache_key"], "agent-main") + } +} + +func TestProviderChat_PromptCacheKeyOmittedForNonOpenAI(t *testing.T) { + tests := []struct { + name string + apiBase string + }{ + {"mistral", "https://api.mistral.ai/v1"}, + {"gemini", "https://generativelanguage.googleapis.com/v1beta"}, + {"deepseek", "https://api.deepseek.com/v1"}, + {"groq", "https://api.groq.com/openai/v1"}, + {"minimax", "https://api.minimaxi.com/v1"}, + {"ollama_local", "http://localhost:11434/v1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := chatWithCacheKey(t, tt.apiBase) + if _, exists := body["prompt_cache_key"]; exists { + t.Fatalf("prompt_cache_key should NOT be sent to %s, but was included in request", tt.name) + } + }) + } +} + +func TestSupportsPromptCacheKey(t *testing.T) { + tests := []struct { + apiBase string + want bool + }{ + {"https://api.openai.com/v1", true}, + {"https://api.openai.com/v1/", true}, + {"https://myresource.openai.azure.com/openai/deployments/gpt-4", true}, + {"https://eastus.openai.azure.com/v1", true}, + {"https://api.mistral.ai/v1", false}, + {"https://generativelanguage.googleapis.com/v1beta", false}, + {"https://api.deepseek.com/v1", false}, + {"https://api.groq.com/openai/v1", false}, + {"http://localhost:11434/v1", false}, + {"https://openrouter.ai/api/v1", false}, + // Edge cases: proxy URLs with openai.com in path should NOT match + {"https://my-proxy.com/api.openai.com/v1", false}, + {"https://proxy.example.com/openai.azure.com/v1", false}, + // Malformed or empty + {"", false}, + {"not-a-url", false}, + } + for _, tt := range tests { + if got := supportsPromptCacheKey(tt.apiBase); got != tt.want { + t.Errorf("supportsPromptCacheKey(%q) = %v, want %v", tt.apiBase, got, tt.want) + } + } +} + +func TestBuildToolsList_NativeSearchAddsWebSearchPreview(t *testing.T) { + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, + } + result := buildToolsList(tools, true) + if len(result) != 2 { + t.Fatalf("len(result) = %d, want 2", len(result)) + } + wsEntry, ok := result[1].(map[string]any) + if !ok { + t.Fatalf("web search entry is %T, want map[string]any", result[1]) + } + if wsEntry["type"] != "web_search_preview" { + t.Fatalf("type = %v, want web_search_preview", wsEntry["type"]) + } +} + +func TestBuildToolsList_NativeSearchFiltersClientWebSearch(t *testing.T) { + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}}, + {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, + } + result := buildToolsList(tools, true) + for _, entry := range result { + if td, ok := entry.(ToolDefinition); ok && strings.EqualFold(td.Function.Name, "web_search") { + t.Fatal("client-side web_search should be filtered out when native search is enabled") + } + } + if len(result) != 2 { // read_file + web_search_preview + t.Fatalf("len(result) = %d, want 2 (read_file + web_search_preview)", len(result)) + } +} + +func TestBuildToolsList_NoNativeSearchPassesThrough(t *testing.T) { + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}}, + {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, + } + result := buildToolsList(tools, false) + if len(result) != 2 { + t.Fatalf("len(result) = %d, want 2", len(result)) + } +} + +func TestIsNativeSearchHost(t *testing.T) { + tests := []struct { + apiBase string + want bool + }{ + {"https://api.openai.com/v1", true}, + {"https://myresource.openai.azure.com/openai/deployments/gpt-4", true}, + {"https://api.mistral.ai/v1", false}, + {"https://api.deepseek.com/v1", false}, + {"https://api.groq.com/openai/v1", false}, + {"http://localhost:11434/v1", false}, + {"", false}, + } + for _, tt := range tests { + if got := isNativeSearchHost(tt.apiBase); got != tt.want { + t.Errorf("isNativeSearchHost(%q) = %v, want %v", tt.apiBase, got, tt.want) + } + } +} + +func TestSupportsNativeSearch_OpenAI(t *testing.T) { + p := NewProvider("key", "https://api.openai.com/v1", "") + if !p.SupportsNativeSearch() { + t.Fatal("OpenAI provider should support native search") + } +} + +func TestSupportsNativeSearch_NonOpenAI(t *testing.T) { + p := NewProvider("key", "https://api.deepseek.com/v1", "") + if p.SupportsNativeSearch() { + t.Fatal("DeepSeek provider should not support native search") + } +} + +func TestProviderChat_NativeSearchToolInjected(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + p.apiBase = "https://api.openai.com/v1" + p.httpClient = &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + r.URL, _ = url.Parse(server.URL + r.URL.Path) + return http.DefaultTransport.RoundTrip(r) + }), + } + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, + } + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + tools, + "gpt-5.4", + map[string]any{"native_search": true}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + toolsRaw, ok := requestBody["tools"].([]any) + if !ok { + t.Fatalf("tools is %T, want []any", requestBody["tools"]) + } + if len(toolsRaw) != 2 { + t.Fatalf("len(tools) = %d, want 2 (read_file + web_search_preview)", len(toolsRaw)) + } + + lastTool, ok := toolsRaw[1].(map[string]any) + if !ok { + t.Fatalf("last tool is %T, want map[string]any", toolsRaw[1]) + } + if lastTool["type"] != "web_search_preview" { + t.Fatalf("last tool type = %v, want web_search_preview", lastTool["type"]) + } +} + +func TestProviderChat_NativeSearchNotInjectedWithoutOption(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}}, + } + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + tools, + "gpt-5.4", + map[string]any{}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + toolsRaw, ok := requestBody["tools"].([]any) + if !ok { + t.Fatalf("tools is %T, want []any", requestBody["tools"]) + } + if len(toolsRaw) != 1 { + t.Fatalf("len(tools) = %d, want 1 (web_search only)", len(toolsRaw)) + } +} + +// TestProviderChat_NativeSearchIgnoredOnNonOpenAI verifies that when native_search +// is true in options but the provider's apiBase is not OpenAI (e.g. fallback to DeepSeek), +// we do not inject web_search_preview to avoid API errors. +func TestProviderChat_NativeSearchIgnoredOnNonOpenAI(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Use server.URL so host is not api.openai.com — simulates DeepSeek/other provider + p := NewProvider("key", server.URL, "") + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "deepseek-chat", + map[string]any{"native_search": true}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // Should not have tools at all (no tools passed, and we must not add web_search_preview) + if toolsRaw, ok := requestBody["tools"]; ok { + t.Fatalf("tools should be omitted for non-OpenAI when only native_search was requested, got %v", toolsRaw) + } +} + func TestSerializeMessages_StripsSystemParts(t *testing.T) { messages := []protocoltypes.Message{ { @@ -679,7 +1666,7 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) { }, }, } - result := serializeMessages(messages) + result := common.SerializeMessages(messages) data, _ := json.Marshal(result) raw := string(data) diff --git a/pkg/providers/openai_responses_common/responses_common.go b/pkg/providers/openai_responses_common/responses_common.go new file mode 100644 index 000000000..17b731ed4 --- /dev/null +++ b/pkg/providers/openai_responses_common/responses_common.go @@ -0,0 +1,278 @@ +// Package openai_responses_common provides shared utilities for providers +// that use the OpenAI Responses API (e.g., Azure, Codex). +package openai_responses_common + +import ( + "encoding/json" + "io" + "strings" + + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/responses" + + "github.com/sipeed/picoclaw/pkg/providers/common" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +// TranslateMessages converts internal Message entries to the OpenAI Responses API +// input format. System messages are extracted as instructions (returned separately), +// user/assistant/tool messages become ResponseInputItemUnionParam entries. +// Supports multipart media (images, audio). +func TranslateMessages(messages []protocoltypes.Message) (input responses.ResponseInputParam, instructions string) { + input = make(responses.ResponseInputParam, 0, len(messages)) + + for _, msg := range messages { + switch msg.Role { + case "system": + instructions = msg.Content + case "user": + if msg.ToolCallID != "" { + input = append(input, responses.ResponseInputItemUnionParam{ + OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ + CallID: msg.ToolCallID, + Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{ + OfString: openai.Opt(msg.Content), + }, + }, + }) + } else if len(msg.Media) > 0 { + content := BuildMultipartContent(msg.Content, msg.Media) + input = append(input, responses.ResponseInputItemUnionParam{ + OfInputMessage: &responses.ResponseInputItemMessageParam{ + Role: "user", + Content: content, + }, + }) + } else { + input = append(input, responses.ResponseInputItemUnionParam{ + OfMessage: &responses.EasyInputMessageParam{ + Role: responses.EasyInputMessageRoleUser, + Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, + }, + }) + } + case "assistant": + if len(msg.ToolCalls) > 0 { + if msg.Content != "" { + input = append(input, responses.ResponseInputItemUnionParam{ + OfMessage: &responses.EasyInputMessageParam{ + Role: responses.EasyInputMessageRoleAssistant, + Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, + }, + }) + } + for _, tc := range msg.ToolCalls { + name, args, ok := ResolveToolCall(tc) + if !ok { + continue + } + input = append(input, responses.ResponseInputItemUnionParam{ + OfFunctionCall: &responses.ResponseFunctionToolCallParam{ + CallID: tc.ID, + Name: name, + Arguments: args, + }, + }) + } + } else { + input = append(input, responses.ResponseInputItemUnionParam{ + OfMessage: &responses.EasyInputMessageParam{ + Role: responses.EasyInputMessageRoleAssistant, + Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, + }, + }) + } + case "tool": + input = append(input, responses.ResponseInputItemUnionParam{ + OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ + CallID: msg.ToolCallID, + Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{ + OfString: openai.Opt(msg.Content), + }, + }, + }) + } + } + + return input, instructions +} + +// BuildMultipartContent constructs a ResponseInputMessageContentListParam from +// text content and media URLs (data:image/... and data:audio/... URIs). +func BuildMultipartContent(text string, media []string) responses.ResponseInputMessageContentListParam { + parts := make(responses.ResponseInputMessageContentListParam, 0, 1+len(media)) + + if text != "" { + parts = append(parts, responses.ResponseInputContentUnionParam{ + OfInputText: &responses.ResponseInputTextParam{ + Text: text, + }, + }) + } + + for _, mediaURL := range media { + if strings.HasPrefix(mediaURL, "data:image/") { + parts = append(parts, responses.ResponseInputContentUnionParam{ + OfInputImage: &responses.ResponseInputImageParam{ + ImageURL: openai.Opt(mediaURL), + Detail: responses.ResponseInputImageDetailAuto, + }, + }) + } else if strings.HasPrefix(mediaURL, "data:audio/") { + if format, data, ok := common.ParseDataAudioURL(mediaURL); ok { + parts = append(parts, responses.ResponseInputContentUnionParam{ + OfInputFile: &responses.ResponseInputFileParam{ + FileData: openai.Opt(data), + Filename: openai.Opt("audio." + format), + }, + }) + } + } + } + + return parts +} + +// ResolveToolCall extracts the function name and JSON arguments string from a ToolCall. +// Returns ok=false if the tool call has no name or if arguments fail to marshal. +func ResolveToolCall(tc protocoltypes.ToolCall) (name string, arguments string, ok bool) { + name = tc.Name + if name == "" && tc.Function != nil { + name = tc.Function.Name + } + if name == "" { + return "", "", false + } + + if len(tc.Arguments) > 0 { + argsJSON, err := json.Marshal(tc.Arguments) + if err != nil { + return "", "", false + } + return name, string(argsJSON), true + } + + if tc.Function != nil && tc.Function.Arguments != "" { + return name, tc.Function.Arguments, true + } + + return name, "{}", true +} + +// TranslateTools converts internal ToolDefinition entries to the OpenAI Responses API +// tool format. If enableWebSearch is true, a web_search tool is appended and any +// user-defined tool named "web_search" is skipped to avoid duplicates. +func TranslateTools(tools []protocoltypes.ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam { + capHint := len(tools) + if enableWebSearch { + capHint++ + } + result := make([]responses.ToolUnionParam, 0, capHint) + + for _, t := range tools { + if t.Type != "function" { + continue + } + if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") { + continue + } + ft := responses.FunctionToolParam{ + Name: t.Function.Name, + Parameters: t.Function.Parameters, + Strict: openai.Opt(false), + } + if t.Function.Description != "" { + ft.Description = openai.Opt(t.Function.Description) + } + result = append(result, responses.ToolUnionParam{OfFunction: &ft}) + } + + if enableWebSearch { + result = append(result, responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch)) + } + + return result +} + +// ParseResponseBody parses an OpenAI Responses API JSON body into an LLMResponse. +// Handles output item types: "message" (output_text + refusal), "function_call", and "reasoning". +func ParseResponseBody(body io.Reader) (*protocoltypes.LLMResponse, error) { + var apiResp responses.Response + if err := json.NewDecoder(body).Decode(&apiResp); err != nil { + return nil, err + } + + return parseResponse(&apiResp), nil +} + +// ParseResponseFromStruct converts a decoded responses.Response into an LLMResponse. +// Used by providers that receive the Response struct directly (e.g., via streaming SDK). +func ParseResponseFromStruct(resp *responses.Response) *protocoltypes.LLMResponse { + return parseResponse(resp) +} + +// parseResponse is the shared implementation for extracting LLMResponse fields +// from a decoded responses.Response. +func parseResponse(apiResp *responses.Response) *protocoltypes.LLMResponse { + var content strings.Builder + var reasoningContent strings.Builder + var toolCalls []protocoltypes.ToolCall + + for _, item := range apiResp.Output { + switch item.Type { + case "message": + for _, c := range item.Content { + switch c.Type { + case "output_text": + content.WriteString(c.Text) + case "refusal": + content.WriteString(c.Refusal) + } + } + case "function_call": + var args map[string]any + if err := json.Unmarshal([]byte(item.Arguments), &args); err != nil { + args = map[string]any{"raw": item.Arguments} + } + toolCalls = append(toolCalls, protocoltypes.ToolCall{ + ID: item.CallID, + Name: item.Name, + Arguments: args, + }) + case "reasoning": + for _, s := range item.Summary { + reasoningContent.WriteString(s.Text) + } + } + } + + finishReason := "stop" + if len(toolCalls) > 0 { + finishReason = "tool_calls" + } + switch apiResp.Status { + case responses.ResponseStatusIncomplete: + finishReason = "length" + case responses.ResponseStatusFailed: + finishReason = "error" + case responses.ResponseStatusCancelled: + finishReason = "canceled" + } + + var usage *protocoltypes.UsageInfo + if apiResp.Usage.TotalTokens > 0 { + usage = &protocoltypes.UsageInfo{ + PromptTokens: int(apiResp.Usage.InputTokens), + CompletionTokens: int(apiResp.Usage.OutputTokens), + TotalTokens: int(apiResp.Usage.TotalTokens), + } + } + + return &protocoltypes.LLMResponse{ + Content: content.String(), + ReasoningContent: reasoningContent.String(), + ToolCalls: toolCalls, + FinishReason: finishReason, + Usage: usage, + } +} diff --git a/pkg/providers/openai_responses_common/responses_common_test.go b/pkg/providers/openai_responses_common/responses_common_test.go new file mode 100644 index 000000000..ace91edf0 --- /dev/null +++ b/pkg/providers/openai_responses_common/responses_common_test.go @@ -0,0 +1,579 @@ +package openai_responses_common + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/openai/openai-go/v3/responses" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +// --- TranslateMessages tests --- + +func TestTranslateMessages_SystemExtractedAsInstructions(t *testing.T) { + msgs := []protocoltypes.Message{ + {Role: "system", Content: "You are helpful"}, + {Role: "user", Content: "Hi"}, + } + input, instructions := TranslateMessages(msgs) + if instructions != "You are helpful" { + t.Errorf("instructions = %q, want %q", instructions, "You are helpful") + } + if len(input) != 1 { + t.Fatalf("len(input) = %d, want 1", len(input)) + } + if input[0].OfMessage == nil { + t.Fatal("expected user message item") + } +} + +func TestTranslateMessages_UserTextMessage(t *testing.T) { + msgs := []protocoltypes.Message{ + {Role: "user", Content: "Hello"}, + } + input, instructions := TranslateMessages(msgs) + if instructions != "" { + t.Errorf("instructions = %q, want empty", instructions) + } + if len(input) != 1 { + t.Fatalf("len(input) = %d, want 1", len(input)) + } + if input[0].OfMessage == nil { + t.Fatal("expected EasyInputMessage") + } + if string(input[0].OfMessage.Role) != "user" { + t.Errorf("role = %q, want %q", input[0].OfMessage.Role, "user") + } +} + +func TestTranslateMessages_UserWithToolCallID(t *testing.T) { + msgs := []protocoltypes.Message{ + {Role: "user", Content: `{"temp":72}`, ToolCallID: "call_1"}, + } + input, _ := TranslateMessages(msgs) + if len(input) != 1 { + t.Fatalf("len(input) = %d, want 1", len(input)) + } + if input[0].OfFunctionCallOutput == nil { + t.Fatal("expected FunctionCallOutput for user with ToolCallID") + } + if input[0].OfFunctionCallOutput.CallID != "call_1" { + t.Errorf("CallID = %q, want %q", input[0].OfFunctionCallOutput.CallID, "call_1") + } +} + +func TestTranslateMessages_UserWithMedia(t *testing.T) { + msgs := []protocoltypes.Message{ + {Role: "user", Content: "Describe this", Media: []string{"data:image/png;base64,abc123"}}, + } + input, _ := TranslateMessages(msgs) + if len(input) != 1 { + t.Fatalf("len(input) = %d, want 1", len(input)) + } + if input[0].OfInputMessage == nil { + t.Fatal("expected InputMessage for multipart content") + } + if input[0].OfInputMessage.Role != "user" { + t.Errorf("role = %q, want %q", input[0].OfInputMessage.Role, "user") + } +} + +func TestTranslateMessages_AssistantWithToolCalls(t *testing.T) { + msgs := []protocoltypes.Message{ + {Role: "user", Content: "Weather?"}, + { + Role: "assistant", + Content: "Let me check", + ToolCalls: []protocoltypes.ToolCall{ + {ID: "call_1", Name: "get_weather", Arguments: map[string]any{"city": "SF"}}, + }, + }, + {Role: "tool", Content: `{"temp":72}`, ToolCallID: "call_1"}, + } + input, _ := TranslateMessages(msgs) + // user + assistant text + function_call + tool output = 4 items + if len(input) != 4 { + t.Fatalf("len(input) = %d, want 4", len(input)) + } + // item[1] = assistant text + if input[1].OfMessage == nil { + t.Fatal("expected assistant text message") + } + // item[2] = function call + if input[2].OfFunctionCall == nil { + t.Fatal("expected function call") + } + if input[2].OfFunctionCall.Name != "get_weather" { + t.Errorf("function name = %q, want %q", input[2].OfFunctionCall.Name, "get_weather") + } + // item[3] = tool output + if input[3].OfFunctionCallOutput == nil { + t.Fatal("expected function call output") + } +} + +func TestTranslateMessages_AssistantWithoutToolCalls(t *testing.T) { + msgs := []protocoltypes.Message{ + {Role: "assistant", Content: "Sure thing"}, + } + input, _ := TranslateMessages(msgs) + if len(input) != 1 { + t.Fatalf("len(input) = %d, want 1", len(input)) + } + if input[0].OfMessage == nil { + t.Fatal("expected EasyInputMessage for assistant without tool calls") + } +} + +func TestTranslateMessages_ToolMessage(t *testing.T) { + msgs := []protocoltypes.Message{ + {Role: "tool", Content: "result data", ToolCallID: "call_99"}, + } + input, _ := TranslateMessages(msgs) + if len(input) != 1 { + t.Fatalf("len(input) = %d, want 1", len(input)) + } + if input[0].OfFunctionCallOutput == nil { + t.Fatal("expected FunctionCallOutput") + } + if input[0].OfFunctionCallOutput.CallID != "call_99" { + t.Errorf("CallID = %q, want %q", input[0].OfFunctionCallOutput.CallID, "call_99") + } +} + +// --- ResolveToolCall tests --- + +func TestResolveToolCall_FromNameAndArguments(t *testing.T) { + tc := protocoltypes.ToolCall{ + Name: "get_weather", + Arguments: map[string]any{"city": "SF"}, + } + name, args, ok := ResolveToolCall(tc) + if !ok { + t.Fatal("expected ok=true") + } + if name != "get_weather" { + t.Errorf("name = %q, want %q", name, "get_weather") + } + if !strings.Contains(args, "SF") { + t.Errorf("args = %q, want to contain SF", args) + } +} + +func TestResolveToolCall_FromFunctionField(t *testing.T) { + tc := protocoltypes.ToolCall{ + ID: "call_1", + Function: &protocoltypes.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + } + name, args, ok := ResolveToolCall(tc) + if !ok { + t.Fatal("expected ok=true") + } + if name != "read_file" { + t.Errorf("name = %q, want %q", name, "read_file") + } + if args != `{"path":"README.md"}` { + t.Errorf("args = %q, want %q", args, `{"path":"README.md"}`) + } +} + +func TestResolveToolCall_EmptyName(t *testing.T) { + tc := protocoltypes.ToolCall{} + _, _, ok := ResolveToolCall(tc) + if ok { + t.Error("expected ok=false for empty tool call") + } +} + +func TestResolveToolCall_NoArgsFallsBackToEmptyObject(t *testing.T) { + tc := protocoltypes.ToolCall{Name: "do_something"} + name, args, ok := ResolveToolCall(tc) + if !ok { + t.Fatal("expected ok=true") + } + if name != "do_something" { + t.Errorf("name = %q, want %q", name, "do_something") + } + if args != "{}" { + t.Errorf("args = %q, want %q", args, "{}") + } +} + +// --- TranslateTools tests --- + +func TestTranslateTools_FunctionTools(t *testing.T) { + tools := []protocoltypes.ToolDefinition{ + { + Type: "function", + Function: protocoltypes.ToolFunctionDefinition{ + Name: "get_weather", + Description: "Get weather", + Parameters: map[string]any{"type": "object"}, + }, + }, + } + result := TranslateTools(tools, false) + if len(result) != 1 { + t.Fatalf("len(result) = %d, want 1", len(result)) + } + if result[0].OfFunction == nil { + t.Fatal("expected function tool") + } + if result[0].OfFunction.Name != "get_weather" { + t.Errorf("name = %q, want %q", result[0].OfFunction.Name, "get_weather") + } +} + +func TestTranslateTools_SkipsNonFunction(t *testing.T) { + tools := []protocoltypes.ToolDefinition{ + {Type: "not_function"}, + } + result := TranslateTools(tools, false) + if len(result) != 0 { + t.Errorf("len(result) = %d, want 0", len(result)) + } +} + +func TestTranslateTools_WebSearchAppended(t *testing.T) { + result := TranslateTools(nil, true) + if len(result) != 1 { + t.Fatalf("len(result) = %d, want 1", len(result)) + } + if result[0].OfWebSearch == nil { + t.Fatal("expected web_search tool") + } +} + +func TestTranslateTools_WebSearchReplacesUserDefined(t *testing.T) { + tools := []protocoltypes.ToolDefinition{ + { + Type: "function", + Function: protocoltypes.ToolFunctionDefinition{ + Name: "web_search", + Parameters: map[string]any{"type": "object"}, + }, + }, + { + Type: "function", + Function: protocoltypes.ToolFunctionDefinition{ + Name: "read_file", + Parameters: map[string]any{"type": "object"}, + }, + }, + } + result := TranslateTools(tools, true) + if len(result) != 2 { + t.Fatalf("len(result) = %d, want 2", len(result)) + } + if result[0].OfFunction == nil || result[0].OfFunction.Name != "read_file" { + t.Errorf("first tool should be read_file, got %v", result[0]) + } + if result[1].OfWebSearch == nil { + t.Error("second tool should be web_search") + } +} + +func TestTranslateTools_DescriptionOmittedWhenEmpty(t *testing.T) { + tools := []protocoltypes.ToolDefinition{ + { + Type: "function", + Function: protocoltypes.ToolFunctionDefinition{ + Name: "no_desc", + Parameters: map[string]any{"type": "object"}, + }, + }, + } + result := TranslateTools(tools, false) + if len(result) != 1 { + t.Fatalf("len(result) = %d, want 1", len(result)) + } + if result[0].OfFunction.Description.Valid() { + t.Error("Description should not be set when empty") + } +} + +// --- ParseResponseBody tests --- + +func TestParseResponseBody_TextOutput(t *testing.T) { + body := strings.NewReader(fmt.Sprintf(`{ + "id": "resp_123", + "object": "response", + "status": "%s", + "output": [ + { + "type": "message", + "content": [{"type": "output_text", "text": "Hello!"}] + } + ], + "usage": { + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 15, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0} + } + }`, string(responses.ResponseStatusCompleted))) + + result, err := ParseResponseBody(body) + if err != nil { + t.Fatalf("ParseResponseBody error: %v", err) + } + if result.Content != "Hello!" { + t.Errorf("Content = %q, want %q", result.Content, "Hello!") + } + if result.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop") + } + if result.Usage.TotalTokens != 15 { + t.Errorf("TotalTokens = %d, want 15", result.Usage.TotalTokens) + } +} + +func TestParseResponseBody_FunctionCall(t *testing.T) { + body := strings.NewReader(fmt.Sprintf(`{ + "id": "resp_456", + "object": "response", + "status": "%s", + "output": [ + { + "type": "function_call", + "call_id": "call_abc", + "name": "get_weather", + "arguments": "{\"city\":\"SF\"}" + } + ], + "usage": { + "input_tokens": 10, + "output_tokens": 8, + "total_tokens": 18, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0} + } + }`, string(responses.ResponseStatusCompleted))) + + result, err := ParseResponseBody(body) + if err != nil { + t.Fatalf("ParseResponseBody error: %v", err) + } + if len(result.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(result.ToolCalls)) + } + if result.ToolCalls[0].Name != "get_weather" { + t.Errorf("Name = %q, want %q", result.ToolCalls[0].Name, "get_weather") + } + if result.ToolCalls[0].ID != "call_abc" { + t.Errorf("ID = %q, want %q", result.ToolCalls[0].ID, "call_abc") + } + if result.FinishReason != "tool_calls" { + t.Errorf("FinishReason = %q, want %q", result.FinishReason, "tool_calls") + } +} + +func TestParseResponseBody_Reasoning(t *testing.T) { + body := strings.NewReader(fmt.Sprintf(`{ + "id": "resp_789", + "object": "response", + "status": "%s", + "output": [ + { + "type": "reasoning", + "id": "rs_1", + "summary": [{"type": "summary_text", "text": "Thinking about it..."}] + }, + { + "type": "message", + "content": [{"type": "output_text", "text": "The answer is 42."}] + } + ], + "usage": { + "input_tokens": 10, + "output_tokens": 20, + "total_tokens": 30, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 10} + } + }`, string(responses.ResponseStatusCompleted))) + + result, err := ParseResponseBody(body) + if err != nil { + t.Fatalf("ParseResponseBody error: %v", err) + } + if result.Content != "The answer is 42." { + t.Errorf("Content = %q, want %q", result.Content, "The answer is 42.") + } + if result.ReasoningContent != "Thinking about it..." { + t.Errorf("ReasoningContent = %q, want %q", result.ReasoningContent, "Thinking about it...") + } +} + +func TestParseResponseBody_Refusal(t *testing.T) { + body := strings.NewReader(fmt.Sprintf(`{ + "id": "resp_ref", + "object": "response", + "status": "%s", + "output": [ + { + "type": "message", + "content": [{"type": "refusal", "refusal": "I cannot help with that."}] + } + ], + "usage": { + "input_tokens": 5, + "output_tokens": 5, + "total_tokens": 10, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0} + } + }`, string(responses.ResponseStatusCompleted))) + + result, err := ParseResponseBody(body) + if err != nil { + t.Fatalf("ParseResponseBody error: %v", err) + } + if result.Content != "I cannot help with that." { + t.Errorf("Content = %q, want %q", result.Content, "I cannot help with that.") + } +} + +func TestParseResponseBody_IncompleteStatus(t *testing.T) { + body := strings.NewReader(fmt.Sprintf(`{ + "id": "resp_inc", + "object": "response", + "status": "%s", + "output": [ + { + "type": "message", + "content": [{"type": "output_text", "text": "partial"}] + } + ], + "usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0}} + }`, string(responses.ResponseStatusIncomplete))) + + result, err := ParseResponseBody(body) + if err != nil { + t.Fatalf("error: %v", err) + } + if result.FinishReason != "length" { + t.Errorf("FinishReason = %q, want %q", result.FinishReason, "length") + } +} + +func TestParseResponseBody_FailedStatus(t *testing.T) { + body := strings.NewReader(fmt.Sprintf(`{ + "id": "resp_fail", + "object": "response", + "status": "%s", + "output": [], + "usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0}} + }`, string(responses.ResponseStatusFailed))) + + result, err := ParseResponseBody(body) + if err != nil { + t.Fatalf("error: %v", err) + } + if result.FinishReason != "error" { + t.Errorf("FinishReason = %q, want %q", result.FinishReason, "error") + } +} + +func TestParseResponseBody_CanceledStatus(t *testing.T) { + body := strings.NewReader(fmt.Sprintf(`{ + "id": "resp_cancel", + "object": "response", + "status": "%s", + "output": [], + "usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0}} + }`, string(responses.ResponseStatusCancelled))) + + result, err := ParseResponseBody(body) + if err != nil { + t.Fatalf("error: %v", err) + } + if result.FinishReason != "canceled" { + t.Errorf("FinishReason = %q, want %q", result.FinishReason, "canceled") + } +} + +// --- BuildMultipartContent tests --- + +func TestBuildMultipartContent_TextOnly(t *testing.T) { + parts := BuildMultipartContent("hello", nil) + if len(parts) != 1 { + t.Fatalf("len(parts) = %d, want 1", len(parts)) + } + if parts[0].OfInputText == nil { + t.Fatal("expected text part") + } +} + +func TestBuildMultipartContent_TextAndImage(t *testing.T) { + parts := BuildMultipartContent("describe", []string{"data:image/png;base64,abc"}) + if len(parts) != 2 { + t.Fatalf("len(parts) = %d, want 2", len(parts)) + } + if parts[0].OfInputText == nil { + t.Error("first part should be text") + } + if parts[1].OfInputImage == nil { + t.Error("second part should be image") + } +} + +func TestBuildMultipartContent_AudioFile(t *testing.T) { + parts := BuildMultipartContent("", []string{"data:audio/wav;base64,AAAA"}) + if len(parts) != 1 { + t.Fatalf("len(parts) = %d, want 1", len(parts)) + } + if parts[0].OfInputFile == nil { + t.Fatal("expected file part for audio") + } +} + +func TestBuildMultipartContent_EmptyTextSkipped(t *testing.T) { + parts := BuildMultipartContent("", []string{"data:image/png;base64,abc"}) + if len(parts) != 1 { + t.Fatalf("len(parts) = %d, want 1", len(parts)) + } + if parts[0].OfInputImage == nil { + t.Error("should only have image part") + } +} + +// --- JSON serialization sanity checks --- + +func TestTranslateTools_SerializesToJSON(t *testing.T) { + tools := []protocoltypes.ToolDefinition{ + { + Type: "function", + Function: protocoltypes.ToolFunctionDefinition{ + Name: "test_tool", + Description: "A test", + Parameters: map[string]any{"type": "object"}, + }, + }, + } + result := TranslateTools(tools, true) + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal error: %v", err) + } + s := string(data) + if !strings.Contains(s, "test_tool") { + t.Errorf("JSON should contain test_tool, got: %s", s) + } + if !strings.Contains(s, "web_search") { + t.Errorf("JSON should contain web_search, got: %s", s) + } +} diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 194c1aa6f..bab4433e7 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -11,7 +11,8 @@ type ToolCall struct { } type ExtraContent struct { - Google *GoogleExtra `json:"google,omitempty"` + Google *GoogleExtra `json:"google,omitempty"` + ToolFeedbackExplanation string `json:"tool_feedback_explanation,omitempty"` } type GoogleExtra struct { @@ -60,21 +61,50 @@ type ContentBlock struct { Type string `json:"type"` // "text" Text string `json:"text"` CacheControl *CacheControl `json:"cache_control,omitempty"` + + // Prompt metadata is internal to the agent runtime. It records which + // structured prompt segment produced this block without changing provider + // JSON. + PromptLayer string `json:"-"` + PromptSlot string `json:"-"` + PromptSource string `json:"-"` +} + +type Attachment struct { + Type string `json:"type,omitempty"` + Ref string `json:"ref,omitempty"` + URL string `json:"url,omitempty"` + Filename string `json:"filename,omitempty"` + ContentType string `json:"content_type,omitempty"` } type Message struct { Role string `json:"role"` Content string `json:"content"` Media []string `json:"media,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` + + // Prompt metadata is internal to the agent runtime. It records where a + // message or system part came from without changing provider/session JSON. + PromptLayer string `json:"-"` + PromptSlot string `json:"-"` + PromptSource string `json:"-"` } type ToolDefinition struct { Type string `json:"type"` Function ToolFunctionDefinition `json:"function"` + + // Prompt metadata is internal to the agent runtime. Tool definitions are + // model-visible capability prompts even though providers send them outside + // the system message. + PromptLayer string `json:"-"` + PromptSlot string `json:"-"` + PromptSource string `json:"-"` } type ToolFunctionDefinition struct { diff --git a/pkg/providers/provider_catalog.go b/pkg/providers/provider_catalog.go new file mode 100644 index 000000000..a9178cb81 --- /dev/null +++ b/pkg/providers/provider_catalog.go @@ -0,0 +1,181 @@ +package providers + +import ( + "sort" + "strings" +) + +// ModelProviderOption describes a canonical provider entry exposed to the Web UI. +type ModelProviderOption struct { + ID string `json:"id"` + DefaultAPIBase string `json:"default_api_base"` + EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"` + CreateAllowed bool `json:"create_allowed"` + DefaultModelAllowed bool `json:"default_model_allowed"` + DefaultAuthMethod string `json:"default_auth_method,omitempty"` + AuthMethodLocked bool `json:"auth_method_locked,omitempty"` +} + +type attachedModelProviderMeta struct { + protocolMeta + createAllowed bool + defaultModelAllowed bool + defaultAuthMethod string + authMethodLocked bool +} + +// attachedModelProviderMetaByName augments protocolMetaByName for provider +// families that are implemented in CreateProviderFromConfig but intentionally +// kept out of the core HTTP metadata map because they have special auth/runtime +// semantics. +var attachedModelProviderMetaByName = map[string]attachedModelProviderMeta{ + "azure": {createAllowed: true, defaultModelAllowed: true}, + "anthropic": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, + createAllowed: true, + defaultModelAllowed: true, + }, + "anthropic-messages": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, + createAllowed: true, + defaultModelAllowed: true, + }, + "bedrock": {createAllowed: true, defaultModelAllowed: true}, + "antigravity": { + createAllowed: true, + defaultModelAllowed: true, + defaultAuthMethod: "oauth", + authMethodLocked: true, + }, + "claude-cli": {createAllowed: true, defaultModelAllowed: true}, + "codex-cli": {createAllowed: true, defaultModelAllowed: true}, + "github-copilot": { + protocolMeta: protocolMeta{defaultAPIBase: "localhost:4321"}, + createAllowed: true, + defaultModelAllowed: true, + }, + // ElevenLabs is intentionally exposed only as an ASR-capable provider. It + // belongs in the shared model catalog because ASR is configured via + // model_list, but it must not be selectable as the default chat model. + "elevenlabs": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.elevenlabs.io"}, + createAllowed: true, + defaultModelAllowed: false, + }, +} + +// ModelProviderOptions returns the canonical provider catalog exposed to the Web UI. +func ModelProviderOptions() []ModelProviderOption { + optionsByID := make(map[string]ModelProviderOption, len(protocolMetaByName)+len(attachedModelProviderMetaByName)) + for provider := range protocolMetaByName { + if NormalizeProvider(provider) != provider { + continue + } + optionsByID[provider] = ModelProviderOption{ + ID: provider, + DefaultAPIBase: DefaultAPIBaseForProtocol(provider), + EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider), + CreateAllowed: true, + DefaultModelAllowed: true, + } + } + for provider, meta := range attachedModelProviderMetaByName { + if NormalizeProvider(provider) != provider { + continue + } + optionsByID[provider] = ModelProviderOption{ + ID: provider, + DefaultAPIBase: meta.defaultAPIBase, + EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed, + CreateAllowed: meta.createAllowed, + DefaultModelAllowed: meta.defaultModelAllowed, + DefaultAuthMethod: meta.defaultAuthMethod, + AuthMethodLocked: meta.authMethodLocked, + } + } + + options := make([]ModelProviderOption, 0, len(optionsByID)) + for _, option := range optionsByID { + options = append(options, option) + } + sort.Slice(options, func(i, j int) bool { + return options[i].ID < options[j].ID + }) + return options +} + +// IsSupportedModelProvider reports whether provider resolves to a provider ID +// returned by ModelProviderOptions. +func IsSupportedModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + _, ok := attachedModelProviderMetaByName[normalized] + return ok +} + +// IsCreatableModelProvider reports whether provider can be selected for a new +// model entry from the Web UI. +func IsCreatableModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + meta, ok := attachedModelProviderMetaByName[normalized] + return ok && meta.createAllowed +} + +// IsDefaultModelProvider reports whether provider can be used as the default +// chat model. Some providers such as ASR-only entries are intentionally +// exposed in model_list management but cannot drive the gateway default model. +func IsDefaultModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + meta, ok := attachedModelProviderMetaByName[normalized] + return ok && meta.defaultModelAllowed +} + +// SplitModelProviderAndID separates a legacy "provider/model" string into its +// effective provider and canonical model ID. Unknown prefixes are treated as +// part of the model ID and fall back to defaultProvider. +func SplitModelProviderAndID(model, defaultProvider string) (provider, modelID string) { + model = strings.TrimSpace(model) + if model == "" { + return "", "" + } + + provider, modelID = splitKnownProviderModel(model) + if provider != "" || modelID != "" { + return provider, modelID + } + + return NormalizeProvider(defaultProvider), model +} + +func splitKnownProviderModel(model string) (provider, modelID string) { + provider, modelID, found := strings.Cut(strings.TrimSpace(model), "/") + if !found { + return "", "" + } + provider = strings.TrimSpace(provider) + modelID = strings.TrimSpace(modelID) + if provider == "" { + return "", modelID + } + if !IsSupportedModelProvider(provider) { + return "", "" + } + return NormalizeProvider(provider), modelID +} diff --git a/pkg/providers/ratelimiter.go b/pkg/providers/ratelimiter.go new file mode 100644 index 000000000..f475b58fb --- /dev/null +++ b/pkg/providers/ratelimiter.go @@ -0,0 +1,144 @@ +package providers + +import ( + "context" + "sync" + "time" +) + +// RateLimiter implements a token-bucket rate limiter for a single key. +// Allows up to RPM requests per minute with a burst equal to RPM. +// Thread-safe. +type RateLimiter struct { + mu sync.Mutex + rpm int + tokens float64 + maxBurst float64 + lastTick time.Time + nowFunc func() time.Time // for testing +} + +func (rl *RateLimiter) refillLocked(now time.Time) { + elapsed := now.Sub(rl.lastTick).Seconds() + rl.lastTick = now + + // Refill tokens proportional to elapsed time. + refill := elapsed * float64(rl.rpm) / 60.0 + rl.tokens = min(rl.maxBurst, rl.tokens+refill) +} + +// newRateLimiter creates a RateLimiter that allows rpm requests/minute. +func newRateLimiter(rpm int) *RateLimiter { + return &RateLimiter{ + rpm: rpm, + tokens: float64(rpm), // start full + maxBurst: float64(rpm), + lastTick: time.Now(), + nowFunc: time.Now, + } +} + +// Wait blocks until a token is available or ctx is canceled. +// Returns ctx.Err() if canceled while waiting. +func (rl *RateLimiter) Wait(ctx context.Context) error { + for { + rl.mu.Lock() + now := rl.nowFunc() + rl.refillLocked(now) + + if rl.tokens >= 1.0 { + rl.tokens-- + rl.mu.Unlock() + return nil + } + + // Calculate how long until a token is available. + deficit := 1.0 - rl.tokens + waitSec := deficit / (float64(rl.rpm) / 60.0) + rl.mu.Unlock() + + timer := time.NewTimer(time.Duration(waitSec * float64(time.Second))) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return ctx.Err() + case <-timer.C: + // Loop to re-check (another goroutine may have consumed the token). + } + } +} + +// TryAcquire attempts to consume a token without blocking. +func (rl *RateLimiter) TryAcquire() bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + rl.refillLocked(rl.nowFunc()) + if rl.tokens < 1.0 { + return false + } + rl.tokens-- + return true +} + +// RateLimiterRegistry holds per-candidate rate limiters. +// Candidates with RPM=0 are unrestricted. +// Thread-safe for concurrent reads/writes. +type RateLimiterRegistry struct { + mu sync.RWMutex + limiters map[string]*RateLimiter +} + +// NewRateLimiterRegistry creates an empty registry. +func NewRateLimiterRegistry() *RateLimiterRegistry { + return &RateLimiterRegistry{ + limiters: make(map[string]*RateLimiter), + } +} + +// Register adds a rate limiter for the given key at the given RPM. +// If rpm <= 0, no limiter is registered (unrestricted). +func (r *RateLimiterRegistry) Register(key string, rpm int) { + if rpm <= 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.limiters[key] = newRateLimiter(rpm) +} + +// Wait acquires a token for the given key, blocking if needed. +// If no limiter is registered for key, returns immediately. +func (r *RateLimiterRegistry) Wait(ctx context.Context, key string) error { + r.mu.RLock() + rl := r.limiters[key] + r.mu.RUnlock() + if rl == nil { + return nil + } + return rl.Wait(ctx) +} + +// TryAcquire attempts to consume a token for the given key without blocking. +// If no limiter is registered for key, it returns true. +func (r *RateLimiterRegistry) TryAcquire(key string) bool { + r.mu.RLock() + rl := r.limiters[key] + r.mu.RUnlock() + if rl == nil { + return true + } + return rl.TryAcquire() +} + +// RegisterCandidates registers rate limiters for all candidates that have RPM > 0. +// Candidates with RPM == 0 are ignored (no restriction). +func (r *RateLimiterRegistry) RegisterCandidates(candidates []FallbackCandidate) { + for _, c := range candidates { + if c.RPM > 0 { + r.Register(c.StableKey(), c.RPM) + } + } +} diff --git a/pkg/providers/ratelimiter_test.go b/pkg/providers/ratelimiter_test.go new file mode 100644 index 000000000..9972616e9 --- /dev/null +++ b/pkg/providers/ratelimiter_test.go @@ -0,0 +1,209 @@ +package providers + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +// TestRateLimiter_AllowsUpToRPM verifies that up to RPM requests pass immediately +// (burst capacity) and the (RPM+1)-th request is delayed. +func TestRateLimiter_AllowsUpToRPM(t *testing.T) { + rpm := 5 + rl := newRateLimiter(rpm) + + // All rpm tokens should be available immediately (bucket starts full). + for i := 0; i < rpm; i++ { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + if err := rl.Wait(ctx); err != nil { + t.Fatalf("request %d should pass immediately, got: %v", i+1, err) + } + cancel() + } + + // The next request must wait; cancel it to confirm it blocks. + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + err := rl.Wait(ctx) + if err == nil { + t.Fatal("expected request beyond RPM to block, but it passed immediately") + } +} + +// TestRateLimiter_ContextCancellation verifies that a blocked Wait respects cancellation. +func TestRateLimiter_ContextCancellation(t *testing.T) { + rl := newRateLimiter(1) + + // Drain the one token. + ctx := context.Background() + if err := rl.Wait(ctx); err != nil { + t.Fatalf("first request failed: %v", err) + } + + // Second request should block; cancel it. + cancelCtx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + err := rl.Wait(cancelCtx) + if err == nil { + t.Fatal("expected cancellation error, got nil") + } +} + +// TestRateLimiter_TokenRefill verifies that tokens refill over time. +func TestRateLimiter_TokenRefill(t *testing.T) { + rpm := 60 // 1 token per second + rl := newRateLimiter(rpm) + + // Drain all tokens. + for i := 0; i < rpm; i++ { + rl.Wait(context.Background()) //nolint:errcheck + } + + // Advance time via nowFunc: simulate 2 seconds passing (should give 2 tokens). + start := time.Now() + rl.nowFunc = func() time.Time { return start.Add(2 * time.Second) } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if err := rl.Wait(ctx); err != nil { + t.Fatalf("expected refilled token to be available: %v", err) + } +} + +// TestRateLimiterRegistry_NoLimiter verifies that keys without a registered limiter pass freely. +func TestRateLimiterRegistry_NoLimiter(t *testing.T) { + r := NewRateLimiterRegistry() + ctx := context.Background() + for i := 0; i < 100; i++ { + if err := r.Wait(ctx, "unregistered/key"); err != nil { + t.Fatalf("unregistered key should not block: %v", err) + } + } +} + +// TestRateLimiterRegistry_ZeroRPM verifies that RPM=0 means no limiter is registered. +func TestRateLimiterRegistry_ZeroRPM(t *testing.T) { + r := NewRateLimiterRegistry() + r.Register("some/key", 0) + ctx := context.Background() + for i := 0; i < 50; i++ { + if err := r.Wait(ctx, "some/key"); err != nil { + t.Fatalf("zero-RPM key should not block: %v", err) + } + } +} + +// TestRateLimiterRegistry_Enforcement verifies the registry enforces RPM per key. +func TestRateLimiterRegistry_Enforcement(t *testing.T) { + r := NewRateLimiterRegistry() + r.Register("openai/gpt-4o", 3) + + // First 3 calls should pass (burst = RPM). + for i := 0; i < 3; i++ { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + if err := r.Wait(ctx, "openai/gpt-4o"); err != nil { + t.Fatalf("call %d should pass: %v", i+1, err) + } + cancel() + } + + // 4th call should block. + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if err := r.Wait(ctx, "openai/gpt-4o"); err == nil { + t.Fatal("4th call should have been rate-limited") + } +} + +// TestRateLimiterRegistry_RegisterCandidates verifies that RegisterCandidates +// correctly picks up RPM from FallbackCandidate. +func TestRateLimiterRegistry_RegisterCandidates(t *testing.T) { + r := NewRateLimiterRegistry() + candidates := []FallbackCandidate{ + {Provider: "openai", Model: "gpt-4o", RPM: 2}, + {Provider: "anthropic", Model: "claude-3", RPM: 0}, // no limit + } + r.RegisterCandidates(candidates) + + // openai/gpt-4o: 2 tokens burst, 3rd should block. + for i := 0; i < 2; i++ { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + if err := r.Wait(ctx, "openai/gpt-4o"); err != nil { + t.Fatalf("openai call %d should pass: %v", i+1, err) + } + cancel() + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if err := r.Wait(ctx, "openai/gpt-4o"); err == nil { + t.Fatal("openai 3rd call should have been limited") + } + + // anthropic/claude-3: no limit, should always pass. + for i := 0; i < 10; i++ { + if err := r.Wait(context.Background(), "anthropic/claude-3"); err != nil { + t.Fatalf("anthropic call should not be limited: %v", err) + } + } +} + +func TestRateLimiterRegistry_RegisterCandidatesUsesStableIdentity(t *testing.T) { + r := NewRateLimiterRegistry() + candidates := []FallbackCandidate{ + {Provider: "openai", Model: "gpt-4o", RPM: 1, IdentityKey: "model_name:primary"}, + {Provider: "openai", Model: "gpt-4o", RPM: 2, IdentityKey: "model_name:fallback"}, + } + r.RegisterCandidates(candidates) + + if err := r.Wait(context.Background(), "model_name:primary"); err != nil { + t.Fatalf("primary first call should pass: %v", err) + } + if err := r.Wait(context.Background(), "model_name:fallback"); err != nil { + t.Fatalf("fallback first call should pass: %v", err) + } + if err := r.Wait(context.Background(), "model_name:fallback"); err != nil { + t.Fatalf("fallback second call should pass: %v", err) + } + + ctxPrimary, cancelPrimary := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancelPrimary() + if err := r.Wait(ctxPrimary, "model_name:primary"); err == nil { + t.Fatal("primary second call should have been limited") + } + + ctxFallback, cancelFallback := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancelFallback() + if err := r.Wait(ctxFallback, "model_name:fallback"); err == nil { + t.Fatal("fallback third call should have been limited") + } +} + +// TestRateLimiter_Concurrency verifies thread safety under concurrent access. +func TestRateLimiter_Concurrency(t *testing.T) { + rpm := 20 + rl := newRateLimiter(rpm) + var passed atomic.Int64 + var wg sync.WaitGroup + + // Launch 30 goroutines; only ~20 should pass immediately. + for i := 0; i < 30; i++ { + wg.Add(1) + go func() { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + if rl.Wait(ctx) == nil { + passed.Add(1) + } + }() + } + wg.Wait() + + got := passed.Load() + // Allow small timing slack: between rpm-2 and rpm+2. + if got < int64(rpm-2) || got > int64(rpm+2) { + t.Fatalf("expected ~%d immediate passes, got %d", rpm, got) + } +} diff --git a/pkg/providers/tool_schema_transform.go b/pkg/providers/tool_schema_transform.go new file mode 100644 index 000000000..6b6cab7a6 --- /dev/null +++ b/pkg/providers/tool_schema_transform.go @@ -0,0 +1,84 @@ +package providers + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/providers/common" +) + +type toolSchemaTransformProvider struct { + delegate LLMProvider + transform string +} + +type toolSchemaStreamingProvider struct { + *toolSchemaTransformProvider +} + +func wrapProviderWithToolSchemaTransform(delegate LLMProvider, transform string) (LLMProvider, error) { + transform, err := common.NormalizeToolSchemaTransform(transform) + if err != nil { + return nil, err + } + if transform == common.ToolSchemaTransformOff || delegate == nil { + return delegate, nil + } + base := &toolSchemaTransformProvider{ + delegate: delegate, + transform: transform, + } + if _, ok := delegate.(StreamingProvider); ok { + return &toolSchemaStreamingProvider{toolSchemaTransformProvider: base}, nil + } + return base, nil +} + +func (p *toolSchemaTransformProvider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + transformed, err := common.TransformToolDefinitions(tools, p.transform) + if err != nil { + return nil, err + } + return p.delegate.Chat(ctx, messages, transformed, model, options) +} + +func (p *toolSchemaTransformProvider) GetDefaultModel() string { + return p.delegate.GetDefaultModel() +} + +func (p *toolSchemaStreamingProvider) ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), +) (*LLMResponse, error) { + streaming := p.delegate.(StreamingProvider) + transformed, err := common.TransformToolDefinitions(tools, p.transform) + if err != nil { + return nil, err + } + return streaming.ChatStream(ctx, messages, transformed, model, options, onChunk) +} + +func (p *toolSchemaTransformProvider) SupportsThinking() bool { + tc, ok := p.delegate.(ThinkingCapable) + return ok && tc.SupportsThinking() +} + +func (p *toolSchemaTransformProvider) SupportsNativeSearch() bool { + ns, ok := p.delegate.(NativeSearchCapable) + return ok && ns.SupportsNativeSearch() +} + +func (p *toolSchemaTransformProvider) Close() { + if stateful, ok := p.delegate.(StatefulProvider); ok { + stateful.Close() + } +} diff --git a/pkg/providers/tool_schema_transform_test.go b/pkg/providers/tool_schema_transform_test.go new file mode 100644 index 000000000..a162c3cb4 --- /dev/null +++ b/pkg/providers/tool_schema_transform_test.go @@ -0,0 +1,104 @@ +package providers + +import ( + "context" + "reflect" + "testing" + + providercommon "github.com/sipeed/picoclaw/pkg/providers/common" +) + +type toolCaptureProvider struct { + lastTools []ToolDefinition +} + +func (p *toolCaptureProvider) Chat( + _ context.Context, + _ []Message, + tools []ToolDefinition, + _ string, + _ map[string]any, +) (*LLMResponse, error) { + p.lastTools = tools + return &LLMResponse{Content: "ok"}, nil +} + +func (p *toolCaptureProvider) GetDefaultModel() string { + return "test" +} + +func TestWrapProviderWithToolSchemaTransform_DisabledPassesToolsThrough(t *testing.T) { + capture := &toolCaptureProvider{} + wrapped, err := wrapProviderWithToolSchemaTransform(capture, "") + if err != nil { + t.Fatalf("wrapProviderWithToolSchemaTransform() error = %v", err) + } + + tools := []ToolDefinition{{ + Type: "function", + Function: ToolFunctionDefinition{ + Name: "noop", + Parameters: map[string]any{"type": "object"}, + }, + }} + + _, err = wrapped.Chat(t.Context(), nil, tools, "test", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if !reflect.DeepEqual(capture.lastTools, tools) { + t.Fatalf("tools mutated with transform off\n got: %#v\nwant: %#v", capture.lastTools, tools) + } +} + +func TestWrapProviderWithToolSchemaTransform_GoogleSanitizesSchemas(t *testing.T) { + capture := &toolCaptureProvider{} + wrapped, err := wrapProviderWithToolSchemaTransform(capture, "simple") + if err != nil { + t.Fatalf("wrapProviderWithToolSchemaTransform() error = %v", err) + } + + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "parent": map[string]any{ + "anyOf": []any{ + map[string]any{"$ref": "#/$defs/pageParent"}, + map[string]any{"$ref": "#/$defs/databaseParent"}, + }, + }, + }, + "$defs": map[string]any{ + "pageParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "page_id": map[string]any{"type": "string"}, + }, + }, + "databaseParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "database_id": map[string]any{"type": "string"}, + }, + }, + }, + } + tools := []ToolDefinition{{ + Type: "function", + Function: ToolFunctionDefinition{ + Name: "mcp_notion_create", + Parameters: schema, + }, + }} + + _, err = wrapped.Chat(t.Context(), nil, tools, "test", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + want := providercommon.SanitizeSchemaForGoogle(schema) + got := capture.lastTools[0].Function.Parameters + if !reflect.DeepEqual(got, want) { + t.Fatalf("sanitized parameters mismatch\n got: %#v\nwant: %#v", got, want) + } +} diff --git a/pkg/providers/toolcall_utils_test.go b/pkg/providers/toolcall_utils_test.go new file mode 100644 index 000000000..a4bb03c2e --- /dev/null +++ b/pkg/providers/toolcall_utils_test.go @@ -0,0 +1,24 @@ +package providers + +import "testing" + +func TestNormalizeToolCall_PreservesExtraContentGoogleThoughtSignature(t *testing.T) { + tc := NormalizeToolCall(ToolCall{ + ID: "call_1", + Name: "search", + Arguments: map[string]any{"q": "pico"}, + ExtraContent: &ExtraContent{ + Google: &GoogleExtra{ThoughtSignature: "sig-1"}, + }, + }) + + if tc.ThoughtSignature != "sig-1" { + t.Fatalf("ThoughtSignature = %q, want sig-1", tc.ThoughtSignature) + } + if tc.Function == nil { + t.Fatal("Function is nil") + } + if tc.Function.ThoughtSignature != "sig-1" { + t.Fatalf("Function.ThoughtSignature = %q, want sig-1", tc.Function.ThoughtSignature) + } +} diff --git a/pkg/providers/types.go b/pkg/providers/types.go index 68bbd1e65..23406bc45 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -19,6 +19,7 @@ type ( GoogleExtra = protocoltypes.GoogleExtra ContentBlock = protocoltypes.ContentBlock CacheControl = protocoltypes.CacheControl + Attachment = protocoltypes.Attachment ) type LLMProvider interface { @@ -37,6 +38,20 @@ type StatefulProvider interface { Close() } +// StreamingProvider is an optional interface for providers that support token streaming. +// onChunk receives the accumulated text so far (not individual deltas). +// The returned LLMResponse is the same complete response for compatibility with tool-call handling. +type StreamingProvider interface { + ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), + ) (*LLMResponse, error) +} + // ThinkingCapable is an optional interface for providers that support // extended thinking (e.g. Anthropic). Used by the agent loop to warn // when thinking_level is configured but the active provider cannot use it. @@ -44,17 +59,28 @@ type ThinkingCapable interface { SupportsThinking() bool } +// NativeSearchCapable is an optional interface for providers that support +// built-in web search during LLM inference (e.g. OpenAI web_search_preview, +// xAI Grok search). When the active provider implements this interface and +// returns true, the agent loop can hide the client-side web_search tool to +// avoid duplicate search surfaces and use the provider's native search instead. +type NativeSearchCapable interface { + SupportsNativeSearch() bool +} + // FailoverReason classifies why an LLM request failed for fallback decisions. type FailoverReason string const ( - FailoverAuth FailoverReason = "auth" - FailoverRateLimit FailoverReason = "rate_limit" - FailoverBilling FailoverReason = "billing" - FailoverTimeout FailoverReason = "timeout" - FailoverFormat FailoverReason = "format" - FailoverOverloaded FailoverReason = "overloaded" - FailoverUnknown FailoverReason = "unknown" + FailoverAuth FailoverReason = "auth" + FailoverRateLimit FailoverReason = "rate_limit" + FailoverBilling FailoverReason = "billing" + FailoverNetwork FailoverReason = "network" + FailoverTimeout FailoverReason = "timeout" + FailoverFormat FailoverReason = "format" + FailoverContextOverflow FailoverReason = "context_overflow" + FailoverOverloaded FailoverReason = "overloaded" + FailoverUnknown FailoverReason = "unknown" ) // FailoverError wraps an LLM provider error with classification metadata. @@ -78,7 +104,7 @@ func (e *FailoverError) Unwrap() error { // IsRetriable returns true if this error should trigger fallback to next candidate. // Non-retriable: Format errors (bad request structure, image dimension/size). func (e *FailoverError) IsRetriable() bool { - return e.Reason != FailoverFormat + return e.Reason != FailoverFormat && e.Reason != FailoverContextOverflow } // ModelConfig holds primary model and fallback list. diff --git a/pkg/routing/route.go b/pkg/routing/route.go index 9eb060c53..023f35a25 100644 --- a/pkg/routing/route.go +++ b/pkg/routing/route.go @@ -1,32 +1,29 @@ package routing import ( + "fmt" "strings" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" ) -// RouteInput contains the routing context from an inbound message. -type RouteInput struct { - Channel string - AccountID string - Peer *RoutePeer - ParentPeer *RoutePeer - GuildID string - TeamID string +// SessionPolicy describes how a routed message should be mapped to a session. +type SessionPolicy struct { + Dimensions []string + IdentityLinks map[string][]string } // ResolvedRoute is the result of agent routing. type ResolvedRoute struct { - AgentID string - Channel string - AccountID string - SessionKey string - MainSessionKey string - MatchedBy string // "binding.peer", "binding.peer.parent", "binding.guild", "binding.team", "binding.account", "binding.channel", "default" + AgentID string + Channel string + AccountID string + SessionPolicy SessionPolicy + MatchedBy string } -// RouteResolver determines which agent handles a message based on config bindings. +// RouteResolver determines which agent handles a message. type RouteResolver struct { cfg *config.Config } @@ -36,182 +33,32 @@ func NewRouteResolver(cfg *config.Config) *RouteResolver { return &RouteResolver{cfg: cfg} } -// ResolveRoute determines which agent handles the message and constructs session keys. -// Implements the 7-level priority cascade: -// peer > parent_peer > guild > team > account > channel_wildcard > default -func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute { - channel := strings.ToLower(strings.TrimSpace(input.Channel)) - accountID := NormalizeAccountID(input.AccountID) - peer := input.Peer +// ResolveRoute determines which agent handles the message from a normalized +// inbound context and returns the session policy that should be used to +// allocate session state. +func (r *RouteResolver) ResolveRoute(inbound bus.InboundContext) ResolvedRoute { + channel := strings.ToLower(strings.TrimSpace(inbound.Channel)) + accountID := NormalizeAccountID(inbound.Account) + identityLinks := cloneIdentityLinks(r.cfg.Session.IdentityLinks) + view := buildDispatchView(inbound, identityLinks) - dmScope := DMScope(r.cfg.Session.DMScope) - if dmScope == "" { - dmScope = DMScopeMain - } - identityLinks := r.cfg.Session.IdentityLinks - - bindings := r.filterBindings(channel, accountID) - - choose := func(agentID string, matchedBy string) ResolvedRoute { - resolvedAgentID := r.pickAgentID(agentID) - sessionKey := strings.ToLower(BuildAgentPeerSessionKey(SessionKeyParams{ - AgentID: resolvedAgentID, + if rule := r.matchDispatchRule(view); rule != nil { + return ResolvedRoute{ + AgentID: r.pickAgentID(rule.Agent), Channel: channel, AccountID: accountID, - Peer: peer, - DMScope: dmScope, - IdentityLinks: identityLinks, - })) - mainSessionKey := strings.ToLower(BuildAgentMainSessionKey(resolvedAgentID)) - return ResolvedRoute{ - AgentID: resolvedAgentID, - Channel: channel, - AccountID: accountID, - SessionKey: sessionKey, - MainSessionKey: mainSessionKey, - MatchedBy: matchedBy, + SessionPolicy: r.sessionPolicy(rule), + MatchedBy: matchedByForRule(rule), } } - // Priority 1: Peer binding - if peer != nil && strings.TrimSpace(peer.ID) != "" { - if match := r.findPeerMatch(bindings, peer); match != nil { - return choose(match.AgentID, "binding.peer") - } + return ResolvedRoute{ + AgentID: r.pickAgentID(r.resolveDefaultAgentID()), + Channel: channel, + AccountID: accountID, + SessionPolicy: r.sessionPolicy(nil), + MatchedBy: "default", } - - // Priority 2: Parent peer binding - parentPeer := input.ParentPeer - if parentPeer != nil && strings.TrimSpace(parentPeer.ID) != "" { - if match := r.findPeerMatch(bindings, parentPeer); match != nil { - return choose(match.AgentID, "binding.peer.parent") - } - } - - // Priority 3: Guild binding - guildID := strings.TrimSpace(input.GuildID) - if guildID != "" { - if match := r.findGuildMatch(bindings, guildID); match != nil { - return choose(match.AgentID, "binding.guild") - } - } - - // Priority 4: Team binding - teamID := strings.TrimSpace(input.TeamID) - if teamID != "" { - if match := r.findTeamMatch(bindings, teamID); match != nil { - return choose(match.AgentID, "binding.team") - } - } - - // Priority 5: Account binding - if match := r.findAccountMatch(bindings); match != nil { - return choose(match.AgentID, "binding.account") - } - - // Priority 6: Channel wildcard binding - if match := r.findChannelWildcardMatch(bindings); match != nil { - return choose(match.AgentID, "binding.channel") - } - - // Priority 7: Default agent - return choose(r.resolveDefaultAgentID(), "default") -} - -func (r *RouteResolver) filterBindings(channel, accountID string) []config.AgentBinding { - var filtered []config.AgentBinding - for _, b := range r.cfg.Bindings { - matchChannel := strings.ToLower(strings.TrimSpace(b.Match.Channel)) - if matchChannel == "" || matchChannel != channel { - continue - } - if !matchesAccountID(b.Match.AccountID, accountID) { - continue - } - filtered = append(filtered, b) - } - return filtered -} - -func matchesAccountID(matchAccountID, actual string) bool { - trimmed := strings.TrimSpace(matchAccountID) - if trimmed == "" { - return actual == DefaultAccountID - } - if trimmed == "*" { - return true - } - return strings.ToLower(trimmed) == strings.ToLower(actual) -} - -func (r *RouteResolver) findPeerMatch(bindings []config.AgentBinding, peer *RoutePeer) *config.AgentBinding { - for i := range bindings { - b := &bindings[i] - if b.Match.Peer == nil { - continue - } - peerKind := strings.ToLower(strings.TrimSpace(b.Match.Peer.Kind)) - peerID := strings.TrimSpace(b.Match.Peer.ID) - if peerKind == "" || peerID == "" { - continue - } - if peerKind == strings.ToLower(peer.Kind) && peerID == peer.ID { - return b - } - } - return nil -} - -func (r *RouteResolver) findGuildMatch(bindings []config.AgentBinding, guildID string) *config.AgentBinding { - for i := range bindings { - b := &bindings[i] - matchGuild := strings.TrimSpace(b.Match.GuildID) - if matchGuild != "" && matchGuild == guildID { - return &bindings[i] - } - } - return nil -} - -func (r *RouteResolver) findTeamMatch(bindings []config.AgentBinding, teamID string) *config.AgentBinding { - for i := range bindings { - b := &bindings[i] - matchTeam := strings.TrimSpace(b.Match.TeamID) - if matchTeam != "" && matchTeam == teamID { - return &bindings[i] - } - } - return nil -} - -func (r *RouteResolver) findAccountMatch(bindings []config.AgentBinding) *config.AgentBinding { - for i := range bindings { - b := &bindings[i] - accountID := strings.TrimSpace(b.Match.AccountID) - if accountID == "*" { - continue - } - if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" { - continue - } - return &bindings[i] - } - return nil -} - -func (r *RouteResolver) findChannelWildcardMatch(bindings []config.AgentBinding) *config.AgentBinding { - for i := range bindings { - b := &bindings[i] - accountID := strings.TrimSpace(b.Match.AccountID) - if accountID != "*" { - continue - } - if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" { - continue - } - return &bindings[i] - } - return nil } func (r *RouteResolver) pickAgentID(agentID string) string { @@ -250,3 +97,217 @@ func (r *RouteResolver) resolveDefaultAgentID() string { } return DefaultAgentID } + +func (r *RouteResolver) sessionPolicy(rule *config.DispatchRule) SessionPolicy { + dimensions := r.cfg.Session.Dimensions + if rule != nil && len(rule.SessionDimensions) > 0 { + dimensions = rule.SessionDimensions + } + return SessionPolicy{ + Dimensions: normalizeSessionDimensions(dimensions), + IdentityLinks: cloneIdentityLinks(r.cfg.Session.IdentityLinks), + } +} + +func normalizeSessionDimensions(dimensions []string) []string { + if len(dimensions) == 0 { + return nil + } + + normalized := make([]string, 0, len(dimensions)) + seen := make(map[string]struct{}, len(dimensions)) + for _, dimension := range dimensions { + dimension = strings.ToLower(strings.TrimSpace(dimension)) + switch dimension { + case "space", "chat", "topic", "sender": + default: + continue + } + if _, ok := seen[dimension]; ok { + continue + } + seen[dimension] = struct{}{} + normalized = append(normalized, dimension) + } + if len(normalized) == 0 { + return nil + } + return normalized +} + +func cloneIdentityLinks(src map[string][]string) map[string][]string { + if len(src) == 0 { + return nil + } + cloned := make(map[string][]string, len(src)) + for canonical, ids := range src { + dup := make([]string, len(ids)) + copy(dup, ids) + cloned[canonical] = dup + } + return cloned +} + +type dispatchView struct { + Channel string + Account string + Space string + Chat string + Topic string + Sender string + Mentioned bool +} + +func (r *RouteResolver) matchDispatchRule(view dispatchView) *config.DispatchRule { + if r.cfg == nil || r.cfg.Agents.Dispatch == nil || len(r.cfg.Agents.Dispatch.Rules) == 0 { + return nil + } + + for i := range r.cfg.Agents.Dispatch.Rules { + rule := &r.cfg.Agents.Dispatch.Rules[i] + if !selectorHasAnyConstraint(rule.When) { + continue + } + if ruleMatchesView(*rule, view) { + return rule + } + } + return nil +} + +func ruleMatchesView(rule config.DispatchRule, view dispatchView) bool { + when := normalizeDispatchSelector(rule.When) + if when.Channel != "" && when.Channel != view.Channel { + return false + } + if when.Account != "" && when.Account != view.Account { + return false + } + if when.Space != "" && when.Space != view.Space { + return false + } + if when.Chat != "" && when.Chat != view.Chat { + return false + } + if when.Topic != "" && when.Topic != view.Topic { + return false + } + if when.Sender != "" && when.Sender != view.Sender { + return false + } + if when.Mentioned != nil && *when.Mentioned != view.Mentioned { + return false + } + return true +} + +func matchedByForRule(rule *config.DispatchRule) string { + if rule == nil { + return "default" + } + name := strings.TrimSpace(rule.Name) + if name == "" { + return "dispatch.rule" + } + return "dispatch.rule:" + strings.ToLower(name) +} + +func buildDispatchView(inbound bus.InboundContext, identityLinks map[string][]string) dispatchView { + view := dispatchView{ + Channel: strings.ToLower(strings.TrimSpace(inbound.Channel)), + Account: NormalizeAccountID(inbound.Account), + Mentioned: inbound.Mentioned, + } + + if spaceID := strings.TrimSpace(inbound.SpaceID); spaceID != "" { + spaceType := strings.ToLower(strings.TrimSpace(inbound.SpaceType)) + if spaceType == "" { + spaceType = "space" + } + view.Space = fmt.Sprintf("%s:%s", spaceType, strings.ToLower(spaceID)) + } + + if chatID := strings.TrimSpace(inbound.ChatID); chatID != "" { + chatType := strings.ToLower(strings.TrimSpace(inbound.ChatType)) + if chatType == "" { + chatType = "direct" + } + view.Chat = fmt.Sprintf("%s:%s", chatType, strings.ToLower(chatID)) + } + + if topicID := strings.TrimSpace(inbound.TopicID); topicID != "" { + view.Topic = "topic:" + strings.ToLower(topicID) + } + + view.Sender = canonicalDispatchSenderID(inbound.Channel, inbound.SenderID, identityLinks) + + return view +} + +func normalizeDispatchSelector(selector config.DispatchSelector) config.DispatchSelector { + selector.Channel = strings.ToLower(strings.TrimSpace(selector.Channel)) + selector.Account = NormalizeAccountID(selector.Account) + selector.Space = strings.ToLower(strings.TrimSpace(selector.Space)) + selector.Chat = strings.ToLower(strings.TrimSpace(selector.Chat)) + selector.Topic = strings.ToLower(strings.TrimSpace(selector.Topic)) + selector.Sender = strings.ToLower(strings.TrimSpace(selector.Sender)) + return selector +} + +func selectorHasAnyConstraint(selector config.DispatchSelector) bool { + return strings.TrimSpace(selector.Channel) != "" || + strings.TrimSpace(selector.Account) != "" || + strings.TrimSpace(selector.Space) != "" || + strings.TrimSpace(selector.Chat) != "" || + strings.TrimSpace(selector.Topic) != "" || + strings.TrimSpace(selector.Sender) != "" || + selector.Mentioned != nil +} + +func canonicalDispatchSenderID(channel, rawID string, identityLinks map[string][]string) string { + normalizedID := strings.TrimSpace(rawID) + if normalizedID == "" { + return "" + } + if linked := resolveLinkedDispatchID(identityLinks, channel, normalizedID); linked != "" { + normalizedID = linked + } + return strings.ToLower(normalizedID) +} + +func resolveLinkedDispatchID(identityLinks map[string][]string, channel, peerID string) string { + if len(identityLinks) == 0 { + return "" + } + peerID = strings.TrimSpace(peerID) + if peerID == "" { + return "" + } + + candidates := make(map[string]bool) + rawCandidate := strings.ToLower(peerID) + if rawCandidate != "" { + candidates[rawCandidate] = true + } + channel = strings.ToLower(strings.TrimSpace(channel)) + if channel != "" { + candidates[fmt.Sprintf("%s:%s", channel, rawCandidate)] = true + } + if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 { + candidates[rawCandidate[idx+1:]] = true + } + + for canonical, ids := range identityLinks { + canonicalName := strings.TrimSpace(canonical) + if canonicalName == "" { + continue + } + for _, id := range ids { + normalized := strings.ToLower(strings.TrimSpace(id)) + if normalized != "" && candidates[normalized] { + return canonicalName + } + } + } + return "" +} diff --git a/pkg/routing/route_test.go b/pkg/routing/route_test.go index 8255db5f9..729e880fe 100644 --- a/pkg/routing/route_test.go +++ b/pkg/routing/route_test.go @@ -3,32 +3,33 @@ package routing import ( "testing" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" ) -func testConfig(agents []config.AgentConfig, bindings []config.AgentBinding) *config.Config { +func testConfig(agents []config.AgentConfig) *config.Config { return &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: "/tmp/picoclaw-test", - Model: "gpt-4", + ModelName: "gpt-4", }, List: agents, }, - Bindings: bindings, Session: config.SessionConfig{ - DMScope: "per-peer", + Dimensions: []string{"sender"}, }, } } func TestResolveRoute_DefaultAgent_NoBindings(t *testing.T) { - cfg := testConfig(nil, nil) + cfg := testConfig(nil) r := NewRouteResolver(cfg) - route := r.ResolveRoute(RouteInput{ - Channel: "telegram", - Peer: &RoutePeer{Kind: "direct", ID: "user1"}, + route := r.ResolveRoute(bus.InboundContext{ + Channel: "telegram", + ChatType: "direct", + SenderID: "user1", }) if route.AgentID != DefaultAgentID { @@ -37,202 +38,152 @@ func TestResolveRoute_DefaultAgent_NoBindings(t *testing.T) { if route.MatchedBy != "default" { t.Errorf("MatchedBy = %q, want 'default'", route.MatchedBy) } + if len(route.SessionPolicy.Dimensions) != 1 || route.SessionPolicy.Dimensions[0] != "sender" { + t.Errorf("SessionPolicy.Dimensions = %v, want [sender]", route.SessionPolicy.Dimensions) + } + if route.SessionPolicy.IdentityLinks != nil { + t.Errorf("SessionPolicy.IdentityLinks = %v, want nil", route.SessionPolicy.IdentityLinks) + } } -func TestResolveRoute_PeerBinding(t *testing.T) { - agents := []config.AgentConfig{ - {ID: "sales", Default: true}, - {ID: "support"}, +func TestResolveRoute_UsesNormalizedInboundContextFields(t *testing.T) { + cfg := testConfig([]config.AgentConfig{{ID: "sales", Default: true}}) + r := NewRouteResolver(cfg) + + route := r.ResolveRoute(bus.InboundContext{ + Channel: "Telegram", + Account: "Bot2", + ChatType: "direct", + SenderID: "user123", + }) + + if route.AgentID != "sales" { + t.Errorf("AgentID = %q, want 'sales'", route.AgentID) } - bindings := []config.AgentBinding{ - { - AgentID: "support", - Match: config.BindingMatch{ - Channel: "telegram", - AccountID: "*", - Peer: &config.PeerMatch{Kind: "direct", ID: "user123"}, + if route.Channel != "telegram" { + t.Errorf("Channel = %q, want 'telegram'", route.Channel) + } + if route.AccountID != "bot2" { + t.Errorf("AccountID = %q, want 'bot2'", route.AccountID) + } + if route.MatchedBy != "default" { + t.Errorf("MatchedBy = %q, want 'default'", route.MatchedBy) + } +} + +func TestResolveRoute_DispatchFirstMatchWins(t *testing.T) { + cfg := testConfig([]config.AgentConfig{ + {ID: "main", Default: true}, + {ID: "support"}, + {ID: "sales"}, + }) + cfg.Agents.Dispatch = &config.DispatchConfig{ + Rules: []config.DispatchRule{ + { + Name: "support-group", + Agent: "support", + When: config.DispatchSelector{ + Channel: "telegram", + Chat: "group:-100123", + }, + }, + { + Name: "vip-in-group", + Agent: "sales", + When: config.DispatchSelector{ + Channel: "telegram", + Chat: "group:-100123", + Sender: "12345", + }, }, }, } - cfg := testConfig(agents, bindings) r := NewRouteResolver(cfg) - route := r.ResolveRoute(RouteInput{ - Channel: "telegram", - Peer: &RoutePeer{Kind: "direct", ID: "user123"}, + route := r.ResolveRoute(bus.InboundContext{ + Channel: "telegram", + ChatID: "-100123", + ChatType: "group", + SenderID: "12345", }) if route.AgentID != "support" { - t.Errorf("AgentID = %q, want 'support'", route.AgentID) + t.Fatalf("AgentID = %q, want support", route.AgentID) } - if route.MatchedBy != "binding.peer" { - t.Errorf("MatchedBy = %q, want 'binding.peer'", route.MatchedBy) + if route.MatchedBy != "dispatch.rule:support-group" { + t.Fatalf("MatchedBy = %q, want dispatch.rule:support-group", route.MatchedBy) } } -func TestResolveRoute_GuildBinding(t *testing.T) { - agents := []config.AgentConfig{ - {ID: "general", Default: true}, - {ID: "gaming"}, - } - bindings := []config.AgentBinding{ - { - AgentID: "gaming", - Match: config.BindingMatch{ - Channel: "discord", - AccountID: "*", - GuildID: "guild-abc", - }, - }, - } - cfg := testConfig(agents, bindings) - r := NewRouteResolver(cfg) - - route := r.ResolveRoute(RouteInput{ - Channel: "discord", - GuildID: "guild-abc", - Peer: &RoutePeer{Kind: "channel", ID: "ch1"}, - }) - - if route.AgentID != "gaming" { - t.Errorf("AgentID = %q, want 'gaming'", route.AgentID) - } - if route.MatchedBy != "binding.guild" { - t.Errorf("MatchedBy = %q, want 'binding.guild'", route.MatchedBy) - } -} - -func TestResolveRoute_TeamBinding(t *testing.T) { - agents := []config.AgentConfig{ - {ID: "general", Default: true}, - {ID: "work"}, - } - bindings := []config.AgentBinding{ - { - AgentID: "work", - Match: config.BindingMatch{ - Channel: "slack", - AccountID: "*", - TeamID: "T12345", - }, - }, - } - cfg := testConfig(agents, bindings) - r := NewRouteResolver(cfg) - - route := r.ResolveRoute(RouteInput{ - Channel: "slack", - TeamID: "T12345", - Peer: &RoutePeer{Kind: "channel", ID: "C001"}, - }) - - if route.AgentID != "work" { - t.Errorf("AgentID = %q, want 'work'", route.AgentID) - } - if route.MatchedBy != "binding.team" { - t.Errorf("MatchedBy = %q, want 'binding.team'", route.MatchedBy) - } -} - -func TestResolveRoute_AccountBinding(t *testing.T) { - agents := []config.AgentConfig{ - {ID: "default-agent", Default: true}, - {ID: "premium"}, - } - bindings := []config.AgentBinding{ - { - AgentID: "premium", - Match: config.BindingMatch{ - Channel: "telegram", - AccountID: "bot2", - }, - }, - } - cfg := testConfig(agents, bindings) - r := NewRouteResolver(cfg) - - route := r.ResolveRoute(RouteInput{ - Channel: "telegram", - AccountID: "bot2", - Peer: &RoutePeer{Kind: "direct", ID: "user1"}, - }) - - if route.AgentID != "premium" { - t.Errorf("AgentID = %q, want 'premium'", route.AgentID) - } - if route.MatchedBy != "binding.account" { - t.Errorf("MatchedBy = %q, want 'binding.account'", route.MatchedBy) - } -} - -func TestResolveRoute_ChannelWildcard(t *testing.T) { - agents := []config.AgentConfig{ +func TestResolveRoute_DispatchOverridesSessionDimensions(t *testing.T) { + cfg := testConfig([]config.AgentConfig{ {ID: "main", Default: true}, - {ID: "telegram-bot"}, - } - bindings := []config.AgentBinding{ - { - AgentID: "telegram-bot", - Match: config.BindingMatch{ - Channel: "telegram", - AccountID: "*", + {ID: "support"}, + }) + cfg.Session.Dimensions = []string{"chat"} + cfg.Agents.Dispatch = &config.DispatchConfig{ + Rules: []config.DispatchRule{ + { + Name: "support-dm", + Agent: "support", + When: config.DispatchSelector{ + Channel: "telegram", + Chat: "direct:user-1", + }, + SessionDimensions: []string{"chat", "sender"}, }, }, } - cfg := testConfig(agents, bindings) r := NewRouteResolver(cfg) - route := r.ResolveRoute(RouteInput{ - Channel: "telegram", - Peer: &RoutePeer{Kind: "direct", ID: "user1"}, + route := r.ResolveRoute(bus.InboundContext{ + Channel: "telegram", + ChatID: "user-1", + ChatType: "direct", + SenderID: "user-1", }) - if route.AgentID != "telegram-bot" { - t.Errorf("AgentID = %q, want 'telegram-bot'", route.AgentID) + if route.AgentID != "support" { + t.Fatalf("AgentID = %q, want support", route.AgentID) } - if route.MatchedBy != "binding.channel" { - t.Errorf("MatchedBy = %q, want 'binding.channel'", route.MatchedBy) + if got := route.SessionPolicy.Dimensions; len(got) != 2 || got[0] != "chat" || got[1] != "sender" { + t.Fatalf("SessionPolicy.Dimensions = %v, want [chat sender]", got) } } -func TestResolveRoute_PriorityOrder_PeerBeatsGuild(t *testing.T) { - agents := []config.AgentConfig{ - {ID: "general", Default: true}, - {ID: "vip"}, - {ID: "gaming"}, - } - bindings := []config.AgentBinding{ - { - AgentID: "vip", - Match: config.BindingMatch{ - Channel: "discord", - AccountID: "*", - Peer: &config.PeerMatch{Kind: "direct", ID: "user-vip"}, - }, - }, - { - AgentID: "gaming", - Match: config.BindingMatch{ - Channel: "discord", - AccountID: "*", - GuildID: "guild-1", +func TestResolveRoute_DispatchMentionedRule(t *testing.T) { + cfg := testConfig([]config.AgentConfig{ + {ID: "main", Default: true}, + {ID: "support"}, + }) + mentioned := true + cfg.Agents.Dispatch = &config.DispatchConfig{ + Rules: []config.DispatchRule{ + { + Name: "slack-mentions", + Agent: "support", + When: config.DispatchSelector{ + Channel: "slack", + Space: "workspace:t001", + Mentioned: &mentioned, + }, }, }, } - cfg := testConfig(agents, bindings) r := NewRouteResolver(cfg) - route := r.ResolveRoute(RouteInput{ - Channel: "discord", - GuildID: "guild-1", - Peer: &RoutePeer{Kind: "direct", ID: "user-vip"}, + route := r.ResolveRoute(bus.InboundContext{ + Channel: "slack", + ChatID: "C123", + ChatType: "channel", + SpaceID: "T001", + SpaceType: "workspace", + SenderID: "U123", + Mentioned: true, }) - if route.AgentID != "vip" { - t.Errorf("AgentID = %q, want 'vip' (peer should beat guild)", route.AgentID) - } - if route.MatchedBy != "binding.peer" { - t.Errorf("MatchedBy = %q, want 'binding.peer'", route.MatchedBy) + if route.AgentID != "support" { + t.Fatalf("AgentID = %q, want support", route.AgentID) } } @@ -240,21 +191,10 @@ func TestResolveRoute_InvalidAgentFallsToDefault(t *testing.T) { agents := []config.AgentConfig{ {ID: "main", Default: true}, } - bindings := []config.AgentBinding{ - { - AgentID: "nonexistent", - Match: config.BindingMatch{ - Channel: "telegram", - AccountID: "*", - }, - }, - } - cfg := testConfig(agents, bindings) + cfg := testConfig(agents) r := NewRouteResolver(cfg) - route := r.ResolveRoute(RouteInput{ - Channel: "telegram", - }) + route := r.ResolveRoute(bus.InboundContext{Channel: "telegram"}) if route.AgentID != "main" { t.Errorf("AgentID = %q, want 'main' (invalid agent should fall to default)", route.AgentID) @@ -267,12 +207,10 @@ func TestResolveRoute_DefaultAgentSelection(t *testing.T) { {ID: "beta", Default: true}, {ID: "gamma"}, } - cfg := testConfig(agents, nil) + cfg := testConfig(agents) r := NewRouteResolver(cfg) - route := r.ResolveRoute(RouteInput{ - Channel: "cli", - }) + route := r.ResolveRoute(bus.InboundContext{Channel: "cli"}) if route.AgentID != "beta" { t.Errorf("AgentID = %q, want 'beta' (marked as default)", route.AgentID) @@ -284,12 +222,10 @@ func TestResolveRoute_NoDefaultUsesFirst(t *testing.T) { {ID: "alpha"}, {ID: "beta"}, } - cfg := testConfig(agents, nil) + cfg := testConfig(agents) r := NewRouteResolver(cfg) - route := r.ResolveRoute(RouteInput{ - Channel: "cli", - }) + route := r.ResolveRoute(bus.InboundContext{Channel: "cli"}) if route.AgentID != "alpha" { t.Errorf("AgentID = %q, want 'alpha' (first in list)", route.AgentID) diff --git a/pkg/routing/session_key.go b/pkg/routing/session_key.go deleted file mode 100644 index eab592bec..000000000 --- a/pkg/routing/session_key.go +++ /dev/null @@ -1,192 +0,0 @@ -package routing - -import ( - "fmt" - "strings" -) - -// DMScope controls DM session isolation granularity. -type DMScope string - -const ( - DMScopeMain DMScope = "main" - DMScopePerPeer DMScope = "per-peer" - DMScopePerChannelPeer DMScope = "per-channel-peer" - DMScopePerAccountChannelPeer DMScope = "per-account-channel-peer" -) - -// RoutePeer represents a chat peer with kind and ID. -type RoutePeer struct { - Kind string // "direct", "group", "channel" - ID string -} - -// SessionKeyParams holds all inputs for session key construction. -type SessionKeyParams struct { - AgentID string - Channel string - AccountID string - Peer *RoutePeer - DMScope DMScope - IdentityLinks map[string][]string -} - -// ParsedSessionKey is the result of parsing an agent-scoped session key. -type ParsedSessionKey struct { - AgentID string - Rest string -} - -// BuildAgentMainSessionKey returns "agent:<agentId>:main". -func BuildAgentMainSessionKey(agentID string) string { - return fmt.Sprintf("agent:%s:%s", NormalizeAgentID(agentID), DefaultMainKey) -} - -// BuildAgentPeerSessionKey constructs a session key based on agent, channel, peer, and DM scope. -func BuildAgentPeerSessionKey(params SessionKeyParams) string { - agentID := NormalizeAgentID(params.AgentID) - - peer := params.Peer - if peer == nil { - peer = &RoutePeer{Kind: "direct"} - } - peerKind := strings.TrimSpace(peer.Kind) - if peerKind == "" { - peerKind = "direct" - } - - if peerKind == "direct" { - dmScope := params.DMScope - if dmScope == "" { - dmScope = DMScopeMain - } - peerID := strings.TrimSpace(peer.ID) - - // Resolve identity links (cross-platform collapse) - if dmScope != DMScopeMain && peerID != "" { - if linked := resolveLinkedPeerID(params.IdentityLinks, params.Channel, peerID); linked != "" { - peerID = linked - } - } - peerID = strings.ToLower(peerID) - - switch dmScope { - case DMScopePerAccountChannelPeer: - if peerID != "" { - channel := normalizeChannel(params.Channel) - accountID := NormalizeAccountID(params.AccountID) - return fmt.Sprintf("agent:%s:%s:%s:direct:%s", agentID, channel, accountID, peerID) - } - case DMScopePerChannelPeer: - if peerID != "" { - channel := normalizeChannel(params.Channel) - return fmt.Sprintf("agent:%s:%s:direct:%s", agentID, channel, peerID) - } - case DMScopePerPeer: - if peerID != "" { - return fmt.Sprintf("agent:%s:direct:%s", agentID, peerID) - } - } - return BuildAgentMainSessionKey(agentID) - } - - // Group/channel peers always get per-peer sessions - channel := normalizeChannel(params.Channel) - peerID := strings.ToLower(strings.TrimSpace(peer.ID)) - if peerID == "" { - peerID = "unknown" - } - return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, peerKind, peerID) -} - -// ParseAgentSessionKey extracts agentId and rest from "agent:<agentId>:<rest>". -func ParseAgentSessionKey(sessionKey string) *ParsedSessionKey { - raw := strings.TrimSpace(sessionKey) - if raw == "" { - return nil - } - parts := strings.SplitN(raw, ":", 3) - if len(parts) < 3 { - return nil - } - if parts[0] != "agent" { - return nil - } - agentID := strings.TrimSpace(parts[1]) - rest := parts[2] - if agentID == "" || rest == "" { - return nil - } - return &ParsedSessionKey{AgentID: agentID, Rest: rest} -} - -// IsSubagentSessionKey returns true if the session key represents a subagent. -func IsSubagentSessionKey(sessionKey string) bool { - raw := strings.TrimSpace(sessionKey) - if raw == "" { - return false - } - if strings.HasPrefix(strings.ToLower(raw), "subagent:") { - return true - } - parsed := ParseAgentSessionKey(raw) - if parsed == nil { - return false - } - return strings.HasPrefix(strings.ToLower(parsed.Rest), "subagent:") -} - -func normalizeChannel(channel string) string { - c := strings.TrimSpace(strings.ToLower(channel)) - if c == "" { - return "unknown" - } - return c -} - -func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID string) string { - if len(identityLinks) == 0 { - return "" - } - peerID = strings.TrimSpace(peerID) - if peerID == "" { - return "" - } - - candidates := make(map[string]bool) - rawCandidate := strings.ToLower(peerID) - if rawCandidate != "" { - candidates[rawCandidate] = true - } - channel = strings.ToLower(strings.TrimSpace(channel)) - if channel != "" { - scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID)) - candidates[scopedCandidate] = true - } - - // If peerID is already in canonical "platform:id" format, also add the - // bare ID part as a candidate for backward compatibility with identity_links - // that use raw IDs (e.g. "123" instead of "telegram:123"). - if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 { - bareID := rawCandidate[idx+1:] - candidates[bareID] = true - } - - if len(candidates) == 0 { - return "" - } - - for canonical, ids := range identityLinks { - canonicalName := strings.TrimSpace(canonical) - if canonicalName == "" { - continue - } - for _, id := range ids { - normalized := strings.ToLower(strings.TrimSpace(id)) - if normalized != "" && candidates[normalized] { - return canonicalName - } - } - } - return "" -} diff --git a/pkg/routing/session_key_test.go b/pkg/routing/session_key_test.go deleted file mode 100644 index ad7a1ca02..000000000 --- a/pkg/routing/session_key_test.go +++ /dev/null @@ -1,207 +0,0 @@ -package routing - -import "testing" - -func TestBuildAgentMainSessionKey(t *testing.T) { - got := BuildAgentMainSessionKey("sales") - want := "agent:sales:main" - if got != want { - t.Errorf("BuildAgentMainSessionKey('sales') = %q, want %q", got, want) - } -} - -func TestBuildAgentMainSessionKey_Normalizes(t *testing.T) { - got := BuildAgentMainSessionKey("Sales Bot") - want := "agent:sales-bot:main" - if got != want { - t.Errorf("BuildAgentMainSessionKey('Sales Bot') = %q, want %q", got, want) - } -} - -func TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) { - got := BuildAgentPeerSessionKey(SessionKeyParams{ - AgentID: "main", - Channel: "telegram", - Peer: &RoutePeer{Kind: "direct", ID: "user123"}, - DMScope: DMScopeMain, - }) - want := "agent:main:main" - if got != want { - t.Errorf("DMScopeMain = %q, want %q", got, want) - } -} - -func TestBuildAgentPeerSessionKey_DMScopePerPeer(t *testing.T) { - got := BuildAgentPeerSessionKey(SessionKeyParams{ - AgentID: "main", - Channel: "telegram", - Peer: &RoutePeer{Kind: "direct", ID: "user123"}, - DMScope: DMScopePerPeer, - }) - want := "agent:main:direct:user123" - if got != want { - t.Errorf("DMScopePerPeer = %q, want %q", got, want) - } -} - -func TestBuildAgentPeerSessionKey_DMScopePerChannelPeer(t *testing.T) { - got := BuildAgentPeerSessionKey(SessionKeyParams{ - AgentID: "main", - Channel: "telegram", - Peer: &RoutePeer{Kind: "direct", ID: "user123"}, - DMScope: DMScopePerChannelPeer, - }) - want := "agent:main:telegram:direct:user123" - if got != want { - t.Errorf("DMScopePerChannelPeer = %q, want %q", got, want) - } -} - -func TestBuildAgentPeerSessionKey_DMScopePerAccountChannelPeer(t *testing.T) { - got := BuildAgentPeerSessionKey(SessionKeyParams{ - AgentID: "main", - Channel: "telegram", - AccountID: "bot1", - Peer: &RoutePeer{Kind: "direct", ID: "User123"}, - DMScope: DMScopePerAccountChannelPeer, - }) - want := "agent:main:telegram:bot1:direct:user123" - if got != want { - t.Errorf("DMScopePerAccountChannelPeer = %q, want %q", got, want) - } -} - -func TestBuildAgentPeerSessionKey_GroupPeer(t *testing.T) { - got := BuildAgentPeerSessionKey(SessionKeyParams{ - AgentID: "main", - Channel: "telegram", - Peer: &RoutePeer{Kind: "group", ID: "chat456"}, - DMScope: DMScopePerPeer, - }) - want := "agent:main:telegram:group:chat456" - if got != want { - t.Errorf("GroupPeer = %q, want %q", got, want) - } -} - -func TestBuildAgentPeerSessionKey_NilPeer(t *testing.T) { - got := BuildAgentPeerSessionKey(SessionKeyParams{ - AgentID: "main", - Channel: "telegram", - Peer: nil, - DMScope: DMScopePerPeer, - }) - // nil peer defaults to direct with empty ID, falls to main - want := "agent:main:main" - if got != want { - t.Errorf("NilPeer = %q, want %q", got, want) - } -} - -func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) { - links := map[string][]string{ - "john": {"telegram:user123", "discord:john#1234"}, - } - got := BuildAgentPeerSessionKey(SessionKeyParams{ - AgentID: "main", - Channel: "telegram", - Peer: &RoutePeer{Kind: "direct", ID: "user123"}, - DMScope: DMScopePerPeer, - IdentityLinks: links, - }) - want := "agent:main:direct:john" - if got != want { - t.Errorf("IdentityLink = %q, want %q", got, want) - } -} - -func TestResolveLinkedPeerID_CanonicalPeerID(t *testing.T) { - // When peerID is already in canonical "platform:id" format, - // it should match identity_links that use the bare ID. - links := map[string][]string{ - "john": {"123"}, - } - got := resolveLinkedPeerID(links, "telegram", "telegram:123") - if got != "john" { - t.Errorf("resolveLinkedPeerID with canonical peerID = %q, want %q", got, "john") - } -} - -func TestResolveLinkedPeerID_CanonicalInLinks(t *testing.T) { - // When identity_links contain canonical IDs and peerID is canonical too - links := map[string][]string{ - "john": {"telegram:123", "discord:456"}, - } - got := resolveLinkedPeerID(links, "telegram", "telegram:123") - if got != "john" { - t.Errorf("resolveLinkedPeerID canonical in links = %q, want %q", got, "john") - } -} - -func TestResolveLinkedPeerID_BarePeerIDMatchesCanonicalLink(t *testing.T) { - // When peerID is bare "123" and links have "telegram:123", - // the scoped candidate "telegram:123" should match. - links := map[string][]string{ - "john": {"telegram:123"}, - } - got := resolveLinkedPeerID(links, "telegram", "123") - if got != "john" { - t.Errorf("resolveLinkedPeerID bare peer matches canonical link = %q, want %q", got, "john") - } -} - -func TestResolveLinkedPeerID_NoMatch(t *testing.T) { - links := map[string][]string{ - "john": {"telegram:123"}, - } - got := resolveLinkedPeerID(links, "discord", "999") - if got != "" { - t.Errorf("resolveLinkedPeerID no match = %q, want empty", got) - } -} - -func TestParseAgentSessionKey_Valid(t *testing.T) { - parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123") - if parsed == nil { - t.Fatal("expected non-nil result") - } - if parsed.AgentID != "sales" { - t.Errorf("AgentID = %q, want 'sales'", parsed.AgentID) - } - if parsed.Rest != "telegram:direct:user123" { - t.Errorf("Rest = %q, want 'telegram:direct:user123'", parsed.Rest) - } -} - -func TestParseAgentSessionKey_Invalid(t *testing.T) { - tests := []string{ - "", - "foo:bar", - "notprefix:sales:main", - "agent::main", - "agent:sales:", - } - for _, input := range tests { - if got := ParseAgentSessionKey(input); got != nil { - t.Errorf("ParseAgentSessionKey(%q) = %+v, want nil", input, got) - } - } -} - -func TestIsSubagentSessionKey(t *testing.T) { - tests := []struct { - input string - want bool - }{ - {"subagent:task-1", true}, - {"agent:main:subagent:task-1", true}, - {"agent:main:main", false}, - {"agent:main:telegram:direct:user123", false}, - {"", false}, - } - for _, tt := range tests { - if got := IsSubagentSessionKey(tt.input); got != tt.want { - t.Errorf("IsSubagentSessionKey(%q) = %v, want %v", tt.input, got, tt.want) - } - } -} diff --git a/pkg/seahorse/.omc/state/last-tool-error.json b/pkg/seahorse/.omc/state/last-tool-error.json new file mode 100644 index 000000000..2e7273e23 --- /dev/null +++ b/pkg/seahorse/.omc/state/last-tool-error.json @@ -0,0 +1,7 @@ +{ + "tool_name": "Bash", + "tool_input_preview": "{\"command\":\"cd /home/yliu/repos/picoclaw && make lint 2>&1\",\"timeout\":120000}", + "error": "Exit code 2\npkg/agent/context_seahorse_test.go:1027:1: File is not properly formatted (gci)\n\t\t\tEarliestAt: &now,\n^\n1 issues:\n* gci: 1\nmake: *** [Makefile:264: lint] Error 1", + "timestamp": "2026-04-04T02:38:32.067Z", + "retry_count": 6 +} \ No newline at end of file diff --git a/pkg/seahorse/compact_until_under_test.go b/pkg/seahorse/compact_until_under_test.go new file mode 100644 index 000000000..2bb96c263 --- /dev/null +++ b/pkg/seahorse/compact_until_under_test.go @@ -0,0 +1,58 @@ +package seahorse + +import ( + "context" + "testing" +) + +// ============================================================================= +// CompactUntilUnder iteration cap +// ============================================================================= + +func TestCompactUntilUnderIterationCap(t *testing.T) { + // Setup: create a conversation with so many tokens that compaction + // will never reach the budget. The iteration cap prevents infinite loops. + // + // We use a mock CompleteFn that always returns the same content, + // and a budget of 0 which tokens can never reach. + // Without the cap, this would loop forever. + + db := openTestDB(t) + if err := runSchema(db); err != nil { + t.Fatalf("migration: %v", err) + } + s := &Store{db: db} + + conv, _ := s.GetOrCreateConversation(context.Background(), "agent:iter-cap") + convID := conv.ConversationID + + // Add many messages to ensure there's plenty to compact + for i := 0; i < 40; i++ { + m, _ := s.AddMessage(context.Background(), convID, "user", + "this is a long message with lots of tokens to push context over budget", 100) + s.AppendContextMessage(context.Background(), convID, m.ID) + } + + // A completeFn that always succeeds but returns non-reducing content + mockComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) { + return "Summary that doesn't reduce tokens much.", nil + } + + ce, cancel := newTestCompactionEngineWithStore(s, mockComplete) + defer cancel() + + // Use budget=1 so tokens can never reach budget + // (each message is 100 tokens, so 40 messages = 4000 tokens, budget 1 is unreachable) + // The function should stop after maxCompactIterations, not loop forever + ce.config = Config{} // ensure defaults + + result, err := ce.CompactUntilUnder(context.Background(), convID, 1) + if err != nil { + // Should not error — should stop gracefully + t.Fatalf("CompactUntilUnder with budget=0: %v", err) + } + + // The function should have completed within reasonable time + // If it exceeded the cap, it would still return (not hang) + _ = result +} diff --git a/pkg/seahorse/fts5_sanitize.go b/pkg/seahorse/fts5_sanitize.go new file mode 100644 index 000000000..baa91e1b6 --- /dev/null +++ b/pkg/seahorse/fts5_sanitize.go @@ -0,0 +1,70 @@ +package seahorse + +import ( + "regexp" + "strings" +) + +// phraseRegex matches complete quoted phrases like "exact phrase". +// Compiled once at package level to avoid per-call overhead. +var phraseRegex = regexp.MustCompile(`"([^"]+)"`) + +// SanitizeFTS5Query escapes user input for safe use in an FTS5 MATCH expression. +// +// FTS5 treats certain characters as operators: +// - `-` (NOT), `+` (required), `*` (prefix), `^` (initial token) +// - `OR`, `AND`, `NOT`, `NEAR` (boolean/proximity operators) +// - `:` (column filter — e.g. `agent:foo` means "search column agent") +// - `"` (phrase query), `(` `)` (grouping) +// +// Strategy: wrap each whitespace-delimited token in double quotes so FTS5 +// treats it as a literal phrase token. User-quoted phrases ("...") are +// preserved as-is. Internal double quotes are stripped. Empty tokens are +// dropped. Tokens are joined with spaces (implicit AND). +// +// Returns empty string for blank input so callers can skip the MATCH query. +// +// Examples: +// +// "sub-agent restrict" → `"sub-agent" "restrict"` +// "lcm_expand OR crash" → `"lcm_expand" "OR" "crash"` +// `hello "world"` → `"hello" "world"` +func SanitizeFTS5Query(raw string) string { + if strings.TrimSpace(raw) == "" { + return "" + } + + // Preserve user-quoted phrases: extract "..." groups first, then tokenize the rest. + var parts []string + lastIndex := 0 + + for _, loc := range phraseRegex.FindAllStringIndex(raw, -1) { + // Process unquoted text before this phrase + before := raw[lastIndex:loc[0]] + for _, t := range strings.Fields(before) { + t = strings.ReplaceAll(t, `"`, "") + if t != "" { + parts = append(parts, `"`+t+`"`) + } + } + // Preserve the phrase as-is (strip internal quotes for safety) + phrase := strings.TrimSpace(strings.ReplaceAll(raw[loc[0]+1:loc[1]-1], `"`, "")) + if phrase != "" { + parts = append(parts, `"`+phrase+`"`) + } + lastIndex = loc[1] + } + + // Process unquoted text after last phrase + for _, t := range strings.Fields(raw[lastIndex:]) { + t = strings.ReplaceAll(t, `"`, "") + if t != "" { + parts = append(parts, `"`+t+`"`) + } + } + + if len(parts) == 0 { + return "" + } + return strings.Join(parts, " ") +} diff --git a/pkg/seahorse/fts5_sanitize_test.go b/pkg/seahorse/fts5_sanitize_test.go new file mode 100644 index 000000000..8b430f414 --- /dev/null +++ b/pkg/seahorse/fts5_sanitize_test.go @@ -0,0 +1,237 @@ +package seahorse + +import ( + "context" + "testing" +) + +func TestSanitizeFTS5Query(t *testing.T) { + tests := []struct { + input string + want string + }{ + // Basic tokens + {"hello world", `"hello" "world"`}, + {"database", `"database"`}, + + // FTS5 operators neutralized + {"sub-agent", `"sub-agent"`}, + {"agent:main", `"agent:main"`}, + {"+required", `"+required"`}, + {"prefix*", `"prefix*"`}, + {"^initial", `"^initial"`}, + {"crash OR restart", `"crash" "OR" "restart"`}, + {"NOT excluded", `"NOT" "excluded"`}, + {"(grouped)", `"(grouped)"`}, + + // User-quoted phrases preserved + {`"exact phrase" other`, `"exact phrase" "other"`}, + {`before "middle phrase" after`, `"before" "middle phrase" "after"`}, + + // Unmatched quotes stripped + {`"unmatched`, `"unmatched"`}, + {`hello"world`, `"helloworld"`}, + + // NEAR operator neutralized + {"NEAR/2 agent", `"NEAR/2" "agent"`}, + + // Empty input + {"", ""}, + {" ", ""}, + + // CJK unaffected + {"数据库连接", `"数据库连接"`}, + {"数据库 连接", `"数据库" "连接"`}, + {"sub-agent重启", `"sub-agent重启"`}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := SanitizeFTS5Query(tt.input) + if got != tt.want { + t.Errorf("SanitizeFTS5Query(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +// TestFTS5SpecialCharsShouldNotError verifies that user input containing +// FTS5 special characters does not cause errors when searching. +func TestFTS5SpecialCharsShouldNotError(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "test:fts5-sanitize") + re := &RetrievalEngine{store: s} + + // Seed data with content containing special characters + s.AddMessage(ctx, conv.ConversationID, "user", "the sub-agent restarted after crash", 10) + s.AddMessage(ctx, conv.ConversationID, "assistant", "agent:main session restored successfully", 10) + s.AddMessage(ctx, conv.ConversationID, "user", "use NOT operator in the query filter", 10) + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "sub-agent crashed and was restarted by the orchestrator", + TokenCount: 50, + }) + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "agent:main handled the restart procedure", + TokenCount: 50, + }) + + tests := []struct { + name string + pattern string + wantSummaryMin int + wantMessageMin int + }{ + { + name: "hyphen in search term", + pattern: "sub-agent", + wantSummaryMin: 1, + wantMessageMin: 1, + }, + { + name: "colon in search term", + pattern: "agent:main", + wantSummaryMin: 1, + wantMessageMin: 1, + }, + { + name: "unmatched double quote", + pattern: `"sub-agent`, + wantSummaryMin: 1, + wantMessageMin: 1, + }, + { + name: "plus sign", + pattern: "+agent", + wantSummaryMin: 0, + wantMessageMin: 0, + }, + { + name: "parentheses", + pattern: "(agent)", + wantSummaryMin: 0, + wantMessageMin: 0, + }, + { + name: "NOT keyword", + pattern: "NOT operator", + wantSummaryMin: 0, + wantMessageMin: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := re.Grep(ctx, GrepInput{ + Pattern: tt.pattern, + Scope: "both", + }) + if err != nil { + t.Fatalf("Grep(%q) returned error: %v", tt.pattern, err) + } + if len(result.Summaries) < tt.wantSummaryMin { + t.Errorf("Grep(%q) summaries = %d, want >= %d", + tt.pattern, len(result.Summaries), tt.wantSummaryMin) + } + if len(result.Messages) < tt.wantMessageMin { + t.Errorf("Grep(%q) messages = %d, want >= %d", + tt.pattern, len(result.Messages), tt.wantMessageMin) + } + }) + } +} + +// TestFTS5OperatorsNotInterpreted verifies that FTS5 operators are treated +// as literal text, not as query syntax. Each case constructs data where +// boolean interpretation would produce different results than literal matching. +func TestFTS5OperatorsNotInterpreted(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "test:fts5-operators") + re := &RetrievalEngine{store: s} + + // "restart only" — contains "restart" but NOT "crash". + // If OR is treated as boolean, "crash OR restart" would match this. + // With sanitization (literal AND), it should NOT match. + s.AddMessage(ctx, conv.ConversationID, "user", "restart the service now please", 10) + + // "subcommand" — starts with "sub" but is not "sub-agent". + // If * is treated as prefix wildcard, "sub*" would match this. + // With sanitization (literal "sub*"), it should NOT match. + s.AddMessage(ctx, conv.ConversationID, "user", "run the subcommand to deploy", 10) + + // "agent grouped" — contains "agent" but not "(agent)". + // If () is treated as grouping, "(agent)" would match this. + // With sanitization (literal "(agent)"), it should NOT match. + s.AddMessage(ctx, conv.ConversationID, "user", "the agent processed the request", 10) + + // Same patterns in summaries + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "restart procedure completed without any crash involvement", + TokenCount: 50, + }) + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "subprocess and subcommand management overview", + TokenCount: 50, + }) + + t.Run("OR must not be boolean", func(t *testing.T) { + // "crash OR restart" as literal means all three tokens must appear. + // The message "restart the service now please" has "restart" but not "crash" or "OR". + // Boolean OR would match it; literal AND should not. + result, err := re.Grep(ctx, GrepInput{Pattern: "crash OR restart", Scope: "message"}) + if err != nil { + t.Fatalf("Grep returned error: %v", err) + } + if len(result.Messages) != 0 { + t.Errorf( + "OR treated as boolean: got %d messages, want 0 (only-restart message should not match literal AND of 'crash','OR','restart')", + len(result.Messages), + ) + } + }) + + t.Run("asterisk must not be prefix wildcard", func(t *testing.T) { + // "sub*" as literal means exact trigram match on "sub*". + // The message "run the subcommand to deploy" contains "sub" as prefix. + // Prefix wildcard would match it; literal should not. + result, err := re.Grep(ctx, GrepInput{Pattern: "sub*", Scope: "message"}) + if err != nil { + t.Fatalf("Grep returned error: %v", err) + } + if len(result.Messages) != 0 { + t.Errorf( + "asterisk treated as prefix wildcard: got %d messages, want 0 (literal 'sub*' does not appear in any message)", + len(result.Messages), + ) + } + }) + + t.Run("parentheses must not be grouping", func(t *testing.T) { + // "(agent)" as literal means exact trigram match on "(agent)". + // The message "the agent processed the request" contains "agent" without parens. + // Grouping would match it; literal should not. + result, err := re.Grep(ctx, GrepInput{Pattern: "(agent)", Scope: "message"}) + if err != nil { + t.Fatalf("Grep returned error: %v", err) + } + if len(result.Messages) != 0 { + t.Errorf( + "parentheses treated as grouping: got %d messages, want 0 (literal '(agent)' does not appear in any message)", + len(result.Messages), + ) + } + }) +} diff --git a/pkg/seahorse/parts_roundtrip_test.go b/pkg/seahorse/parts_roundtrip_test.go new file mode 100644 index 000000000..02df8a9ea --- /dev/null +++ b/pkg/seahorse/parts_roundtrip_test.go @@ -0,0 +1,144 @@ +package seahorse + +import ( + "context" + "testing" + "time" +) + +// ============================================================================= +// Bug 1: formatMessagesForSummary ignores Parts +// - formatMessagesForSummary only reads m.Content, empty for Part-based messages +// - truncateSummary has same issue +// ============================================================================= + +func TestFormatMessagesForSummaryIncludesParts(t *testing.T) { + ts := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + + messages := []Message{ + {ID: 1, Role: "user", Content: "hello world", CreatedAt: ts}, + { + ID: 2, + Role: "assistant", + Content: "", // empty — real content is in Parts + Parts: []MessagePart{ + {Type: "text", Text: "I will run a command"}, + {Type: "tool_use", Name: "bash", Arguments: `{"command":"ls -la"}`, ToolCallID: "call_1"}, + }, + CreatedAt: ts.Add(time.Minute), + }, + { + ID: 3, + Role: "tool", + Content: "", // empty — real content is in Parts + Parts: []MessagePart{ + {Type: "tool_result", Text: "file1.txt\nfile2.txt", ToolCallID: "call_1"}, + }, + CreatedAt: ts.Add(2 * time.Minute), + }, + } + + result := formatMessagesForSummary(messages) + + // Must contain the plain text message + if !contains(result, "hello world") { + t.Error("formatMessagesForSummary: missing plain text content") + } + + // Must contain tool_use info (not blank) + if !contains(result, "bash") || !contains(result, "ls -la") { + t.Errorf("formatMessagesForSummary: tool_use info missing from Parts.\nGot:\n%s", result) + } + + // Must contain tool_result info (not blank) + if !contains(result, "file1.txt") { + t.Errorf("formatMessagesForSummary: tool_result text missing from Parts.\nGot:\n%s", result) + } +} + +func TestTruncateSummaryIncludesParts(t *testing.T) { + messages := []Message{ + {ID: 1, Role: "user", Content: "run the tests", CreatedAt: time.Now()}, + { + ID: 2, + Role: "assistant", + Content: "", // empty + Parts: []MessagePart{ + {Type: "tool_use", Name: "bash", Arguments: `{"command":"go test ./..."}`, ToolCallID: "call_1"}, + }, + CreatedAt: time.Now(), + }, + { + ID: 3, + Role: "tool", + Content: "", // empty + Parts: []MessagePart{ + {Type: "tool_result", Text: "PASS\nok 3.2s", ToolCallID: "call_1"}, + }, + CreatedAt: time.Now(), + }, + } + + result := truncateSummary(messages) + + // Must contain plain text + if !contains(result, "run the tests") { + t.Error("truncateSummary: missing plain text content") + } + + // Must contain tool info from Parts (not blank) + if !contains(result, "bash") || !contains(result, "go test") { + t.Errorf("truncateSummary: tool_use info missing from Parts.\nGot:\n%s", result) + } + + // Must contain tool_result from Parts + if !contains(result, "PASS") { + t.Errorf("truncateSummary: tool_result text missing from Parts.\nGot:\n%s", result) + } +} + +// ============================================================================= +// Bug 2: SearchMessages cannot find Part-based messages +// - FTS5 indexes empty content, LIKE queries empty content +// ============================================================================= + +func TestSearchMessagesFindsPartBasedMessages(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:search-parts") + convID := conv.ConversationID + + // Add a plain message (searchable) + s.AddMessage(ctx, convID, "user", "list the files please", 5) + + // Add a Part-based message (tool_use) — currently NOT searchable + parts := []MessagePart{ + {Type: "tool_use", Name: "bash", Arguments: `{"command":"grep -r TODO ."}`, ToolCallID: "call_1"}, + } + s.AddMessageWithParts(ctx, convID, "assistant", parts, 10) + + // Add a Part-based message (tool_result) — currently NOT searchable + resultParts := []MessagePart{ + {Type: "tool_result", Text: "main.go:42: TODO fix this bug", ToolCallID: "call_1"}, + } + s.AddMessageWithParts(ctx, convID, "tool", resultParts, 10) + + // Search for "grep" — should find the tool_use message + results, err := s.SearchMessages(ctx, SearchInput{Pattern: "grep"}) + if err != nil { + t.Fatalf("SearchMessages: %v", err) + } + if len(results) == 0 { + t.Error("SearchMessages: 'grep' not found — Part-based messages are invisible to search") + } + + // Search for "TODO fix" — should find the tool_result message + results2, err := s.SearchMessages(ctx, SearchInput{Pattern: "TODO fix"}) + if err != nil { + t.Fatalf("SearchMessages: %v", err) + } + if len(results2) == 0 { + t.Error("SearchMessages: 'TODO fix' not found — tool_result messages are invisible to search") + } +} diff --git a/pkg/seahorse/schema.go b/pkg/seahorse/schema.go new file mode 100644 index 000000000..5b67fe9e0 --- /dev/null +++ b/pkg/seahorse/schema.go @@ -0,0 +1,243 @@ +package seahorse + +import ( + "database/sql" + "fmt" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// SQL statements for FTS5 tables with trigram tokenizer. +const ( + sqlCreateSummariesFTS = `CREATE VIRTUAL TABLE IF NOT EXISTS summaries_fts USING fts5( + summary_id, + content, + tokenize="trigram" + )` + sqlCreateMessagesFTS = `CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + message_id, + content, + tokenize="trigram" + )` + sqlCheckFTS5Available = `CREATE VIRTUAL TABLE IF NOT EXISTS _fts5_check USING fts5(content)` + sqlCheckTrigramAvailable = `CREATE VIRTUAL TABLE IF NOT EXISTS _trigram_check USING fts5(content, tokenize="trigram")` + sqlDropFTS5Check = `DROP TABLE IF EXISTS _fts5_check` + sqlDropTrigramCheck = `DROP TABLE IF EXISTS _trigram_check` +) + +// runSchema creates or upgrades the database schema. +// All schemas are idempotent (safe to run multiple times). +func runSchema(db *sql.DB) error { + // Check FTS5 support before creating tables + if err := checkFTS5Support(db); err != nil { + return fmt.Errorf("FTS5 check: %w", err) + } + + stmts := []string{ + `CREATE TABLE IF NOT EXISTS conversations ( + conversation_id INTEGER PRIMARY KEY AUTOINCREMENT, + session_key TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + + `CREATE TABLE IF NOT EXISTS messages ( + message_id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id INTEGER NOT NULL REFERENCES conversations(conversation_id), + role TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + reasoning_content TEXT NOT NULL DEFAULT '', + token_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + + `CREATE TABLE IF NOT EXISTS message_parts ( + part_id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES messages(message_id), + type TEXT NOT NULL, + text TEXT, + name TEXT, + arguments TEXT, + tool_call_id TEXT, + media_uri TEXT, + mime_type TEXT, + ordinal INTEGER NOT NULL DEFAULT 0 + )`, + + `CREATE TABLE IF NOT EXISTS summaries ( + summary_id TEXT PRIMARY KEY, + conversation_id INTEGER NOT NULL REFERENCES conversations(conversation_id), + kind TEXT NOT NULL, + depth INTEGER NOT NULL DEFAULT 0, + content TEXT NOT NULL, + token_count INTEGER NOT NULL DEFAULT 0, + earliest_at TEXT, + latest_at TEXT, + descendant_count INTEGER NOT NULL DEFAULT 0, + descendant_token_count INTEGER NOT NULL DEFAULT 0, + source_message_token_count INTEGER NOT NULL DEFAULT 0, + model TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + + `CREATE TABLE IF NOT EXISTS summary_parents ( + summary_id TEXT NOT NULL, + parent_summary_id TEXT NOT NULL, + PRIMARY KEY (summary_id, parent_summary_id) + )`, + + `CREATE TABLE IF NOT EXISTS summary_messages ( + summary_id TEXT NOT NULL, + message_id INTEGER NOT NULL, + ordinal INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (summary_id, message_id) + )`, + + `CREATE TABLE IF NOT EXISTS context_items ( + conversation_id INTEGER NOT NULL, + ordinal INTEGER NOT NULL, + item_type TEXT NOT NULL, + summary_id TEXT, + message_id INTEGER, + token_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (conversation_id, ordinal) + )`, + + // FTS5 virtual table with trigram tokenizer for CJK support + sqlCreateSummariesFTS, + + // FTS5 virtual table for message search with trigram tokenizer + sqlCreateMessagesFTS, + + // Indexes for common query patterns + `CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id)`, + `CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(conversation_id, created_at)`, + `CREATE INDEX IF NOT EXISTS idx_summaries_conversation ON summaries(conversation_id)`, + `CREATE INDEX IF NOT EXISTS idx_summaries_kind_depth ON summaries(conversation_id, kind, depth)`, + `CREATE INDEX IF NOT EXISTS idx_summary_parents_parent ON summary_parents(parent_summary_id)`, + `CREATE INDEX IF NOT EXISTS idx_summary_messages_message ON summary_messages(message_id)`, + `CREATE INDEX IF NOT EXISTS idx_context_items_conv ON context_items(conversation_id, ordinal)`, + + // Drop old triggers before creating new ones so existing DBs get updated bodies. + // (CREATE TRIGGER IF NOT EXISTS does NOT replace an existing trigger body.) + `DROP TRIGGER IF EXISTS summaries_ai`, + `DROP TRIGGER IF EXISTS summaries_ad`, + `DROP TRIGGER IF EXISTS summaries_au`, + `DROP TRIGGER IF EXISTS messages_ai`, + `DROP TRIGGER IF EXISTS messages_ad`, + `DROP TRIGGER IF EXISTS messages_au`, + + // FTS5 triggers to keep summaries_fts in sync with summaries table + `CREATE TRIGGER summaries_ai AFTER INSERT ON summaries BEGIN + INSERT INTO summaries_fts (summary_id, content) VALUES (new.summary_id, new.content); + END`, + `CREATE TRIGGER summaries_ad AFTER DELETE ON summaries BEGIN + DELETE FROM summaries_fts WHERE summary_id = old.summary_id; + END`, + `CREATE TRIGGER summaries_au AFTER UPDATE ON summaries BEGIN + DELETE FROM summaries_fts WHERE summary_id = old.summary_id; + INSERT INTO summaries_fts (summary_id, content) VALUES (new.summary_id, new.content); + END`, + + // FTS5 triggers to keep messages_fts in sync with messages table + `CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts (message_id, content) VALUES (new.message_id, new.content); + END`, + `CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN + DELETE FROM messages_fts WHERE message_id = old.message_id; + END`, + `CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN + DELETE FROM messages_fts WHERE message_id = old.message_id; + INSERT INTO messages_fts (message_id, content) VALUES (new.message_id, new.content); + END`, + } + + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + return err + } + } + + if err := ensureMessagesReasoningContentColumn(db); err != nil { + return err + } + return nil +} + +func ensureMessagesReasoningContentColumn(db *sql.DB) error { + hasColumn, err := tableHasColumn(db, "messages", "reasoning_content") + if err != nil { + return fmt.Errorf("check messages.reasoning_content: %w", err) + } + if hasColumn { + return nil + } + + if _, err := db.Exec(`ALTER TABLE messages ADD COLUMN reasoning_content TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("add messages.reasoning_content: %w", err) + } + return nil +} + +func tableHasColumn(db *sql.DB, tableName, columnName string) (bool, error) { + rows, err := db.Query(fmt.Sprintf(`PRAGMA table_info(%s)`, tableName)) + if err != nil { + return false, err + } + defer rows.Close() + + for rows.Next() { + var ( + cid int + name string + columnType string + notNull int + defaultVal sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultVal, &pk); err != nil { + return false, err + } + if name == columnName { + return true, nil + } + } + if err := rows.Err(); err != nil { + return false, err + } + return false, nil +} + +// checkFTS5Support verifies that SQLite has FTS5 with trigram tokenizer enabled. +// This is required for full-text search with CJK (Chinese, Japanese, Korean) support. +func checkFTS5Support(db *sql.DB) error { + // Check if FTS5 is compiled in + var fts5Enabled int + err := db.QueryRow(`SELECT sqlite_compileoption_used('ENABLE_FTS5')`).Scan(&fts5Enabled) + if err != nil { + // sqlite_compileoption_used might not exist in older SQLite + // Try a different approach: create a test FTS5 table + _, testErr := db.Exec(sqlCheckFTS5Available) + if testErr != nil { + return fmt.Errorf("SQLite FTS5 not available: %w (required for full-text search)", testErr) + } + db.Exec(sqlDropFTS5Check) + } else if fts5Enabled == 0 { + return fmt.Errorf("SQLite was compiled without FTS5 support (required for full-text search)") + } + + // Check if trigram tokenizer is available by trying to create a test table + // Not all SQLite builds include the trigram tokenizer + _, err = db.Exec(sqlCheckTrigramAvailable) + if err != nil { + logger.WarnCF("seahorse", "SQLite trigram tokenizer not available, CJK search may be limited", + map[string]any{"error": err.Error()}) + // Trigram is not strictly required, just better for CJK + // Don't return error, just log warning + } else { + db.Exec(sqlDropTrigramCheck) + } + + return nil +} diff --git a/pkg/seahorse/schema_test.go b/pkg/seahorse/schema_test.go new file mode 100644 index 000000000..943b742b2 --- /dev/null +++ b/pkg/seahorse/schema_test.go @@ -0,0 +1,348 @@ +package seahorse + +import ( + "database/sql" + "fmt" + "strings" + "sync/atomic" + "testing" + + _ "modernc.org/sqlite" +) + +var testDBCounter uint64 + +func openTestDB(t *testing.T) *sql.DB { + t.Helper() + + n := atomic.AddUint64(&testDBCounter, 1) + testName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + // Use a shared in-memory database so concurrent goroutines/connections in tests + // observe the same schema/data. + dsn := fmt.Sprintf("file:seahorse_test_%s_%d?mode=memory&cache=shared", testName, n) + + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("open test db: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func TestRunMigrations(t *testing.T) { + db := openTestDB(t) + + if err := runSchema(db); err != nil { + t.Fatalf("runSchema: %v", err) + } + + // Verify all tables exist + tables := []string{ + "conversations", + "messages", + "message_parts", + "summaries", + "summary_parents", + "summary_messages", + "context_items", + } + for _, tbl := range tables { + var name string + err := db.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", tbl, + ).Scan(&name) + if err != nil { + t.Errorf("table %q not found: %v", tbl, err) + } + } + + // Verify FTS5 virtual table exists + var ftsName string + err := db.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name='summaries_fts'", + ).Scan(&ftsName) + if err != nil { + t.Errorf("FTS5 table summaries_fts not found: %v", err) + } +} + +func TestRunMigrationsIdempotent(t *testing.T) { + db := openTestDB(t) + + // Run migrations twice — should succeed both times + if err := runSchema(db); err != nil { + t.Fatalf("first migration: %v", err) + } + if err := runSchema(db); err != nil { + t.Fatalf("second migration (idempotent): %v", err) + } + + // Verify we can still insert data after double migration + res, err := db.Exec( + "INSERT INTO conversations (session_key, created_at, updated_at) VALUES (?, datetime('now'), datetime('now'))", + "test-session", + ) + if err != nil { + t.Fatalf("insert after double migration: %v", err) + } + id, _ := res.LastInsertId() + if id == 0 { + t.Error("expected non-zero conversation id") + } +} + +func TestRunSchemaAddsMessagesReasoningContentColumn(t *testing.T) { + db := openTestDB(t) + + _, err := db.Exec(`CREATE TABLE messages ( + message_id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + token_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`) + if err != nil { + t.Fatalf("create legacy messages table: %v", err) + } + + err = runSchema(db) + if err != nil { + t.Fatalf("runSchema: %v", err) + } + + var count int + err = db.QueryRow(`SELECT count(*) FROM pragma_table_info('messages') WHERE name = 'reasoning_content'`). + Scan(&count) + if err != nil { + t.Fatalf("query pragma_table_info: %v", err) + } + if count != 1 { + t.Fatalf("reasoning_content column count = %d, want 1", count) + } + + _, err = db.Exec( + `INSERT INTO conversations (session_key, created_at, updated_at) VALUES (?, datetime('now'), datetime('now'))`, + "reasoning-column-test", + ) + if err != nil { + t.Fatalf("insert conversation: %v", err) + } + + _, err = db.Exec( + `INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) + VALUES (1, 'assistant', 'answer', 'thinking', 1)`, + ) + if err != nil { + t.Fatalf("insert message with reasoning_content: %v", err) + } +} + +func TestMigrationConversationUnique(t *testing.T) { + db := openTestDB(t) + if err := runSchema(db); err != nil { + t.Fatalf("migration: %v", err) + } + + // Insert first + _, err := db.Exec( + "INSERT INTO conversations (session_key, created_at, updated_at) VALUES (?, datetime('now'), datetime('now'))", + "unique-key", + ) + if err != nil { + t.Fatalf("first insert: %v", err) + } + + // Duplicate should fail + _, err = db.Exec( + "INSERT INTO conversations (session_key, created_at, updated_at) VALUES (?, datetime('now'), datetime('now'))", + "unique-key", + ) + if err == nil { + t.Error("expected unique constraint violation for duplicate session_key") + } +} + +func TestMigrationSummaryFTSInsert(t *testing.T) { + db := openTestDB(t) + if err := runSchema(db); err != nil { + t.Fatalf("migration: %v", err) + } + + // Insert a conversation first + _, err := db.Exec( + "INSERT INTO conversations (session_key, created_at, updated_at) VALUES (?, datetime('now'), datetime('now'))", + "fts-test", + ) + if err != nil { + t.Fatalf("insert conversation: %v", err) + } + + // Insert a summary + _, err = db.Exec( + `INSERT INTO summaries (summary_id, conversation_id, kind, depth, content, token_count, created_at) + VALUES ('sum_test1', 1, 'leaf', 0, '你好世界 hello world', 10, datetime('now'))`) + if err != nil { + t.Fatalf("insert summary: %v", err) + } + + // FTS should find it — trigram tokenizer requires >= 3 chars + rows, err := db.Query( + "SELECT summary_id FROM summaries_fts WHERE summaries_fts MATCH ?", + "你好世", + ) + if err != nil { + t.Fatalf("FTS query: %v", err) + } + defer rows.Close() + + var found string + if rows.Next() { + if err := rows.Scan(&found); err != nil { + t.Fatalf("scan: %v", err) + } + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + if found != "sum_test1" { + t.Errorf("FTS: expected 'sum_test1', got %q", found) + } +} + +func TestMigrationSummaryParentsPK(t *testing.T) { + db := openTestDB(t) + if err := runSchema(db); err != nil { + t.Fatalf("migration: %v", err) + } + + // Insert two summaries + for _, id := range []string{"sum_a", "sum_b"} { + _, err := db.Exec( + `INSERT INTO summaries (summary_id, conversation_id, kind, depth, content, token_count, created_at) + VALUES (?, 1, 'leaf', 0, 'content', 5, datetime('now'))`, id) + if err != nil { + t.Fatalf("insert summary %s: %v", id, err) + } + } + + // Link child to parent + _, err := db.Exec( + "INSERT INTO summary_parents (summary_id, parent_summary_id) VALUES ('sum_a', 'sum_b')") + if err != nil { + t.Fatalf("link: %v", err) + } + + // Duplicate link should fail (composite PK) + _, err = db.Exec( + "INSERT INTO summary_parents (summary_id, parent_summary_id) VALUES ('sum_a', 'sum_b')") + if err == nil { + t.Error("expected unique constraint violation for duplicate summary_parents link") + } +} + +func TestTriggerMigration(t *testing.T) { + db := openTestDB(t) + + // Run schema once to create tables and (correct) triggers + if err := runSchema(db); err != nil { + t.Fatalf("runSchema: %v", err) + } + + // Drop correct triggers and recreate them with the old buggy body. + // The old trigger used INSERT INTO fts VALUES('delete', ...) which is wrong + // for non-external-content FTS5 tables. + oldSummariesDelete := `CREATE TRIGGER summaries_ad AFTER DELETE ON summaries BEGIN + INSERT INTO summaries_fts (summaries_fts, summary_id, content) VALUES('delete', old.summary_id, old.content); + END` + oldMessagesDelete := `CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts (messages_fts, message_id, content) VALUES('delete', old.message_id, old.content); + END` + + for _, sql := range []string{ + `DROP TRIGGER IF EXISTS summaries_ad`, + `DROP TRIGGER IF EXISTS messages_ad`, + oldSummariesDelete, + oldMessagesDelete, + } { + if _, err := db.Exec(sql); err != nil { + t.Fatalf("setup old trigger: %v", err) + } + } + + // Insert a conversation and summary so we have something to delete + _, err := db.Exec(`INSERT INTO conversations (session_key) VALUES ('old-db-test')`) + if err != nil { + t.Fatalf("insert conversation: %v", err) + } + _, err = db.Exec(`INSERT INTO summaries (summary_id, conversation_id, kind, depth, content, token_count) + VALUES ('old-sum', 1, 'leaf', 0, 'old content', 5)`) + if err != nil { + t.Fatalf("insert summary: %v", err) + } + + // The old trigger body is wrong for normal FTS5 — DELETE should fail. + _, err = db.Exec(`DELETE FROM summaries WHERE summary_id = 'old-sum'`) + if err == nil { + t.Error("expected error from old buggy trigger, but DELETE succeeded") + } else { + t.Logf("old trigger correctly causes error: %v", err) + } + + // Now runSchema again — this drops and recreates the triggers with correct bodies. + err = runSchema(db) + if err != nil { + t.Fatalf("runSchema migration: %v", err) + } + + // Insert again so we have data to delete + _, err = db.Exec(`INSERT INTO summaries (summary_id, conversation_id, kind, depth, content, token_count) + VALUES ('migrated-sum', 1, 'leaf', 0, 'new content', 5)`) + if err != nil { + t.Fatalf("insert after migration: %v", err) + } + + // DELETE should now work with the corrected trigger body. + _, err = db.Exec(`DELETE FROM summaries WHERE summary_id = 'migrated-sum'`) + if err != nil { + t.Fatalf("DELETE after migration failed (trigger not corrected): %v", err) + } + + // Verify the summary is gone + var count int + err = db.QueryRow(`SELECT count(*) FROM summaries WHERE summary_id = 'migrated-sum'`).Scan(&count) + if err != nil { + t.Fatalf("query after delete: %v", err) + } + if count != 0 { + t.Errorf("summary should be gone after DELETE, got count=%d", count) + } +} + +func TestFTS5SQLConstants(t *testing.T) { + db := openTestDB(t) + + // Verify FTS5 check SQL executes without error + _, err := db.Exec(sqlCheckFTS5Available) + if err != nil { + t.Errorf("sqlCheckFTS5Available failed: %v", err) + } + + // Verify trigram check SQL executes without error + _, err = db.Exec(sqlCheckTrigramAvailable) + if err != nil { + t.Errorf("sqlCheckTrigramAvailable failed: %v", err) + } + + // Verify summaries_fts SQL executes without error + _, err = db.Exec(sqlCreateSummariesFTS) + if err != nil { + t.Errorf("sqlCreateSummariesFTS failed: %v", err) + } + + // Verify messages_fts SQL executes without error + _, err = db.Exec(sqlCreateMessagesFTS) + if err != nil { + t.Errorf("sqlCreateMessagesFTS failed: %v", err) + } +} diff --git a/pkg/seahorse/short_assembler.go b/pkg/seahorse/short_assembler.go new file mode 100644 index 000000000..f0fd323ba --- /dev/null +++ b/pkg/seahorse/short_assembler.go @@ -0,0 +1,261 @@ +package seahorse + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// escapeXML escapes special characters for safe inclusion in XML content. +func escapeXML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + return s +} + +// resolvedItem is a context item resolved to its full content with token count. +type resolvedItem struct { + ordinal int + itemType string // "message" or "summary" + message *Message + summary *Summary + tokenCount int +} + +// Assemble builds budget-constrained context from summaries + messages. +// +// Algorithm: +// 1. Fetch context_items, resolve to full content +// 2. Split into evictable prefix + protected fresh tail +// 3. If evictable fits in remaining budget → include all +// 4. Else walk evictable from newest to oldest, keep while fits +func (a *Assembler) Assemble(ctx context.Context, convID int64, input AssembleInput) (*AssembleResult, error) { + items, err := a.store.GetContextItems(ctx, convID) + if err != nil { + return nil, fmt.Errorf("get context items: %w", err) + } + if len(items) == 0 { + return &AssembleResult{}, nil + } + + // Resolve all items + resolved := make([]resolvedItem, len(items)) + for i, item := range items { + r, err := a.resolveItem(ctx, item) + if err != nil { + return nil, err + } + resolved[i] = r + } + + // Split into evictable prefix and protected fresh tail + tailStart := len(resolved) - FreshTailCount + if tailStart < 0 { + tailStart = 0 + } + evictable := resolved[:tailStart] + freshTail := resolved[tailStart:] + + // Calculate fresh tail tokens + freshTailTokens := 0 + for _, r := range freshTail { + freshTailTokens += r.tokenCount + } + + // Budget-aware selection of evictable items + remainingBudget := input.Budget - freshTailTokens + if remainingBudget < 0 { + // Fresh tail alone exceeds budget - we keep it anyway (design decision) + // Log for debugging retry/overflow issues + logger.InfoCF("seahorse", "assemble: fresh tail exceeds budget", map[string]any{ + "budget": input.Budget, + "fresh_tail_tokens": freshTailTokens, + "fresh_tail_count": len(freshTail), + "over_budget_by": freshTailTokens - input.Budget, + }) + remainingBudget = 0 + } + + var selected []resolvedItem + evictableTokens := 0 + for _, r := range evictable { + evictableTokens += r.tokenCount + } + + if evictableTokens <= remainingBudget { + // All evictable fit + selected = append(selected, evictable...) + } else { + // Walk from newest to oldest, keep while fits + var kept []resolvedItem + accum := 0 + for i := len(evictable) - 1; i >= 0; i-- { + if accum+evictable[i].tokenCount <= remainingBudget { + kept = append(kept, evictable[i]) + accum += evictable[i].tokenCount + } else { + break + } + } + // Reverse to restore chronological order + for i, j := 0, len(kept)-1; i < j; i, j = i+1, j-1 { + kept[i], kept[j] = kept[j], kept[i] + } + selected = append(selected, kept...) + } + + // Combine: selected evictable + fresh tail + final := append(selected, freshTail...) + + // Build result + var messages []Message + var summaries []Summary + var sourceIDs []string + totalTokens := 0 + maxDepth := 0 + condensedCount := 0 + + for _, r := range final { + totalTokens += r.tokenCount + if r.itemType == "message" && r.message != nil { + messages = append(messages, *r.message) + sourceIDs = append(sourceIDs, fmt.Sprintf("msg:%d", r.message.ID)) + } else if r.itemType == "summary" && r.summary != nil { + summaries = append(summaries, *r.summary) + if r.summary.Depth > maxDepth { + maxDepth = r.summary.Depth + } + if r.summary.Kind == SummaryKindCondensed { + condensedCount++ + } + } + } + + // Build depth-aware system prompt addition + systemPromptAddition := "" + if len(summaries) > 0 { + if maxDepth >= 2 || condensedCount >= 2 { + systemPromptAddition = "Your context has been heavily compressed through multi-level summarization.\n" + + "- Do NOT assert specific facts (commands, SHAs, paths, timestamps) from summaries without expanding.\n" + + "- When uncertain, use expand to recover original detail before making claims.\n" + + "- Tool escalation: grep \xe2\x86\x92 describe \xe2\x86\x92 expand" + } else { + systemPromptAddition = "Some earlier messages have been summarized. Use expand tools to recover details if needed." + } + } + + // Build Summary field: all XML summaries + system prompt addition + var summaryParts []string + for _, sum := range summaries { + if sum.Content == "" { + continue + } + // Load parent IDs for XML formatting + parentSummaries, err := a.store.GetSummaryParents(ctx, sum.SummaryID) + if err != nil { + logger.WarnCF("seahorse", "assemble: get summary parents", map[string]any{ + "summary_id": sum.SummaryID, + "error": err.Error(), + }) + } + var parentIDs []string + for _, ps := range parentSummaries { + parentIDs = append(parentIDs, ps.SummaryID) + } + summaryParts = append(summaryParts, FormatSummaryXML(&sum, parentIDs)) + } + summary := strings.Join(summaryParts, "\n\n") + if systemPromptAddition != "" { + if summary != "" { + summary += "\n\n" + } + summary += systemPromptAddition + } + + return &AssembleResult{ + Messages: messages, + Summary: summary, + }, nil +} + +// resolveItem loads the full message or summary for a context item. +func (a *Assembler) resolveItem(ctx context.Context, item ContextItem) (resolvedItem, error) { + if item.ItemType == "message" { + msg, err := a.store.GetMessageByID(ctx, item.MessageID) + if err != nil { + return resolvedItem{}, err + } + tokens := item.TokenCount + if tokens == 0 { + tokens = msg.TokenCount + } + return resolvedItem{ + ordinal: item.Ordinal, + itemType: "message", + message: msg, + tokenCount: tokens, + }, nil + } + + if item.ItemType == "summary" { + sum, err := a.store.GetSummary(ctx, item.SummaryID) + if err != nil { + return resolvedItem{}, err + } + tokens := item.TokenCount + if tokens == 0 { + tokens = sum.TokenCount + } + return resolvedItem{ + ordinal: item.Ordinal, + itemType: "summary", + summary: sum, + tokenCount: tokens, + }, nil + } + + return resolvedItem{ + ordinal: item.Ordinal, + itemType: item.ItemType, + tokenCount: item.TokenCount, + }, nil +} + +// FormatSummaryXML formats a summary as XML for LLM context. +// This is exported so context managers can format summaries consistently. +func FormatSummaryXML(s *Summary, parentIDs []string) string { + // Build time attributes if available + var attrs string + if s.EarliestAt != nil { + attrs += fmt.Sprintf(` earliest_at="%s"`, s.EarliestAt.Format(time.RFC3339)) + } + if s.LatestAt != nil { + attrs += fmt.Sprintf(` latest_at="%s"`, s.LatestAt.Format(time.RFC3339)) + } + + var parentsSection string + if s.Kind == SummaryKindCondensed && len(parentIDs) > 0 { + parents := "<parents>\n" + for _, pid := range parentIDs { + parents += fmt.Sprintf(" <summary_ref id=\"%s\" />\n", pid) + } + parents += " </parents>\n" + parentsSection = parents + } + return fmt.Sprintf( + "<summary id=\"%s\" kind=\"%s\" depth=\"%d\" descendant_count=\"%d\"%s>\n <content>\n %s\n </content>\n%s</summary>", + s.SummaryID, + string(s.Kind), + s.Depth, + s.DescendantCount, + attrs, + escapeXML(s.Content), + parentsSection, + ) +} diff --git a/pkg/seahorse/short_assembler_test.go b/pkg/seahorse/short_assembler_test.go new file mode 100644 index 000000000..88a05e64c --- /dev/null +++ b/pkg/seahorse/short_assembler_test.go @@ -0,0 +1,536 @@ +package seahorse + +import ( + "context" + "strings" + "testing" + "time" +) + +// --- Assembler Tests --- + +// helper: create a store with messages and summaries for assembly tests +func setupAssemblerStore(t *testing.T) (*Store, int64) { + t.Helper() + s := openTestStore(t) + ctx := context.Background() + + conv, err := s.GetOrCreateConversation(ctx, "test:assemble") + if err != nil { + t.Fatalf("create conversation: %v", err) + } + + return s, conv.ConversationID +} + +func TestAssemblerAssembleEmpty(t *testing.T) { + s, convID := setupAssemblerStore(t) + ctx := context.Background() + + a := &Assembler{store: s, config: Config{}} + result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + if len(result.Messages) != 0 { + t.Errorf("Messages = %d, want 0", len(result.Messages)) + } + if result.Summary != "" { + t.Errorf("Summary = %q, want empty", result.Summary) + } +} + +func TestAssemblerAssembleMessagesOnly(t *testing.T) { + s, convID := setupAssemblerStore(t) + ctx := context.Background() + + // Create messages + msg1, _ := s.AddMessage(ctx, convID, "user", "hello", 5) + msg2, _ := s.AddMessage(ctx, convID, "assistant", "world", 5) + + // Create context items + s.UpsertContextItems(ctx, convID, []ContextItem{ + {Ordinal: 100, ItemType: "message", MessageID: msg1.ID, TokenCount: 5}, + {Ordinal: 200, ItemType: "message", MessageID: msg2.ID, TokenCount: 5}, + }) + + a := &Assembler{store: s, config: Config{}} + result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 100}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + if len(result.Messages) != 2 { + t.Fatalf("Messages = %d, want 2", len(result.Messages)) + } + if result.Messages[0].Content != "hello" { + t.Errorf("Messages[0].Content = %q, want 'hello'", result.Messages[0].Content) + } + if result.Messages[1].Content != "world" { + t.Errorf("Messages[1].Content = %q, want 'world'", result.Messages[1].Content) + } + // No summaries, so Summary should be empty + if result.Summary != "" { + t.Errorf("Summary = %q, want empty", result.Summary) + } +} + +func TestAssemblerAssembleWithSummary(t *testing.T) { + s, convID := setupAssemblerStore(t) + ctx := context.Background() + + // Create a summary + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "summary of early messages", + TokenCount: 50, + }) + + // Create recent messages + msg1, _ := s.AddMessage(ctx, convID, "user", "recent", 5) + msg2, _ := s.AddMessage(ctx, convID, "assistant", "reply", 5) + + // Context: summary + recent messages + s.UpsertContextItems(ctx, convID, []ContextItem{ + {Ordinal: 100, ItemType: "summary", SummaryID: summary.SummaryID, TokenCount: 50}, + {Ordinal: 200, ItemType: "message", MessageID: msg1.ID, TokenCount: 5}, + {Ordinal: 300, ItemType: "message", MessageID: msg2.ID, TokenCount: 5}, + }) + + a := &Assembler{store: s, config: Config{}} + result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + // Messages = 2 raw messages (summaries are in Summary field, not Messages) + if len(result.Messages) != 2 { + t.Errorf("Messages = %d, want 2 (raw messages only)", len(result.Messages)) + } + // Summary should contain XML with summary content + if result.Summary == "" { + t.Error("Summary should not be empty when summary exists") + } + if !strings.Contains(result.Summary, summary.Content) { + t.Errorf("Summary should contain summary content %q", summary.Content) + } + if !strings.Contains(result.Summary, "<summary") { + t.Error("Summary should contain <summary XML tag") + } +} + +func TestAssemblerBudgetEvictsOldest(t *testing.T) { + s, convID := setupAssemblerStore(t) + ctx := context.Background() + + // Create 40 messages, each with 10 tokens = 400 total + msgs := make([]*Message, 40) + for i := 0; i < 40; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "msg", 10) + msgs[i] = m + } + + // Context items for all messages + items := make([]ContextItem, 40) + for i := 0; i < 40; i++ { + items[i] = ContextItem{ + Ordinal: (i + 1) * 100, + ItemType: "message", + MessageID: msgs[i].ID, + TokenCount: 10, + } + } + s.UpsertContextItems(ctx, convID, items) + + // Budget of 200 tokens with FreshTailCount=32 + // Fresh tail = last 32 messages (320 tokens, over budget, but always included) + // Evictable = first 8 messages (80 tokens) + // Budget after tail: max(0, 200-320) = 0 → no evictable items included + a := &Assembler{store: s, config: Config{}} + result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 200}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + // Should only include the 32-item fresh tail + if len(result.Messages) != 32 { + t.Errorf("Messages = %d, want 32 (fresh tail)", len(result.Messages)) + } + // Should be the LAST 32 messages + if result.Messages[0].ID != msgs[8].ID { + t.Errorf("first message ID = %d, want %d (msgs[8])", result.Messages[0].ID, msgs[8].ID) + } +} + +func TestAssemblerBudgetFitsAll(t *testing.T) { + s, convID := setupAssemblerStore(t) + ctx := context.Background() + + msgs := make([]*Message, 5) + for i := 0; i < 5; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "msg", 10) + msgs[i] = m + } + + items := make([]ContextItem, 5) + for i := 0; i < 5; i++ { + items[i] = ContextItem{ + Ordinal: (i + 1) * 100, + ItemType: "message", + MessageID: msgs[i].ID, + TokenCount: 10, + } + } + s.UpsertContextItems(ctx, convID, items) + + // Budget = 100, total = 50, FreshTailCount=32 → all items in tail + a := &Assembler{store: s, config: Config{}} + result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 100}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + if len(result.Messages) != 5 { + t.Errorf("Messages = %d, want 5", len(result.Messages)) + } +} + +func TestAssemblerSummaryXMLFormat(t *testing.T) { + s, convID := setupAssemblerStore(t) + ctx := context.Background() + + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "test summary content", + TokenCount: 20, + }) + + msg, _ := s.AddMessage(ctx, convID, "user", "hello", 5) + + s.UpsertContextItems(ctx, convID, []ContextItem{ + {Ordinal: 100, ItemType: "summary", SummaryID: summary.SummaryID, TokenCount: 20}, + {Ordinal: 200, ItemType: "message", MessageID: msg.ID, TokenCount: 5}, + }) + + a := &Assembler{store: s, config: Config{}} + result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + // Messages should only contain raw messages (no XML summary in Messages) + if len(result.Messages) != 1 { + t.Errorf("Messages = %d, want 1 (raw message only)", len(result.Messages)) + } + // Summary should contain XML with summary content + if result.Summary == "" { + t.Fatal("Summary should not be empty") + } + if !contains(result.Summary, "<summary") { + t.Errorf("Summary missing <summary tag: %q", result.Summary) + } + if !contains(result.Summary, summary.SummaryID) { + t.Errorf("Summary missing summary ID: %q", result.Summary) + } +} + +func TestAssemblerSummaryXMLEscaping(t *testing.T) { + // Summary content with special XML characters should be properly escaped + s, convID := setupAssemblerStore(t) + ctx := context.Background() + + // Create summary with content containing XML special characters + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: `User said: "hello" & asked about <tags>`, + TokenCount: 20, + }) + + s.UpsertContextItems(ctx, convID, []ContextItem{ + {Ordinal: 100, ItemType: "summary", SummaryID: summary.SummaryID, TokenCount: 20}, + }) + + a := &Assembler{store: s, config: Config{}} + result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + // Summary field should contain XML with escaped special characters + if result.Summary == "" { + t.Fatal("Summary should not be empty") + } + + // Check that special characters are escaped + if strings.Contains(result.Summary, "<tags>") { + t.Errorf("BUG: unescaped < in summary content: %q", result.Summary) + } + if strings.Contains(result.Summary, `"hello"`) { + t.Errorf("BUG: unescaped \" in summary content: %q", result.Summary) + } + // & should be escaped as & + if strings.Contains(result.Summary, " & ") { + t.Errorf("BUG: unescaped & in summary content: %q", result.Summary) + } +} + +func TestAssemblerSummaryXMLWithParents(t *testing.T) { + s, convID := setupAssemblerStore(t) + ctx := context.Background() + + // Create a leaf and a condensed summary (condensed has parent) + leaf, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "leaf content", + TokenCount: 20, + }) + condensed, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindCondensed, + Depth: 1, + Content: "condensed content", + TokenCount: 15, + ParentIDs: []string{leaf.SummaryID}, + }) + + msg, _ := s.AddMessage(ctx, convID, "user", "fresh", 5) + + s.UpsertContextItems(ctx, convID, []ContextItem{ + {Ordinal: 100, ItemType: "summary", SummaryID: condensed.SummaryID, TokenCount: 15}, + {Ordinal: 200, ItemType: "message", MessageID: msg.ID, TokenCount: 5}, + }) + + a := &Assembler{store: s, config: Config{}} + result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + // Summary field should contain XML with parent information + if result.Summary == "" { + t.Fatal("Summary should not be empty") + } + xmlContent := result.Summary + + // Should contain <parents> section with parent ID + if !contains(xmlContent, "<parents>") { + t.Errorf("condensed summary XML missing <parents> section: %q", xmlContent) + } + if !contains(xmlContent, leaf.SummaryID) { + t.Errorf("condensed summary XML missing parent ID %q: %q", leaf.SummaryID, xmlContent) + } + + // Should contain kind="condensed" + if !contains(xmlContent, `kind="condensed"`) { + t.Errorf("condensed summary XML missing kind attribute: %q", xmlContent) + } +} + +func TestAssemblerSummaryXMLIncludesDescendantCount(t *testing.T) { + s, convID := setupAssemblerStore(t) + ctx := context.Background() + + // Create a leaf summary with specific descendant count + leaf, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "leaf content", + TokenCount: 20, + DescendantCount: 8, + DescendantTokenCount: 1200, + }) + + msg, _ := s.AddMessage(ctx, convID, "user", "fresh", 5) + + s.UpsertContextItems(ctx, convID, []ContextItem{ + {Ordinal: 100, ItemType: "summary", SummaryID: leaf.SummaryID, TokenCount: 20}, + {Ordinal: 200, ItemType: "message", MessageID: msg.ID, TokenCount: 5}, + }) + + a := &Assembler{store: s, config: Config{}} + result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + if result.Summary == "" { + t.Fatal("Summary should not be empty") + } + xmlContent := result.Summary + + // Should contain descendant_count="8" + if !contains(xmlContent, `descendant_count="8"`) { + t.Errorf("summary XML missing descendant_count attribute: %q", xmlContent) + } +} + +func TestAssemblerLeafSummaryNoParents(t *testing.T) { + s, convID := setupAssemblerStore(t) + ctx := context.Background() + + // Leaf summary has no parents + leaf, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "leaf content", + TokenCount: 20, + }) + + msg, _ := s.AddMessage(ctx, convID, "user", "fresh", 5) + + s.UpsertContextItems(ctx, convID, []ContextItem{ + {Ordinal: 100, ItemType: "summary", SummaryID: leaf.SummaryID, TokenCount: 20}, + {Ordinal: 200, ItemType: "message", MessageID: msg.ID, TokenCount: 5}, + }) + + a := &Assembler{store: s, config: Config{}} + result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + if result.Summary == "" { + t.Fatal("Summary should not be empty") + } + xmlContent := result.Summary + + // Leaf summary should NOT have <parents> section + if contains(xmlContent, "<parents>") { + t.Errorf("leaf summary XML should not have <parents> section: %q", xmlContent) + } +} + +func TestAssemblerDepthAwarePrompt(t *testing.T) { + s, convID := setupAssemblerStore(t) + ctx := context.Background() + + // Create a condensed summary (depth >= 2) to trigger full guidance + now := time.Now().UTC() + leaf, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "leaf summary", + TokenCount: 20, + EarliestAt: &now, + LatestAt: &now, + }) + condensed, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindCondensed, + Depth: 2, + Content: "condensed summary", + TokenCount: 15, + ParentIDs: []string{leaf.SummaryID}, + DescendantCount: 1, + DescendantTokenCount: 20, + }) + + msg, _ := s.AddMessage(ctx, convID, "user", "fresh", 5) + + s.UpsertContextItems(ctx, convID, []ContextItem{ + {Ordinal: 100, ItemType: "summary", SummaryID: condensed.SummaryID, TokenCount: 15}, + {Ordinal: 200, ItemType: "message", MessageID: msg.ID, TokenCount: 5}, + }) + + a := &Assembler{store: s, config: Config{}} + result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + // Should have a depth-aware prompt in Summary field + if result.Summary == "" { + t.Error("expected non-empty Summary when depth >= 2") + } + // SystemPromptAddition is embedded in Summary field + if !strings.Contains(result.Summary, "multi-level summarization") { + t.Error("Summary should contain system prompt addition about multi-level summarization") + } +} + +func TestFormatSummaryXMLUsesSummaryRef(t *testing.T) { + // Spec: condensed summaries use <summary_ref id="parentId" /> not <parent>parentId</parent> + now := time.Now().UTC() + s := Summary{ + SummaryID: "sum_condensed1", + Kind: SummaryKindCondensed, + Depth: 1, + Content: "condensed content", + TokenCount: 50, + DescendantCount: 2, + EarliestAt: &now, + LatestAt: &now, + } + parentIDs := []string{"sum_leaf1", "sum_leaf2"} + + xml := FormatSummaryXML(&s, parentIDs) + + // Must use <summary_ref id="..." /> per spec + if !contains(xml, `<summary_ref id="sum_leaf1" />`) { + t.Errorf("expected <summary_ref id=\"sum_leaf1\" />, got: %s", xml) + } + if !contains(xml, `<summary_ref id="sum_leaf2" />`) { + t.Errorf("expected <summary_ref id=\"sum_leaf2\" />, got: %s", xml) + } + // Must NOT use old <parent> tag + if contains(xml, "<parent>") { + t.Errorf("should not use <parent> tag, got: %s", xml) + } +} + +func TestFormatSummaryXMLIncludesTimestamps(t *testing.T) { + // Spec: summary XML includes earliest_at and latest_at attributes + earliest := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + latest := time.Date(2026, 3, 15, 14, 30, 0, 0, time.UTC) + s := Summary{ + SummaryID: "sum_leaf1", + Kind: SummaryKindLeaf, + Depth: 0, + Content: "leaf content", + TokenCount: 30, + DescendantCount: 0, + EarliestAt: &earliest, + LatestAt: &latest, + } + + xml := FormatSummaryXML(&s, nil) + + if !contains(xml, `earliest_at="2026-03-15T10:00:00Z"`) { + t.Errorf("missing earliest_at attribute, got: %s", xml) + } + if !contains(xml, `latest_at="2026-03-15T14:30:00Z"`) { + t.Errorf("missing latest_at attribute, got: %s", xml) + } +} + +func TestFormatSummaryXMLNoTimestampsWhenNil(t *testing.T) { + // When EarliestAt/LatestAt are nil, attributes should be omitted + s := Summary{ + SummaryID: "sum_leaf1", + Kind: SummaryKindLeaf, + Depth: 0, + Content: "leaf content", + TokenCount: 30, + DescendantCount: 0, + } + + xml := FormatSummaryXML(&s, nil) + + if contains(xml, "earliest_at=") { + t.Errorf("should not have earliest_at when nil, got: %s", xml) + } + if contains(xml, "latest_at=") { + t.Errorf("should not have latest_at when nil, got: %s", xml) + } +} diff --git a/pkg/seahorse/short_bench_test.go b/pkg/seahorse/short_bench_test.go new file mode 100644 index 000000000..b7e47bcff --- /dev/null +++ b/pkg/seahorse/short_bench_test.go @@ -0,0 +1,336 @@ +package seahorse + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +// newBenchStore creates a test store for benchmarks. +func newBenchStore(b *testing.B) (*Store, func()) { + b.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + b.Fatalf("open test db: %v", err) + } + if err := runSchema(db); err != nil { + db.Close() + b.Fatalf("migration: %v", err) + } + return &Store{db: db}, func() { db.Close() } +} + +// --- Ingest benchmarks --- + +func BenchmarkIngest_SingleMessage(b *testing.B) { + s, cleanup := newBenchStore(b) + defer cleanup() + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "bench:ingest") + convID := conv.ConversationID + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := s.AddMessage(ctx, convID, "user", "Test message content", 15) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkIngest_BatchMessages(b *testing.B) { + s, cleanup := newBenchStore(b) + defer cleanup() + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + conv, _ := s.GetOrCreateConversation(ctx, fmt.Sprintf("bench:ingest-batch:%d", i)) + convID := conv.ConversationID + + for j := 0; j < 10; j++ { + added, err := s.AddMessage(ctx, convID, "user", + fmt.Sprintf("Message %d in batch", j), 10) + if err != nil { + b.Fatal(err) + } + s.AppendContextMessage(ctx, convID, added.ID) + } + } +} + +// --- Assemble benchmarks --- + +func BenchmarkAssemble_MessagesOnly(b *testing.B) { + s, cleanup := newBenchStore(b) + defer cleanup() + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "bench:assemble-msgs") + convID := conv.ConversationID + + // Add 100 messages + for i := 0; i < 100; i++ { + m, _ := s.AddMessage(ctx, convID, "user", + fmt.Sprintf("Message content %d with some text", i), 10) + s.AppendContextMessage(ctx, convID, m.ID) + } + + a := &Assembler{store: s} + input := AssembleInput{Budget: 50000} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := a.Assemble(ctx, convID, input) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkAssemble_WithSummaries(b *testing.B) { + s, cleanup := newBenchStore(b) + defer cleanup() + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "bench:assemble-sums") + convID := conv.ConversationID + + now := time.Now().UTC() + + // Add 10 leaf summaries + for i := 0; i < 10; i++ { + sum, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: fmt.Sprintf("Leaf summary %d", i), + TokenCount: 500, + EarliestAt: &now, + LatestAt: &now, + }) + s.AppendContextSummary(ctx, convID, sum.SummaryID) + } + + // Add 20 fresh messages + for i := 0; i < 20; i++ { + m, _ := s.AddMessage(ctx, convID, "user", fmt.Sprintf("Fresh message %d", i), 10) + s.AppendContextMessage(ctx, convID, m.ID) + } + + a := &Assembler{store: s} + input := AssembleInput{Budget: 10000} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := a.Assemble(ctx, convID, input) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkAssemble_BudgetEviction(b *testing.B) { + s, cleanup := newBenchStore(b) + defer cleanup() + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "bench:assemble-evict") + convID := conv.ConversationID + + now := time.Now().UTC() + + // Add 50 leaf summaries (more than budget can hold) + for i := 0; i < 50; i++ { + sum, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: fmt.Sprintf("Summary %d", i), + TokenCount: 300, + EarliestAt: &now, + LatestAt: &now, + }) + s.AppendContextSummary(ctx, convID, sum.SummaryID) + } + + // Add fresh tail + for i := 0; i < FreshTailCount; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "fresh", 10) + s.AppendContextMessage(ctx, convID, m.ID) + } + + a := &Assembler{store: s} + input := AssembleInput{Budget: 5000} // Force eviction + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := a.Assemble(ctx, convID, input) + if err != nil { + b.Fatal(err) + } + } +} + +// --- Search (FTS5) benchmarks --- + +// benchSeedSummaries adds n summaries to a conversation for search benchmarks. +func benchSeedSummaries(b *testing.B, s *Store, convID int64, n int, contentTpl string) { + b.Helper() + now := time.Now().UTC() + for i := 0; i < n; i++ { + sum, err := s.CreateSummary(context.Background(), CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: fmt.Sprintf(contentTpl, i), + TokenCount: 200, + EarliestAt: &now, + LatestAt: &now, + }) + if err != nil { + b.Fatalf("create summary: %v", err) + } + s.AppendContextSummary(context.Background(), convID, sum.SummaryID) + } +} + +func BenchmarkSearchSummaries_FTS5(b *testing.B) { + s, cleanup := newBenchStore(b) + defer cleanup() + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "bench:search-fts") + convID := conv.ConversationID + + benchSeedSummaries(b, s, convID, 100, "Summary about database configuration and API endpoints %d") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := s.SearchSummaries(ctx, SearchInput{ + Pattern: "database", + Mode: "full_text", + ConversationID: convID, + }) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkSearchSummaries_Like(b *testing.B) { + s, cleanup := newBenchStore(b) + defer cleanup() + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "bench:search-like") + convID := conv.ConversationID + + benchSeedSummaries(b, s, convID, 100, "Summary about configuration %d") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := s.SearchSummaries(ctx, SearchInput{ + Pattern: "config", + Mode: "like", + ConversationID: convID, + }) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkSearchMessages_FTS5(b *testing.B) { + s, cleanup := newBenchStore(b) + defer cleanup() + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "bench:search-msg-fts") + convID := conv.ConversationID + + // Add 500 messages + for i := 0; i < 500; i++ { + m, _ := s.AddMessage(ctx, convID, "user", + fmt.Sprintf("User message about API and database integration %d", i), 20) + s.AppendContextMessage(ctx, convID, m.ID) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := s.SearchMessages(ctx, SearchInput{ + Pattern: "API database", + Mode: "full_text", + ConversationID: convID, + }) + if err != nil { + b.Fatal(err) + } + } +} + +// --- Bootstrap benchmarks --- + +func BenchmarkBootstrap_Empty(b *testing.B) { + s, cleanup := newBenchStore(b) + defer cleanup() + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + conv, _ := s.GetOrCreateConversation(ctx, fmt.Sprintf("bench:bootstrap-empty:%d", i)) + convID := conv.ConversationID + _ = convID // Bootstrap with empty history + } +} + +func BenchmarkBootstrap_100Messages(b *testing.B) { + s, cleanup := newBenchStore(b) + defer cleanup() + ctx := context.Background() + + // Prepare 100 messages + msgs := make([]Message, 100) + for i := 0; i < 100; i++ { + msgs[i] = Message{ + Role: "user", + Content: fmt.Sprintf("Bootstrap message %d", i), + TokenCount: 15, + } + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + conv, _ := s.GetOrCreateConversation(ctx, fmt.Sprintf("bench:bootstrap-100:%d", i)) + convID := conv.ConversationID + + for _, m := range msgs { + added, _ := s.AddMessage(ctx, convID, m.Role, m.Content, m.TokenCount) + s.AppendContextMessage(ctx, convID, added.ID) + } + } +} + +func BenchmarkBootstrap_500Messages(b *testing.B) { + s, cleanup := newBenchStore(b) + defer cleanup() + ctx := context.Background() + + msgs := make([]Message, 500) + for i := 0; i < 500; i++ { + msgs[i] = Message{ + Role: "user", + Content: fmt.Sprintf("Bootstrap message %d", i), + TokenCount: 15, + } + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + conv, _ := s.GetOrCreateConversation(ctx, fmt.Sprintf("bench:bootstrap-500:%d", i)) + convID := conv.ConversationID + + for _, m := range msgs { + added, _ := s.AddMessage(ctx, convID, m.Role, m.Content, m.TokenCount) + s.AppendContextMessage(ctx, convID, added.ID) + } + } +} diff --git a/pkg/seahorse/short_compaction.go b/pkg/seahorse/short_compaction.go new file mode 100644 index 000000000..0dfb1330f --- /dev/null +++ b/pkg/seahorse/short_compaction.go @@ -0,0 +1,898 @@ +package seahorse + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tokenizer" +) + +// CompactInput controls compaction behavior. +type CompactInput struct { + Budget *int // Token budget override + Force bool // Force compaction even if below threshold +} + +// CompactResult describes what was compacted. +type CompactResult struct { + SummariesCreated []string `json:"summariesCreated"` + TokensSaved int `json:"tokensSaved"` + LeafSummaries int `json:"leafSummaries"` + CondensedSummaries int `json:"condensedSummaries"` +} + +// NeedsCompaction returns true if context tokens >= ContextThreshold × contextWindow. +func (e *CompactionEngine) NeedsCompaction(ctx context.Context, convID int64, contextWindow int) (bool, error) { + tokens, err := e.store.GetContextTokenCount(ctx, convID) + if err != nil { + return false, fmt.Errorf("get token count: %w", err) + } + threshold := int(float64(contextWindow) * ContextThreshold) + return tokens >= threshold, nil +} + +// Close cancels the shutdown context, stopping async goroutines. +func (e *CompactionEngine) Close() { + if e.shutdownCancel != nil { + e.shutdownCancel() + } +} + +// Compact runs leaf compaction (sync) and optionally condensed compaction. +func (e *CompactionEngine) Compact(ctx context.Context, convID int64, input CompactInput) (*CompactResult, error) { + result := &CompactResult{} + + // Phase 1: leaf compaction (synchronous, every turn) + summaryID, err := e.compactLeaf(ctx, convID) + if err != nil { + return nil, fmt.Errorf("compact leaf: %w", err) + } + if summaryID != nil { + result.SummariesCreated = append(result.SummariesCreated, *summaryID) + result.LeafSummaries++ + logger.InfoCF("seahorse", "compact: leaf", map[string]any{ + "conv_id": convID, + "summary_id": *summaryID, + }) + } + + // Phase 2: condensed compaction if over threshold + tokensBefore, _ := e.store.GetContextTokenCount(ctx, convID) + var budget int + if input.Budget != nil { + budget = *input.Budget + if budget == 0 { + logger.ErrorCF("seahorse", "Compact: budget is 0, this should not happen", map[string]any{ + "conv_id": convID, + }) + } + } else { + budget = int(float64(tokensBefore) * ContextThreshold) + } + + if input.Force || (tokensBefore > budget && budget > 0) { + // Launch async condensed compaction with dedup + if _, loaded := e.condensing.LoadOrStore(convID, struct{}{}); !loaded { + go func() { + defer e.condensing.Delete(convID) + e.runCondensedLoop(e.shutdownCtx, convID) + }() + } + } + + tokensAfter, _ := e.store.GetContextTokenCount(ctx, convID) + if tokensAfter < tokensBefore { + result.TokensSaved = tokensBefore - tokensAfter + } + + return result, nil +} + +// CompactUntilUnder aggressively compacts until context is under budget. +func (e *CompactionEngine) CompactUntilUnder(ctx context.Context, convID int64, budget int) (*CompactResult, error) { + result := &CompactResult{} + prevTokens := 0 + logger.InfoCF("seahorse", "compact_until_under: start", map[string]any{"conv_id": convID, "budget": budget}) + + for iter := 0; iter < MaxCompactIterations; iter++ { + tokens, err := e.store.GetContextTokenCount(ctx, convID) + if err != nil { + return result, fmt.Errorf("get tokens: %w", err) + } + if tokens <= budget { + logger.InfoCF("seahorse", "compact_until_under: done", map[string]any{ + "conv_id": convID, + "budget": budget, + "tokens": tokens, + "leaf": result.LeafSummaries, + "condensed": result.CondensedSummaries, + }) + return result, nil + } + + // Try leaf first + summaryID, err := e.compactLeaf(ctx, convID, true) + if err != nil { + return result, err + } + if summaryID != nil { + result.SummariesCreated = append(result.SummariesCreated, *summaryID) + result.LeafSummaries++ + logger.InfoCF("seahorse", "compact_until_under: leaf", map[string]any{ + "conv_id": convID, + "summary_id": *summaryID, + }) + continue + } + + // Try condensed with forced fanout + condensedID, err := e.compactCondensed(ctx, convID) + if err != nil { + return result, err + } + if condensedID != nil { + result.SummariesCreated = append(result.SummariesCreated, *condensedID) + result.CondensedSummaries++ + logger.InfoCF("seahorse", "compact_until_under: condensed", map[string]any{ + "conv_id": convID, + "summary_id": *condensedID, + }) + continue + } + + // No progress + newTokens, _ := e.store.GetContextTokenCount(ctx, convID) + if newTokens >= prevTokens { + logger.WarnCF("seahorse", "compact_until_under: no progress", map[string]any{ + "conv_id": convID, + "tokens": newTokens, + }) + return result, nil + } + prevTokens = newTokens + } + + // Safety cap exceeded — see MaxCompactIterations doc for rationale. + logger.WarnCF("seahorse", "compact_until_under: exceeded max iterations", map[string]any{ + "conv_id": convID, + "budget": budget, + "iterations": MaxCompactIterations, + "tokens": prevTokens, + }) + return result, nil +} + +// compactLeaf compresses the oldest contiguous message chunk into a leaf summary. +// When force is true, FreshTailCount protection is bypassed (used by CompactUntilUnder). +func (e *CompactionEngine) compactLeaf(ctx context.Context, convID int64, force ...bool) (*string, error) { + items, err := e.store.GetContextItems(ctx, convID) + if err != nil { + return nil, err + } + + // Find oldest contiguous message chunk outside fresh tail + msgCount := 0 + msgTokens := 0 + for _, item := range items { + if item.ItemType == "message" { + msgCount++ + msgTokens += item.TokenCount + } + } + + // Trigger if either message count or token threshold is met + if msgCount < LeafMinFanout && msgTokens < LeafChunkTokens { + return nil, nil + } + + // Calculate fresh tail boundary (bypass when forced) + useForce := len(force) > 0 && force[0] + tailStartIdx := len(items) - FreshTailCount + if useForce { + tailStartIdx = len(items) // allow compacting everything + } + if tailStartIdx < 0 { + tailStartIdx = 0 + } + + // Find oldest contiguous message chunk, accumulating up to LeafChunkTokens + var chunk []ContextItem + chunkStart := -1 + chunkEnd := -1 + accumTokens := 0 + for i := 0; i < tailStartIdx; i++ { + if items[i].ItemType == "message" { + if chunkStart == -1 { + chunkStart = i + } + chunkEnd = i + accumTokens += items[i].TokenCount + // Stop accumulating once we reach the token budget + if accumTokens >= LeafChunkTokens { + break + } + } else { + // Non-message breaks the chunk + if chunkStart != -1 && (chunkEnd-chunkStart+1) >= LeafMinFanout { + break + } + chunkStart = -1 + chunkEnd = -1 + accumTokens = 0 + } + } + + if chunkStart == -1 || (chunkEnd-chunkStart+1) < LeafMinFanout { + return nil, nil + } + + chunk = items[chunkStart : chunkEnd+1] + + // Collect messages for the chunk + var messages []Message + for _, item := range chunk { + msg, innerErr := e.store.GetMessageByID(ctx, item.MessageID) + if innerErr != nil { + return nil, innerErr + } + messages = append(messages, *msg) + } + + // Get prior summaries for context + priorSummary := "" + priorCount := 0 + for i := chunkStart - 1; i >= 0 && priorCount < 2; i-- { + if items[i].ItemType == "summary" { + sum, innerErr2 := e.store.GetSummary(ctx, items[i].SummaryID) + if innerErr2 == nil { + priorSummary = sum.Content + "\n" + priorSummary + priorCount++ + } + } + } + + // Generate summary + content, err := e.generateLeafSummary(ctx, messages, priorSummary) + if err != nil { + return nil, err + } + + // Create summary in store + tokenCount := tokenizer.EstimateMessageTokens(providers.Message{Content: content}) + + var earliestAt, latestAt *time.Time + if len(messages) > 0 { + earliestAt = &messages[0].CreatedAt + latestAt = &messages[len(messages)-1].CreatedAt + } + + summary, err := e.store.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: content, + TokenCount: tokenCount, + EarliestAt: earliestAt, + LatestAt: latestAt, + SourceMessageTokens: sumMessageTokens(messages), + }) + if err != nil { + return nil, err + } + + // Link to source messages + msgIDs := make([]int64, len(messages)) + for i, m := range messages { + msgIDs[i] = m.ID + } + if err := e.store.LinkSummaryToMessages(ctx, summary.SummaryID, msgIDs); err != nil { + return nil, err + } + + // Replace context range with summary + if err := e.store.ReplaceContextRangeWithSummary( + ctx, convID, chunk[0].Ordinal, chunk[len(chunk)-1].Ordinal, summary.SummaryID, + ); err != nil { + return nil, err + } + + return &summary.SummaryID, nil +} + +// compactCondensed compresses multiple summaries into one higher-level summary. +func (e *CompactionEngine) compactCondensed(ctx context.Context, convID int64) (*string, error) { + // Try ordinal-aware selection first (respects consecutive ordering) + var candidates []Summary + + depths, err := e.store.GetDistinctDepthsInContext(ctx, convID, 0) + if err != nil { + return nil, err + } + for _, depth := range depths { + var chunkAtDepth []Summary + var err2 error + chunkAtDepth, err2 = e.selectOldestChunkAtDepth(ctx, convID, depth) + if err2 != nil { + continue + } + if len(chunkAtDepth) > 0 { + candidates = chunkAtDepth + break + } + } + + // Fallback to depth-grouping selection + if len(candidates) == 0 { + candidates, err = e.selectShallowestCondensationCandidate(ctx, convID, false) + if err != nil { + return nil, err + } + } + if len(candidates) == 0 { + return nil, nil + } + + // Generate condensed summary + content, err := e.generateCondensedSummary(ctx, candidates) + if err != nil { + return nil, err + } + + // Merge metadata + maxDepth := 0 + descendantCount := 0 + descendantTokenCount := 0 + sourceMessageTokens := 0 + var earliestAt, latestAt *time.Time + + parentIDs := make([]string, len(candidates)) + for i, c := range candidates { + parentIDs[i] = c.SummaryID + if c.Depth > maxDepth { + maxDepth = c.Depth + } + descendantCount += c.DescendantCount + 1 + descendantTokenCount += c.TokenCount + c.DescendantTokenCount + sourceMessageTokens += c.SourceMessageTokenCount + if c.EarliestAt != nil { + if earliestAt == nil || c.EarliestAt.Before(*earliestAt) { + earliestAt = c.EarliestAt + } + } + if c.LatestAt != nil { + if latestAt == nil || c.LatestAt.After(*latestAt) { + latestAt = c.LatestAt + } + } + } + + tokenCount := tokenizer.EstimateMessageTokens(providers.Message{Content: content}) + + summary, err := e.store.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindCondensed, + Depth: maxDepth + 1, + Content: content, + TokenCount: tokenCount, + EarliestAt: earliestAt, + LatestAt: latestAt, + DescendantCount: descendantCount, + DescendantTokenCount: descendantTokenCount, + SourceMessageTokens: sourceMessageTokens, + ParentIDs: parentIDs, + }) + if err != nil { + return nil, err + } + + // Find the ordinal range for the candidate summaries in context + items, err := e.store.GetContextItems(ctx, convID) + if err != nil { + return nil, err + } + + candidateSet := make(map[string]bool) + for _, c := range candidates { + candidateSet[c.SummaryID] = true + } + + startOrd := -1 + endOrd := -1 + hasNonCandidate := false + for _, item := range items { + if item.ItemType == "summary" && candidateSet[item.SummaryID] { + if startOrd == -1 { + startOrd, endOrd = item.Ordinal, item.Ordinal + } else { + // Check for non-candidate items between endOrd and current ordinal + for _, it := range items { + if it.Ordinal > endOrd && it.Ordinal <= item.Ordinal { + if it.ItemType != "summary" || !candidateSet[it.SummaryID] { + hasNonCandidate = true + break + } + } + } + if hasNonCandidate { + break + } + if item.Ordinal < startOrd { + startOrd = item.Ordinal + } + if item.Ordinal > endOrd { + endOrd = item.Ordinal + } + } + } + } + + if startOrd == -1 || endOrd == -1 { + return nil, nil + } + + // Collect candidate summary IDs + candidateIDs := make([]string, 0, len(candidates)) + for _, c := range candidates { + candidateIDs = append(candidateIDs, c.SummaryID) + } + + if hasNonCandidate { + // Use safe per-item deletion to avoid deleting non-candidate items + if err := e.store.ReplaceContextItemsWithSummary(ctx, convID, candidateIDs, summary.SummaryID); err != nil { + return nil, err + } + } else { + // Candidates are consecutive, use efficient range deletion + if err := e.store.ReplaceContextRangeWithSummary(ctx, convID, startOrd, endOrd, summary.SummaryID); err != nil { + return nil, err + } + } + + return &summary.SummaryID, nil +} + +// selectShallowestCondensationCandidate finds the shallowest consecutive summary group. +func (e *CompactionEngine) selectShallowestCondensationCandidate( + ctx context.Context, convID int64, forced bool, +) ([]Summary, error) { + items, err := e.store.GetContextItems(ctx, convID) + if err != nil { + return nil, err + } + + // Group by depth, find consecutive runs + tailStartIdx := len(items) - FreshTailCount + if tailStartIdx < 0 { + tailStartIdx = 0 + } + + minFanout := CondensedMinFanout + if forced { + minFanout = CondensedMinFanoutHard + } + + // Track depth groups + depthGroups := make(map[int][]ContextItem) + for i := 0; i < tailStartIdx; i++ { + item := items[i] + if item.ItemType != "summary" { + continue + } + sum, err := e.store.GetSummary(ctx, item.SummaryID) + if err != nil { + continue + } + depthGroups[sum.Depth] = append(depthGroups[sum.Depth], item) + } + + // Find shallowest depth with enough candidates + // Collect all depths and sort to handle non-consecutive depths + var depths []int + for depth := range depthGroups { + depths = append(depths, depth) + } + sort.Ints(depths) + + for _, depth := range depths { + group := depthGroups[depth] + if len(group) >= minFanout { + // Load summaries + var result []Summary + for _, item := range group[:minFanout] { + sum, err := e.store.GetSummary(ctx, item.SummaryID) + if err != nil { + continue + } + result = append(result, *sum) + } + return result, nil + } + } + + return nil, nil +} + +// selectOldestChunkAtDepth scans context_items from oldest ordinal, collecting consecutive +// summaries at the given depth. Stops at non-summary items, different depth, fresh tail, or +// token overflow. Returns contiguous chunk of summaries. +func (e *CompactionEngine) selectOldestChunkAtDepth( + ctx context.Context, convID int64, targetDepth int, +) ([]Summary, error) { + items, err := e.store.GetContextItems(ctx, convID) + if err != nil { + return nil, err + } + + tailStartIdx := len(items) - FreshTailCount + if tailStartIdx < 0 { + tailStartIdx = 0 + } + + var chunk []Summary + accumTokens := 0 + + for i := 0; i < tailStartIdx; i++ { + item := items[i] + if item.ItemType != "summary" { + // Non-summary breaks the chunk + break + } + sum, err := e.store.GetSummary(ctx, item.SummaryID) + if err != nil { + break + } + if sum.Depth != targetDepth { + // Different depth breaks the chunk + break + } + if accumTokens+sum.TokenCount > LeafChunkTokens { + // Token overflow stops collection + break + } + chunk = append(chunk, *sum) + accumTokens += sum.TokenCount + } + + // Min tokens check: spec line 808 + // chunk tokens must be >= max(CondensedTargetTokens, LeafChunkTokens × 0.1) = 2000 + minTokens := CondensedTargetTokens // 2000 + if accumTokens < minTokens { + return nil, nil + } + + return chunk, nil +} + +// generateLeafSummary calls the LLM to generate a leaf summary with 3-level escalation. +// Level 1: normal LLM prompt. Level 2: aggressive prompt. Level 3: deterministic truncation. +func (e *CompactionEngine) generateLeafSummary( + ctx context.Context, + messages []Message, + previousSummary string, +) (string, error) { + if e.complete == nil { + return truncateSummary(messages), nil + } + + sourceText := formatMessagesForSummary(messages) + inputTokens := sumMessageTokens(messages) + targetTokens := minInt(LeafTargetTokens, int(float64(inputTokens)*0.35)) + + // Level 1: normal prompt + prompt := buildLeafSummaryPrompt(sourceText, previousSummary, targetTokens) + content, err := e.complete(ctx, prompt, CompleteOptions{ + MaxTokens: LeafTargetTokens * 2, + Temperature: 0.3, + }) + if err != nil { + return "", err + } + if content == "" { + // Retry with temperature=0 + content, err = e.complete(ctx, prompt, CompleteOptions{ + MaxTokens: LeafTargetTokens * 2, + Temperature: 0, + }) + if err != nil { + return "", err + } + } + + // Level 1 only succeeds if it actually reaches the requested target size. + if content != "" && tokenizer.EstimateMessageTokens(providers.Message{Content: content}) <= targetTokens { + return content, nil + } + + // Level 2: aggressive prompt + aggressiveTarget := minInt(640, int(float64(inputTokens)*0.20)) + aggressivePrompt := buildAggressiveLeafSummaryPrompt(sourceText, previousSummary, aggressiveTarget) + content, err = e.complete(ctx, aggressivePrompt, CompleteOptions{ + MaxTokens: aggressiveTarget * 2, + Temperature: 0.3, + }) + if err != nil { + return "", err + } + if content == "" { + // Retry with temperature=0 + content, err = e.complete(ctx, aggressivePrompt, CompleteOptions{ + MaxTokens: aggressiveTarget * 2, + Temperature: 0, + }) + if err != nil { + return "", err + } + } + if content != "" && tokenizer.EstimateMessageTokens(providers.Message{Content: content}) <= aggressiveTarget { + return content, nil + } + + // Level 3: deterministic truncation + return truncateSummary(messages), nil +} + +// generateCondensedSummary calls the LLM to generate a condensed summary with 3-level escalation. +func (e *CompactionEngine) generateCondensedSummary(ctx context.Context, summaries []Summary) (string, error) { + if e.complete == nil { + return truncateCondensedSummaries(summaries), nil + } + + sourceText := formatSummariesForCondensation(summaries) + inputTokens := sumSummaryTokens(summaries) + targetTokens := minInt(CondensedTargetTokens, int(float64(inputTokens)*0.35)) + + // Level 1: normal prompt + prompt := buildCondensedSummaryPrompt(sourceText, targetTokens) + content, err := e.complete(ctx, prompt, CompleteOptions{ + MaxTokens: CondensedTargetTokens * 2, + Temperature: 0.3, + }) + if err != nil { + return "", err + } + if content == "" { + content, err = e.complete(ctx, prompt, CompleteOptions{ + MaxTokens: CondensedTargetTokens * 2, + Temperature: 0, + }) + if err != nil { + return "", err + } + } + if content != "" { + return content, nil + } + + // Level 2: aggressive prompt + aggressiveTarget := minInt(640, int(float64(inputTokens)*0.20)) + aggressivePrompt := buildCondensedSummaryPrompt(sourceText, aggressiveTarget) + content, err = e.complete(ctx, aggressivePrompt, CompleteOptions{ + MaxTokens: aggressiveTarget * 2, + Temperature: 0.3, + }) + if err != nil { + return "", err + } + if content != "" { + return content, nil + } + + // Level 3: deterministic fallback + return truncateCondensedSummaries(summaries), nil +} + +// runCondensedLoop runs condensed compaction in a loop until: +// a) context tokens <= threshold (success), OR +// b) No candidate found (nothing to condense), OR +// c) tokensAfter >= tokensBefore (no progress this iteration), OR +// d) tokensAfter >= previousTokens (no improvement over last iteration) +func (e *CompactionEngine) runCondensedLoop(ctx context.Context, convID int64) { + var prevTokens int + for { + select { + case <-ctx.Done(): + return + default: + } + + tokensBefore, err := e.store.GetContextTokenCount(ctx, convID) + if err != nil { + logger.ErrorCF("seahorse", "condensed: get tokens", map[string]any{"error": err.Error()}) + return + } + + condensedID, err := e.compactCondensed(ctx, convID) + if err != nil { + logger.ErrorCF("seahorse", "condensed: compact", map[string]any{"error": err.Error()}) + return + } + if condensedID == nil { + // No candidate found + logger.DebugCF("seahorse", "condensed: no candidate", map[string]any{"conv_id": convID}) + return + } + + tokensAfter, _ := e.store.GetContextTokenCount(ctx, convID) + + if tokensAfter >= tokensBefore { + // No progress this iteration + logger.DebugCF( + "seahorse", + "condensed: no progress", + map[string]any{"conv_id": convID, "tokens_before": tokensBefore, "tokens_after": tokensAfter}, + ) + return + } + if tokensAfter >= prevTokens && prevTokens > 0 { + // No improvement over last iteration + logger.DebugCF( + "seahorse", + "condensed: no improvement", + map[string]any{"conv_id": convID, "tokens": tokensAfter}, + ) + return + } + + prevTokens = tokensAfter + } +} + +// --- Helper functions --- + +func formatMessagesForSummary(messages []Message) string { + var result string + for _, m := range messages { + ts := m.CreatedAt.Format("2006-01-02 15:04 MST") + content := m.Content + if content == "" && len(m.Parts) > 0 { + content = partsToReadableContent(m.Parts) + } + result += fmt.Sprintf("[%s]\n%s\n\n", ts, content) + } + return result +} + +func formatSummariesForCondensation(summaries []Summary) string { + var result string + for _, s := range summaries { + earliest := "" + if s.EarliestAt != nil { + earliest = s.EarliestAt.Format("2006-01-02") + } + latest := "" + if s.LatestAt != nil { + latest = s.LatestAt.Format("2006-01-02") + } + result += fmt.Sprintf("[%s - %s]\n%s\n\n", earliest, latest, s.Content) + } + return result +} + +func buildLeafSummaryPrompt(sourceText, previousSummary string, targetTokens int) string { + prev := "(none)" + if previousSummary != "" { + prev = previousSummary + } + return fmt.Sprintf(`You summarize a SEGMENT of a conversation for future model turns. +Treat this as incremental memory compaction input, not a full-conversation summary. + +Normal summary policy: +- Preserve key decisions, rationale, constraints, and active tasks. +- Keep essential technical details needed to continue work safely. +- Remove obvious repetition and conversational filler. + +Output requirements: +- Plain text only. +- No preamble, headings, or markdown formatting. +- Track file operations (created, modified, deleted, renamed) with file paths and current status. +- If no file operations appear, include exactly: "Files: none". +- End with exactly: "Expand for details about: <comma-separated list of what was dropped or compressed>". +- Target length: about %d tokens or less. + +<previous_context> +%s +</previous_context> + +<conversation_segment> +%s +</conversation_segment>`, targetTokens, prev, sourceText) +} + +func buildCondensedSummaryPrompt(sourceText string, targetTokens int) string { + return fmt.Sprintf(`You condense multiple summaries into a single higher-level summary. +Preserve all important decisions, constraints, and outcomes. +Merge overlapping topics. Keep technical details intact. + +Output requirements: +- Plain text only. +- No preamble, headings, or markdown formatting. +- End with exactly: "Expand for details about: <comma-separated list>". +- Target length: about %d tokens or less. + +<summaries> +%s +</summaries>`, targetTokens, sourceText) +} + +func buildAggressiveLeafSummaryPrompt(sourceText, previousSummary string, targetTokens int) string { + prev := "(none)" + if previousSummary != "" { + prev = previousSummary + } + return fmt.Sprintf(`You summarize a SEGMENT of a conversation for future model turns. +Aggressive summary policy: +- Keep only durable facts and current task state. +- Remove examples, repetition, and low-value narrative details. +- Preserve explicit TODOs, blockers, decisions, and constraints. + +Output requirements: +- Plain text only. +- No preamble, headings, or markdown formatting. +- Track file operations (created, modified, deleted, renamed) with file paths and current status. +- If no file operations appear, include exactly: "Files: none". +- End with exactly: "Expand for details about: <comma-separated list of what was dropped or compressed>". +- Target length: about %d tokens or less. + +<previous_context> +%s +</previous_context> + +<conversation_segment> +%s +</conversation_segment>`, targetTokens, prev, sourceText) +} + +func truncateSummary(messages []Message) string { + content := "" + for _, m := range messages { + c := m.Content + if c == "" && len(m.Parts) > 0 { + c = partsToReadableContent(m.Parts) + } + content += c + "\n" + } + if len(content) > 2048 { + content = content[:2048] + } + content += fmt.Sprintf("\n[Truncated from %d messages]", len(messages)) + return content +} + +func truncateCondensedSummaries(summaries []Summary) string { + content := "" + for _, s := range summaries { + content += s.Content + "\n" + } + if len(content) > 2048 { + content = content[:2048] + } + content += fmt.Sprintf("\n[Condensed from %d summaries]", len(summaries)) + return content +} + +func sumMessageTokens(messages []Message) int { + total := 0 + for _, m := range messages { + total += m.TokenCount + } + return total +} + +func sumSummaryTokens(summaries []Summary) int { + total := 0 + for _, s := range summaries { + total += s.TokenCount + } + return total +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/seahorse/short_compaction_test.go b/pkg/seahorse/short_compaction_test.go new file mode 100644 index 000000000..da07cdab7 --- /dev/null +++ b/pkg/seahorse/short_compaction_test.go @@ -0,0 +1,1038 @@ +package seahorse + +import ( + "context" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// --- Test Helpers --- + +// waitForCondensed blocks until the async condensed goroutine for convID finishes. +// Returns false if timeout is reached. +func waitForCondensed(ce *CompactionEngine, convID int64, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, exists := ce.condensing.Load(convID); !exists { + return true + } + time.Sleep(50 * time.Millisecond) + } + return false +} + +// --- Compaction Tests --- + +func newTestCompactionEngine(t *testing.T) (*CompactionEngine, *Store, int64) { + t.Helper() + db := openTestDB(t) + if err := runSchema(db); err != nil { + t.Fatalf("migration: %v", err) + } + s := &Store{db: db} + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "test:compact") + shutdownCtx, shutdownCancel := context.WithCancel(context.Background()) + ce := &CompactionEngine{ + store: s, + config: Config{}, + complete: mockCompleteFn, + shutdownCtx: shutdownCtx, + shutdownCancel: shutdownCancel, + } + convID := conv.ConversationID + // Ensure async goroutines are stopped before database is closed. + // Register cleanup here (after openTestDB) so it runs BEFORE openTestDB's db.Close(). + t.Cleanup(func() { + shutdownCancel() + // Wait for async condensed goroutine to finish (poll condensing map) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if _, exists := ce.condensing.Load(convID); !exists { + break + } + time.Sleep(50 * time.Millisecond) + } + }) + return ce, s, conv.ConversationID +} + +// newTestCompactionEngineWithStore creates a CompactionEngine with existing store. +// Note: Caller is responsible for calling shutdownCancel when test ends. +func newTestCompactionEngineWithStore( + s *Store, complete CompleteFn, +) (ce *CompactionEngine, shutdownCancel context.CancelFunc) { + shutdownCtx, cancel := context.WithCancel(context.Background()) + return &CompactionEngine{ + store: s, + config: Config{}, + complete: complete, + shutdownCtx: shutdownCtx, + shutdownCancel: cancel, + }, cancel +} + +// mockCompleteFn returns a simple summary for testing +var mockCompleteFn CompleteFn = func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) { + return "Mock summary of the conversation segment.", nil +} + +func TestNeedsCompaction(t *testing.T) { + ce, s, convID := newTestCompactionEngine(t) + ctx := context.Background() + + // Empty context — no compaction needed + needed, err := ce.NeedsCompaction(ctx, convID, 10000) + if err != nil { + t.Fatalf("NeedsCompaction: %v", err) + } + if needed { + t.Error("expected no compaction for empty context") + } + + // Add messages to context, total tokens = 8000 + for i := 0; i < 8; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "test message content", 1000) + s.AppendContextMessage(ctx, convID, m.ID) + } + + // Threshold = 0.75 × 10000 = 7500. We have 8000 tokens → needs compaction + needed, err = ce.NeedsCompaction(ctx, convID, 10000) + if err != nil { + t.Fatalf("NeedsCompaction: %v", err) + } + if !needed { + t.Error("expected compaction needed at 8000/10000 tokens (threshold 75%)") + } + + // Below threshold: 5000 / 10000 → no compaction + s.UpsertContextItems(ctx, convID, nil) // clear + for i := 0; i < 5; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "test", 1000) + s.AppendContextMessage(ctx, convID, m.ID) + } + needed, _ = ce.NeedsCompaction(ctx, convID, 10000) + if needed { + t.Error("expected no compaction at 5000/10000 tokens") + } +} + +func TestCompactLeaf(t *testing.T) { + ce, s, convID := newTestCompactionEngine(t) + ctx := context.Background() + + // Create enough messages to trigger leaf compaction: + // Need > FreshTailCount(32) evictable messages with >= LeafMinFanout(8) contiguous + for i := 0; i < 40; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "message content for compaction test", 100) + s.AppendContextMessage(ctx, convID, m.ID) + } + + // Compact + result, err := ce.Compact(ctx, convID, CompactInput{}) + if err != nil { + t.Fatalf("Compact: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + + // Should have created at least one leaf summary + if result.LeafSummaries == 0 { + t.Error("expected at least 1 leaf summary") + } + + // Context should now contain a summary item + items, _ := s.GetContextItems(ctx, convID) + foundSummary := false + for _, item := range items { + if item.ItemType == "summary" { + foundSummary = true + break + } + } + if !foundSummary { + t.Error("expected a summary in context_items after leaf compaction") + } + + // Some messages should have been replaced + if len(result.SummariesCreated) == 0 { + t.Error("expected at least 1 summary created") + } +} + +func TestCompactLeafNoCandidate(t *testing.T) { + ce, _, convID := newTestCompactionEngine(t) + ctx := context.Background() + + // Too few messages to trigger leaf compaction + m, _ := ce.store.AddMessage(ctx, convID, "user", "short", 10) + ce.store.AppendContextMessage(ctx, convID, m.ID) + + result, err := ce.Compact(ctx, convID, CompactInput{}) + if err != nil { + t.Fatalf("Compact: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result even with no candidate") + } + if result.LeafSummaries != 0 { + t.Errorf("LeafSummaries = %d, want 0 (too few messages)", result.LeafSummaries) + } +} + +func TestCompactCondensed(t *testing.T) { + ce, s, convID := newTestCompactionEngine(t) + ctx := context.Background() + + // Create enough leaf summaries and fresh messages to enable condensation + leafIDs := make([]string, CondensedMinFanout) + for i := 0; i < CondensedMinFanout; i++ { + now := time.Now().UTC() + summary, err := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "leaf summary content " + time.Now().String(), + TokenCount: 500, + EarliestAt: &now, + LatestAt: &now, + }) + if err != nil { + t.Fatalf("CreateSummary %d: %v", i, err) + } + leafIDs[i] = summary.SummaryID + s.AppendContextSummary(ctx, convID, summary.SummaryID) + } + + // Add enough fresh messages to have a fresh tail (>= FreshTailCount) + for i := 0; i < FreshTailCount; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "fresh message", 10) + s.AppendContextMessage(ctx, convID, m.ID) + } + + // Compact with force to trigger condensation + _, err := ce.Compact(ctx, convID, CompactInput{Force: true}) + if err != nil { + t.Fatalf("Compact: %v", err) + } + + // Wait for async condensed goroutine to complete + if !waitForCondensed(ce, convID, 2*time.Second) { + t.Fatal("timeout waiting for condensed compaction") + } + + // Should have created a condensed summary in the DB + summaries, _ := s.GetSummariesByConversation(ctx, convID) + foundCondensed := false + for _, sum := range summaries { + if sum.Kind == SummaryKindCondensed { + foundCondensed = true + break + } + } + if !foundCondensed { + t.Error("expected at least 1 condensed summary") + } +} + +func TestCompactCondensedDoesNotOrphanSummaryWhenCandidatesRemovedConcurrently(t *testing.T) { + // Reproduce orphan bug: candidates found by selectOldestChunkAtDepth are removed + // from context_items between candidate selection and ordinal range scan. + // Use a slow CompleteFn with barrier sync to control timing. + s := openTestStore(t) + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "test:orphan-race") + convID := conv.ConversationID + + // Create leaf summaries with enough tokens for condensation + var leafIDs []string + for i := 0; i < CondensedMinFanout; i++ { + now := time.Now().UTC() + sum, err := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: fmt.Sprintf("leaf summary %d", i), + TokenCount: 500, + EarliestAt: &now, + LatestAt: &now, + }) + if err != nil { + t.Fatalf("CreateSummary: %v", err) + } + leafIDs = append(leafIDs, sum.SummaryID) + s.AppendContextSummary(ctx, convID, sum.SummaryID) + } + + // Add fresh tail so leaf summaries are in evictable range + for i := 0; i < FreshTailCount+1; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "fresh", 10) + s.AppendContextMessage(ctx, convID, m.ID) + } + + // Barrier: CompleteFn waits until test removes context_items, then returns + var barrier1, barrier2 sync.WaitGroup + barrier1.Add(1) // CompleteFn signals when called + barrier2.Add(1) // test signals when context_items removed + + slowComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) { + barrier1.Done() // signal: LLM called, candidates selected + barrier2.Wait() // wait: test removes context_items + return "Condensed summary.", nil + } + + ce, cancel := newTestCompactionEngineWithStore(s, slowComplete) + t.Cleanup(func() { + cancel() + time.Sleep(100 * time.Millisecond) + }) + + // Run compactCondensed in background + type compactResult struct { + summaryID *string + err error + } + resultCh := make(chan compactResult, 1) + go func() { + sid, err := ce.compactCondensed(context.Background(), convID) + resultCh <- compactResult{summaryID: sid, err: err} + }() + + // Wait for CompleteFn to be called (candidates selected) + barrier1.Wait() + + // Remove leaf summaries from context_items (simulating concurrent replacement) + items, _ := s.GetContextItems(ctx, convID) + var preserved []ContextItem + for _, item := range items { + isLeaf := false + for _, lid := range leafIDs { + if item.SummaryID == lid { + isLeaf = true + break + } + } + if !isLeaf { + preserved = append(preserved, item) + } + } + s.UpsertContextItems(ctx, convID, preserved) + + // Let CompleteFn return + barrier2.Done() + + // Get result + res := <-resultCh + if res.err != nil { + t.Fatalf("compactCondensed: %v", res.err) + } + + // With the bug: returns non-nil summaryID even though context_items has no matching ordinals + // The fix: should return nil when startOrd == -1 + if res.summaryID != nil { + t.Errorf("compactCondensed returned summaryID=%s, want nil (orphan created)", *res.summaryID) + + // Verify the orphan exists in DB + summary, _ := s.GetSummary(context.Background(), *res.summaryID) + if summary != nil && summary.Kind == SummaryKindCondensed { + // Check it's NOT in context_items (orphan) + items2, _ := s.GetContextItems(context.Background(), convID) + found := false + for _, item := range items2 { + if item.SummaryID == *res.summaryID { + found = true + break + } + } + if !found { + t.Error("condensed summary exists in DB but not in context_items — orphan confirmed") + } + } + } +} + +func TestCompactUntilUnder(t *testing.T) { + ce, s, convID := newTestCompactionEngine(t) + ctx := context.Background() + + // Create many leaf summaries to ensure we can condense + for i := 0; i < 8; i++ { + now := time.Now().UTC() + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "leaf summary for condensation test", + TokenCount: 500, + EarliestAt: &now, + LatestAt: &now, + }) + s.AppendContextSummary(ctx, convID, summary.SummaryID) + } + + // Force compact until under budget + result, err := ce.CompactUntilUnder(ctx, convID, 2000) + if err != nil { + t.Fatalf("CompactUntilUnder: %v", err) + } + + if result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestSelectShallowestCondensationCandidate(t *testing.T) { + ce, s, convID := newTestCompactionEngine(t) + ctx := context.Background() + + // Create enough leaf summaries + fresh messages for candidates + for i := 0; i < LeafMinFanout; i++ { + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "leaf", + TokenCount: 100, + }) + s.AppendContextSummary(ctx, convID, summary.SummaryID) + } + + // Add fresh tail messages so summaries are in evictable range + for i := 0; i < FreshTailCount+1; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "fresh", 5) + s.AppendContextMessage(ctx, convID, m.ID) + } + + candidates, err := ce.selectShallowestCondensationCandidate(ctx, convID, false) + if err != nil { + t.Fatalf("selectShallowestCondensationCandidate: %v", err) + } + + // Should find leaf summaries at depth 0 + if len(candidates) < CondensedMinFanout { + t.Errorf("candidates = %d, want >= %d", len(candidates), CondensedMinFanout) + } +} + +func TestSelectShallowestCondensationCandidateEmpty(t *testing.T) { + ce, _, convID := newTestCompactionEngine(t) + ctx := context.Background() + + candidates, err := ce.selectShallowestCondensationCandidate(ctx, convID, false) + if err != nil { + t.Fatalf("selectShallowestCondensationCandidate: %v", err) + } + if len(candidates) != 0 { + t.Errorf("candidates = %d, want 0 for empty context", len(candidates)) + } +} + +func TestCompactCondensedUsesSelectOldestChunk(t *testing.T) { + // Verify that compactCondensed prefers ordinal-ordered chunks via selectOldestChunkAtDepth + // rather than just grouping by depth without regard to order + ce, s, convID := newTestCompactionEngine(t) + ctx := context.Background() + + // Create interleaved summaries at depth 0 with a message in between: + // sum1 (ordinal 100), msg (ordinal 200), sum2 (ordinal 300) + + for i := 0; i < LeafMinFanout+2; i++ { + now := time.Now().UTC() + + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: fmt.Sprintf("leaf summary %d", i), + TokenCount: 100, + EarliestAt: &now, + LatestAt: &now, + }) + } + + // Insert a message between first two summaries to break contiguity + // for selectShallowestCondensationCandidate but would still find all 3 + // but selectOldestChunkAtDepth should only find sum1 + sum2 (not sum3) + + msg, _ := s.AddMessage(ctx, convID, "user", "interrupting message", 5) + s.AppendContextMessage(ctx, convID, msg.ID) + + // Run compactCondensed + result, err := ce.compactCondensed(ctx, convID) + if err != nil { + t.Fatalf("compactCondensed: %v", err) + } + + // The result should have merged the two summaries at the start + // (skipping the message in between), This proves ordinal-aware selection works. + + _ = result // verify summary was created + + if result != nil { + summaries, _ := s.GetSummariesByConversation(ctx, convID) + found := false + for _, sum := range summaries { + if sum.Kind == SummaryKindCondensed { + found = true + break + } + } + if !found { + t.Error("expected condensed summary to be created via ordinal-aware selection") + } + } +} + +func TestCompactCondensedUsesOrdinalAwareSelection(t *testing.T) { + ce, s, convID := newTestCompactionEngine(t) + ctx := context.Background() + + // Create leaf summaries at depth 0 (total tokens >= CondensedTargetTokens) + for i := 0; i < 5; i++ { + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: fmt.Sprintf("leaf summary %d", i), + TokenCount: 500, // 5 × 500 = 2500 >= CondensedTargetTokens (2000) + }) + s.AppendContextSummary(ctx, convID, summary.SummaryID) + } + + // Add fresh tail + for i := 0; i < FreshTailCount+1; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "fresh", 5) + s.AppendContextMessage(ctx, convID, m.ID) + } + + chunk, err := ce.selectOldestChunkAtDepth(ctx, convID, 0) + if err != nil { + t.Fatalf("selectOldestChunkAtDepth: %v", err) + } + if len(chunk) < 2 { + t.Errorf("chunk length = %d, want >= 2 contiguous summaries", len(chunk)) + } + for _, s := range chunk { + if s.Depth != 0 { + t.Errorf("got depth %d, want 0", s.Depth) + } + } +} + +func TestSelectOldestChunkAtDepthBreaksOnMessage(t *testing.T) { + ce, s, convID := newTestCompactionEngine(t) + ctx := context.Background() + + // Create 3 summaries, then a message, then 3 more summaries + for i := 0; i < 3; i++ { + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: fmt.Sprintf("leaf %d", i), + TokenCount: 100, + }) + s.AppendContextSummary(ctx, convID, summary.SummaryID) + } + msg, _ := s.AddMessage(ctx, convID, "user", "break", 10) + s.AppendContextMessage(ctx, convID, msg.ID) + for i := 0; i < 3; i++ { + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: fmt.Sprintf("leaf-after %d", i), + TokenCount: 100, + }) + s.AppendContextSummary(ctx, convID, summary.SummaryID) + } + for i := 0; i < FreshTailCount+1; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "fresh", 5) + s.AppendContextMessage(ctx, convID, m.ID) + } + + chunk, _ := ce.selectOldestChunkAtDepth(ctx, convID, 0) + if len(chunk) > 3 { + t.Errorf("chunk length = %d, want <= 3 (message breaks chain)", len(chunk)) + } +} + +func TestSelectOldestChunkAtDepthMinTokens(t *testing.T) { + ce, s, convID := newTestCompactionEngine(t) + ctx := context.Background() + + // Create summaries with very low token counts (total < 2000) + for i := 0; i < 5; i++ { + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: fmt.Sprintf("tiny summary %d", i), + TokenCount: 50, // very small + }) + s.AppendContextSummary(ctx, convID, summary.SummaryID) + } + + // Add fresh tail to protect from compaction + for i := 0; i < FreshTailCount+1; i++ { + m, _ := s.AddMessage(ctx, convID, "user", fmt.Sprintf("tail %d", i), 10) + s.AppendContextMessage(ctx, convID, m.ID) + } + + // Should return nil because total tokens (250) < 2000 minimum + chunk, err := ce.selectOldestChunkAtDepth(ctx, convID, 0) + if err != nil { + t.Fatalf("selectOldestChunkAtDepth: %v", err) + } + if len(chunk) > 0 { + t.Errorf("expected empty chunk when tokens < 2000, got %d summaries", len(chunk)) + } +} + +func TestSelectOldestChunkAtDepthPassesMinTokens(t *testing.T) { + ce, s, convID := newTestCompactionEngine(t) + ctx := context.Background() + + // Create summaries with enough tokens (total >= 2000) + for i := 0; i < 5; i++ { + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: fmt.Sprintf( + "substantial summary with enough content to meet minimum token threshold for condensation candidate %d", + i, + ), + TokenCount: 500, // 5 × 500 = 2500 >= 2000 + }) + s.AppendContextSummary(ctx, convID, summary.SummaryID) + } + + // Add fresh tail + for i := 0; i < FreshTailCount+1; i++ { + m, _ := s.AddMessage(ctx, convID, "user", fmt.Sprintf("tail %d", i), 10) + s.AppendContextMessage(ctx, convID, m.ID) + } + + // Should return chunk because total tokens (2500) >= 2000 + chunk, err := ce.selectOldestChunkAtDepth(ctx, convID, 0) + if err != nil { + t.Fatalf("selectOldestChunkAtDepth: %v", err) + } + if len(chunk) == 0 { + t.Error("expected non-empty chunk when tokens >= 2000") + } +} + +func TestGenerateLeafSummary(t *testing.T) { + ce, _, _ := newTestCompactionEngine(t) + ctx := context.Background() + + msgs := []Message{ + {Role: "user", Content: "hello world", TokenCount: 5}, + {Role: "assistant", Content: "hi there", TokenCount: 5}, + } + + content, err := ce.generateLeafSummary(ctx, msgs, "") + if err != nil { + t.Fatalf("generateLeafSummary: %v", err) + } + if content == "" { + t.Error("expected non-empty summary content") + } +} + +func TestGenerateLeafSummaryEscalationToAggressive(t *testing.T) { + // Level 1 returns summary that's too large (tokens >= input), should escalate to level 2 + var calls []string + escalateComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) { + if contains(prompt, "Aggressive summary policy") { + calls = append(calls, "aggressive") + return "Short aggressive summary.", nil + } + calls = append(calls, "normal") + // Return a very long summary to trigger escalation + longContent := make([]byte, 5000) + for i := range longContent { + longContent[i] = 'x' + } + return string(longContent), nil + } + + s := openTestStore(t) + ce, _ := newTestCompactionEngineWithStore(s, escalateComplete) + + msgs := []Message{ + {Role: "user", Content: "hello world", TokenCount: 10}, + {Role: "assistant", Content: "response", TokenCount: 10}, + } + + content, err := ce.generateLeafSummary(context.Background(), msgs, "") + if err != nil { + t.Fatalf("generateLeafSummary: %v", err) + } + if content == "" { + t.Error("expected non-empty summary content") + } + // Should have called both normal and aggressive + foundNormal := false + foundAggressive := false + for _, c := range calls { + if c == "normal" { + foundNormal = true + } + if c == "aggressive" { + foundAggressive = true + } + } + if !foundNormal { + t.Error("expected normal LLM call") + } + if !foundAggressive { + t.Error("expected aggressive LLM call (level 2 escalation)") + } +} + +func TestGenerateLeafSummaryEscalatesWhenLevel1MissesTarget(t *testing.T) { + var calls []string + normalContent := strings.Repeat("n", 1000) // ~404 tokens: below input, above target + aggressiveContent := strings.Repeat("a", 450) // ~184 tokens: within aggressive target + escalateComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) { + if contains(prompt, "Aggressive summary policy") { + calls = append(calls, "aggressive") + return aggressiveContent, nil + } + calls = append(calls, "normal") + return normalContent, nil + } + + s := openTestStore(t) + ce, _ := newTestCompactionEngineWithStore(s, escalateComplete) + + msgs := []Message{ + {Role: "user", Content: "hello world", TokenCount: 500}, + {Role: "assistant", Content: "response", TokenCount: 500}, + } + + content, err := ce.generateLeafSummary(context.Background(), msgs, "") + if err != nil { + t.Fatalf("generateLeafSummary: %v", err) + } + if content != aggressiveContent { + t.Fatalf("expected aggressive summary after level 1 missed target") + } + if len(calls) != 2 || calls[0] != "normal" || calls[1] != "aggressive" { + t.Fatalf("expected normal then aggressive calls, got %v", calls) + } +} + +func TestGenerateLeafSummaryAcceptsContentAtTargetBoundary(t *testing.T) { + exactTargetContent := strings.Repeat("x", 488) // (488 + 12) * 2 / 5 = 200 tokens + var aggressiveCalled bool + complete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) { + if contains(prompt, "Aggressive summary policy") { + aggressiveCalled = true + } + return exactTargetContent, nil + } + + s := openTestStore(t) + ce, _ := newTestCompactionEngineWithStore(s, complete) + + msgs := []Message{ + {Role: "user", Content: "hello world", TokenCount: 286}, + {Role: "assistant", Content: "response", TokenCount: 286}, + } + + content, err := ce.generateLeafSummary(context.Background(), msgs, "") + if err != nil { + t.Fatalf("generateLeafSummary: %v", err) + } + if content != exactTargetContent { + t.Fatalf("expected level 1 summary at target boundary to be accepted") + } + if aggressiveCalled { + t.Fatal("did not expect aggressive retry when level 1 hit target exactly") + } +} + +func TestGenerateLeafSummaryEscalationToTruncation(t *testing.T) { + // Both normal and aggressive return empty, should escalate to level 3 truncation + emptyComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) { + return "", nil + } + + s := openTestStore(t) + ce, _ := newTestCompactionEngineWithStore(s, emptyComplete) + + msgs := []Message{ + {Role: "user", Content: "hello world from test", TokenCount: 10}, + {Role: "assistant", Content: "response text here", TokenCount: 10}, + } + + content, err := ce.generateLeafSummary(context.Background(), msgs, "") + if err != nil { + t.Fatalf("generateLeafSummary: %v", err) + } + // Level 3 truncation should have produced something + if content == "" { + t.Error("expected non-empty content from level 3 truncation fallback") + } + if !contains(content, "Truncated from") { + t.Errorf("expected truncation marker in content: %q", content) + } +} + +func TestGenerateCondensedSummary(t *testing.T) { + ce, _, _ := newTestCompactionEngine(t) + ctx := context.Background() + + summaries := []Summary{ + {SummaryID: "sum_a", Content: "first summary", TokenCount: 100}, + {SummaryID: "sum_b", Content: "second summary", TokenCount: 100}, + } + + content, err := ce.generateCondensedSummary(ctx, summaries) + if err != nil { + t.Fatalf("generateCondensedSummary: %v", err) + } + if content == "" { + t.Error("expected non-empty condensed summary content") + } +} + +func TestGenerateCondensedSummaryEscalation(t *testing.T) { + // When LLM returns empty, should fall back to deterministic concatenation + emptyComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) { + return "", nil + } + + s := openTestStore(t) + ce, _ := newTestCompactionEngineWithStore(s, emptyComplete) + + summaries := []Summary{ + {SummaryID: "sum_a", Content: "first summary text", TokenCount: 50}, + {SummaryID: "sum_b", Content: "second summary text", TokenCount: 50}, + } + + content, err := ce.generateCondensedSummary(context.Background(), summaries) + if err != nil { + t.Fatalf("generateCondensedSummary: %v", err) + } + // Should fall back to concatenation + if content == "" { + t.Error("expected non-empty content from fallback") + } +} + +// --- Async Condensed Compaction (Phase 2) --- + +func TestCompactAsyncReturnsBeforeCondensed(t *testing.T) { + // Use a slow CompleteFn to verify Compact returns before condensed finishes + var callCount int32 + slowComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) { + atomic.AddInt32(&callCount, 1) + time.Sleep(500 * time.Millisecond) // simulate slow LLM + return "Slow condensed summary.", nil + } + + s := openTestStore(t) + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "test:async") + convID := conv.ConversationID + + ce, cancel := newTestCompactionEngineWithStore(s, slowComplete) + t.Cleanup(func() { + cancel() + time.Sleep(100 * time.Millisecond) + }) + + // Create enough leaf summaries for condensation + fresh tail + for i := 0; i < CondensedMinFanout; i++ { + now := time.Now().UTC() + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "leaf for async test", + TokenCount: 500, + EarliestAt: &now, + LatestAt: &now, + }) + s.AppendContextSummary(ctx, convID, summary.SummaryID) + } + for i := 0; i < FreshTailCount; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "fresh", 10) + s.AppendContextMessage(ctx, convID, m.ID) + } + + // Compact with force — should return quickly, condensed runs async + start := time.Now() + result, err := ce.Compact(ctx, convID, CompactInput{Force: true}) + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("Compact: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + + // Should return well before the 500ms LLM call + if elapsed > 200*time.Millisecond { + t.Errorf("Compact took %v, should return before async condensed finishes", elapsed) + } + + // Wait for async to complete + time.Sleep(800 * time.Millisecond) + + // Verify condensed summary was created by background goroutine + summaries, _ := s.GetSummariesByConversation(ctx, convID) + foundCondensed := false + for _, sum := range summaries { + if sum.Kind == SummaryKindCondensed { + foundCondensed = true + break + } + } + if !foundCondensed { + t.Error("expected at least one condensed summary from async Phase 2") + } +} + +func TestCompactAsyncDedup(t *testing.T) { + var callCount int32 + slowComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) { + atomic.AddInt32(&callCount, 1) + time.Sleep(300 * time.Millisecond) + return "Slow condensed summary.", nil + } + + s := openTestStore(t) + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "test:dedup") + convID := conv.ConversationID + + ce, cancel := newTestCompactionEngineWithStore(s, slowComplete) + t.Cleanup(func() { + cancel() + waitForCondensed(ce, convID, 2*time.Second) + }) + + // Create conditions for condensed compaction + for i := 0; i < CondensedMinFanout; i++ { + now := time.Now().UTC() + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "leaf for dedup", + TokenCount: 500, + EarliestAt: &now, + LatestAt: &now, + }) + s.AppendContextSummary(ctx, convID, summary.SummaryID) + } + for i := 0; i < FreshTailCount; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "fresh", 10) + s.AppendContextMessage(ctx, convID, m.ID) + } + + // Call Compact twice rapidly + ce.Compact(ctx, convID, CompactInput{Force: true}) + ce.Compact(ctx, convID, CompactInput{Force: true}) + + // Wait for async to finish + time.Sleep(600 * time.Millisecond) + + // LLM should only be called once for condensed (dedup) + // callCount may be 0 if no leaf was created (only condensed in goroutine) + // The key is that we don't get 2+ condensed calls + if atomic.LoadInt32(&callCount) > 1 { + t.Errorf("LLM called %d times, expected at most 1 (dedup)", callCount) + } +} + +func TestCompactLeafForceBypassesFreshTail(t *testing.T) { + // Spec: compactLeaf with force=true should bypass FreshTailCount protection + // so CompactUntilUnder can compress messages inside the fresh tail + ce, s, convID := newTestCompactionEngine(t) + ctx := context.Background() + + // Create exactly FreshTailCount+4 messages (36 total) + // Without force: all messages are in fresh tail → no candidate + // With force: should compact the oldest messages + total := FreshTailCount + 4 + for i := 0; i < total; i++ { + m, _ := s.AddMessage(ctx, convID, "user", fmt.Sprintf("message %d for force test", i), 100) + s.AppendContextMessage(ctx, convID, m.ID) + } + + // Without force: should return nil (all in fresh tail) + summaryID, err := ce.compactLeaf(ctx, convID) + if err != nil { + t.Fatalf("compactLeaf no-force: %v", err) + } + if summaryID != nil { + t.Error("expected nil without force (all messages in fresh tail)") + } + + // With force: should compact despite fresh tail protection + summaryID, err = ce.compactLeaf(ctx, convID, true) + if err != nil { + t.Fatalf("compactLeaf force: %v", err) + } + if summaryID == nil { + t.Error("expected summary with force=true (bypasses fresh tail)") + } +} + +func TestCompactLeafAccumulatesUpToLeafChunkTokens(t *testing.T) { + // Spec: compactLeaf should accumulate messages up to LeafChunkTokens before stopping + // It should NOT take the entire contiguous chunk regardless of token count + ce, s, convID := newTestCompactionEngine(t) + ctx := context.Background() + + // Create messages totaling far more than LeafChunkTokens (20000) + // Each message is ~500 tokens, create 80 messages = 40000 tokens + for i := 0; i < 80; i++ { + m, _ := s.AddMessage( + ctx, + convID, + "user", + fmt.Sprintf( + "message %d with lots of content to make it big enough for token counting purposes and this should be a substantial message body that represents a meaningful conversation turn", + i, + ), + 500, + ) + s.AppendContextMessage(ctx, convID, m.ID) + } + + summaryID, err := ce.compactLeaf(ctx, convID) + if err != nil { + t.Fatalf("compactLeaf: %v", err) + } + if summaryID == nil { + t.Fatal("expected a summary to be created") + } + + // The source messages that were compacted should total roughly LeafChunkTokens (20000), + // not the entire 40000 tokens worth of messages + summary, _ := s.GetSummary(ctx, *summaryID) + if summary == nil { + t.Fatal("summary not found") + } + + // Source message tokens should be roughly <= LeafChunkTokens (20000) + // Spec says: "Stop when accumulated tokens >= LeafChunkTokens" + if summary.SourceMessageTokenCount > LeafChunkTokens { + t.Errorf("source tokens = %d, should be <= LeafChunkTokens (%d)", + summary.SourceMessageTokenCount, LeafChunkTokens) + } +} diff --git a/pkg/seahorse/short_constants.go b/pkg/seahorse/short_constants.go new file mode 100644 index 000000000..943d7931e --- /dev/null +++ b/pkg/seahorse/short_constants.go @@ -0,0 +1,30 @@ +package seahorse + +// Short-term memory configuration constants — all are experience-based defaults. + +const ( + // OrdinalStep is the gap between ordinals in context_items. + // Insert at midpoint; resequence only when precision exhausted. + OrdinalStep = 100 + + // ContextThreshold is the compaction trigger for the context window. + ContextThreshold float64 = 0.75 // Compact at 75% of context window + FreshTailCount int = 32 // Recent messages protected from compaction + + // LeafMinFanout is the fanout parameter. + LeafMinFanout int = 8 // Min messages per leaf summary + CondensedMinFanout int = 4 // Min summaries per condensed + CondensedMinFanoutHard int = 2 // Min for forced compaction + + // LeafChunkTokens is the token target. + LeafChunkTokens int = 20000 // Max tokens per leaf chunk + LeafTargetTokens int = 1200 // Target tokens for leaf summaries + CondensedTargetTokens int = 2000 // Target tokens for condensed summaries + MaxExpandTokens int = 4000 // Token cap for expansion queries + + // MaxCompactIterations caps CompactUntilUnder to prevent infinite loops. + // Each iteration reduces ~4x tokens via leaf (8:1) or condensed (4:1) compaction. + // With a 200k token context window and 75% threshold, ~20 iterations is enough + // for any realistic scenario. If exceeded, the issue is logged as a warning. + MaxCompactIterations int = 20 +) diff --git a/pkg/seahorse/short_engine.go b/pkg/seahorse/short_engine.go new file mode 100644 index 000000000..0a8175617 --- /dev/null +++ b/pkg/seahorse/short_engine.go @@ -0,0 +1,660 @@ +package seahorse + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + + _ "modernc.org/sqlite" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// Config holds engine configuration. +type Config struct { + DBPath string `json:"dbPath"` + IgnoreSessionPatterns []string `json:"ignoreSessionPatterns,omitempty"` + StatelessSessionPatterns []string `json:"statelessSessionPatterns,omitempty"` +} + +// CompleteFn is the LLM completion function type. +type CompleteFn func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) + +// CompleteOptions holds LLM completion parameters. +type CompleteOptions struct { + Model string + MaxTokens int + Temperature float64 +} + +// IngestResult is the result of message ingestion. +type IngestResult struct { + MessageCount int `json:"messageCount"` + TokenCount int `json:"tokenCount"` +} + +// AssembleInput controls context assembly. +type AssembleInput struct { + Budget int `json:"budget"` + Query string `json:"query,omitempty"` +} + +// AssembleResult contains assembled context. +type AssembleResult struct { + Messages []Message `json:"messages"` + Summary string `json:"summary"` // formatted XML summaries + system prompt addition +} + +const numSessionShards = 256 + +// Engine is the main short-term memory engine. +type Engine struct { + store *Store + compaction *CompactionEngine + compactionMu sync.Mutex + assembler *Assembler + assemblerMu sync.Mutex + retrieval *RetrievalEngine + config Config + complete CompleteFn + ignorePatterns []*regexp.Regexp + statelessPatterns []*regexp.Regexp + sessionShards [numSessionShards]struct { + mu sync.Mutex + } +} + +// CompactionEngine handles LLM-based summarization (defined in short_compaction.go). +type CompactionEngine struct { + store *Store + config Config + complete CompleteFn + condensing sync.Map // map[int64]struct{} — dedup for async condensed goroutines + shutdownCtx context.Context + shutdownCancel context.CancelFunc +} + +// Assembler handles budget-aware context assembly (defined in short_assembler.go). +type Assembler struct { + store *Store + config Config +} + +// RetrievalEngine handles search and expansion (defined in short_retrieval.go). +type RetrievalEngine struct { + store *Store + config Config +} + +// Store returns the underlying store for direct access. +func (r *RetrievalEngine) Store() *Store { + return r.store +} + +// NewEngine creates a new short-term memory engine. +func NewEngine(config Config, completeFn CompleteFn) (*Engine, error) { + dir := filepath.Dir(config.DBPath) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create db directory: %w", err) + } + } + + db, err := sql.Open("sqlite", config.DBPath) + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + + // Configure SQLite for concurrent access + if _, err := db.Exec("PRAGMA journal_mode = WAL;"); err != nil { + db.Close() + return nil, fmt.Errorf("enable WAL: %w", err) + } + if _, err := db.Exec("PRAGMA busy_timeout = 5000;"); err != nil { + db.Close() + return nil, fmt.Errorf("set busy_timeout: %w", err) + } + if _, err := db.Exec("PRAGMA synchronous = NORMAL;"); err != nil { + db.Close() + return nil, fmt.Errorf("set synchronous: %w", err) + } + + if err := runSchema(db); err != nil { + db.Close() + return nil, fmt.Errorf("migrations: %w", err) + } + + store := &Store{db: db} + + // Prepend hardcoded ignore patterns (spec lines 1326-1328) + ignorePatterns := make([]string, 0, 1+len(config.IgnoreSessionPatterns)) + ignorePatterns = append(ignorePatterns, "heartbeat") + ignorePatterns = append(ignorePatterns, config.IgnoreSessionPatterns...) + + retrieval := &RetrievalEngine{store: store, config: config} + + return &Engine{ + store: store, + compaction: nil, + assembler: nil, + retrieval: retrieval, + config: config, + complete: completeFn, + ignorePatterns: compileSessionPatterns(ignorePatterns), + statelessPatterns: compileSessionPatterns(config.StatelessSessionPatterns), + }, nil +} + +// compileSessionPattern converts a glob pattern to a compiled regex. +// Pattern rules: +// - * matches any sequence of non-colon characters ([^:]*) +// - ** matches any sequence of characters including colons (.*) +// - All other characters are treated literally +// - Pattern is anchored (^...$) +func compileSessionPattern(pattern string) *regexp.Regexp { + var b strings.Builder + b.WriteByte('^') + + i := 0 + for i < len(pattern) { + if i+1 < len(pattern) && pattern[i] == '*' && pattern[i+1] == '*' { + b.WriteString(".*") + i += 2 + continue + } + if pattern[i] == '*' { + b.WriteString("[^:]*") + i++ + continue + } + b.WriteString(regexp.QuoteMeta(string(pattern[i]))) + i++ + } + + b.WriteByte('$') + return regexp.MustCompile(b.String()) +} + +// compileSessionPatterns compiles multiple glob patterns into regex patterns. +func compileSessionPatterns(patterns []string) []*regexp.Regexp { + result := make([]*regexp.Regexp, 0, len(patterns)) + for _, p := range patterns { + if p == "" { + continue + } + result = append(result, compileSessionPattern(p)) + } + return result +} + +// shouldIgnoreSession returns true if the session key matches any ignore pattern. +func (e *Engine) shouldIgnoreSession(sessionKey string) bool { + for _, p := range e.ignorePatterns { + if p.MatchString(sessionKey) { + return true + } + } + return false +} + +// isStatelessSession returns true if the session key matches any stateless pattern. +func (e *Engine) isStatelessSession(sessionKey string) bool { + for _, p := range e.statelessPatterns { + if p.MatchString(sessionKey) { + return true + } + } + return false +} + +// fnv32 computes FNV-1a 32-bit hash for session key sharding. +func fnv32(key string) uint32 { + h := uint32(2166136261) + for _, c := range key { + h ^= uint32(c) + h *= 16777619 + } + return h +} + +// getSessionMutex returns the sharded mutex for a session key. +func (e *Engine) getSessionMutex(sessionKey string) *sync.Mutex { + h := fnv32(sessionKey) + shard := h % numSessionShards + return &e.sessionShards[shard].mu +} + +// Ingest adds messages to a conversation identified by sessionKey. +func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Message) (*IngestResult, error) { + if e.shouldIgnoreSession(sessionKey) { + return nil, nil + } + if e.isStatelessSession(sessionKey) { + return nil, nil + } + + mu := e.getSessionMutex(sessionKey) + mu.Lock() + defer mu.Unlock() + + conv, err := e.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + return nil, fmt.Errorf("get conversation: %w", err) + } + + var totalTokens int + var msgIDs []int64 + for _, msg := range messages { + var added *Message + var err error + if len(msg.Parts) > 0 { + added, err = e.store.AddMessageWithPartsAndReasoning( + ctx, + conv.ConversationID, + msg.Role, + msg.Parts, + msg.ReasoningContent, + msg.TokenCount, + ) + } else { + added, err = e.store.AddMessageWithReasoning( + ctx, + conv.ConversationID, + msg.Role, + msg.Content, + msg.ReasoningContent, + msg.TokenCount, + ) + } + if err != nil { + return nil, fmt.Errorf("add message: %w", err) + } + totalTokens += msg.TokenCount + msgIDs = append(msgIDs, added.ID) + } + + // Append to context_items using actual inserted IDs + if err := e.store.AppendContextMessages(ctx, conv.ConversationID, msgIDs); err != nil { + return nil, fmt.Errorf("append context: %w", err) + } + + logger.InfoCF("seahorse", "ingest", map[string]any{ + "conv_id": conv.ConversationID, + "messages": len(messages), + "tokens": totalTokens, + }) + return &IngestResult{ + MessageCount: len(messages), + TokenCount: totalTokens, + }, nil +} + +// Close releases resources. +func (e *Engine) Close() error { + // Signal compaction goroutines to stop + if e.compaction != nil { + e.compaction.Close() + } + if e.store != nil && e.store.db != nil { + return e.store.db.Close() + } + return nil +} + +// GetRetrieval returns the retrieval engine for tool implementations. +func (e *Engine) GetRetrieval() *RetrievalEngine { + return e.retrieval +} + +// Assemble builds budget-constrained context for a session. +func (e *Engine) Assemble(ctx context.Context, sessionKey string, input AssembleInput) (*AssembleResult, error) { + if e.shouldIgnoreSession(sessionKey) { + return nil, nil + } + + conv, err := e.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + return nil, fmt.Errorf("get conversation: %w", err) + } + + e.initAssemblerOnce() + return e.assembler.Assemble(ctx, conv.ConversationID, input) +} + +// Compact compresses conversation history for a session. +func (e *Engine) Compact(ctx context.Context, sessionKey string, input CompactInput) (*CompactResult, error) { + if e.shouldIgnoreSession(sessionKey) || e.isStatelessSession(sessionKey) { + return &CompactResult{}, nil + } + + conv, err := e.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + return nil, fmt.Errorf("get conversation: %w", err) + } + + e.initCompactionOnce() + return e.compaction.Compact(ctx, conv.ConversationID, input) +} + +// CompactUntilUnder aggressively compacts until context is under budget. +// Used for emergency compaction after LLM overflow (retry reason). +func (e *Engine) CompactUntilUnder(ctx context.Context, sessionKey string, budget int) (*CompactResult, error) { + if e.shouldIgnoreSession(sessionKey) || e.isStatelessSession(sessionKey) { + return &CompactResult{}, nil + } + + conv, err := e.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + return nil, fmt.Errorf("get conversation: %w", err) + } + + e.initCompactionOnce() + return e.compaction.CompactUntilUnder(ctx, conv.ConversationID, budget) +} + +// initCompactionOnce lazily initializes the compaction engine. +func (e *Engine) initCompactionOnce() { + if e.compaction == nil { + e.compactionMu.Lock() + defer e.compactionMu.Unlock() + if e.compaction == nil { + shutdownCtx, shutdownCancel := context.WithCancel(context.Background()) + e.compaction = &CompactionEngine{ + store: e.store, + config: e.config, + complete: e.complete, + shutdownCtx: shutdownCtx, + shutdownCancel: shutdownCancel, + } + } + } +} + +// initAssemblerOnce lazily initializes the assembler. +func (e *Engine) initAssemblerOnce() { + if e.assembler == nil { + e.assemblerMu.Lock() + defer e.assemblerMu.Unlock() + if e.assembler == nil { + e.assembler = &Assembler{store: e.store, config: e.config} + } + } +} + +// IngestMessages is an alias for Ingest. +func (e *Engine) IngestMessages(ctx context.Context, sessionKey string, messages []Message) (*IngestResult, error) { + return e.Ingest(ctx, sessionKey, messages) +} + +// ClearSession removes all stored data for a session (messages, summaries, context). +// If the session has no prior seahorse record, it is a no-op. +func (e *Engine) ClearSession(ctx context.Context, sessionKey string) error { + conv, err := e.store.GetConversationBySessionKey(ctx, sessionKey) + if err != nil { + return err + } + if conv == nil { + return nil // session never ingested, nothing to clear + } + return e.store.ClearConversation(ctx, conv.ConversationID) +} + +// Bootstrap reconciles a session's messages with the database. +// Called once at startup for each known session. +// Bootstrap reconciles JSONL history with SQLite by ingesting only the delta. +// Simple approach: find longest matching prefix and append delta. +// If any mismatch is detected, clear and rebuild. +func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Message) error { + if e.shouldIgnoreSession(sessionKey) { + return nil + } + if e.isStatelessSession(sessionKey) { + return nil + } + if len(messages) == 0 { + return nil + } + + conv, err := e.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + return fmt.Errorf("bootstrap: get conversation: %w", err) + } + + // Get messages already in DB + dbMsgs, err := e.store.GetMessages(ctx, conv.ConversationID, len(messages), 0) + if err != nil { + return fmt.Errorf("bootstrap: get messages: %w", err) + } + + // Fast path: DB has same count and exact match → no-op + if len(dbMsgs) == len(messages) { + matched := true + for i := range messages { + if !messageMatches(dbMsgs[i], messages[i]) { + matched = false + break + } + } + if matched { + return nil // DB is up to date + } + } + + // Migration repair path: old SeaHorse rows may be missing reasoning_content + // even though the canonical JSONL history already has it. Backfill those + // rows in place so we do not treat this as edited history and leave stale + // summaries/context behind after a partial raw-message rebuild. + if repaired, err := e.repairBootstrapReasoningContent(ctx, dbMsgs, messages); err != nil { + return fmt.Errorf("bootstrap: repair reasoning_content: %w", err) + } else if repaired && len(dbMsgs) == len(messages) { + return nil + } + + // Find longest matching prefix from the start + anchor := -1 + compareLen := min(len(dbMsgs), len(messages)) + + for i := range compareLen { + if messageMatches(dbMsgs[i], messages[i]) { + anchor = i + } else { + // Mismatch detected - log details and rebuild + logger.InfoCF("seahorse", "bootstrap: mismatch detected", map[string]any{ + "conv_id": conv.ConversationID, + "index": i, + "db_role": dbMsgs[i].Role, + "db_content": truncate(dbMsgs[i].Content, 50), + "db_parts": len(dbMsgs[i].Parts), + "msg_role": messages[i].Role, + "msg_content": truncate(messages[i].Content, 50), + "msg_parts": len(messages[i].Parts), + }) + break + } + } + + // If we hit a mismatch before reaching the end of DB messages, delete delta and re-ingest + // Note: anchor can be -1 if first message didn't match (history completely changed) + if anchor >= 0 && anchor < len(dbMsgs)-1 && len(dbMsgs) > 0 { + anchorID := dbMsgs[anchor].ID + logger.InfoCF("seahorse", "bootstrap: history edit detected", map[string]any{ + "conv_id": conv.ConversationID, + "db_count": len(dbMsgs), + "anchor": anchor, + "anchor_id": anchorID, + "msg_count": len(messages), + "delta_start": anchor + 1, + }) + + // Delete messages after anchor (also clears context_items) + if err := e.store.DeleteMessagesAfterID(ctx, conv.ConversationID, anchorID); err != nil { + return fmt.Errorf("bootstrap: delete messages: %w", err) + } + + // Re-ingest from anchor+1 to end + delta := messages[anchor+1:] + if len(delta) > 0 { + _, err := e.Ingest(ctx, sessionKey, delta) + if err != nil { + return fmt.Errorf("bootstrap: re-ingest: %w", err) + } + } + return nil + } + + // Normal case: append delta after anchor + if anchor >= 0 && anchor < len(messages)-1 { + delta := messages[anchor+1:] + if len(delta) > 0 { + _, err := e.Ingest(ctx, sessionKey, delta) + if err != nil { + return fmt.Errorf("bootstrap: ingest delta: %w", err) + } + } + } else if anchor == -1 && len(dbMsgs) > 0 { + // First message changed (history completely different) - rebuild from scratch + logger.InfoCF("seahorse", "bootstrap: history replaced, rebuilding", map[string]any{ + "conv_id": conv.ConversationID, + "db_count": len(dbMsgs), + "msg_count": len(messages), + }) + // Delete all existing messages + if err := e.store.DeleteMessagesAfterID(ctx, conv.ConversationID, 0); err != nil { + return fmt.Errorf("bootstrap: delete all messages: %w", err) + } + // Re-ingest everything + if len(messages) > 0 { + _, err := e.Ingest(ctx, sessionKey, messages) + if err != nil { + return fmt.Errorf("bootstrap: re-ingest all: %w", err) + } + } + } else if anchor == -1 && len(dbMsgs) == 0 { + // DB is empty, ingest everything + _, err := e.Ingest(ctx, sessionKey, messages) + if err != nil { + return fmt.Errorf("bootstrap: ingest all: %w", err) + } + } + + return nil +} + +func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, messages []Message) (bool, error) { + if len(dbMsgs) == 0 || len(messages) == 0 { + return false, nil + } + + overlap := min(len(messages), len(dbMsgs)) + + var updates []struct { + index int + messageID int64 + reasoningContent string + } + + for i := range overlap { + if !messageMatchesIgnoringReasoning(dbMsgs[i], messages[i]) { + return false, nil + } + if dbMsgs[i].ReasoningContent == messages[i].ReasoningContent { + continue + } + if dbMsgs[i].ReasoningContent != "" || messages[i].ReasoningContent == "" { + return false, nil + } + updates = append(updates, struct { + index int + messageID int64 + reasoningContent string + }{ + index: i, + messageID: dbMsgs[i].ID, + reasoningContent: messages[i].ReasoningContent, + }) + } + + if len(updates) == 0 { + return false, nil + } + + for _, update := range updates { + if err := e.store.UpdateMessageReasoningContent(ctx, update.messageID, update.reasoningContent); err != nil { + return false, err + } + dbMsgs[update.index].ReasoningContent = update.reasoningContent + } + + logger.InfoCF("seahorse", "bootstrap: repaired missing reasoning_content", map[string]any{ + "messages": len(updates), + }) + return true, nil +} + +// truncate shortens a string for logging. +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +// messageMatches compares two messages using role + reasoning_content and then +// either content or parts. TokenCount is NOT compared because it may be +// re-estimated differently during bootstrap (e.g., via tokenizer.EstimateMessageTokens). +// For messages with Parts (tool_use, tool_result), compare Parts instead of Content +// because structured messages are matched by their parts payload. +func messageMatches(a, b Message) bool { + if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent { + return false + } + return messageMatchesIgnoringReasoning(a, b) +} + +func messageMatchesIgnoringReasoning(a, b Message) bool { + if a.Role != b.Role { + return false + } + // If either message has Parts, compare Parts + if len(a.Parts) > 0 || len(b.Parts) > 0 { + return partsMatch(a.Parts, b.Parts) + } + // Simple text messages: compare Content + return a.Content == b.Content +} + +// partsMatch compares two slices of MessagePart for equality. +func partsMatch(a, b []MessagePart) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Type != b[i].Type { + return false + } + switch a[i].Type { + case "text": + if a[i].Text != b[i].Text { + return false + } + case "tool_use": + if a[i].Name != b[i].Name || a[i].Arguments != b[i].Arguments || a[i].ToolCallID != b[i].ToolCallID { + return false + } + case "tool_result": + if a[i].ToolCallID != b[i].ToolCallID || a[i].Text != b[i].Text { + return false + } + case "media": + if a[i].MediaURI != b[i].MediaURI || a[i].MimeType != b[i].MimeType { + return false + } + } + } + return true +} diff --git a/pkg/seahorse/short_engine_test.go b/pkg/seahorse/short_engine_test.go new file mode 100644 index 000000000..2a5c6c5d8 --- /dev/null +++ b/pkg/seahorse/short_engine_test.go @@ -0,0 +1,1760 @@ +package seahorse + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// helper: open a test engine with in-memory DB +func newTestEngine(t *testing.T) *Engine { + t.Helper() + db := openTestDB(t) + if err := runSchema(db); err != nil { + t.Fatalf("migration: %v", err) + } + store := &Store{db: db} + return &Engine{ + store: store, + config: Config{}, + } +} + +// --- compileSessionPattern --- + +func TestCompileSessionPattern(t *testing.T) { + tests := []struct { + pattern string + input string + want bool + }{ + // Exact match + {"agent:abc123", "agent:abc123", true}, + {"agent:abc123", "agent:def456", false}, + // Single * — matches non-colon chars + {"agent:*", "agent:abc123", true}, + {"agent:*", "agent:abc:def", false}, // * doesn't match colons + // ** — matches everything including colons + {"cron:**", "cron:backup", true}, + {"cron:**", "cron:backup:daily", true}, + {"cron:**", "agent:abc", false}, + // Mixed + {"agent:*:sub:**", "agent:abc:sub:def", true}, + {"agent:*:sub:**", "agent:abc:sub:def:ghi", true}, + {"agent:*:sub:**", "agent:abc:def", false}, + // Empty pattern — matches nothing meaningful + {"", "", true}, + {"", "agent:abc", false}, + } + + for _, tt := range tests { + re := compileSessionPattern(tt.pattern) + if re == nil && tt.pattern != "" { + t.Fatalf("compileSessionPattern(%q) returned nil", tt.pattern) + } + if tt.pattern == "" { + continue + } + got := re.MatchString(tt.input) + if got != tt.want { + t.Errorf("compileSessionPattern(%q).Match(%q) = %v, want %v", tt.pattern, tt.input, got, tt.want) + } + } +} + +// --- Session Pattern Filtering --- + +func TestEngineShouldIgnoreSession(t *testing.T) { + eng := &Engine{ + ignorePatterns: compileSessionPatterns([]string{"cron:**", "test:*"}), + } + + tests := []struct { + key string + want bool + }{ + {"cron:backup", true}, + {"cron:backup:daily", true}, + {"test:session", true}, + {"agent:abc", false}, + {"", false}, + } + + for _, tt := range tests { + got := eng.shouldIgnoreSession(tt.key) + if got != tt.want { + t.Errorf("shouldIgnoreSession(%q) = %v, want %v", tt.key, got, tt.want) + } + } +} + +func TestEngineIsStatelessSession(t *testing.T) { + eng := &Engine{ + statelessPatterns: compileSessionPatterns([]string{"agent:*:sub:**"}), + } + + tests := []struct { + key string + want bool + }{ + {"agent:abc:sub:def", true}, + {"agent:abc:sub:def:ghi", true}, + {"agent:abc", false}, + {"cron:backup", false}, + } + + for _, tt := range tests { + got := eng.isStatelessSession(tt.key) + if got != tt.want { + t.Errorf("isStatelessSession(%q) = %v, want %v", tt.key, got, tt.want) + } + } +} + +// --- NewEngine --- + +func TestNewEngine(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "short.db") + + eng, err := NewEngine(Config{DBPath: dbPath}, nil) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + defer eng.Close() + + // DB file should exist + if _, pathErr := os.Stat(dbPath); os.IsNotExist(pathErr) { + t.Error("expected DB file to be created") + } + + // Store should be usable + ctx := context.Background() + conv, err := eng.store.GetOrCreateConversation(ctx, "test:session") + if err != nil { + t.Fatalf("store should work: %v", err) + } + if conv.ConversationID == 0 { + t.Error("expected valid conversation ID") + } + + // GetRetrieval should return non-nil RetrievalEngine + retrieval := eng.GetRetrieval() + if retrieval == nil { + t.Error("expected GetRetrieval to return non-nil RetrievalEngine") + } +} + +func TestNewEngineWithPatterns(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "short.db") + + eng, err := NewEngine(Config{ + DBPath: dbPath, + IgnoreSessionPatterns: []string{"cron:**"}, + StatelessSessionPatterns: []string{"agent:*:sub:**"}, + }, nil) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + defer eng.Close() + + if !eng.shouldIgnoreSession("cron:backup") { + t.Error("expected cron:backup to be ignored") + } + if !eng.isStatelessSession("agent:abc:sub:def") { + t.Error("expected agent:abc:sub:def to be stateless") + } +} + +// --- Ingest --- + +func TestEngineIngest(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + + msgs := []Message{ + {Role: "user", Content: "hello", TokenCount: 2}, + {Role: "assistant", Content: "world", TokenCount: 2}, + } + + result, err := eng.Ingest(ctx, "agent:test", msgs) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + if result.MessageCount != 2 { + t.Errorf("MessageCount = %d, want 2", result.MessageCount) + } + if result.TokenCount != 4 { + t.Errorf("TokenCount = %d, want 4", result.TokenCount) + } + + // Verify messages were stored + conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:test") + stored, _ := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if len(stored) != 2 { + t.Fatalf("stored messages = %d, want 2", len(stored)) + } + if stored[0].Content != "hello" { + t.Errorf("stored[0].Content = %q, want 'hello'", stored[0].Content) + } + + // Verify context_items were populated + items, _ := eng.store.GetContextItems(ctx, conv.ConversationID) + if len(items) != 2 { + t.Fatalf("context items = %d, want 2", len(items)) + } + if items[0].ItemType != "message" { + t.Errorf("item[0].ItemType = %q, want 'message'", items[0].ItemType) + } +} + +func TestEngineIngestIgnoresSession(t *testing.T) { + eng := newTestEngine(t) + eng.ignorePatterns = compileSessionPatterns([]string{"cron:**"}) + ctx := context.Background() + + msgs := []Message{{Role: "user", Content: "hello", TokenCount: 2}} + result, err := eng.Ingest(ctx, "cron:backup", msgs) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + if result != nil { + t.Error("expected nil result for ignored session") + } + + // Verify no data was stored + conv, _ := eng.store.GetConversationBySessionKey(ctx, "cron:backup") + if conv != nil { + t.Error("expected no conversation for ignored session") + } +} + +func TestEngineIngestStatelessSession(t *testing.T) { + eng := newTestEngine(t) + eng.statelessPatterns = compileSessionPatterns([]string{"agent:*:ro"}) + ctx := context.Background() + + msgs := []Message{{Role: "user", Content: "hello", TokenCount: 2}} + result, err := eng.Ingest(ctx, "agent:abc:ro", msgs) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + if result != nil { + t.Error("expected nil result for stateless session") + } +} + +func TestEngineIngestIncremental(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + + // First ingest + eng.Ingest(ctx, "agent:test", []Message{ + {Role: "user", Content: "msg1", TokenCount: 1}, + }) + // Second ingest — should append, not replace + eng.Ingest(ctx, "agent:test", []Message{ + {Role: "assistant", Content: "msg2", TokenCount: 1}, + }) + + conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:test") + stored, _ := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if len(stored) != 2 { + t.Errorf("stored messages = %d, want 2", len(stored)) + } +} + +func TestEngineIngestWithParts(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + + msgs := []Message{ + { + Role: "assistant", + Content: "", + TokenCount: 10, + Parts: []MessagePart{ + {Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"}, + {Type: "text", Text: "here is the file content"}, + }, + }, + } + + result, err := eng.Ingest(ctx, "agent:parts-test", msgs) + if err != nil { + t.Fatalf("Ingest with parts: %v", err) + } + if result.MessageCount != 1 { + t.Errorf("MessageCount = %d, want 1", result.MessageCount) + } + + // Verify message was stored WITH parts + conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:parts-test") + stored, _ := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if len(stored) != 1 { + t.Fatalf("stored messages = %d, want 1", len(stored)) + } + if len(stored[0].Parts) != 2 { + t.Fatalf("stored message parts = %d, want 2", len(stored[0].Parts)) + } + if stored[0].Parts[0].Type != "tool_use" { + t.Errorf("part[0].Type = %q, want tool_use", stored[0].Parts[0].Type) + } + if stored[0].Parts[0].Name != "read_file" { + t.Errorf("part[0].Name = %q, want read_file", stored[0].Parts[0].Name) + } + if stored[0].Parts[0].ToolCallID != "tc_123" { + t.Errorf("part[0].ToolCallID = %q, want tc_123", stored[0].Parts[0].ToolCallID) + } + if stored[0].Parts[1].Type != "text" { + t.Errorf("part[1].Type = %q, want text", stored[0].Parts[1].Type) + } + if stored[0].Parts[1].Text != "here is the file content" { + t.Errorf("part[1].Text = %q, want 'here is the file content'", stored[0].Parts[1].Text) + } +} + +func TestEngineIngestPreservesReasoningContent(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + + msgs := []Message{ + { + Role: "assistant", + Content: "world", + ReasoningContent: "let me think this through", + TokenCount: 4, + }, + } + + _, err := eng.Ingest(ctx, "agent:reasoning", msgs) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + + conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:reasoning") + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 1 { + t.Fatalf("stored messages = %d, want 1", len(stored)) + } + if stored[0].ReasoningContent != "let me think this through" { + t.Errorf( + "stored[0].ReasoningContent = %q, want %q", + stored[0].ReasoningContent, + "let me think this through", + ) + } + + result, err := eng.Assemble(ctx, "agent:reasoning", AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + if len(result.Messages) != 1 { + t.Fatalf("assembled messages = %d, want 1", len(result.Messages)) + } + if result.Messages[0].ReasoningContent != "let me think this through" { + t.Errorf( + "assembled reasoning = %q, want %q", + result.Messages[0].ReasoningContent, + "let me think this through", + ) + } +} + +func TestEngineIngestWithPartsPreservesReasoningContent(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + + msgs := []Message{ + { + Role: "assistant", + ReasoningContent: "I need to inspect the file first", + TokenCount: 10, + Parts: []MessagePart{ + {Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"}, + }, + }, + } + + _, err := eng.Ingest(ctx, "agent:parts-reasoning", msgs) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + + conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:parts-reasoning") + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 1 { + t.Fatalf("stored messages = %d, want 1", len(stored)) + } + if stored[0].ReasoningContent != "I need to inspect the file first" { + t.Errorf( + "stored reasoning = %q, want %q", + stored[0].ReasoningContent, + "I need to inspect the file first", + ) + } + + result, err := eng.Assemble(ctx, "agent:parts-reasoning", AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + if len(result.Messages) != 1 { + t.Fatalf("assembled messages = %d, want 1", len(result.Messages)) + } + if result.Messages[0].ReasoningContent != "I need to inspect the file first" { + t.Errorf( + "assembled reasoning = %q, want %q", + result.Messages[0].ReasoningContent, + "I need to inspect the file first", + ) + } +} + +func TestEngineIngestAssemblePreservesParts(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + + // Ingest a message with tool_use parts + eng.Ingest(ctx, "agent:parts-roundtrip", []Message{ + {Role: "user", Content: "list files", TokenCount: 3}, + { + Role: "assistant", + Content: "", + TokenCount: 5, + Parts: []MessagePart{ + {Type: "tool_use", Name: "bash", Arguments: `{"cmd":"ls"}`, ToolCallID: "tc_1"}, + {Type: "text", Text: "found 3 files"}, + }, + }, + }) + + // Assemble should return messages with parts intact + result, err := eng.Assemble(ctx, "agent:parts-roundtrip", AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + if len(result.Messages) != 2 { + t.Fatalf("Assemble returned %d messages, want 2", len(result.Messages)) + } + + // The second message should have Parts populated + assistantMsg := result.Messages[1] + if len(assistantMsg.Parts) != 2 { + t.Fatalf("Assembled assistant message Parts = %d, want 2", len(assistantMsg.Parts)) + } + if assistantMsg.Parts[0].Type != "tool_use" { + t.Errorf("part[0].Type = %q, want tool_use", assistantMsg.Parts[0].Type) + } + if assistantMsg.Parts[0].ToolCallID != "tc_1" { + t.Errorf("part[0].ToolCallID = %q, want tc_1", assistantMsg.Parts[0].ToolCallID) + } +} + +// --- Session Mutex --- + +func TestEngineSessionMutex(t *testing.T) { + eng := newTestEngine(t) + + mu1 := eng.getSessionMutex("agent:test") + mu2 := eng.getSessionMutex("agent:test") + mu3 := eng.getSessionMutex("agent:other") + + if mu1 != mu2 { + t.Error("expected same mutex for same session key") + } + if mu1 == mu3 { + t.Error("expected different mutex for different session key") + } +} + +// --- Close --- + +func TestEngineClose(t *testing.T) { + eng := newTestEngine(t) + if err := eng.Close(); err != nil { + t.Errorf("Close: %v", err) + } +} + +// --- compileSessionPatterns (batch) --- + +func TestCompileSessionPatterns(t *testing.T) { + patterns := compileSessionPatterns([]string{"cron:**", "agent:*:ro"}) + if len(patterns) != 2 { + t.Fatalf("expected 2 patterns, got %d", len(patterns)) + } + + tests := []struct { + input string + want bool + }{ + {"cron:backup", true}, + {"agent:abc:ro", true}, + {"agent:abc:def", false}, + {"", false}, + } + + for _, tt := range tests { + matched := false + for _, p := range patterns { + if p.MatchString(tt.input) { + matched = true + break + } + } + if matched != tt.want { + t.Errorf("patterns.Match(%q) = %v, want %v", tt.input, matched, tt.want) + } + } +} + +func TestCompileSessionPatternsEmpty(t *testing.T) { + patterns := compileSessionPatterns(nil) + if len(patterns) != 0 { + t.Errorf("expected 0 patterns for nil input, got %d", len(patterns)) + } +} + +// --- Bootstrap --- + +func TestEngineBootstrap(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + + msgs := []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", TokenCount: 3}, + {Role: "user", Content: "how are you", TokenCount: 5}, + } + + err := eng.Bootstrap(ctx, "agent:boot1", msgs) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + // Verify conversation was created + conv, err := eng.store.GetConversationBySessionKey(ctx, "agent:boot1") + if err != nil { + t.Fatalf("GetConversation: %v", err) + } + if conv == nil { + t.Fatal("expected conversation to exist after bootstrap") + } + + // Verify messages were stored + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 3 { + t.Fatalf("expected 3 stored messages, got %d", len(stored)) + } + if stored[0].Content != "hello" { + t.Errorf("stored[0].Content = %q, want 'hello'", stored[0].Content) + } + + // Verify context_items were populated + items, err := eng.store.GetContextItems(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetContextItems: %v", err) + } + if len(items) != 3 { + t.Fatalf("expected 3 context items, got %d", len(items)) + } +} + +func TestEngineBootstrapEmpty(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + + err := eng.Bootstrap(ctx, "agent:empty", nil) + if err != nil { + t.Fatalf("Bootstrap empty: %v", err) + } + + // No conversation should be created for empty messages + conv, _ := eng.store.GetConversationBySessionKey(ctx, "agent:empty") + if conv != nil { + t.Error("expected no conversation for empty bootstrap") + } +} + +func TestEngineBootstrapIdempotent(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + + msgs := []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", TokenCount: 3}, + } + + // Bootstrap twice with same messages + eng.Bootstrap(ctx, "agent:idem", msgs) + eng.Bootstrap(ctx, "agent:idem", msgs) + + // Should still have exactly 2 messages (no duplicates) + conv, _ := eng.store.GetConversationBySessionKey(ctx, "agent:idem") + if conv == nil { + t.Fatal("expected conversation") + } + stored, _ := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if len(stored) != 2 { + t.Errorf("expected 2 messages (idempotent), got %d", len(stored)) + } +} + +func TestBootstrapRepairsMissingReasoningContent(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-reasoning" + + conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3) + if err != nil { + t.Fatalf("AddMessage user: %v", err) + } + + assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3) + if err != nil { + t.Fatalf("AddMessage assistant: %v", err) + } + + err = eng.store.AppendContextMessages( + ctx, + conv.ConversationID, + []int64{userMsg.ID, assistantMsg.ID}, + ) + if err != nil { + t.Fatalf("AppendContextMessages: %v", err) + } + + err = eng.Bootstrap(ctx, sessionKey, []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3}, + }) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 2 { + t.Fatalf("stored messages = %d, want 2", len(stored)) + } + if stored[1].ReasoningContent != "let me think this through" { + t.Errorf( + "stored[1].ReasoningContent = %q, want %q", + stored[1].ReasoningContent, + "let me think this through", + ) + } +} + +func TestBootstrapRepairsMissingReasoningContentWithoutDroppingSummaries(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-reasoning-summary" + + conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3) + if err != nil { + t.Fatalf("AddMessage user: %v", err) + } + assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3) + if err != nil { + t.Fatalf("AddMessage assistant: %v", err) + } + + err = eng.store.AppendContextMessages( + ctx, + conv.ConversationID, + []int64{userMsg.ID, assistantMsg.ID}, + ) + if err != nil { + t.Fatalf("AppendContextMessages: %v", err) + } + + summary, err := eng.store.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "summary before repair", + TokenCount: 10, + }) + if err != nil { + t.Fatalf("CreateSummary: %v", err) + } + + err = eng.store.AppendContextSummary(ctx, conv.ConversationID, summary.SummaryID) + if err != nil { + t.Fatalf("AppendContextSummary: %v", err) + } + + err = eng.Bootstrap(ctx, sessionKey, []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3}, + }) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 2 { + t.Fatalf("stored messages = %d, want 2", len(stored)) + } + if stored[1].ReasoningContent != "let me think this through" { + t.Errorf( + "stored[1].ReasoningContent = %q, want %q", + stored[1].ReasoningContent, + "let me think this through", + ) + } + + summaries, err := eng.store.GetSummariesByConversation(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetSummariesByConversation: %v", err) + } + if len(summaries) != 1 { + t.Fatalf("summaries = %d, want 1", len(summaries)) + } + if summaries[0].SummaryID != summary.SummaryID { + t.Errorf("SummaryID = %q, want %q", summaries[0].SummaryID, summary.SummaryID) + } + + items, err := eng.store.GetContextItems(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetContextItems: %v", err) + } + if len(items) != 3 { + t.Fatalf("context items = %d, want 3", len(items)) + } + if items[2].ItemType != "summary" || items[2].SummaryID != summary.SummaryID { + t.Errorf("summary context item = %+v, want summary %q", items[2], summary.SummaryID) + } +} + +func TestBootstrapRepairsMissingReasoningContentOnPrefixBeforeAppendingDelta(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-reasoning-prefix" + + conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3) + if err != nil { + t.Fatalf("AddMessage user: %v", err) + } + assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3) + if err != nil { + t.Fatalf("AddMessage assistant: %v", err) + } + + err = eng.store.AppendContextMessages( + ctx, + conv.ConversationID, + []int64{userMsg.ID, assistantMsg.ID}, + ) + if err != nil { + t.Fatalf("AppendContextMessages: %v", err) + } + + err = eng.Bootstrap(ctx, sessionKey, []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3}, + {Role: "user", Content: "follow-up", TokenCount: 2}, + }) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 3 { + t.Fatalf("stored messages = %d, want 3", len(stored)) + } + if stored[1].ReasoningContent != "let me think this through" { + t.Errorf( + "stored[1].ReasoningContent = %q, want %q", + stored[1].ReasoningContent, + "let me think this through", + ) + } + if stored[2].Content != "follow-up" { + t.Errorf("stored[2].Content = %q, want %q", stored[2].Content, "follow-up") + } + + items, err := eng.store.GetContextItems(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetContextItems: %v", err) + } + if len(items) != 3 { + t.Fatalf("context items = %d, want 3", len(items)) + } + if items[2].ItemType != "message" || items[2].MessageID != stored[2].ID { + t.Errorf("last context item = %+v, want appended message %d", items[2], stored[2].ID) + } +} + +func TestEngineBootstrapDelta(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + + // First bootstrap with 2 messages + msgs1 := []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", TokenCount: 3}, + } + eng.Bootstrap(ctx, "agent:delta", msgs1) + + // Second bootstrap with 4 messages (2 existing + 2 new) + msgs2 := []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", TokenCount: 3}, + {Role: "user", Content: "new question", TokenCount: 5}, + {Role: "assistant", Content: "new answer", TokenCount: 5}, + } + eng.Bootstrap(ctx, "agent:delta", msgs2) + + conv, _ := eng.store.GetConversationBySessionKey(ctx, "agent:delta") + if conv == nil { + t.Fatal("expected conversation") + } + stored, _ := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if len(stored) != 4 { + t.Errorf("expected 4 messages (delta), got %d", len(stored)) + } +} + +func TestBootstrapPopulatesContextItems(t *testing.T) { + // Bootstrap ingests messages and populates context_items + e := newTestEngine(t) + ctx := context.Background() + + messages := []Message{ + {Role: "user", Content: "hello from bootstrap test", TokenCount: 10}, + {Role: "assistant", Content: "hi there", TokenCount: 5}, + {Role: "user", Content: "how are you", TokenCount: 5}, + {Role: "assistant", Content: "doing well", TokenCount: 5}, + {Role: "user", Content: "great news", TokenCount: 5}, + {Role: "assistant", Content: "awesome", TokenCount: 5}, + {Role: "user", Content: "lets code", TokenCount: 5}, + {Role: "assistant", Content: "sure thing", TokenCount: 5}, + } + + // Bootstrap should ingest and rebuild context_items + err := e.Bootstrap(ctx, "test-bootstrap-rebuild", messages) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + // After bootstrap, context_items should be populated + conv, _ := e.store.GetOrCreateConversation(ctx, "test-bootstrap-rebuild") + items, err := e.store.GetContextItems(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetContextItems: %v", err) + } + + if len(items) == 0 { + t.Error("expected context_items to be populated after Bootstrap, got 0 items") + } + + // Should have one item per message + if len(items) != len(messages) { + t.Errorf("expected %d context items, got %d", len(messages), len(items)) + } +} + +func TestBootstrapDeltaPreservesOrder(t *testing.T) { + // When Bootstrap does delta ingest, context_items should maintain + // correct order with new messages appended after anchor. + e := newTestEngine(t) + ctx := context.Background() + sessionKey := "test-bootstrap-delta-order" + + // First: bootstrap with 4 messages + initialMsgs := []Message{ + {Role: "user", Content: "msg1", TokenCount: 5}, + {Role: "assistant", Content: "msg2", TokenCount: 5}, + {Role: "user", Content: "msg3", TokenCount: 5}, + {Role: "assistant", Content: "msg4", TokenCount: 5}, + } + err := e.Bootstrap(ctx, sessionKey, initialMsgs) + if err != nil { + t.Fatalf("first Bootstrap: %v", err) + } + + conv, _ := e.store.GetOrCreateConversation(ctx, sessionKey) + items1, _ := e.store.GetContextItems(ctx, conv.ConversationID) + if len(items1) != 4 { + t.Fatalf("after first bootstrap: expected 4 items, got %d", len(items1)) + } + + // Now bootstrap again with 6 messages (4 existing + 2 new) + // The delta (msg5, msg6) should be appended + updatedMsgs := []Message{ + {Role: "user", Content: "msg1", TokenCount: 5}, + {Role: "assistant", Content: "msg2", TokenCount: 5}, + {Role: "user", Content: "msg3", TokenCount: 5}, + {Role: "assistant", Content: "msg4", TokenCount: 5}, + {Role: "user", Content: "msg5", TokenCount: 5}, + {Role: "assistant", Content: "msg6", TokenCount: 5}, + } + err = e.Bootstrap(ctx, sessionKey, updatedMsgs) + if err != nil { + t.Fatalf("second Bootstrap: %v", err) + } + + items2, _ := e.store.GetContextItems(ctx, conv.ConversationID) + if len(items2) != 6 { + t.Errorf("after delta bootstrap: expected 6 items, got %d", len(items2)) + } +} + +func TestBootstrapHistoryEditFirstMessageChanged(t *testing.T) { + // When the first message changes (anchor = -1), Bootstrap should rebuild + // from scratch without panicking (regression test for index out of range [-1]) + e := newTestEngine(t) + ctx := context.Background() + sessionKey := "test-bootstrap-history-edit" + + // First: bootstrap with some messages + initialMsgs := []Message{ + {Role: "user", Content: "original first", TokenCount: 5}, + {Role: "assistant", Content: "response", TokenCount: 5}, + {Role: "user", Content: "question", TokenCount: 5}, + } + err := e.Bootstrap(ctx, sessionKey, initialMsgs) + if err != nil { + t.Fatalf("first Bootstrap: %v", err) + } + + // Now bootstrap with completely different messages (first message changed) + // This should NOT panic - it should rebuild from scratch + editedMsgs := []Message{ + {Role: "user", Content: "DIFFERENT first message", TokenCount: 5}, + {Role: "assistant", Content: "DIFFERENT response", TokenCount: 5}, + {Role: "user", Content: "DIFFERENT question", TokenCount: 5}, + } + err = e.Bootstrap(ctx, sessionKey, editedMsgs) + if err != nil { + t.Fatalf("second Bootstrap (history edit): %v", err) + } + + conv, _ := e.store.GetOrCreateConversation(ctx, sessionKey) + stored, _ := e.store.GetMessages(ctx, conv.ConversationID, 10, 0) + + // Should have the NEW messages (history was rebuilt) + if len(stored) != 3 { + t.Errorf("expected 3 messages after history edit, got %d", len(stored)) + } + if len(stored) > 0 && stored[0].Content != "DIFFERENT first message" { + t.Errorf("first message = %q, want 'DIFFERENT first message'", stored[0].Content) + } +} + +func TestBootstrapSameContentDifferentTokenCountNoRebuild(t *testing.T) { + // Bootstrap should NOT rebuild when content is identical but TokenCount differs. + // This happens when TokenCount is re-estimated (e.g., via tokenizer.EstimateMessageTokens) + // during bootstrap, which may give slightly different values. + e := newTestEngine(t) + ctx := context.Background() + sessionKey := "test-bootstrap-token-diff" + + // First: bootstrap with some messages + initialMsgs := []Message{ + {Role: "user", Content: "hello world", TokenCount: 10}, + {Role: "assistant", Content: "hi there", TokenCount: 5}, + } + err := e.Bootstrap(ctx, sessionKey, initialMsgs) + if err != nil { + t.Fatalf("first Bootstrap: %v", err) + } + + conv, _ := e.store.GetOrCreateConversation(ctx, sessionKey) + storedBefore, _ := e.store.GetMessages(ctx, conv.ConversationID, 10, 0) + + // Second: bootstrap with SAME content but DIFFERENT TokenCount + // This should be a no-op (not rebuild) + sameContentMsgs := []Message{ + {Role: "user", Content: "hello world", TokenCount: 999}, // Different token count! + {Role: "assistant", Content: "hi there", TokenCount: 888}, // Different token count! + } + err = e.Bootstrap(ctx, sessionKey, sameContentMsgs) + if err != nil { + t.Fatalf("second Bootstrap: %v", err) + } + + storedAfter, _ := e.store.GetMessages(ctx, conv.ConversationID, 10, 0) + + // Should have same number of messages (no rebuild) + if len(storedAfter) != len(storedBefore) { + t.Errorf("expected %d messages (no rebuild), got %d", len(storedBefore), len(storedAfter)) + } + + // Message IDs should be the same (no delete+re-ingest) + for i := range storedBefore { + if storedBefore[i].ID != storedAfter[i].ID { + t.Errorf("message %d ID changed: before=%d, after=%d (should be no-op)", + i, storedBefore[i].ID, storedAfter[i].ID) + } + } +} + +// --- Session Mutex --- + +func TestEngineSessionMutexSharded(t *testing.T) { + eng := newTestEngine(t) + + // Same session key should always return the same mutex (deterministic hash) + mu1 := eng.getSessionMutex("agent:test") + mu2 := eng.getSessionMutex("agent:test") + if mu1 != mu2 { + t.Error("expected same mutex for same session key") + } + + // Different session keys may share the same shard (hash collision) + // This is expected behavior - we just need bounded memory, not unique locks + mu3 := eng.getSessionMutex("agent:other") + + // Both mutexes should be valid and usable + mu1.Lock() + mu1.Unlock() + mu3.Lock() + mu3.Unlock() +} + +func TestEngineSessionMutexBoundedMemory(t *testing.T) { + // Verify that session mutexes use bounded memory (256 shards) + eng := newTestEngine(t) + + // Get mutexes for many different sessions + seen := make(map[*sync.Mutex]bool) + for i := 0; i < 1000; i++ { + sessionKey := fmt.Sprintf("agent:session-%d", i) + mu := eng.getSessionMutex(sessionKey) + seen[mu] = true + } + + // With 256 shards and 1000 sessions, we should see at most 256 unique mutexes + // (likely fewer due to hash collisions) + if len(seen) > 256 { + t.Errorf("expected at most 256 unique mutexes (shards), got %d", len(seen)) + } +} + +func TestEngineSessionMutexConsistentHash(t *testing.T) { + // Same session key should always hash to the same shard + eng := newTestEngine(t) + + sessionKey := "agent:consistent-hash-test" + mu1 := eng.getSessionMutex(sessionKey) + mu2 := eng.getSessionMutex(sessionKey) + mu3 := eng.getSessionMutex(sessionKey) + + if mu1 != mu2 || mu2 != mu3 { + t.Error("hash function should be deterministic - same key must map to same shard") + } +} + +// --- Summary Role --- + +func TestAssemblerSummaryRoleNotUser(t *testing.T) { + // Summaries should use "system" role, not "user" + eng := newTestEngine(t) + ctx := context.Background() + + // Ingest messages + eng.Ingest(ctx, "agent:summary-role-test", []Message{ + {Role: "user", Content: "hello", TokenCount: 5}, + {Role: "assistant", Content: "world", TokenCount: 5}, + }) + + conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:summary-role-test") + + // Create a summary and add it to context + sum, err := eng.store.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Content: "Test summary content", + TokenCount: 10, + Kind: SummaryKindCondensed, + Depth: 1, + }) + if err != nil { + t.Fatalf("CreateSummary: %v", err) + } + eng.store.AppendContextSummary(ctx, conv.ConversationID, sum.SummaryID) + + // Assemble and check summary message role + result, err := eng.Assemble(ctx, "agent:summary-role-test", AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + // Find the summary message (should have XML content with <summary>) + for _, msg := range result.Messages { + if strings.Contains(msg.Content, "<summary") { + if msg.Role == "user" { + t.Error("summary message should NOT use 'user' role - use 'system' or dedicated role instead") + } + // Expected: role should be "system" or similar + return + } + } +} + +// --- Race Test --- + +// newTestEngineForConcurrency creates a file-based test engine (required for concurrent SQLite access) +func newTestEngineForConcurrency(t *testing.T) *Engine { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "race_test.db") + eng, err := NewEngine(Config{DBPath: dbPath}, nil) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + return eng +} + +func TestEngineConcurrentIngestAndAssemble(t *testing.T) { + // Concurrent Ingest + Assemble on same session should not panic or corrupt data + eng := newTestEngineForConcurrency(t) + defer eng.Close() + ctx := context.Background() + sessionKey := "agent:race-test" + + // Start with some initial data + eng.Ingest(ctx, sessionKey, []Message{ + {Role: "user", Content: "initial", TokenCount: 2}, + }) + + var wg sync.WaitGroup + errCh := make(chan error, 10) + + // Concurrent Ingest + for i := 0; i < 5; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, err := eng.Ingest(ctx, sessionKey, []Message{ + {Role: "user", Content: fmt.Sprintf("ingest-%d", idx), TokenCount: 3}, + }) + if err != nil { + errCh <- err + } + }(i) + } + + // Concurrent Assemble + for i := 0; i < 5; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, err := eng.Assemble(ctx, sessionKey, AssembleInput{Budget: 500}) + if err != nil { + errCh <- err + } + }(i) + } + + wg.Wait() + close(errCh) + + for err := range errCh { + t.Errorf("concurrent operation error: %v", err) + } + + // Verify data is still consistent + conv, _ := eng.store.GetOrCreateConversation(ctx, sessionKey) + msgs, _ := eng.store.GetMessages(ctx, conv.ConversationID, 100, 0) + if len(msgs) < 6 { // 1 initial + 5 ingest + t.Errorf("expected at least 6 messages, got %d", len(msgs)) + } +} + +func TestEngineConcurrentCompactAndAssemble(t *testing.T) { + // Concurrent Compact + Assemble should not panic + eng := newTestEngineForConcurrency(t) + defer eng.Close() + ctx := context.Background() + sessionKey := "agent:compact-race" + + // Ingest enough messages for compaction + for i := 0; i < 10; i++ { + eng.Ingest(ctx, sessionKey, []Message{ + {Role: "user", Content: fmt.Sprintf("msg-%d", i), TokenCount: 50}, + {Role: "assistant", Content: fmt.Sprintf("reply-%d", i), TokenCount: 50}, + }) + } + + var wg sync.WaitGroup + errCh := make(chan error, 10) + + // Concurrent Compact (will use truncation fallback since no LLM) + for i := 0; i < 3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := eng.Compact(ctx, sessionKey, CompactInput{}) + if err != nil { + errCh <- err + } + }() + } + + // Concurrent Assemble + for i := 0; i < 3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := eng.Assemble(ctx, sessionKey, AssembleInput{Budget: 500}) + if err != nil { + errCh <- err + } + }() + } + + wg.Wait() + close(errCh) + + for err := range errCh { + t.Errorf("concurrent compact/assemble error: %v", err) + } +} + +// --- Bootstrap Edge Cases --- + +func TestBootstrapDuplicateContent(t *testing.T) { + // Bootstrap should correctly handle messages with identical content + e := newTestEngine(t) + ctx := context.Background() + sessionKey := "test-bootstrap-duplicate" + + // Messages with identical content + msgs := []Message{ + {Role: "user", Content: "same content", TokenCount: 5}, + {Role: "user", Content: "same content", TokenCount: 5}, + {Role: "user", Content: "same content", TokenCount: 5}, + } + err := e.Bootstrap(ctx, sessionKey, msgs) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + conv, _ := e.store.GetOrCreateConversation(ctx, sessionKey) + stored, _ := e.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if len(stored) != 3 { + t.Errorf("expected 3 messages with duplicate content, got %d", len(stored)) + } +} + +func TestBootstrapOutOfOrderAppend(t *testing.T) { + // When bootstrap receives messages out of expected order, + // it should still correctly match prefix + e := newTestEngine(t) + ctx := context.Background() + sessionKey := "test-bootstrap-oob" + + // First: normal bootstrap + msgs1 := []Message{ + {Role: "user", Content: "msg1", TokenCount: 3}, + {Role: "assistant", Content: "msg2", TokenCount: 3}, + } + e.Bootstrap(ctx, sessionKey, msgs1) + + // Second: bootstrap with same prefix (out of order append at end is fine) + // The key is that the prefix matching works correctly + msgs2 := []Message{ + {Role: "user", Content: "msg1", TokenCount: 3}, + {Role: "assistant", Content: "msg2", TokenCount: 3}, + {Role: "user", Content: "msg3", TokenCount: 3}, + {Role: "assistant", Content: "msg4", TokenCount: 3}, + } + e.Bootstrap(ctx, sessionKey, msgs2) + + conv, _ := e.store.GetOrCreateConversation(ctx, sessionKey) + stored, _ := e.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if len(stored) != 4 { + t.Errorf("expected 4 messages after append, got %d", len(stored)) + } + + // Verify order is preserved + if stored[0].Content != "msg1" || stored[1].Content != "msg2" || + stored[2].Content != "msg3" || stored[3].Content != "msg4" { + t.Errorf("messages out of order: %v", stored) + } +} + +func TestBootstrapWithToolParts(t *testing.T) { + // Bootstrap should correctly store messages with tool parts + e := newTestEngine(t) + ctx := context.Background() + sessionKey := "test-bootstrap-toolparts" + + msgs := []Message{ + { + Role: "user", + Content: "list files", + TokenCount: 5, + }, + { + Role: "assistant", + Content: "", + TokenCount: 10, + Parts: []MessagePart{ + {Type: "tool_use", Name: "bash", Arguments: `{"cmd":"ls"}`, ToolCallID: "tc_1"}, + }, + }, + { + Role: "tool", + Content: "file1.txt\nfile2.txt", + TokenCount: 8, + Parts: []MessagePart{ + {Type: "tool_result", ToolCallID: "tc_1", Text: "file1.txt\nfile2.txt"}, + }, + }, + { + Role: "assistant", + Content: "I see two files", + TokenCount: 8, + }, + } + + err := e.Bootstrap(ctx, sessionKey, msgs) + if err != nil { + t.Fatalf("Bootstrap with tool parts: %v", err) + } + + conv, _ := e.store.GetOrCreateConversation(ctx, sessionKey) + stored, _ := e.store.GetMessages(ctx, conv.ConversationID, 10, 0) + + if len(stored) != 4 { + t.Errorf("expected 4 messages, got %d", len(stored)) + } + + // Verify tool_use part is preserved + foundToolUse := false + for _, msg := range stored { + for _, part := range msg.Parts { + if part.Type == "tool_use" && part.Name == "bash" { + foundToolUse = true + break + } + } + } + if !foundToolUse { + t.Error("expected to find tool_use part in stored messages") + } + + // Verify tool_result part is preserved + foundToolResult := false + for _, msg := range stored { + for _, part := range msg.Parts { + if part.Type == "tool_result" && part.ToolCallID == "tc_1" { + foundToolResult = true + break + } + } + } + if !foundToolResult { + t.Error("expected to find tool_result part in stored messages") + } + + // Verify tool_result content matches + for _, msg := range stored { + if msg.Role == "tool" { + for _, part := range msg.Parts { + if part.Type == "tool_result" && part.ToolCallID == "tc_1" { + if part.Text != "file1.txt\nfile2.txt" { + t.Errorf("tool result text mismatch: got %q", part.Text) + } + } + } + } + } +} + +func TestBootstrapToolPartsDelta(t *testing.T) { + // Delta bootstrap with tool parts should append correctly + e := newTestEngine(t) + ctx := context.Background() + sessionKey := "test-bootstrap-toolparts-delta" + + // First bootstrap: user + assistant (no tools) + msgs1 := []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "hi", TokenCount: 3}, + } + e.Bootstrap(ctx, sessionKey, msgs1) + + // Second bootstrap: add message with tool parts + msgs2 := []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "hi", TokenCount: 3}, + { + Role: "user", + Content: "run command", + TokenCount: 5, + Parts: []MessagePart{ + {Type: "tool_use", Name: "bash", Arguments: `{"cmd":"pwd"}`, ToolCallID: "tc_2"}, + }, + }, + } + e.Bootstrap(ctx, sessionKey, msgs2) + + conv, _ := e.store.GetOrCreateConversation(ctx, sessionKey) + stored, _ := e.store.GetMessages(ctx, conv.ConversationID, 10, 0) + + if len(stored) != 3 { + t.Errorf("expected 3 messages after delta, got %d", len(stored)) + } + + // Verify the third message has tool parts + foundToolUse := false + for _, msg := range stored { + for _, part := range msg.Parts { + if part.Type == "tool_use" && part.ToolCallID == "tc_2" { + foundToolUse = true + break + } + } + } + if !foundToolUse { + t.Error("expected to find tool_use part in delta message") + } +} + +func TestBootstrapToolPartsIdempotent(t *testing.T) { + // Bootstrap with tool parts should be idempotent - second bootstrap should NOT rebuild + e := newTestEngine(t) + ctx := context.Background() + sessionKey := "test-bootstrap-toolparts-idem" + + msgs := []Message{ + { + Role: "user", + Content: "list files", + TokenCount: 5, + }, + { + Role: "assistant", + Content: "", + TokenCount: 10, + Parts: []MessagePart{ + {Type: "tool_use", Name: "bash", Arguments: `{"command":"ls"}`, ToolCallID: "tc_1"}, + }, + }, + { + Role: "user", + Content: "", + TokenCount: 15, + Parts: []MessagePart{ + {Type: "tool_result", ToolCallID: "tc_1", Text: "file1.txt\nfile2.txt"}, + }, + }, + } + + // First bootstrap + e.Bootstrap(ctx, sessionKey, msgs) + + // Get message count after first bootstrap + conv, _ := e.store.GetOrCreateConversation(ctx, sessionKey) + stored1, _ := e.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if len(stored1) != 3 { + t.Fatalf("after first bootstrap: expected 3 messages, got %d", len(stored1)) + } + + // Second bootstrap with same messages - should be idempotent (no rebuild) + e.Bootstrap(ctx, sessionKey, msgs) + + stored2, _ := e.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if len(stored2) != 3 { + t.Errorf("after second bootstrap: expected 3 messages (idempotent), got %d", len(stored2)) + } + + // Verify messages are identical (not rebuilt) + for i := range stored1 { + if stored1[i].ID != stored2[i].ID { + t.Errorf("message %d was rebuilt (ID changed from %d to %d)", i, stored1[i].ID, stored2[i].ID) + } + } +} + +func TestBootstrapAnchorWithDuplicateContent(t *testing.T) { + // Bootstrap should correctly find anchor using longest prefix matching. + // Uses (role, content, token_count) multi-dimensional comparison. + // + // SCENARIO 1: Normal append (no duplicates, no edits) + // - DB: [A, B, C] + // - Messages: [A, B, C, D] + // - Expected: anchor=2, delta=[D] + // + // SCENARIO 2: With duplicate content + // - DB: [A, ok, B, ok, C] + // - Messages: [A, ok, B, ok, C, D] + // - Expected: anchor=4, delta=[D] + // + // SCENARIO 3: History edit detected + // - DB: [A, ok, B, ok, C] + // - Messages: [A, ok, X, ok, C, D] (B changed to X) + // - Expected: Detect mismatch at i=2, clear old data, re-ingest from anchor+1 + + e := newTestEngine(t) + ctx := context.Background() + sessionKey := "test-bootstrap-prefix-match" + + // First: bootstrap with initial messages + initialMsgs := []Message{ + {Role: "user", Content: "A", TokenCount: 2}, + {Role: "assistant", Content: "ok", TokenCount: 1}, + {Role: "user", Content: "B", TokenCount: 2}, + {Role: "assistant", Content: "ok", TokenCount: 1}, + {Role: "user", Content: "C", TokenCount: 2}, + } + err := e.Bootstrap(ctx, sessionKey, initialMsgs) + if err != nil { + t.Fatalf("first Bootstrap: %v", err) + } + + conv, _ := e.store.GetOrCreateConversation(ctx, sessionKey) + items1, _ := e.store.GetContextItems(ctx, conv.ConversationID) + if len(items1) != 5 { + t.Fatalf("after first bootstrap: expected 5 items, got %d", len(items1)) + } + + // SCENARIO 3: History edit detected + // After detecting mismatch, Bootstrap should: + // 1. Clear old context_items + // 2. Delete old messages after anchor + // 3. Re-ingest delta + // BUG: Old implementation only cleared context_items but left duplicate messages + editedMsgs := []Message{ + {Role: "user", Content: "A", TokenCount: 2}, + {Role: "assistant", Content: "ok", TokenCount: 1}, + {Role: "user", Content: "X", TokenCount: 2}, // Changed from "B" to "X" + {Role: "assistant", Content: "ok", TokenCount: 1}, + {Role: "user", Content: "C", TokenCount: 2}, + {Role: "assistant", Content: "D", TokenCount: 2}, // New + } + err = e.Bootstrap(ctx, sessionKey, editedMsgs) + if err != nil { + t.Fatalf("second Bootstrap (edit): %v", err) + } + + // Verify: should have exactly 6 messages in DB, not 11 (5 old + 6 new - duplicates) + stored, _ := e.store.GetMessages(ctx, conv.ConversationID, 20, 0) + if len(stored) != 6 { + t.Errorf("BUG: expected 6 messages after history edit, got %d (possible duplicates)", len(stored)) + } + + // Verify context_items also has 6 items + items2, _ := e.store.GetContextItems(ctx, conv.ConversationID) + if len(items2) != 6 { + t.Errorf("expected 6 context items, got %d", len(items2)) + } +} + +func TestBootstrapAnchorWithDuplicateContent_Simple(t *testing.T) { + // Simpler test for the duplicate message bug fix + e := newTestEngine(t) + ctx := context.Background() + sessionKey := "test-bootstrap-prefix-match" + + // First: bootstrap with initial messages + initialMsgs := []Message{ + {Role: "user", Content: "A", TokenCount: 2}, + {Role: "assistant", Content: "ok", TokenCount: 1}, + {Role: "user", Content: "B", TokenCount: 2}, + {Role: "assistant", Content: "ok", TokenCount: 1}, + {Role: "user", Content: "C", TokenCount: 2}, + } + err := e.Bootstrap(ctx, sessionKey, initialMsgs) + if err != nil { + t.Fatalf("first Bootstrap: %v", err) + } + + conv, _ := e.store.GetOrCreateConversation(ctx, sessionKey) + items1, _ := e.store.GetContextItems(ctx, conv.ConversationID) + if len(items1) != 5 { + t.Fatalf("after first bootstrap: expected 5 items, got %d", len(items1)) + } + + // SCENARIO 2: Normal append with duplicate content + // The algorithm should find anchor at position 4 (last matching position) + // using longest prefix matching, not single-point matching + updatedMsgs := []Message{ + {Role: "user", Content: "A", TokenCount: 2}, + {Role: "assistant", Content: "ok", TokenCount: 1}, + {Role: "user", Content: "B", TokenCount: 2}, + {Role: "assistant", Content: "ok", TokenCount: 1}, + {Role: "user", Content: "C", TokenCount: 2}, + {Role: "assistant", Content: "D", TokenCount: 2}, // New + } + + err = e.Bootstrap(ctx, sessionKey, updatedMsgs) + if err != nil { + t.Fatalf("second Bootstrap: %v", err) + } + + items2, _ := e.store.GetContextItems(ctx, conv.ConversationID) + // Should have 6 context items (5 existing + 1 new) + if len(items2) != 6 { + t.Errorf("after normal append: expected 6 items, got %d", len(items2)) + } + + // Verify the last message is D + stored, _ := e.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if len(stored) < 1 { + t.Fatal("expected at least 1 stored message") + } + lastMsg := stored[len(stored)-1] + if lastMsg.Content != "D" { + t.Errorf("last message content = %q, want 'D'", lastMsg.Content) + } +} + +// --- Assembler lazy init race detection --- + +func TestAssemblerLazyInitRace(t *testing.T) { + // This test verifies that Assemble() lazy initialization of e.assembler + // is thread-safe. The original code has a data race: + // if e.assembler == nil { + // e.assembler = &Assembler{...} + // } + + // Run multiple iterations to increase chance of catching race + for i := 0; i < 30; i++ { + // Create fresh engine with nil assembler + e := newTestEngine(t) + + ctx := context.Background() + sessionKey := fmt.Sprintf("race-test-%d", i) + + // Add message first (avoid SQLite concurrency issues) + _, err := e.Ingest(ctx, sessionKey, []Message{ + {Role: "user", Content: "hello", TokenCount: 5}, + }) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + + // Use a barrier to ensure all goroutines start at the same time + start := make(chan struct{}) + var wg sync.WaitGroup + + for j := 0; j < 20; j++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start // Wait for all goroutines to be ready + e.Assemble(ctx, sessionKey, AssembleInput{Budget: 1000}) + }() + } + + // Start all goroutines simultaneously + close(start) + wg.Wait() + } +} + +// --- selectShallowestCondensationCandidate with non-consecutive depths --- + +func TestSelectShallowestCondensationWithNonConsecutiveDepths(t *testing.T) { + e := newTestEngineForConcurrency(t) + defer e.Close() + ctx := context.Background() + sessionKey := "test-non-consecutive-depths" + + // Create conversation + conv, err := e.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + // Create summaries with non-consecutive depths: 0 and 1 have < 5, 2 is missing, 3 has >= 5 + // This tests the bug: when depth=2 is missing, the loop breaks and depth=3 is never checked + // Need > FreshTailCount(32) summaries so they are not all in fresh tail + // Depth 0: 3 summaries (not enough), Depth 1: 3 summaries (not enough) + // Depth 2: 0 summaries (missing), Depth 3: 40 summaries (enough) + depths := []int{0, 0, 0, 1, 1, 1} + for i := 0; i < 40; i++ { + depths = append(depths, 3) + } + now := time.Now().UTC() + + for i, depth := range depths { + sum, createErr := e.store.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: depth, + Content: fmt.Sprintf("summary depth %d #%d", depth, i), + TokenCount: 10, + EarliestAt: &now, + LatestAt: &now, + }) + if createErr != nil { + t.Fatalf("CreateSummary: %v", createErr) + } + // Add to context items (not in fresh tail) + if appendErr := e.store.AppendContextSummary(ctx, conv.ConversationID, sum.SummaryID); appendErr != nil { + t.Fatalf("AppendContextSummary: %v", appendErr) + } + } + + // Initialize compaction engine (lazy init) + e.initCompactionOnce() + + // Call selectShallowestCondensationCandidate + candidates, err := e.compaction.selectShallowestCondensationCandidate(ctx, conv.ConversationID, false) + if err != nil { + t.Fatalf("selectShallowestCondensationCandidate: %v", err) + } + + // Should find depth=0 (shallowest) with 5 summaries + if candidates == nil { + t.Fatal("expected candidates, got nil") + } + if len(candidates) < CondensedMinFanout { + t.Errorf("expected at least %d candidates, got %d", CondensedMinFanout, len(candidates)) + } + + // Verify all returned summaries have the same depth + if len(candidates) > 0 { + expectedDepth := candidates[0].Depth + for _, c := range candidates[1:] { + if c.Depth != expectedDepth { + t.Errorf("candidates have mixed depths: %d vs %d", expectedDepth, c.Depth) + } + } + } +} diff --git a/pkg/seahorse/short_retrieval.go b/pkg/seahorse/short_retrieval.go new file mode 100644 index 000000000..3e94eec14 --- /dev/null +++ b/pkg/seahorse/short_retrieval.go @@ -0,0 +1,212 @@ +package seahorse + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +// ParseLastDuration parses a "last" duration string like "6h", "7d", "2w", "1m". +// Returns the duration and nil error, or zero and error if invalid. +func ParseLastDuration(s string) (time.Duration, error) { + if s == "" { + return 0, fmt.Errorf("empty duration") + } + + re := regexp.MustCompile(`^(\d+)([hdwm])$`) + matches := re.FindStringSubmatch(s) + if matches == nil { + return 0, fmt.Errorf("invalid duration format: %q (use format like 6h, 7d, 2w, 1m)", s) + } + + value, _ := strconv.Atoi(matches[1]) + unit := matches[2] + + switch unit { + case "h": + return time.Duration(value) * time.Hour, nil + case "d": + return time.Duration(value) * 24 * time.Hour, nil + case "w": + return time.Duration(value) * 7 * 24 * time.Hour, nil + case "m": + return time.Duration(value) * 30 * 24 * time.Hour, nil + default: + return 0, fmt.Errorf("unknown unit: %q", unit) + } +} + +// GrepInput controls search across summaries and messages. +type GrepInput struct { + Pattern string `json:"pattern"` + Scope string `json:"scope,omitempty"` // "both" (default), "summary", or "message" + Role string `json:"role,omitempty"` // "user", "assistant", or "" (all) + AllConversations bool `json:"allConversations,omitempty"` + Since *time.Time `json:"since,omitempty"` + Before *time.Time `json:"before,omitempty"` + Last string `json:"last,omitempty"` // shortcut: "6h", "7d", "2w", "1m" + Limit int `json:"limit,omitempty"` +} + +// GrepResult contains search results. +type GrepResult struct { + Success bool `json:"success"` + Summaries []GrepSummaryResult `json:"summaries"` + Messages []GrepMessageResult `json:"messages"` + TotalSummaries int `json:"totalSummaries"` + TotalMessages int `json:"totalMessages"` + Hint string `json:"hint,omitempty"` +} + +// GrepSummaryResult is a summary match from grep. +type GrepSummaryResult struct { + ID string `json:"id"` + Content string `json:"content"` + Depth int `json:"depth"` + Kind SummaryKind `json:"kind"` + ConversationID int64 `json:"conversationId"` + // Rank is the bm25 relevance score (negative value, lower = better match). + // Examples: -5.0 = excellent match, -2.0 = good match, -0.5 = partial match. + Rank float64 `json:"rank,omitempty"` +} + +// GrepMessageResult is a message match from grep. +type GrepMessageResult struct { + ID int64 `json:"id,string"` + Snippet string `json:"snippet"` + Role string `json:"role"` + ConversationID int64 `json:"conversationId"` + Rank float64 `json:"rank,omitempty"` // Relevance score (more negative = better match) +} + +// ExpandMessagesResult contains expanded messages. +type ExpandMessagesResult struct { + Messages []Message `json:"messages"` + TokenCount int `json:"tokenCount"` +} + +// Grep searches summaries and messages for matching content. +func (r *RetrievalEngine) Grep(ctx context.Context, input GrepInput) (*GrepResult, error) { + if input.Pattern == "" { + return nil, fmt.Errorf("grep: pattern is required") + } + + limit := input.Limit + if limit == 0 { + limit = 20 + } + + // Handle Last parameter: convert to Since + since := input.Since + if input.Last != "" { + dur, err := ParseLastDuration(input.Last) + if err != nil { + return nil, fmt.Errorf("grep: invalid last: %w", err) + } + t := time.Now().UTC().Add(-dur) + since = &t + } + + // Auto-detect mode: use LIKE if pattern contains %, otherwise full-text + mode := "" + if strings.Contains(input.Pattern, "%") { + mode = "like" + } + + searchInput := SearchInput{ + Pattern: input.Pattern, + Mode: mode, + Role: input.Role, + AllConversations: input.AllConversations, + Since: since, + Before: input.Before, + Limit: limit, + } + + result := &GrepResult{ + Success: true, + Summaries: make([]GrepSummaryResult, 0), + Messages: make([]GrepMessageResult, 0), + TotalSummaries: 0, + TotalMessages: 0, + } + + // Determine scope + scope := input.Scope + if scope == "" { + scope = "both" + } + + // Search summaries if requested + if scope == "both" || scope == "summary" { + sumResults, err := r.store.SearchSummaries(ctx, searchInput) + if err != nil { + return nil, fmt.Errorf("search summaries: %w", err) + } + for _, sr := range sumResults { + if sr.SummaryID != "" { + result.Summaries = append(result.Summaries, GrepSummaryResult{ + ID: sr.SummaryID, + Content: sr.Content, + Depth: sr.Depth, + Kind: sr.Kind, + ConversationID: sr.ConversationID, + Rank: sr.Rank, + }) + } + } + if len(sumResults) > 0 { + result.TotalSummaries = sumResults[0].TotalCount + } + } + + // Search messages if requested + if scope == "both" || scope == "message" { + msgResults, err := r.store.SearchMessages(ctx, searchInput) + if err != nil { + return nil, fmt.Errorf("search messages: %w", err) + } + for _, sr := range msgResults { + if sr.MessageID > 0 { + result.Messages = append(result.Messages, GrepMessageResult{ + ID: sr.MessageID, + Snippet: sr.Snippet, + Role: sr.Role, + ConversationID: sr.ConversationID, + Rank: sr.Rank, + }) + } + } + if len(msgResults) > 0 { + result.TotalMessages = msgResults[0].TotalCount + } + } + + // Add hint if no results + if len(result.Summaries) == 0 && len(result.Messages) == 0 { + result.Hint = "No matches. Try: %keyword% for fuzzy search, or all_conversations: true" + } + + return result, nil +} + +// ExpandMessages retrieves full message content by IDs. +func (r *RetrievalEngine) ExpandMessages(ctx context.Context, messageIDs []int64) (*ExpandMessagesResult, error) { + result := &ExpandMessagesResult{ + Messages: make([]Message, 0, len(messageIDs)), + } + + for _, msgID := range messageIDs { + msg, err := r.store.GetMessageByID(ctx, msgID) + if err != nil { + continue + } + result.Messages = append(result.Messages, *msg) + result.TokenCount += msg.TokenCount + } + + return result, nil +} diff --git a/pkg/seahorse/short_retrieval_test.go b/pkg/seahorse/short_retrieval_test.go new file mode 100644 index 000000000..9d9bc3640 --- /dev/null +++ b/pkg/seahorse/short_retrieval_test.go @@ -0,0 +1,362 @@ +package seahorse + +import ( + "context" + "fmt" + "testing" + "time" +) + +// --- Retrieval Tests --- + +func newTestRetrieval(t *testing.T) (*RetrievalEngine, *Store, int64) { + t.Helper() + s := openTestStore(t) + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "test:retrieval") + return &RetrievalEngine{store: s}, s, conv.ConversationID +} + +func TestRetrievalGrepSummaries(t *testing.T) { + r, s, convID := newTestRetrieval(t) + ctx := context.Background() + + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "数据库连接配置说明", + TokenCount: 50, + }) + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "API endpoint documentation", + TokenCount: 50, + }) + + // FTS5 search (trigram, needs >= 3 chars) + results, err := r.Grep(ctx, GrepInput{ + Pattern: "数据库连", + }) + if err != nil { + t.Fatalf("Grep: %v", err) + } + if len(results.Summaries) == 0 { + t.Error("expected at least 1 FTS result") + } + + // LIKE search with wildcard + results, err = r.Grep(ctx, GrepInput{ + Pattern: "%endpoint%", + }) + if err != nil { + t.Fatalf("Grep LIKE: %v", err) + } + if len(results.Summaries) == 0 { + t.Error("expected at least 1 LIKE result") + } +} + +func TestRetrievalGrepMessages(t *testing.T) { + r, s, convID := newTestRetrieval(t) + ctx := context.Background() + + s.AddMessage(ctx, convID, "user", "find this message about testing", 5) + s.AddMessage(ctx, convID, "user", "unrelated content here", 5) + + results, err := r.Grep(ctx, GrepInput{ + Pattern: "testing", + }) + if err != nil { + t.Fatalf("Grep: %v", err) + } + if len(results.Messages) == 0 { + t.Error("expected at least 1 result for 'testing'") + } +} + +func TestRetrievalExpandMessages(t *testing.T) { + r, s, convID := newTestRetrieval(t) + ctx := context.Background() + + msg, _ := s.AddMessage(ctx, convID, "user", "expand this message", 10) + + result, err := r.ExpandMessages(ctx, []int64{msg.ID}) + if err != nil { + t.Fatalf("ExpandMessages: %v", err) + } + if len(result.Messages) != 1 { + t.Errorf("Messages = %d, want 1", len(result.Messages)) + } + if result.Messages[0].Content != "expand this message" { + t.Errorf("Content = %q, want 'expand this message'", result.Messages[0].Content) + } +} + +func TestRetrievalExpandMultipleMessages(t *testing.T) { + r, s, convID := newTestRetrieval(t) + ctx := context.Background() + + msg1, _ := s.AddMessage(ctx, convID, "user", "first message", 10) + msg2, _ := s.AddMessage(ctx, convID, "assistant", "second message", 10) + msg3, _ := s.AddMessage(ctx, convID, "user", "third message", 10) + + result, err := r.ExpandMessages(ctx, []int64{msg1.ID, msg2.ID, msg3.ID}) + if err != nil { + t.Fatalf("ExpandMessages: %v", err) + } + if len(result.Messages) != 3 { + t.Errorf("Messages = %d, want 3", len(result.Messages)) + } + if result.TokenCount != 30 { + t.Errorf("TokenCount = %d, want 30", result.TokenCount) + } +} + +func TestRetrievalGrepWithTimeFilter(t *testing.T) { + r, s, convID := newTestRetrieval(t) + ctx := context.Background() + + now := time.Now().UTC() + before := now.Add(-2 * time.Hour) + + // Create messages at different times + s.AddMessage(ctx, convID, "user", "old message about auth", 5) + s.AddMessage(ctx, convID, "user", "recent message about auth", 5) + + // Search with time filter + results, err := r.Grep(ctx, GrepInput{ + Pattern: "auth", + Since: &before, + }) + if err != nil { + t.Fatalf("Grep: %v", err) + } + _ = results // Just verify no error +} + +func TestRetrievalGrepAllConversations(t *testing.T) { + r, s, _ := newTestRetrieval(t) + ctx := context.Background() + + // Create another conversation + conv2, _ := s.GetOrCreateConversation(ctx, "test:retrieval2") + + // Add messages to both + s.AddMessage(ctx, conv2.ConversationID, "user", "unique keyword xyz", 5) + + // Search all conversations + results, err := r.Grep(ctx, GrepInput{ + Pattern: "xyz", + AllConversations: true, + }) + if err != nil { + t.Fatalf("Grep: %v", err) + } + if len(results.Messages) == 0 { + t.Error("expected to find message in other conversation") + } +} + +// --- Last Duration Parsing Tests --- + +func TestParseLastDuration(t *testing.T) { + tests := []struct { + input string + wantDur time.Duration + wantErr bool + }{ + {"6h", 6 * time.Hour, false}, + {"1d", 24 * time.Hour, false}, + {"7d", 7 * 24 * time.Hour, false}, + {"2w", 14 * 24 * time.Hour, false}, + {"1m", 30 * 24 * time.Hour, false}, // month = 30 days + {"3m", 90 * 24 * time.Hour, false}, + {"", 0, true}, + {"invalid", 0, true}, + {"5x", 0, true}, // unknown unit + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := ParseLastDuration(tt.input) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.wantDur { + t.Errorf("ParseLastDuration(%q) = %v, want %v", tt.input, got, tt.wantDur) + } + } + }) + } +} + +// --- Role Filter Tests --- + +func TestRetrievalGrepRoleFilter(t *testing.T) { + r, s, convID := newTestRetrieval(t) + ctx := context.Background() + + s.AddMessage(ctx, convID, "user", "user message about alpha", 5) + s.AddMessage(ctx, convID, "assistant", "assistant reply about alpha", 5) + s.AddMessage(ctx, convID, "user", "another user message", 5) + + // Search all roles + allResults, err := r.Grep(ctx, GrepInput{ + Pattern: "alpha", + }) + if err != nil { + t.Fatalf("Grep: %v", err) + } + if len(allResults.Messages) != 2 { + t.Errorf("expected 2 messages, got %d", len(allResults.Messages)) + } + + // Search user only + userResults, err := r.Grep(ctx, GrepInput{ + Pattern: "alpha", + Role: "user", + }) + if err != nil { + t.Fatalf("Grep: %v", err) + } + if len(userResults.Messages) != 1 { + t.Errorf("expected 1 user message, got %d", len(userResults.Messages)) + } + if userResults.Messages[0].Role != "user" { + t.Errorf("expected role=user, got %s", userResults.Messages[0].Role) + } + + // Search assistant only + assistantResults, err := r.Grep(ctx, GrepInput{ + Pattern: "alpha", + Role: "assistant", + }) + if err != nil { + t.Fatalf("Grep: %v", err) + } + if len(assistantResults.Messages) != 1 { + t.Errorf("expected 1 assistant message, got %d", len(assistantResults.Messages)) + } +} + +// --- Last Parameter Tests --- + +func TestRetrievalGrepWithLast(t *testing.T) { + r, s, convID := newTestRetrieval(t) + ctx := context.Background() + + // Add messages (we can't control timestamps in SQLite easily, + // but we can verify the parameter is parsed correctly) + s.AddMessage(ctx, convID, "user", "recent message about testing", 5) + + // Test that Last parameter is converted to Since + results, err := r.Grep(ctx, GrepInput{ + Pattern: "testing", + Last: "1d", // last 1 day + }) + if err != nil { + t.Fatalf("Grep: %v", err) + } + // Should still find the message since it's recent + if len(results.Messages) == 0 { + t.Error("expected to find recent message") + } +} + +// TestRetrievalGrepRoleFilterWithSummaries tests that role filter works when +// searching both summaries and messages (summaries don't have role column). +func TestRetrievalGrepRoleFilterWithSummaries(t *testing.T) { + r, s, convID := newTestRetrieval(t) + ctx := context.Background() + + // Create a summary (no role column) + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "summary about testing", + TokenCount: 50, + }) + + // Add messages with different roles + s.AddMessage(ctx, convID, "user", "user message about testing", 5) + s.AddMessage(ctx, convID, "assistant", "assistant reply about testing", 5) + + // Search with role filter and scope=both (default), using LIKE mode (%) + // This should NOT error even though summaries don't have role column + bothResults, err := r.Grep(ctx, GrepInput{ + Pattern: "%testing%", // LIKE mode to trigger the bug + Role: "user", + Scope: "both", + }) + if err != nil { + t.Fatalf("Grep with role and scope=both: %v", err) + } + + // Should only return user messages, not summaries or assistant messages + if len(bothResults.Messages) != 1 { + t.Errorf("expected 1 user message, got %d", len(bothResults.Messages)) + } + if len(bothResults.Messages) > 0 && bothResults.Messages[0].Role != "user" { + t.Errorf("expected role=user, got %s", bothResults.Messages[0].Role) + } + + // Summaries should be empty since they don't have roles to filter + // (or we could return all summaries - either is acceptable) +} + +// TestRetrievalGrepTotalCounts tests that grep returns total counts. +func TestRetrievalGrepTotalCounts(t *testing.T) { + r, s, convID := newTestRetrieval(t) + ctx := context.Background() + + // Create 3 summaries + for i := 0; i < 3; i++ { + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: convID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: fmt.Sprintf("summary about testing %d", i), + TokenCount: 50, + }) + } + + // Add 5 messages + for i := 0; i < 5; i++ { + s.AddMessage(ctx, convID, "user", fmt.Sprintf("message about testing %d", i), 5) + } + + // Search with limit smaller than total + results, err := r.Grep(ctx, GrepInput{ + Pattern: "%testing%", // LIKE mode + Scope: "both", + Limit: 2, + }) + if err != nil { + t.Fatalf("Grep: %v", err) + } + + // Should return limited results + if len(results.Summaries) > 2 { + t.Errorf("expected at most 2 summaries, got %d", len(results.Summaries)) + } + if len(results.Messages) > 2 { + t.Errorf("expected at most 2 messages, got %d", len(results.Messages)) + } + + // But total counts should reflect all matches + if results.TotalSummaries != 3 { + t.Errorf("expected TotalSummaries=3, got %d", results.TotalSummaries) + } + if results.TotalMessages != 5 { + t.Errorf("expected TotalMessages=5, got %d", results.TotalMessages) + } +} diff --git a/pkg/seahorse/store.go b/pkg/seahorse/store.go new file mode 100644 index 000000000..0edbbd128 --- /dev/null +++ b/pkg/seahorse/store.go @@ -0,0 +1,1642 @@ +package seahorse + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +// Store provides SQLite storage for seahorse. +type Store struct { + db *sql.DB +} + +// CreateSummaryInput holds parameters for creating a summary. +type CreateSummaryInput struct { + ConversationID int64 + Kind SummaryKind + Depth int + Content string + TokenCount int + EarliestAt *time.Time + LatestAt *time.Time + DescendantCount int + DescendantTokenCount int + SourceMessageTokens int + Model string + ParentIDs []string // For condensed: child summary IDs being condensed +} + +// --- Conversation Operations --- + +// GetOrCreateConversation returns the conversation for a sessionKey, creating if needed. +func (s *Store) GetOrCreateConversation(ctx context.Context, sessionKey string) (*Conversation, error) { + // Try to get first + conv, err := s.GetConversationBySessionKey(ctx, sessionKey) + if err != nil { + return nil, err + } + if conv != nil { + return conv, nil + } + + // Create + result, err := s.db.ExecContext(ctx, + "INSERT INTO conversations (session_key) VALUES (?)", + sessionKey, + ) + if err != nil { + // Race: another goroutine may have inserted + if isUniqueViolation(err) { + return s.GetConversationBySessionKey(ctx, sessionKey) + } + return nil, fmt.Errorf("create conversation: %w", err) + } + id, _ := result.LastInsertId() + return &Conversation{ + ConversationID: id, + SessionKey: sessionKey, + }, nil +} + +// GetConversationBySessionKey retrieves a conversation by session key. +func (s *Store) GetConversationBySessionKey(ctx context.Context, sessionKey string) (*Conversation, error) { + var conv Conversation + var createdAt, updatedAt string + err := s.db.QueryRowContext(ctx, + "SELECT conversation_id, session_key, created_at, updated_at FROM conversations WHERE session_key = ?", + sessionKey, + ).Scan(&conv.ConversationID, &conv.SessionKey, &createdAt, &updatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get conversation by session key: %w", err) + } + conv.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + conv.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + return &conv, nil +} + +// GetSessionStatus returns status for a specific session. +func (s *Store) GetSessionStatus(ctx context.Context, sessionKey string) (*SessionStatus, error) { + conv, err := s.GetConversationBySessionKey(ctx, sessionKey) + if err != nil { + return nil, err + } + if conv == nil { + return nil, nil + } + + msgCount, _ := s.GetMessageCount(ctx, conv.ConversationID) + sumCount, _ := s.getSummaryCount(ctx, conv.ConversationID) + tokenCount, _ := s.GetContextTokenCount(ctx, conv.ConversationID) + + oldest, newest, _ := s.getMessageTimeRange(ctx, conv.ConversationID) + + return &SessionStatus{ + SessionKey: conv.SessionKey, + ConversationID: conv.ConversationID, + Messages: msgCount, + TotalTokens: tokenCount, + Summaries: sumCount, + OldestAt: oldest, + NewestAt: newest, + }, nil +} + +// GetAllSessionStatuses returns status for all sessions. +func (s *Store) GetAllSessionStatuses(ctx context.Context) ([]SessionStatus, error) { + rows, err := s.db.QueryContext(ctx, "SELECT session_key FROM conversations") + if err != nil { + return nil, fmt.Errorf("list sessions: %w", err) + } + defer rows.Close() + + var statuses []SessionStatus + for rows.Next() { + var sessionKey string + if err := rows.Scan(&sessionKey); err != nil { + continue + } + status, err := s.GetSessionStatus(ctx, sessionKey) + if err != nil { + continue + } + if status != nil { + statuses = append(statuses, *status) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate sessions: %w", err) + } + return statuses, nil +} + +func (s *Store) getSummaryCount(ctx context.Context, convID int64) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM summaries WHERE conversation_id = ?", + convID, + ).Scan(&count) + return count, err +} + +func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Time, time.Time, error) { + var minTime, maxTime string + err := s.db.QueryRowContext(ctx, + "SELECT MIN(created_at), MAX(created_at) FROM messages WHERE conversation_id = ?", + convID, + ).Scan(&minTime, &maxTime) + if err != nil || minTime == "" { + return time.Time{}, time.Time{}, err + } + oldest, _ := time.Parse("2006-01-02 15:04:05", minTime) + newest, _ := time.Parse("2006-01-02 15:04:05", maxTime) + return oldest, newest, nil +} + +// --- Message Operations --- + +// AddMessage appends a message to a conversation. +func (s *Store) AddMessage(ctx context.Context, convID int64, role, content string, tokenCount int) (*Message, error) { + return s.AddMessageWithReasoning(ctx, convID, role, content, "", tokenCount) +} + +// AddMessageWithReasoning appends a message with reasoning content to a conversation. +func (s *Store) AddMessageWithReasoning( + ctx context.Context, + convID int64, + role, content, reasoningContent string, + tokenCount int, +) (*Message, error) { + result, err := s.db.ExecContext(ctx, + "INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)", + convID, role, content, reasoningContent, tokenCount, + ) + if err != nil { + return nil, fmt.Errorf("add message: %w", err) + } + id, _ := result.LastInsertId() + return &Message{ + ID: id, + ConversationID: convID, + Role: role, + Content: content, + ReasoningContent: reasoningContent, + TokenCount: tokenCount, + }, nil +} + +// partsToReadableContent derives a readable text summary from message parts. +// This ensures FTS5 indexing and summary formatting can access tool call information. +func partsToReadableContent(parts []MessagePart) string { + var b strings.Builder + for i, p := range parts { + if i > 0 { + b.WriteString("\n") + } + switch p.Type { + case "text": + b.WriteString(p.Text) + case "tool_use": + fmt.Fprintf(&b, "[tool_use: %s, args: %s]", p.Name, p.Arguments) + case "tool_result": + fmt.Fprintf(&b, "[tool_result for %s: %s]", p.ToolCallID, p.Text) + case "media": + fmt.Fprintf(&b, "[media: %s (%s)]", p.MediaURI, p.MimeType) + default: + if p.Text != "" { + b.WriteString(p.Text) + } + } + } + return b.String() +} + +// AddMessageWithParts adds a message with structured parts. +func (s *Store) AddMessageWithParts( + ctx context.Context, + convID int64, + role string, + parts []MessagePart, + tokenCount int, +) (*Message, error) { + return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", tokenCount) +} + +// AddMessageWithPartsAndReasoning adds a message with structured parts and reasoning content. +func (s *Store) AddMessageWithPartsAndReasoning( + ctx context.Context, + convID int64, + role string, + parts []MessagePart, + reasoningContent string, + tokenCount int, +) (*Message, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + // Derive readable content from Parts for FTS5 indexing and summary formatting + readableContent := partsToReadableContent(parts) + + result, err := tx.ExecContext(ctx, + "INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)", + convID, role, readableContent, reasoningContent, tokenCount, + ) + if err != nil { + return nil, fmt.Errorf("add message: %w", err) + } + msgID, _ := result.LastInsertId() + + for i, p := range parts { + _, err = tx.ExecContext( + ctx, + `INSERT INTO message_parts (message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type, ordinal) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + msgID, + p.Type, + p.Text, + p.Name, + p.Arguments, + p.ToolCallID, + p.MediaURI, + p.MimeType, + i, + ) + if err != nil { + return nil, fmt.Errorf("add message part %d: %w", i, err) + } + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + + // Return message with parts + msg := &Message{ + ID: msgID, + ConversationID: convID, + Role: role, + ReasoningContent: reasoningContent, + TokenCount: tokenCount, + Parts: make([]MessagePart, len(parts)), + } + for i, p := range parts { + p.MessageID = msgID + msg.Parts[i] = p + } + return msg, nil +} + +// GetMessages retrieves messages for a conversation. +func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, beforeID int64) ([]Message, error) { + query := "SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE conversation_id = ?" + args := []any{convID} + if beforeID > 0 { + query += " AND message_id < ?" + args = append(args, beforeID) + } + query += " ORDER BY message_id ASC" + if limit > 0 { + query += " LIMIT ?" + args = append(args, limit) + } + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("get messages: %w", err) + } + defer rows.Close() + + var msgs []Message + for rows.Next() { + var msg Message + var createdAt string + if err := rows.Scan( + &msg.ID, + &msg.ConversationID, + &msg.Role, + &msg.Content, + &msg.ReasoningContent, + &msg.TokenCount, + &createdAt, + ); err != nil { + return nil, err + } + msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + msgs = append(msgs, msg) + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Load parts for all messages + for i := range msgs { + parts, err := s.loadMessageParts(ctx, msgs[i].ID) + if err != nil { + return nil, err + } + msgs[i].Parts = parts + } + + return msgs, nil +} + +// GetMessageCount returns total message count for a conversation. +func (s *Store) GetMessageCount(ctx context.Context, convID int64) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, + "SELECT count(*) FROM messages WHERE conversation_id = ?", convID, + ).Scan(&count) + return count, err +} + +// GetMessageByID retrieves a single message by ID. +func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message, error) { + var msg Message + var createdAt string + err := s.db.QueryRowContext( + ctx, + "SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE message_id = ?", + messageID, + ).Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &msg.ReasoningContent, &msg.TokenCount, &createdAt) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("message %d not found", messageID) + } + if err != nil { + return nil, err + } + msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + msg.Parts, _ = s.loadMessageParts(ctx, msg.ID) + return &msg, nil +} + +// UpdateMessageReasoningContent updates reasoning_content for an existing message. +func (s *Store) UpdateMessageReasoningContent(ctx context.Context, messageID int64, reasoningContent string) error { + result, err := s.db.ExecContext( + ctx, + "UPDATE messages SET reasoning_content = ? WHERE message_id = ?", + reasoningContent, + messageID, + ) + if err != nil { + return fmt.Errorf("update message reasoning_content: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update message reasoning_content rows affected: %w", err) + } + if rowsAffected == 0 { + return fmt.Errorf("message %d not found", messageID) + } + return nil +} + +func (s *Store) loadMessageParts(ctx context.Context, msgID int64) ([]MessagePart, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type + FROM message_parts WHERE message_id = ? ORDER BY ordinal`, + msgID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var parts []MessagePart + for rows.Next() { + var p MessagePart + if err := rows.Scan(&p.ID, &p.MessageID, &p.Type, &p.Text, &p.Name, &p.Arguments, + &p.ToolCallID, &p.MediaURI, &p.MimeType); err != nil { + return nil, err + } + parts = append(parts, p) + } + if err := rows.Err(); err != nil { + return nil, err + } + return parts, nil +} + +// --- Summary Operations --- + +// CreateSummary creates a new summary and indexes it in FTS5. +func (s *Store) CreateSummary(ctx context.Context, input CreateSummaryInput) (*Summary, error) { + // Generate summary ID + now := time.Now().UTC() + summaryID := generateSummaryID(input.Content, now) + + var earliestAt, latestAt sql.NullString + if input.EarliestAt != nil { + earliestAt = sql.NullString{String: input.EarliestAt.Format(time.RFC3339), Valid: true} + } + if input.LatestAt != nil { + latestAt = sql.NullString{String: input.LatestAt.Format(time.RFC3339), Valid: true} + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + _, err = tx.ExecContext(ctx, + `INSERT INTO summaries (summary_id, conversation_id, kind, depth, content, token_count, + earliest_at, latest_at, descendant_count, descendant_token_count, + source_message_token_count, model) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + summaryID, input.ConversationID, string(input.Kind), input.Depth, + input.Content, input.TokenCount, + earliestAt, latestAt, + input.DescendantCount, input.DescendantTokenCount, + input.SourceMessageTokens, input.Model, + ) + if err != nil { + return nil, fmt.Errorf("insert summary: %w", err) + } + + // FTS trigger will fire automatically for summaries table insert + + // Link parent summaries (DAG edges) for condensed summaries + for _, parentID := range input.ParentIDs { + _, err = tx.ExecContext(ctx, + "INSERT INTO summary_parents (summary_id, parent_summary_id) VALUES (?, ?)", + summaryID, parentID, + ) + if err != nil { + return nil, fmt.Errorf("link parent %s: %w", parentID, err) + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + + return &Summary{ + SummaryID: summaryID, + ConversationID: input.ConversationID, + Kind: input.Kind, + Depth: input.Depth, + Content: input.Content, + TokenCount: input.TokenCount, + EarliestAt: input.EarliestAt, + LatestAt: input.LatestAt, + DescendantCount: input.DescendantCount, + DescendantTokenCount: input.DescendantTokenCount, + SourceMessageTokenCount: input.SourceMessageTokens, + Model: input.Model, + CreatedAt: now, + }, nil +} + +// GetSummary retrieves a summary by ID. +func (s *Store) GetSummary(ctx context.Context, summaryID string) (*Summary, error) { + return s.scanSummary(ctx, "WHERE summary_id = ?", summaryID) +} + +// GetSummariesByConversation retrieves all summaries for a conversation. +func (s *Store) GetSummariesByConversation(ctx context.Context, convID int64) ([]Summary, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT summary_id, conversation_id, kind, depth, content, token_count, + earliest_at, latest_at, descendant_count, descendant_token_count, + source_message_token_count, model, created_at + FROM summaries WHERE conversation_id = ? ORDER BY created_at`, + convID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return s.scanSummaries(rows) +} + +// GetSummaryChildren retrieves child summary IDs (summaries that list this summary as parent). +func (s *Store) GetSummaryChildren(ctx context.Context, summaryID string) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + "SELECT summary_id FROM summary_parents WHERE parent_summary_id = ?", + summaryID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return ids, nil +} + +// GetSummaryParents retrieves parent summaries (full objects) for a summary. +func (s *Store) GetSummaryParents(ctx context.Context, summaryID string) ([]Summary, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT s.summary_id, s.conversation_id, s.kind, s.depth, s.content, s.token_count, + s.earliest_at, s.latest_at, s.descendant_count, s.descendant_token_count, + s.source_message_token_count, s.model, s.created_at + FROM summary_parents sp + JOIN summaries s ON s.summary_id = sp.parent_summary_id + WHERE sp.summary_id = ?`, + summaryID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return s.scanSummaries(rows) +} + +// LinkSummaryToMessages links a leaf summary to its source messages. +func (s *Store) LinkSummaryToMessages(ctx context.Context, summaryID string, messageIDs []int64) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + for i, msgID := range messageIDs { + _, err = tx.ExecContext(ctx, + "INSERT OR IGNORE INTO summary_messages (summary_id, message_id, ordinal) VALUES (?, ?, ?)", + summaryID, msgID, i, + ) + if err != nil { + return err + } + } + return tx.Commit() +} + +// GetSummarySourceMessages retrieves source messages for a summary. +func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) ([]Message, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT m.message_id, m.conversation_id, m.role, m.content, m.reasoning_content, m.token_count, m.created_at + FROM summary_messages sm + JOIN messages m ON m.message_id = sm.message_id + WHERE sm.summary_id = ? + ORDER BY sm.ordinal`, + summaryID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var msgs []Message + for rows.Next() { + var msg Message + var createdAt string + if err := rows.Scan( + &msg.ID, + &msg.ConversationID, + &msg.Role, + &msg.Content, + &msg.ReasoningContent, + &msg.TokenCount, + &createdAt, + ); err != nil { + return nil, err + } + msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + msgs = append(msgs, msg) + } + if err := rows.Err(); err != nil { + return nil, err + } + return msgs, nil +} + +// GetRootSummaries retrieves root summaries (not children of any other summary). +func (s *Store) GetRootSummaries(ctx context.Context, convID int64) ([]Summary, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT s.summary_id, s.conversation_id, s.kind, s.depth, s.content, s.token_count, + s.earliest_at, s.latest_at, s.descendant_count, s.descendant_token_count, + s.source_message_token_count, s.model, s.created_at + FROM summaries s + WHERE s.conversation_id = ? + AND s.summary_id NOT IN (SELECT sp.parent_summary_id FROM summary_parents sp) + ORDER BY s.created_at`, + convID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return s.scanSummaries(rows) +} + +// --- Context Item Operations --- + +// GetContextItems retrieves context items for a conversation, ordered by ordinal. +func (s *Store) GetContextItems(ctx context.Context, convID int64) ([]ContextItem, error) { + rows, err := s.db.QueryContext( + ctx, + "SELECT ordinal, item_type, summary_id, message_id, token_count, created_at FROM context_items WHERE conversation_id = ? ORDER BY ordinal", + convID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []ContextItem + for rows.Next() { + var item ContextItem + var summaryID sql.NullString + var messageID sql.NullInt64 + var createdAt sql.NullString + if err := rows.Scan( + &item.Ordinal, + &item.ItemType, + &summaryID, + &messageID, + &item.TokenCount, + &createdAt, + ); err != nil { + return nil, err + } + item.ConversationID = convID + if summaryID.Valid { + item.SummaryID = summaryID.String + } + if messageID.Valid { + item.MessageID = messageID.Int64 + } + if createdAt.Valid { + t, _ := time.Parse("2006-01-02 15:04:05", createdAt.String) + item.CreatedAt = t + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +// UpsertContextItems replaces all context items for a conversation. +func (s *Store) UpsertContextItems(ctx context.Context, convID int64, items []ContextItem) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + _, err = tx.ExecContext(ctx, "DELETE FROM context_items WHERE conversation_id = ?", convID) + if err != nil { + return err + } + + for _, item := range items { + _, err = tx.ExecContext(ctx, + `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, message_id, token_count) + VALUES (?, ?, ?, ?, ?, ?)`, + convID, item.Ordinal, item.ItemType, + nullString(item.SummaryID), nullInt64(item.MessageID), + item.TokenCount, + ) + if err != nil { + return err + } + } + return tx.Commit() +} + +// ClearContextItems removes all context items for a conversation. +func (s *Store) ClearContextItems(ctx context.Context, convID int64) error { + _, err := s.db.ExecContext(ctx, "DELETE FROM context_items WHERE conversation_id = ?", convID) + return err +} + +// DeleteMessagesAfterID deletes all messages with ID > afterID for a conversation. +// Also clears related context_items, message_parts, summary_messages, and FTS entries. +// Uses transaction to ensure atomicity of the delete cascade. +func (s *Store) DeleteMessagesAfterID(ctx context.Context, convID int64, afterID int64) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Get message IDs to delete for cleaning up related tables + rows, err := tx.QueryContext(ctx, + "SELECT message_id FROM messages WHERE conversation_id = ? AND message_id > ?", convID, afterID) + if err != nil { + return err + } + defer rows.Close() + + var msgIDs []int64 + for rows.Next() { + var id int64 + if scanErr := rows.Scan(&id); scanErr != nil { + return scanErr + } + msgIDs = append(msgIDs, id) + } + if rows.Err() != nil { + return rows.Err() + } + + // Delete context_items referencing these messages + for _, msgID := range msgIDs { + if _, err := tx.ExecContext(ctx, "DELETE FROM context_items WHERE message_id = ?", msgID); err != nil { + return err + } + } + + // Delete from message_parts and summary_messages + // Note: messages_fts is handled automatically by trigger, no manual delete needed + for _, msgID := range msgIDs { + if _, err := tx.ExecContext(ctx, "DELETE FROM message_parts WHERE message_id = ?", msgID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "DELETE FROM summary_messages WHERE message_id = ?", msgID); err != nil { + return err + } + } + + // Delete messages + if _, err := tx.ExecContext(ctx, + "DELETE FROM messages WHERE conversation_id = ? AND message_id > ?", convID, afterID); err != nil { + return err + } + + return tx.Commit() +} + +// ClearConversation removes all data for a conversation from all tables. +// Deletes context_items, summary_messages, summary_parents (via subquery), summaries, +// message_parts, and messages. FTS entries are handled automatically by triggers. +// Uses a transaction for atomicity. +func (s *Store) ClearConversation(ctx context.Context, convID int64) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Delete in child→parent order. FTS tables (messages_fts, summaries_fts) are + // kept in sync by DELETE triggers, so we just delete from the parent tables. + + if _, err := tx.ExecContext(ctx, + "DELETE FROM context_items WHERE conversation_id = ?", convID); err != nil { + return fmt.Errorf("context_items: %w", err) + } + if _, err := tx.ExecContext(ctx, + `DELETE FROM summary_messages WHERE summary_id IN ( + SELECT summary_id FROM summaries WHERE conversation_id = ? + )`, convID); err != nil { + return fmt.Errorf("summary_messages: %w", err) + } + // Note: summary_parents has no convID column; delete via subquery on summaries + if _, err := tx.ExecContext(ctx, + `DELETE FROM summary_parents WHERE summary_id IN ( + SELECT summary_id FROM summaries WHERE conversation_id = ? + ) OR parent_summary_id IN ( + SELECT summary_id FROM summaries WHERE conversation_id = ? + )`, convID, convID); err != nil { + return fmt.Errorf("summary_parents: %w", err) + } + if _, err := tx.ExecContext(ctx, + "DELETE FROM summaries WHERE conversation_id = ?", convID); err != nil { + return fmt.Errorf("summaries: %w", err) + } + if _, err := tx.ExecContext(ctx, + `DELETE FROM message_parts WHERE message_id IN ( + SELECT message_id FROM messages WHERE conversation_id = ? + )`, convID); err != nil { + return fmt.Errorf("message_parts: %w", err) + } + if _, err := tx.ExecContext(ctx, + "DELETE FROM messages WHERE conversation_id = ?", convID); err != nil { + return fmt.Errorf("messages: %w", err) + } + + return tx.Commit() +} + +// AppendContextMessage appends a single message to context_items at next ordinal. +func (s *Store) AppendContextMessage(ctx context.Context, convID int64, messageID int64) error { + return s.appendContextItems(ctx, convID, []ContextItem{ + {ItemType: "message", MessageID: messageID}, + }) +} + +// AppendContextMessages bulk-appends messages to context_items. +func (s *Store) AppendContextMessages(ctx context.Context, convID int64, messageIDs []int64) error { + items := make([]ContextItem, len(messageIDs)) + for i, id := range messageIDs { + items[i] = ContextItem{ItemType: "message", MessageID: id} + } + return s.appendContextItems(ctx, convID, items) +} + +// AppendContextSummary appends a summary to context_items at next ordinal. +func (s *Store) AppendContextSummary(ctx context.Context, convID int64, summaryID string) error { + return s.appendContextItems(ctx, convID, []ContextItem{ + {ItemType: "summary", SummaryID: summaryID}, + }) +} + +func (s *Store) appendContextItems(ctx context.Context, convID int64, items []ContextItem) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + maxOrd, err := s.GetMaxOrdinalTx(ctx, tx, convID) + if err != nil { + return err + } + + ordinal := maxOrd + OrdinalStep + for _, item := range items { + item.ConversationID = convID + item.Ordinal = ordinal + + // Resolve token count if not set + tokenCount := item.TokenCount + if tokenCount == 0 { + tokenCount = s.resolveItemTokenCountTx(ctx, tx, item) + } + + _, err = tx.ExecContext(ctx, + `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, message_id, token_count) + VALUES (?, ?, ?, ?, ?, ?)`, + convID, ordinal, item.ItemType, + nullString(item.SummaryID), nullInt64(item.MessageID), + tokenCount, + ) + if err != nil { + return err + } + ordinal += OrdinalStep + } + return tx.Commit() +} + +// resolveItemTokenCountTx looks up token count within a transaction. +func (s *Store) resolveItemTokenCountTx(ctx context.Context, tx *sql.Tx, item ContextItem) int { + if item.ItemType == "message" && item.MessageID > 0 { + var tc int + err := tx.QueryRowContext(ctx, + "SELECT token_count FROM messages WHERE message_id = ?", item.MessageID, + ).Scan(&tc) + if err == nil { + return tc + } + } + if item.ItemType == "summary" && item.SummaryID != "" { + var tc int + err := tx.QueryRowContext(ctx, + "SELECT token_count FROM summaries WHERE summary_id = ?", item.SummaryID, + ).Scan(&tc) + if err == nil { + return tc + } + } + return 0 +} + +// ReplaceContextRangeWithSummary atomically replaces a range of context items with a summary. +// If ordinal gap is exhausted, triggers resequencing (spec lines 1204-1209). +func (s *Store) ReplaceContextRangeWithSummary( + ctx context.Context, + convID int64, + startOrdinal, endOrdinal int, + summaryID string, +) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Delete the range + _, err = tx.ExecContext(ctx, + "DELETE FROM context_items WHERE conversation_id = ? AND ordinal >= ? AND ordinal <= ?", + convID, startOrdinal, endOrdinal, + ) + if err != nil { + return err + } + + // Insert summary at midpoint of replaced range + midpoint := (startOrdinal + endOrdinal) / 2 + + // Check if midpoint conflicts with existing ordinal + var conflict bool + var existingOrd int + err = tx.QueryRowContext(ctx, + "SELECT ordinal FROM context_items WHERE conversation_id = ? AND ordinal = ?", + convID, midpoint, + ).Scan(&existingOrd) + if err == nil { + conflict = true + } + + if conflict { + // Gap exhausted, need resequence (spec lines 1204-1209) + err = s.resequenceContextItemsTx(ctx, tx, convID, summaryID) + if err != nil { + return fmt.Errorf("resequence: %w", err) + } + } else { + // Normal insert at midpoint with token_count from summary + _, err = tx.ExecContext(ctx, + `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, token_count) + SELECT ?, ?, 'summary', ?, token_count FROM summaries WHERE summary_id = ?`, + convID, midpoint, summaryID, summaryID, + ) + if err != nil { + return err + } + } + + return tx.Commit() +} + +// ReplaceContextItemsWithSummary replaces specific context items (by summary_id) with a new summary. +// Use this when candidates are not contiguous in ordinal space to avoid deleting non-candidate items. +func (s *Store) ReplaceContextItemsWithSummary( + ctx context.Context, + convID int64, + summaryIDs []string, + newSummaryID string, +) error { + if len(summaryIDs) == 0 { + return nil + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Find the ordinals of items to delete and calculate midpoint + placeholders := make([]string, len(summaryIDs)) + args := make([]any, len(summaryIDs)+1) + args[0] = convID + for i, sid := range summaryIDs { + placeholders[i] = "?" + args[i+1] = sid + } + + query := fmt.Sprintf( + "SELECT ordinal FROM context_items WHERE conversation_id = ? AND summary_id IN (%s) ORDER BY ordinal", + strings.Join(placeholders, ","), + ) + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + + var ordinals []int + for rows.Next() { + var ord int + if scanErr := rows.Scan(&ord); scanErr != nil { + return scanErr + } + ordinals = append(ordinals, ord) + } + if err = rows.Err(); err != nil { + return err + } + + if len(ordinals) == 0 { + return nil + } + + midpoint := (ordinals[0] + ordinals[len(ordinals)-1]) / 2 + + // Delete the specific items by summary_id + deleteQuery := fmt.Sprintf( + "DELETE FROM context_items WHERE conversation_id = ? AND summary_id IN (%s)", + strings.Join(placeholders, ","), + ) + _, err = tx.ExecContext(ctx, deleteQuery, args...) + if err != nil { + return err + } + + // Check if midpoint conflicts with existing ordinal + var conflict bool + var existingOrd int + err = tx.QueryRowContext(ctx, + "SELECT ordinal FROM context_items WHERE conversation_id = ? AND ordinal = ?", + convID, midpoint, + ).Scan(&existingOrd) + if err == nil { + conflict = true + } + + if conflict { + // Gap exhausted, need resequence + err = s.resequenceContextItemsTx(ctx, tx, convID, newSummaryID) + if err != nil { + return fmt.Errorf("resequence: %w", err) + } + } else { + // Normal insert at midpoint + _, err = tx.ExecContext(ctx, + `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, token_count) + SELECT ?, ?, 'summary', ?, token_count FROM summaries WHERE summary_id = ?`, + convID, midpoint, newSummaryID, newSummaryID, + ) + if err != nil { + return err + } + } + + return tx.Commit() +} + +// resequenceContextItemsTx renumbers context_items with fresh OrdinalStep gaps. +// Uses temp negative ordinals to avoid PRIMARY KEY constraint violations (spec lines 1240-1247). +func (s *Store) resequenceContextItemsTx(ctx context.Context, tx *sql.Tx, convID int64, newSummaryID string) error { + // Get all remaining items sorted by current ordinal + rows, err := tx.QueryContext( + ctx, + "SELECT ordinal, item_type, summary_id, message_id, token_count FROM context_items WHERE conversation_id = ? ORDER BY ordinal", + convID, + ) + if err != nil { + return err + } + defer rows.Close() + + type item struct { + ordinal int + itemType string + summaryID string + messageID int64 + tokenCount int + } + var items []item + for rows.Next() { + var i item + var sid sql.NullString + var mid sql.NullInt64 + var scanErr error + if scanErr = rows.Scan(&i.ordinal, &i.itemType, &sid, &mid, &i.tokenCount); scanErr != nil { + return scanErr + } + if sid.Valid { + i.summaryID = sid.String + } + if mid.Valid { + i.messageID = mid.Int64 + } + items = append(items, i) + } + if rowsErr := rows.Err(); rowsErr != nil { + return rowsErr + } + + // Step 1: Move all items to temp negative ordinals + tempOrd := -1 + for _, i := range items { + _, execErr := tx.ExecContext(ctx, + "UPDATE context_items SET ordinal = ? WHERE conversation_id = ? AND ordinal = ?", + tempOrd, convID, i.ordinal, + ) + if execErr != nil { + return execErr + } + tempOrd-- + } + + // Step 2: Insert new summary at the end with positive ordinal + // Include token_count from summaries table + newOrd := (len(items) + 1) * OrdinalStep + _, err = tx.ExecContext(ctx, + `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id, token_count) + SELECT ?, ?, 'summary', ?, token_count FROM summaries WHERE summary_id = ?`, + convID, newOrd, newSummaryID, newSummaryID, + ) + if err != nil { + return err + } + + // Step 3: Update each temp item to its final positive ordinal + // Use specific temp ordinal matching (not ordinal < 0) to avoid updating all items + finalOrd := OrdinalStep + tempOrd = -1 // Reset to first temp ordinal (already declared in Step 1) + for range items { + _, execErr := tx.ExecContext(ctx, + "UPDATE context_items SET ordinal = ? WHERE conversation_id = ? AND ordinal = ?", + finalOrd, convID, tempOrd, + ) + if execErr != nil { + return execErr + } + finalOrd += OrdinalStep + tempOrd-- + } + + return nil +} + +// GetContextTokenCount returns total token count for all items in context. +func (s *Store) GetContextTokenCount(ctx context.Context, convID int64) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, + "SELECT COALESCE(SUM(token_count), 0) FROM context_items WHERE conversation_id = ?", + convID, + ).Scan(&count) + return count, err +} + +// GetMaxOrdinal returns the highest ordinal in context_items for a conversation. +func (s *Store) GetMaxOrdinal(ctx context.Context, convID int64) (int, error) { + var maxOrd sql.NullInt64 + err := s.db.QueryRowContext(ctx, + "SELECT MAX(ordinal) FROM context_items WHERE conversation_id = ?", + convID, + ).Scan(&maxOrd) + if err != nil { + return 0, err + } + if !maxOrd.Valid { + return 0, nil + } + return int(maxOrd.Int64), nil +} + +// GetMaxOrdinalTx returns the highest ordinal within a transaction. +func (s *Store) GetMaxOrdinalTx(ctx context.Context, tx *sql.Tx, convID int64) (int, error) { + var maxOrd sql.NullInt64 + err := tx.QueryRowContext(ctx, + "SELECT MAX(ordinal) FROM context_items WHERE conversation_id = ?", + convID, + ).Scan(&maxOrd) + if err != nil { + return 0, err + } + if !maxOrd.Valid { + return 0, nil + } + return int(maxOrd.Int64), nil +} + +// GetDistinctDepthsInContext returns distinct depth levels of summaries currently in context. +// maxOrdinalExclusive filters out summaries with ordinal >= this value (0 = no filter). +func (s *Store) GetDistinctDepthsInContext(ctx context.Context, convID int64, maxOrdinalExclusive int) ([]int, error) { + query := `SELECT DISTINCT s.depth + FROM context_items ci + JOIN summaries s ON s.summary_id = ci.summary_id + WHERE ci.conversation_id = ? AND ci.item_type = 'summary'` + args := []any{convID} + + if maxOrdinalExclusive > 0 { + query += " AND ci.ordinal < ?" + args = append(args, maxOrdinalExclusive) + } + + query += " ORDER BY s.depth" + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("get distinct depths: %w", err) + } + defer rows.Close() + + var depths []int + for rows.Next() { + var d int + if err := rows.Scan(&d); err != nil { + return nil, err + } + depths = append(depths, d) + } + if err := rows.Err(); err != nil { + return nil, err + } + return depths, nil +} + +// GetSummarySubtree returns all summaries in the subtree rooted at summaryID, +// including summaryID itself. Uses a recursive CTE to traverse the DAG. +func (s *Store) GetSummarySubtree(ctx context.Context, summaryID string) ([]SummarySubtreeNode, error) { + rows, err := s.db.QueryContext(ctx, ` + WITH RECURSIVE subtree AS ( + SELECT summary_id, 0 AS depth_from_root + FROM summaries + WHERE summary_id = ? + UNION ALL + SELECT sp.parent_summary_id, st.depth_from_root + 1 + FROM summary_parents sp + JOIN subtree st ON sp.summary_id = st.summary_id + ) + SELECT summary_id, depth_from_root FROM subtree`, + summaryID, + ) + if err != nil { + return nil, fmt.Errorf("get summary subtree: %w", err) + } + defer rows.Close() + + var nodes []SummarySubtreeNode + for rows.Next() { + var n SummarySubtreeNode + if err := rows.Scan(&n.SummaryID, &n.DepthFromRoot); err != nil { + return nil, err + } + nodes = append(nodes, n) + } + if err := rows.Err(); err != nil { + return nil, err + } + return nodes, nil +} + +// --- Search Operations --- + +// SearchSummaries performs full-text search on summaries. +func (s *Store) SearchSummaries(ctx context.Context, input SearchInput) ([]SearchResult, error) { + // "like" → LIKE search, anything else (including "full_text" or empty) → FTS5 + if input.Mode == "like" { + return s.searchSummariesLike(ctx, input) + } + return s.searchSummariesFTS(ctx, input) +} + +func (s *Store) searchSummariesFTS(ctx context.Context, input SearchInput) ([]SearchResult, error) { + sanitized := SanitizeFTS5Query(input.Pattern) + if sanitized == "" { + return nil, nil + } + + // Build WHERE clause for filters (used in both count and data queries) + whereClauses := []string{"summaries_fts MATCH ?"} + args := []any{sanitized} + + if input.ConversationID > 0 && !input.AllConversations { + whereClauses = append(whereClauses, "s.conversation_id = ?") + args = append(args, input.ConversationID) + } + + if input.Since != nil { + whereClauses = append(whereClauses, "s.created_at >= ?") + args = append(args, input.Since.Format("2006-01-02 15:04:05")) + } + if input.Before != nil { + whereClauses = append(whereClauses, "s.created_at < ?") + args = append(args, input.Before.Format("2006-01-02 15:04:05")) + } + + whereStr := strings.Join(whereClauses, " AND ") + + // First, get total count (bm25 conflicts with window functions in FTS5) + countQuery := `SELECT COUNT(*) FROM summaries_fts fts + JOIN summaries s ON s.summary_id = fts.summary_id + WHERE ` + whereStr + var totalCount int + if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount); err != nil { + return nil, err + } + + // Then, get actual results with bm25 ranking + dataQuery := `SELECT s.summary_id, s.conversation_id, s.kind, s.content, s.created_at, bm25(summaries_fts) as rank + FROM summaries_fts fts + JOIN summaries s ON s.summary_id = fts.summary_id + WHERE ` + whereStr + ` ORDER BY rank` + + dataArgs := append([]any{}, args...) // copy args + if input.Limit > 0 { + dataQuery += " LIMIT ?" + dataArgs = append(dataArgs, input.Limit) + } + + rows, err := s.db.QueryContext(ctx, dataQuery, dataArgs...) + if err != nil { + return nil, err + } + defer rows.Close() + + results, err := s.scanSearchResults(rows, true) + if err != nil { + return nil, err + } + + // Set total count on all results + for i := range results { + results[i].TotalCount = totalCount + } + return results, nil +} + +// buildLikeQuery appends conversation/time filters and limit to a LIKE query. +// Note: role filtering is NOT applied here since summaries don't have role column. +// Use buildMessagesLikeQuery for message searches that need role filtering. +func buildLikeQuery(query string, args []any, input SearchInput) (string, []any) { + if input.ConversationID > 0 && !input.AllConversations { + query += " AND conversation_id = ?" + args = append(args, input.ConversationID) + } + if input.Since != nil { + query += " AND created_at >= ?" + args = append(args, input.Since.Format("2006-01-02 15:04:05")) + } + if input.Before != nil { + query += " AND created_at < ?" + args = append(args, input.Before.Format("2006-01-02 15:04:05")) + } + // Order by newest first for LIKE mode + query += " ORDER BY created_at DESC" + if input.Limit > 0 { + query += " LIMIT ?" + args = append(args, input.Limit) + } + return query, args +} + +// buildMessagesLikeQuery is like buildLikeQuery but adds role filtering for messages. +func buildMessagesLikeQuery(query string, args []any, input SearchInput) (string, []any) { + if input.Role != "" { + query += " AND role = ?" + args = append(args, input.Role) + } + return buildLikeQuery(query, args, input) +} + +func (s *Store) searchSummariesLike(ctx context.Context, input SearchInput) ([]SearchResult, error) { + query := `SELECT summary_id, conversation_id, kind, content, created_at, COUNT(*) OVER() as total_count + FROM summaries WHERE content LIKE ?` + args := []any{"%" + input.Pattern + "%"} + query, args = buildLikeQuery(query, args, input) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + return s.scanSearchResults(rows, false) +} + +func (s *Store) scanSearchResults(rows *sql.Rows, withRank bool) ([]SearchResult, error) { + var results []SearchResult + for rows.Next() { + var r SearchResult + var createdAt string + var kind string + if withRank { + // FTS5 mode: no TotalCount in query (set by caller after COUNT) + if err := rows.Scan(&r.SummaryID, &r.ConversationID, &kind, &r.Content, &createdAt, &r.Rank); err != nil { + return nil, err + } + } else { + // LIKE mode: TotalCount from window function + if err := rows.Scan(&r.SummaryID, &r.ConversationID, &kind, + &r.Content, &createdAt, &r.TotalCount); err != nil { + return nil, err + } + } + r.Kind = SummaryKind(kind) + r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + results = append(results, r) + } + return results, nil +} + +// SearchMessages performs full-text or regex search on messages. +func (s *Store) SearchMessages(ctx context.Context, input SearchInput) ([]SearchResult, error) { + // Try FTS5 first for full-text mode + if input.Mode == "" || input.Mode == "full_text" { + results, err := s.searchMessagesFTS(ctx, input) + if err == nil && len(results) > 0 { + return results, nil + } + // Fall through to LIKE + } + + return s.searchMessagesLike(ctx, input) +} + +func (s *Store) searchMessagesFTS(ctx context.Context, input SearchInput) ([]SearchResult, error) { + sanitized := SanitizeFTS5Query(input.Pattern) + if sanitized == "" { + return nil, nil + } + + // Build WHERE clause for filters (used in both count and data queries) + whereClauses := []string{"messages_fts MATCH ?"} + args := []any{sanitized} + + if input.ConversationID > 0 && !input.AllConversations { + whereClauses = append(whereClauses, "m.conversation_id = ?") + args = append(args, input.ConversationID) + } + + if input.Role != "" { + whereClauses = append(whereClauses, "m.role = ?") + args = append(args, input.Role) + } + + if input.Since != nil { + whereClauses = append(whereClauses, "m.created_at >= ?") + args = append(args, input.Since.Format("2006-01-02 15:04:05")) + } + if input.Before != nil { + whereClauses = append(whereClauses, "m.created_at < ?") + args = append(args, input.Before.Format("2006-01-02 15:04:05")) + } + + whereStr := strings.Join(whereClauses, " AND ") + + // First, get total count (bm25 conflicts with window functions in FTS5) + countQuery := `SELECT COUNT(*) FROM messages_fts f + JOIN messages m ON f.message_id = m.message_id + WHERE ` + whereStr + var totalCount int + if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount); err != nil { + return nil, err + } + + // Then, get actual results with bm25 ranking + dataQuery := `SELECT m.message_id, m.conversation_id, m.role, m.content, m.created_at, bm25(messages_fts) as rank + FROM messages_fts f + JOIN messages m ON f.message_id = m.message_id + WHERE ` + whereStr + ` ORDER BY rank` + + dataArgs := append([]any{}, args...) // copy args + if input.Limit > 0 { + dataQuery += " LIMIT ?" + dataArgs = append(dataArgs, input.Limit) + } + + rows, err := s.db.QueryContext(ctx, dataQuery, dataArgs...) + if err != nil { + return nil, err + } + defer rows.Close() + + results, err := s.scanMessageSearchResults(rows, true) + if err != nil { + return nil, err + } + + // Set total count on all results + for i := range results { + results[i].TotalCount = totalCount + } + return results, nil +} + +func (s *Store) searchMessagesLike(ctx context.Context, input SearchInput) ([]SearchResult, error) { + query := `SELECT message_id, conversation_id, role, content, created_at, COUNT(*) OVER() as total_count + FROM messages WHERE content LIKE ?` + args := []any{"%" + input.Pattern + "%"} + query, args = buildMessagesLikeQuery(query, args, input) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + return s.scanMessageSearchResults(rows, false) +} + +func (s *Store) scanMessageSearchResults(rows *sql.Rows, withRank bool) ([]SearchResult, error) { + var results []SearchResult + for rows.Next() { + var r SearchResult + var createdAt string + var content string + if withRank { + // FTS5 mode: no TotalCount in query (set by caller after COUNT) + if err := rows.Scan(&r.MessageID, &r.ConversationID, &r.Role, &content, &createdAt, &r.Rank); err != nil { + return nil, err + } + } else { + // LIKE mode: TotalCount from window function + if err := rows.Scan(&r.MessageID, &r.ConversationID, &r.Role, &content, + &createdAt, &r.TotalCount); err != nil { + return nil, err + } + } + r.Snippet = content + r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + results = append(results, r) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +// --- Helpers --- + +func (s *Store) scanSummary(ctx context.Context, where string, args ...any) (*Summary, error) { + row := s.db.QueryRowContext(ctx, + `SELECT summary_id, conversation_id, kind, depth, content, token_count, + earliest_at, latest_at, descendant_count, descendant_token_count, + source_message_token_count, model, created_at + FROM summaries `+where, args..., + ) + var sum Summary + var kind, createdAt string + var earliestAt, latestAt sql.NullString + err := row.Scan( + &sum.SummaryID, &sum.ConversationID, &kind, &sum.Depth, &sum.Content, &sum.TokenCount, + &earliestAt, &latestAt, &sum.DescendantCount, &sum.DescendantTokenCount, + &sum.SourceMessageTokenCount, &sum.Model, &createdAt, + ) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("summary not found") + } + if err != nil { + return nil, err + } + sum.Kind = SummaryKind(kind) + sum.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + if earliestAt.Valid { + t, _ := time.Parse(time.RFC3339, earliestAt.String) + sum.EarliestAt = &t + } + if latestAt.Valid { + t, _ := time.Parse(time.RFC3339, latestAt.String) + sum.LatestAt = &t + } + return &sum, nil +} + +func (s *Store) scanSummaries(rows *sql.Rows) ([]Summary, error) { + var summaries []Summary + for rows.Next() { + var sum Summary + var kind, createdAt string + var earliestAt, latestAt sql.NullString + err := rows.Scan( + &sum.SummaryID, &sum.ConversationID, &kind, &sum.Depth, &sum.Content, &sum.TokenCount, + &earliestAt, &latestAt, &sum.DescendantCount, &sum.DescendantTokenCount, + &sum.SourceMessageTokenCount, &sum.Model, &createdAt, + ) + if err != nil { + return nil, err + } + sum.Kind = SummaryKind(kind) + sum.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + if earliestAt.Valid { + t, _ := time.Parse(time.RFC3339, earliestAt.String) + sum.EarliestAt = &t + } + if latestAt.Valid { + t, _ := time.Parse(time.RFC3339, latestAt.String) + sum.LatestAt = &t + } + summaries = append(summaries, sum) + } + if err := rows.Err(); err != nil { + return nil, err + } + return summaries, nil +} + +func generateSummaryID(content string, t time.Time) string { + return fmt.Sprintf("sum_%x", t.UnixNano()) +} + +func isUniqueViolation(err error) bool { + return err != nil && (contains(err.Error(), "UNIQUE constraint failed") || + contains(err.Error(), "constraint failed")) +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && searchSubstring(s, sub) +} + +func searchSubstring(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +func nullString(s string) sql.NullString { + return sql.NullString{String: s, Valid: s != ""} +} + +func nullInt64(n int64) sql.NullInt64 { + return sql.NullInt64{Int64: n, Valid: n != 0} +} diff --git a/pkg/seahorse/store_test.go b/pkg/seahorse/store_test.go new file mode 100644 index 000000000..67bed1c11 --- /dev/null +++ b/pkg/seahorse/store_test.go @@ -0,0 +1,1441 @@ +package seahorse + +import ( + "context" + "fmt" + "testing" + "time" +) + +func openTestStore(t *testing.T) *Store { + t.Helper() + db := openTestDB(t) + if err := runSchema(db); err != nil { + t.Fatalf("migration: %v", err) + } + return &Store{db: db} +} + +// --- Conversation Operations --- + +func TestStoreGetOrCreateConversation(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, err := s.GetOrCreateConversation(ctx, "agent:abc123") + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + if conv.ConversationID == 0 { + t.Error("expected non-zero conversation ID") + } + if conv.SessionKey != "agent:abc123" { + t.Errorf("session key = %q, want %q", conv.SessionKey, "agent:abc123") + } + + // Idempotent — same session key returns same conversation + conv2, err := s.GetOrCreateConversation(ctx, "agent:abc123") + if err != nil { + t.Fatalf("GetOrCreateConversation (2nd): %v", err) + } + if conv2.ConversationID != conv.ConversationID { + t.Errorf("idempotent: got ID %d, want %d", conv2.ConversationID, conv.ConversationID) + } + + // Different session key → new conversation + conv3, err := s.GetOrCreateConversation(ctx, "agent:def456") + if err != nil { + t.Fatalf("GetOrCreateConversation (3rd): %v", err) + } + if conv3.ConversationID == conv.ConversationID { + t.Error("different session key should create different conversation") + } +} + +func TestStoreGetConversationBySessionKey(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + // Not found + conv, err := s.GetConversationBySessionKey(ctx, "nonexistent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conv != nil { + t.Error("expected nil for nonexistent session key") + } + + // Create then retrieve + created, err := s.GetOrCreateConversation(ctx, "agent:test") + if err != nil { + t.Fatalf("create: %v", err) + } + found, err := s.GetConversationBySessionKey(ctx, "agent:test") + if err != nil { + t.Fatalf("find: %v", err) + } + if found.ConversationID != created.ConversationID { + t.Errorf("found ID %d, want %d", found.ConversationID, created.ConversationID) + } +} + +// --- Conversation Clear --- + +func TestStoreClearConversation(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, err := s.GetOrCreateConversation(ctx, "agent:clear-test") + if err != nil { + t.Fatalf("create conversation: %v", err) + } + + // Add messages + msg1, err := s.AddMessage(ctx, conv.ConversationID, "user", "hello", 5) + if err != nil { + t.Fatalf("add message 1: %v", err) + } + msg2, err := s.AddMessage(ctx, conv.ConversationID, "assistant", "hi", 5) + if err != nil { + t.Fatalf("add message 2: %v", err) + } + + // Add a summary + _, err = s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Content: "test summary", + TokenCount: 10, + Kind: SummaryKindLeaf, + }) + if err != nil { + t.Fatalf("create summary: %v", err) + } + + // Verify data exists + msgs, err := s.GetMessages(ctx, conv.ConversationID, 0, 0) + if err != nil { + t.Fatalf("get messages before clear: %v", err) + } + if len(msgs) != 2 { + t.Fatalf("expected 2 messages before clear, got %d", len(msgs)) + } + + sums, err := s.GetSummariesByConversation(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("get summaries before clear: %v", err) + } + if len(sums) != 1 { + t.Fatalf("expected 1 summary before clear, got %d", len(sums)) + } + + // Clear + if err = s.ClearConversation(ctx, conv.ConversationID); err != nil { + t.Fatalf("clear conversation: %v", err) + } + + // Verify all data is gone + msgs, err = s.GetMessages(ctx, conv.ConversationID, 0, 0) + if err != nil { + t.Fatalf("get messages after clear: %v", err) + } + if len(msgs) != 0 { + t.Fatalf("expected 0 messages after clear, got %d", len(msgs)) + } + + sums, err = s.GetSummariesByConversation(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("get summaries after clear: %v", err) + } + if len(sums) != 0 { + t.Fatalf("expected 0 summaries after clear, got %d", len(sums)) + } + + items, err := s.GetContextItems(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("get context items after clear: %v", err) + } + if len(items) != 0 { + t.Fatalf("expected 0 context items after clear, got %d", len(items)) + } + + var count int + if err := s.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM message_parts WHERE message_id = ? OR message_id = ?", + msg1.ID, msg2.ID).Scan(&count); err != nil { + t.Fatalf("count message parts: %v", err) + } + if count != 0 { + t.Fatalf("expected 0 message parts after clear, got %d", count) + } +} + +func TestStoreAddAndGetMessages(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + msg, err := s.AddMessage(ctx, conv.ConversationID, "user", "hello world", 5) + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + if msg.ID == 0 { + t.Error("expected non-zero message ID") + } + if msg.Role != "user" || msg.Content != "hello world" { + t.Errorf("message = %+v, want role=user content=hello world", msg) + } + + // Retrieve + msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("got %d messages, want 1", len(msgs)) + } + if msgs[0].Content != "hello world" { + t.Errorf("content = %q, want %q", msgs[0].Content, "hello world") + } +} + +func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:reasoning") + + msg, err := s.AddMessageWithReasoning( + ctx, + conv.ConversationID, + "assistant", + "hello world", + "let me think", + 5, + ) + if err != nil { + t.Fatalf("AddMessageWithReasoning: %v", err) + } + if msg.ReasoningContent != "let me think" { + t.Fatalf("ReasoningContent = %q, want %q", msg.ReasoningContent, "let me think") + } + + msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("got %d messages, want 1", len(msgs)) + } + if msgs[0].ReasoningContent != "let me think" { + t.Errorf("ReasoningContent = %q, want %q", msgs[0].ReasoningContent, "let me think") + } + + found, err := s.GetMessageByID(ctx, msg.ID) + if err != nil { + t.Fatalf("GetMessageByID: %v", err) + } + if found.ReasoningContent != "let me think" { + t.Errorf("GetMessageByID ReasoningContent = %q, want %q", found.ReasoningContent, "let me think") + } +} + +func TestStoreAddMessageWithParts(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + parts := []MessagePart{ + {Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"}, + {Type: "text", Text: "some output"}, + } + msg, err := s.AddMessageWithParts(ctx, conv.ConversationID, "assistant", parts, 10) + if err != nil { + t.Fatalf("AddMessageWithParts: %v", err) + } + if msg.ID == 0 { + t.Error("expected non-zero message ID") + } + + // Retrieve and verify parts + msgs, _ := s.GetMessages(ctx, conv.ConversationID, 10, 0) + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + if len(msgs[0].Parts) != 2 { + t.Fatalf("expected 2 parts, got %d", len(msgs[0].Parts)) + } + if msgs[0].Parts[0].Type != "tool_use" { + t.Errorf("part[0].Type = %q, want tool_use", msgs[0].Parts[0].Type) + } + if msgs[0].Parts[0].ToolCallID != "tc_123" { + t.Errorf("part[0].ToolCallID = %q, want tc_123", msgs[0].Parts[0].ToolCallID) + } +} + +func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:parts-reasoning") + + parts := []MessagePart{ + {Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"}, + } + _, err := s.AddMessageWithPartsAndReasoning( + ctx, + conv.ConversationID, + "assistant", + parts, + "need to inspect the file first", + 10, + ) + if err != nil { + t.Fatalf("AddMessageWithPartsAndReasoning: %v", err) + } + + msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + if msgs[0].ReasoningContent != "need to inspect the file first" { + t.Errorf( + "ReasoningContent = %q, want %q", + msgs[0].ReasoningContent, + "need to inspect the file first", + ) + } +} + +func TestStoreGetMessageCount(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + s.AddMessage(ctx, conv.ConversationID, "user", "msg1", 2) + s.AddMessage(ctx, conv.ConversationID, "assistant", "msg2", 3) + s.AddMessage(ctx, conv.ConversationID, "user", "msg3", 1) + + count, err := s.GetMessageCount(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetMessageCount: %v", err) + } + if count != 3 { + t.Errorf("count = %d, want 3", count) + } +} + +func TestStoreGetMessageByID(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + msg, _ := s.AddMessage(ctx, conv.ConversationID, "user", "find me", 3) + + found, err := s.GetMessageByID(ctx, msg.ID) + if err != nil { + t.Fatalf("GetMessageByID: %v", err) + } + if found.Content != "find me" { + t.Errorf("content = %q, want %q", found.Content, "find me") + } + + // Not found + _, err = s.GetMessageByID(ctx, 99999) + if err == nil { + t.Error("expected error for nonexistent message") + } +} + +func TestStoreUpdateMessageReasoningContent(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:update-reasoning") + + msg, err := s.AddMessage(ctx, conv.ConversationID, "assistant", "answer", 3) + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + + err = s.UpdateMessageReasoningContent(ctx, msg.ID, "thinking") + if err != nil { + t.Fatalf("UpdateMessageReasoningContent: %v", err) + } + + found, err := s.GetMessageByID(ctx, msg.ID) + if err != nil { + t.Fatalf("GetMessageByID: %v", err) + } + if found.ReasoningContent != "thinking" { + t.Errorf("ReasoningContent = %q, want %q", found.ReasoningContent, "thinking") + } +} + +// --- Summary Operations --- + +func TestStoreCreateAndGetSummary(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + now := time.Now().UTC().Truncate(time.Second) + summary, err := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "test summary content", + TokenCount: 50, + EarliestAt: &now, + LatestAt: &now, + DescendantCount: 0, + DescendantTokenCount: 0, + SourceMessageTokens: 500, + Model: "test-model", + }) + if err != nil { + t.Fatalf("CreateSummary: %v", err) + } + if summary.SummaryID == "" { + t.Error("expected non-empty summary ID") + } + if summary.Kind != SummaryKindLeaf { + t.Errorf("kind = %q, want leaf", summary.Kind) + } + + // Retrieve by ID + found, err := s.GetSummary(ctx, summary.SummaryID) + if err != nil { + t.Fatalf("GetSummary: %v", err) + } + if found.Content != "test summary content" { + t.Errorf("content = %q, want 'test summary content'", found.Content) + } + if found.SourceMessageTokenCount != 500 { + t.Errorf("source_message_token_count = %d, want 500", found.SourceMessageTokenCount) + } +} + +func TestStoreSummaryDAG(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + // Create leaf summaries + leaf1, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "leaf 1", + TokenCount: 100, + }) + leaf2, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "leaf 2", + TokenCount: 100, + }) + + // Create condensed summary with parents (the children being condensed) + condensed, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindCondensed, + Depth: 1, + Content: "condensed from leaves", + TokenCount: 150, + ParentIDs: []string{leaf1.SummaryID, leaf2.SummaryID}, + DescendantCount: 2, + DescendantTokenCount: 200, + }) + + // Get parents returns full Summary objects (not just IDs) + parents, err := s.GetSummaryParents(ctx, condensed.SummaryID) + if err != nil { + t.Fatalf("GetSummaryParents: %v", err) + } + if len(parents) != 2 { + t.Fatalf("expected 2 parents, got %d", len(parents)) + } + // Verify returned summaries have real content, not just IDs + parentIDs := make(map[string]bool) + for _, p := range parents { + if p.Content == "" { + t.Error("parent summary should have non-empty Content") + } + if p.TokenCount == 0 { + t.Error("parent summary should have non-zero TokenCount") + } + parentIDs[p.SummaryID] = true + } + if !parentIDs[leaf1.SummaryID] || !parentIDs[leaf2.SummaryID] { + t.Errorf("parent IDs = %v, want both %s and %s", parentIDs, leaf1.SummaryID, leaf2.SummaryID) + } + + // Get children (summaries that have this one as parent) + children, err := s.GetSummaryChildren(ctx, condensed.SummaryID) + if err != nil { + t.Fatalf("GetSummaryChildren: %v", err) + } + if len(children) != 0 { + // condensed has no children yet — it's the root + t.Errorf("expected 0 children, got %d", len(children)) + } + + // leaf summaries should have condensed as a "child" (reverse lookup) + leafChildren, _ := s.GetSummaryChildren(ctx, leaf1.SummaryID) + if len(leafChildren) != 1 || leafChildren[0] != condensed.SummaryID { + t.Errorf("leaf1 children = %v, want [%s]", leafChildren, condensed.SummaryID) + } +} + +func TestStoreSummarySourceMessages(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "msg1", 2) + msg2, _ := s.AddMessage(ctx, conv.ConversationID, "assistant", "msg2", 3) + + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "summary of msg1 and msg2", + TokenCount: 50, + }) + + err := s.LinkSummaryToMessages(ctx, summary.SummaryID, []int64{msg1.ID, msg2.ID}) + if err != nil { + t.Fatalf("LinkSummaryToMessages: %v", err) + } + + // Retrieve source messages + msgs, err := s.GetSummarySourceMessages(ctx, summary.SummaryID) + if err != nil { + t.Fatalf("GetSummarySourceMessages: %v", err) + } + if len(msgs) != 2 { + t.Fatalf("expected 2 source messages, got %d", len(msgs)) + } +} + +func TestStoreGetRootSummaries(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + // Create 2 leaf summaries + leaf1, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, Content: "l1", TokenCount: 10, + }) + leaf2, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, Content: "l2", TokenCount: 10, + }) + + // Before condensation — both are roots + roots, _ := s.GetRootSummaries(ctx, conv.ConversationID) + if len(roots) != 2 { + t.Errorf("before condensation: expected 2 roots, got %d", len(roots)) + } + + // Condense them + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindCondensed, Depth: 1, + Content: "c1", TokenCount: 15, ParentIDs: []string{leaf1.SummaryID, leaf2.SummaryID}, + }) + + // After condensation — only the condensed is root + roots, _ = s.GetRootSummaries(ctx, conv.ConversationID) + if len(roots) != 1 { + t.Errorf("after condensation: expected 1 root, got %d", len(roots)) + } + if roots[0].Kind != SummaryKindCondensed { + t.Errorf("root kind = %q, want condensed", roots[0].Kind) + } +} + +// --- Context Item Operations --- + +func TestStoreContextItems(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "hello", 2) + msg2, _ := s.AddMessage(ctx, conv.ConversationID, "assistant", "world", 2) + + // Upsert items + items := []ContextItem{ + {Ordinal: 100, ItemType: "message", MessageID: msg1.ID, TokenCount: 2}, + {Ordinal: 200, ItemType: "message", MessageID: msg2.ID, TokenCount: 2}, + } + err := s.UpsertContextItems(ctx, conv.ConversationID, items) + if err != nil { + t.Fatalf("UpsertContextItems: %v", err) + } + + // Retrieve + retrieved, err := s.GetContextItems(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetContextItems: %v", err) + } + if len(retrieved) != 2 { + t.Fatalf("expected 2 items, got %d", len(retrieved)) + } + if retrieved[0].Ordinal != 100 || retrieved[1].Ordinal != 200 { + t.Errorf("ordinals = %v, want [100 200]", []int{retrieved[0].Ordinal, retrieved[1].Ordinal}) + } + // CreatedAt should be populated + if retrieved[0].CreatedAt.IsZero() { + t.Error("expected CreatedAt to be populated on context item") + } +} + +func TestStoreAppendContextMessages(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "hello", 2) + msg2, _ := s.AddMessage(ctx, conv.ConversationID, "assistant", "world", 2) + + s.UpsertContextItems(ctx, conv.ConversationID, []ContextItem{ + {Ordinal: 100, ItemType: "message", MessageID: msg1.ID, TokenCount: 2}, + }) + + // Append single message + err := s.AppendContextMessage(ctx, conv.ConversationID, msg2.ID) + if err != nil { + t.Fatalf("AppendContextMessage: %v", err) + } + + items, _ := s.GetContextItems(ctx, conv.ConversationID) + if len(items) != 2 { + t.Fatalf("expected 2 items after append, got %d", len(items)) + } + if items[1].MessageID != msg2.ID { + t.Errorf("appended message ID = %d, want %d", items[1].MessageID, msg2.ID) + } +} + +func TestStoreReplaceContextRangeWithSummary(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + // Create messages and context items + msgs := make([]int64, 4) + for i := 0; i < 4; i++ { + m, _ := s.AddMessage(ctx, conv.ConversationID, "user", "msg", 2) + msgs[i] = m.ID + } + + items := []ContextItem{ + {Ordinal: 100, ItemType: "message", MessageID: msgs[0], TokenCount: 2}, + {Ordinal: 200, ItemType: "message", MessageID: msgs[1], TokenCount: 2}, + {Ordinal: 300, ItemType: "message", MessageID: msgs[2], TokenCount: 2}, + {Ordinal: 400, ItemType: "message", MessageID: msgs[3], TokenCount: 2}, + } + s.UpsertContextItems(ctx, conv.ConversationID, items) + + // Create a summary + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, + Content: "summary", TokenCount: 5, + }) + + // Replace ordinals 200-300 with summary + err := s.ReplaceContextRangeWithSummary(ctx, conv.ConversationID, 200, 300, summary.SummaryID) + if err != nil { + t.Fatalf("ReplaceContextRangeWithSummary: %v", err) + } + + // Verify: should have 3 items — msg[0], summary, msg[3] + result, _ := s.GetContextItems(ctx, conv.ConversationID) + if len(result) != 3 { + t.Fatalf("expected 3 items after replace, got %d", len(result)) + } + // First item should be message + if result[0].ItemType != "message" || result[0].MessageID != msgs[0] { + t.Errorf("item[0] = %+v, want message msgs[0]", result[0]) + } + // Second should be summary + if result[1].ItemType != "summary" || result[1].SummaryID != summary.SummaryID { + t.Errorf("item[1] = %+v, want summary", result[1]) + } + // Third should be message + if result[2].ItemType != "message" || result[2].MessageID != msgs[3] { + t.Errorf("item[2] = %+v, want message msgs[3]", result[2]) + } + // Verify summary token_count is set correctly (not 0) + if result[1].TokenCount != 5 { + t.Errorf("summary item TokenCount = %d, want 5 (from summary.TokenCount)", result[1].TokenCount) + } +} + +func TestStoreReplaceContextRangeResequenceOrdinals(t *testing.T) { + // Verify that resequenceContextItemsTx correctly assigns unique ordinals. + // BUG: The old implementation used `WHERE ordinal < 0` which matched ALL + // negative ordinals in each iteration, causing all items to get the same ordinal. + // + // To trigger resequencing, we need a scenario where the midpoint CONFLICTS + // with an existing ordinal AFTER deletion. This happens when: + // - We delete a range that doesn't include the midpoint + // - Or when ordinals are packed densely (no gaps) + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test-resequence") + + // Create 5 messages with DENSE ordinals (no gaps) to trigger conflict + msgs := make([]int64, 5) + for i := 0; i < 5; i++ { + m, _ := s.AddMessage(ctx, conv.ConversationID, "user", fmt.Sprintf("msg%d", i), 2) + msgs[i] = m.ID + } + + // Use dense ordinals: 100, 101, 102, 103, 104 + // When we delete 101-102 and insert at midpoint 101, it won't conflict. + // But if we use 100, 200, 300, 400, 500 and delete 200-300: + // - Midpoint = 250, which doesn't exist → no conflict → no resequence + // + // To trigger resequence, we need midpoint to land on an EXISTING ordinal. + // Example: ordinals 100, 150, 200, 250, 300 + // Delete 150-200 (midpoint = 175, doesn't exist) + // + // Actually, resequence is triggered when midpoint CONFLICTS with existing. + // Let's use: 100, 150, 200, 201, 202 (dense in the middle) + // Delete 150-200, midpoint = 175 (doesn't exist after delete) + // + // The only way to trigger conflict is if we DON'T delete the midpoint ordinal. + // But ReplaceContextRangeWithSummary deletes the range first, then checks midpoint. + // + // Real-world: resequence is triggered when ordinal space is exhausted + // (midpoint calculation lands on existing ordinal due to density). + // Let's simulate this by having many items with ordinal_step=1: + items := []ContextItem{ + {Ordinal: 100, ItemType: "message", MessageID: msgs[0], TokenCount: 2}, + {Ordinal: 101, ItemType: "message", MessageID: msgs[1], TokenCount: 2}, + {Ordinal: 102, ItemType: "message", MessageID: msgs[2], TokenCount: 2}, + {Ordinal: 103, ItemType: "message", MessageID: msgs[3], TokenCount: 2}, + {Ordinal: 104, ItemType: "message", MessageID: msgs[4], TokenCount: 2}, + } + s.UpsertContextItems(ctx, conv.ConversationID, items) + + // Create a summary + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, + Content: "summary", TokenCount: 5, + }) + + // Delete 101-102, insert at midpoint 101 + // After delete: 100, 103, 104 + // Midpoint = (101+102)/2 = 101, which doesn't exist after delete + // → No conflict, insert at 101 + // → Result: 100, 101 (summary), 103, 104 + // + // This still doesn't trigger resequence! The resequence is only triggered + // when the midpoint lands on an EXISTING ordinal. + // + // Let me try a different approach: delete 101-103, midpoint = 102 + // After delete: 100, 104 + // Midpoint 102 doesn't exist → no conflict + // + // To force conflict, we need midpoint to land on a remaining ordinal. + // With ordinals 100, 101, 102, 103, 104: + // Delete 100-101, midpoint = 100 (exists? NO, we deleted it!) + // + // The resequence is triggered when we can't find a gap to insert. + // This happens when ordinals are very dense AND we try to insert + // at a position that's already taken. + // + // Actually, let's just test the happy path where resequence ISN'T triggered, + // and verify ordinals are still correct: + + err := s.ReplaceContextRangeWithSummary(ctx, conv.ConversationID, 101, 102, summary.SummaryID) + if err != nil { + t.Fatalf("ReplaceContextRangeWithSummary: %v", err) + } + + result, _ := s.GetContextItems(ctx, conv.ConversationID) + if len(result) != 4 { + t.Fatalf("expected 4 items after replace, got %d", len(result)) + } + + // After replace: 100 (msg0), 101 (summary), 103 (msg3), 104 (msg4) + expectedOrdinals := []int{100, 101, 103, 104} + for i, item := range result { + if item.Ordinal != expectedOrdinals[i] { + t.Errorf("item[%d].Ordinal = %d, want %d", i, item.Ordinal, expectedOrdinals[i]) + } + } + + // Verify no duplicate ordinals + ordinalSet := make(map[int]bool) + for _, item := range result { + if ordinalSet[item.Ordinal] { + t.Errorf("duplicate ordinal %d detected", item.Ordinal) + } + ordinalSet[item.Ordinal] = true + } +} + +func TestResequenceContextItemsTxAssignsUniqueOrdinals(t *testing.T) { + // Direct test of resequenceContextItemsTx to verify unique ordinal assignment. + // BUG: The old implementation used `WHERE ordinal < 0` which matched ALL + // negative ordinals, causing all items to get the same final ordinal. + // + // Example with 3 items at temp ordinals -1, -2, -3: + // - Loop 1: UPDATE ... SET ordinal=100 WHERE ordinal<0 → ALL become 100 + // - Loop 2: UPDATE ... SET ordinal=200 WHERE ordinal<0 → ALL become 200 + // - Loop 3: UPDATE ... SET ordinal=300 WHERE ordinal<0 → ALL become 300 + // Result: [300, 300, 300] - WRONG! + // + // Fixed: Use specific temp ordinal matching: + // - Loop 1: UPDATE ... SET ordinal=100 WHERE ordinal=-1 + // - Loop 2: UPDATE ... SET ordinal=200 WHERE ordinal=-2 + // - Loop 3: UPDATE ... SET ordinal=300 WHERE ordinal=-3 + // Result: [100, 200, 300] - CORRECT! + + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test-resequence-direct") + + // Create messages + msgs := make([]int64, 5) + for i := 0; i < 5; i++ { + m, _ := s.AddMessage(ctx, conv.ConversationID, "user", fmt.Sprintf("msg%d", i), 2) + msgs[i] = m.ID + } + + // Use ordinals that will trigger resequence when we try to insert at midpoint + // The key is to have a scenario where ReplaceContextRangeWithSummary calls resequenceContextItemsTx + // + // To trigger resequence, we need midpoint to conflict with an EXISTING ordinal + // AFTER the range deletion. This happens when: + // - Ordinals are: 100, 200, 201, 202, 300 (dense in middle) + // - Delete 200-202 (midpoint = 201, deleted) + // - After delete: 100, 300 + // - Midpoint 201 doesn't exist → no conflict + // + // Alternative: Use transaction directly to test resequenceContextItemsTx + + // First set up context items + items := []ContextItem{ + {Ordinal: 100, ItemType: "message", MessageID: msgs[0], TokenCount: 2}, + {Ordinal: 200, ItemType: "message", MessageID: msgs[1], TokenCount: 2}, + {Ordinal: 300, ItemType: "message", MessageID: msgs[2], TokenCount: 2}, + {Ordinal: 400, ItemType: "message", MessageID: msgs[3], TokenCount: 2}, + {Ordinal: 500, ItemType: "message", MessageID: msgs[4], TokenCount: 2}, + } + s.UpsertContextItems(ctx, conv.ConversationID, items) + + // Create a summary + summary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, + Content: "summary", TokenCount: 5, + }) + + // Call resequenceContextItemsTx directly via a transaction + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + defer tx.Rollback() + + err = s.resequenceContextItemsTx(ctx, tx, conv.ConversationID, summary.SummaryID) + if err != nil { + t.Fatalf("resequenceContextItemsTx: %v", err) + } + tx.Commit() + + // Verify ordinals are unique and properly spaced + result, _ := s.GetContextItems(ctx, conv.ConversationID) + // Should have 6 items: 5 original messages + 1 new summary + if len(result) != 6 { + t.Fatalf("expected 6 items after resequence, got %d", len(result)) + } + + // Expected ordinals: 100, 200, 300, 400, 500, 600 + // (5 existing items get 100-500, new summary gets 600) + expectedOrdinals := []int{100, 200, 300, 400, 500, 600} + for i, item := range result { + if item.Ordinal != expectedOrdinals[i] { + t.Errorf("item[%d].Ordinal = %d, want %d", i, item.Ordinal, expectedOrdinals[i]) + } + } + + // Verify no duplicate ordinals + ordinalSet := make(map[int]bool) + for _, item := range result { + if ordinalSet[item.Ordinal] { + t.Errorf("BUG: duplicate ordinal %d detected (all items got same ordinal)", item.Ordinal) + } + ordinalSet[item.Ordinal] = true + } + + // Verify summary token_count is set correctly (not 0) + var summaryItem *ContextItem + for i := range result { + if result[i].ItemType == "summary" { + summaryItem = &result[i] + break + } + } + if summaryItem == nil { + t.Fatal("no summary item found after resequence") + } + if summaryItem.TokenCount != 5 { + t.Errorf("summary item TokenCount = %d, want 5 (from summary.TokenCount)", summaryItem.TokenCount) + } +} + +func TestStoreGetContextTokenCount(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + msg, _ := s.AddMessage(ctx, conv.ConversationID, "user", "hello", 0) + + s.UpsertContextItems(ctx, conv.ConversationID, []ContextItem{ + {Ordinal: 100, ItemType: "message", MessageID: msg.ID, TokenCount: 42}, + }) + + count, err := s.GetContextTokenCount(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetContextTokenCount: %v", err) + } + if count != 42 { + t.Errorf("token count = %d, want 42", count) + } +} + +func TestStoreGetMaxOrdinal(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + // No items yet + maxOrd, err := s.GetMaxOrdinal(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetMaxOrdinal (empty): %v", err) + } + if maxOrd != 0 { + t.Errorf("max ordinal (empty) = %d, want 0", maxOrd) + } + + // Add items + msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "a", 1) + msg2, _ := s.AddMessage(ctx, conv.ConversationID, "user", "b", 1) + s.UpsertContextItems(ctx, conv.ConversationID, []ContextItem{ + {Ordinal: 100, ItemType: "message", MessageID: msg1.ID, TokenCount: 1}, + {Ordinal: 250, ItemType: "message", MessageID: msg2.ID, TokenCount: 1}, + }) + + maxOrd, _ = s.GetMaxOrdinal(ctx, conv.ConversationID) + if maxOrd != 250 { + t.Errorf("max ordinal = %d, want 250", maxOrd) + } +} + +// --- GetDistinctDepthsInContext --- + +func TestStoreGetDistinctDepthsInContext(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + // Empty context → no depths + depths, err := s.GetDistinctDepthsInContext(ctx, conv.ConversationID, 0) + if err != nil { + t.Fatalf("GetDistinctDepthsInContext (empty): %v", err) + } + if len(depths) != 0 { + t.Errorf("empty context: depths = %v, want []", depths) + } + + // Add leaf summaries at depth 0 + now := time.Now().UTC() + s1, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, + Content: "leaf1", TokenCount: 10, EarliestAt: &now, LatestAt: &now, + }) + s2, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, + Content: "leaf2", TokenCount: 10, EarliestAt: &now, LatestAt: &now, + }) + + // Add summaries to context + s.UpsertContextItems(ctx, conv.ConversationID, []ContextItem{ + {Ordinal: 100, ItemType: "summary", SummaryID: s1.SummaryID, TokenCount: 10}, + {Ordinal: 200, ItemType: "summary", SummaryID: s2.SummaryID, TokenCount: 10}, + }) + + // Should find depth 0 + depths, err = s.GetDistinctDepthsInContext(ctx, conv.ConversationID, 0) + if err != nil { + t.Fatalf("GetDistinctDepthsInContext: %v", err) + } + if len(depths) != 1 || depths[0] != 0 { + t.Errorf("depths = %v, want [0]", depths) + } + + // Add condensed at depth 1 + c1, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindCondensed, Depth: 1, + Content: "condensed1", TokenCount: 15, ParentIDs: []string{s1.SummaryID, s2.SummaryID}, + }) + s.AppendContextSummary(ctx, conv.ConversationID, c1.SummaryID) + + // Should find depths [0, 1] or [1, 0] + depths, _ = s.GetDistinctDepthsInContext(ctx, conv.ConversationID, 0) + if len(depths) != 2 { + t.Errorf("with condensed: depths = %v, want 2 distinct depths", depths) + } + + // Test maxOrdinalExclusive filter + // Get depths excluding ordinals >= 300 (the condensed one) + depths, _ = s.GetDistinctDepthsInContext(ctx, conv.ConversationID, 300) + if len(depths) != 1 || depths[0] != 0 { + t.Errorf("filtered depths = %v, want [0]", depths) + } +} + +// --- GetSummarySubtree --- + +func TestStoreGetSummarySubtree(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + // Create leaf summaries + now := time.Now().UTC() + l1, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, + Content: "leaf1", TokenCount: 10, EarliestAt: &now, LatestAt: &now, + }) + l2, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, + Content: "leaf2", TokenCount: 10, EarliestAt: &now, LatestAt: &now, + }) + l3, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, + Content: "leaf3", TokenCount: 10, EarliestAt: &now, LatestAt: &now, + }) + + // Condense l1+l2 → c1 + c1, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindCondensed, Depth: 1, + Content: "condensed1", TokenCount: 15, ParentIDs: []string{l1.SummaryID, l2.SummaryID}, + }) + + // Get subtree from c1 + nodes, err := s.GetSummarySubtree(ctx, c1.SummaryID) + if err != nil { + t.Fatalf("GetSummarySubtree: %v", err) + } + + // Should include c1 itself + l1 + l2 (but NOT l3) + if len(nodes) != 3 { + t.Errorf("subtree nodes = %d, want 3", len(nodes)) + } + + // Verify l3 is NOT in the subtree + for _, n := range nodes { + if n.SummaryID == l3.SummaryID { + t.Error("l3 should not be in c1's subtree") + } + } + + // Verify c1 has depth-from-root 0 + for _, n := range nodes { + if n.SummaryID == c1.SummaryID && n.DepthFromRoot != 0 { + t.Errorf("c1 depth-from-root = %d, want 0", n.DepthFromRoot) + } + } +} + +// --- Search with Rank and Time Filters --- + +func TestStoreSearchSummariesWithRank(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + // Create summaries with different content (for FTS matching) + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, + Content: "machine learning neural network", TokenCount: 10, + }) + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, + Content: "deep learning reinforcement", TokenCount: 10, + }) + + // FTS search — results should have Rank populated + results, err := s.SearchSummaries(ctx, SearchInput{ + Pattern: "learning", + Mode: "full_text", + ConversationID: conv.ConversationID, + }) + if err != nil { + t.Fatalf("SearchSummaries: %v", err) + } + if len(results) < 1 { + t.Fatalf("expected at least 1 result, got %d", len(results)) + } + // Rank should be populated (negative value from bm25) + for _, r := range results { + if r.Rank == 0 { + t.Error("expected non-zero Rank from FTS search") + } + } +} + +func TestStoreSearchSummariesWithTimeFilter(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + // Create a summary + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, Kind: SummaryKindLeaf, Depth: 0, + Content: "important meeting notes", TokenCount: 10, + }) + + // Search with Since filter (now - 1 hour → should match) + since := time.Now().UTC().Add(-1 * time.Hour) + results, err := s.SearchSummaries(ctx, SearchInput{ + Pattern: "meeting", + Mode: "full_text", + ConversationID: conv.ConversationID, + Since: &since, + }) + if err != nil { + t.Fatalf("SearchSummaries with Since: %v", err) + } + if len(results) != 1 { + t.Errorf("Since=1h-ago: expected 1 result, got %d", len(results)) + } + + // Search with Before filter (1 hour in future → should match) + before := time.Now().UTC().Add(1 * time.Hour) + results, err = s.SearchSummaries(ctx, SearchInput{ + Pattern: "meeting", + Mode: "full_text", + ConversationID: conv.ConversationID, + Before: &before, + }) + if err != nil { + t.Fatalf("SearchSummaries with Before: %v", err) + } + if len(results) != 1 { + t.Errorf("Before=1h-future: expected 1 result, got %d", len(results)) + } + + // Search with Since in the future → should NOT match + futureSince := time.Now().UTC().Add(1 * time.Hour) + results, err = s.SearchSummaries(ctx, SearchInput{ + Pattern: "meeting", + Mode: "full_text", + ConversationID: conv.ConversationID, + Since: &futureSince, + }) + if err != nil { + t.Fatalf("SearchSummaries with future Since: %v", err) + } + if len(results) != 0 { + t.Errorf("Since=1h-future: expected 0 results, got %d", len(results)) + } +} + +func TestSearchMessagesUsesFTS5(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "test:fts5-messages") + convID := conv.ConversationID + + // Add messages with searchable content + s.AddMessage(ctx, convID, "user", "The quick brown fox jumps over the lazy dog", 10) + s.AddMessage(ctx, convID, "assistant", "A response about something else entirely", 10) + s.AddMessage(ctx, convID, "user", "Five boxing wizards jump quickly at dawn", 10) + + input := SearchInput{ + Pattern: "fox jumps", + Mode: "full_text", + ConversationID: convID, + Limit: 10, + } + + results, err := s.SearchMessages(ctx, input) + if err != nil { + t.Fatalf("SearchMessages FTS5: %v", err) + } + + // Should find the message containing "fox jumps" + found := false + for _, r := range results { + if r.MessageID > 0 && contains(r.Snippet, "fox") { + found = true + break + } + } + if !found { + t.Error("FTS5 search should find message with 'fox jumps'") + } +} + +func TestMessagesFTSTriggers(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "test:fts-triggers") + convID := conv.ConversationID + + // Insert a message + _, err := s.AddMessage(ctx, convID, "user", "database migration completed successfully", 10) + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + + // Verify FTS table was populated by INSERT trigger + var count int + err = s.db.QueryRowContext(ctx, + "SELECT count(*) FROM messages_fts WHERE messages_fts MATCH 'migration'", + ).Scan(&count) + if err != nil { + t.Fatalf("query messages_fts: %v", err) + } + if count != 1 { + t.Errorf("messages_fts should have 1 row after INSERT, got %d", count) + } + + // Verify the content column has the right text + var content string + err = s.db.QueryRowContext(ctx, + "SELECT content FROM messages_fts WHERE messages_fts MATCH 'migration'", + ).Scan(&content) + if err != nil { + t.Fatalf("query content from fts: %v", err) + } + if content != "database migration completed successfully" { + t.Errorf("fts content = %q, want original message content", content) + } +} + +func TestSearchMessagesWithTimeFilter(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "test:msg-time") + convID := conv.ConversationID + + // Add messages + s.AddMessage(ctx, convID, "user", "important deployment notes", 10) + + // Search with Since filter (1 hour ago → should match) + since := time.Now().UTC().Add(-1 * time.Hour) + results, err := s.SearchMessages(ctx, SearchInput{ + Pattern: "deployment", + Mode: "like", + ConversationID: convID, + Since: &since, + }) + if err != nil { + t.Fatalf("SearchMessages with Since: %v", err) + } + if len(results) != 1 { + t.Errorf("Since=1h-ago: expected 1 result, got %d", len(results)) + } + + // Search with Before filter (1 hour in future → should match) + before := time.Now().UTC().Add(1 * time.Hour) + results, err = s.SearchMessages(ctx, SearchInput{ + Pattern: "deployment", + Mode: "like", + ConversationID: convID, + Before: &before, + }) + if err != nil { + t.Fatalf("SearchMessages with Before: %v", err) + } + if len(results) != 1 { + t.Errorf("Before=1h-future: expected 1 result, got %d", len(results)) + } + + // Search with Since in the future → should NOT match + futureSince := time.Now().UTC().Add(1 * time.Hour) + results, err = s.SearchMessages(ctx, SearchInput{ + Pattern: "deployment", + Mode: "like", + ConversationID: convID, + Since: &futureSince, + }) + if err != nil { + t.Fatalf("SearchMessages with future Since: %v", err) + } + if len(results) != 0 { + t.Errorf("Since=1h-future: expected 0 results, got %d", len(results)) + } +} + +func TestStoreSearchSummariesReturnsContent(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test") + + // Create a summary with known content + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "This is the summary content for testing", + TokenCount: 10, + }) + + // Search should return the full content, not empty + results, err := s.SearchSummaries(ctx, SearchInput{ + Pattern: "summary content", + Mode: "like", + ConversationID: conv.ConversationID, + }) + if err != nil { + t.Fatalf("SearchSummaries: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Content == "" { + t.Error("SearchResult.Content is empty, want full summary content") + } + if results[0].Content != "This is the summary content for testing" { + t.Errorf("SearchResult.Content = %q, want %q", results[0].Content, "This is the summary content for testing") + } +} + +func TestStoreReplaceContextItemsWithSummary(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:test-replace-items") + + // Create messages + msgs := make([]int64, 5) + for i := 0; i < 5; i++ { + m, _ := s.AddMessage(ctx, conv.ConversationID, "user", fmt.Sprintf("msg%d", i), 2) + msgs[i] = m.ID + } + + // Create summaries + summaries := make([]string, 3) + for i := 0; i < 3; i++ { + sum, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: fmt.Sprintf("summary %d", i), + TokenCount: 10, + }) + summaries[i] = sum.SummaryID + } + + // Insert context items with a message in between summaries: + // Ordinals: 100 (summary0), 200 (message), 300 (summary1), 400 (summary2) + items := []ContextItem{ + {Ordinal: 100, ItemType: "summary", SummaryID: summaries[0], TokenCount: 10}, + {Ordinal: 200, ItemType: "message", MessageID: msgs[1], TokenCount: 2}, + {Ordinal: 300, ItemType: "summary", SummaryID: summaries[1], TokenCount: 10}, + {Ordinal: 400, ItemType: "summary", SummaryID: summaries[2], TokenCount: 10}, + } + s.UpsertContextItems(ctx, conv.ConversationID, items) + + // Create a new summary to replace with + newSummary, _ := s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindCondensed, + Depth: 1, + Content: "condensed summary", + TokenCount: 15, + }) + + // Replace summaries 0 and 1 (not 2) using per-item deletion + // This should NOT delete the message at ordinal 200 + err := s.ReplaceContextItemsWithSummary( + ctx, conv.ConversationID, + []string{summaries[0], summaries[1]}, + newSummary.SummaryID) + if err != nil { + t.Fatalf("ReplaceContextItemsWithSummary: %v", err) + } + + // Verify result: should have 3 items (message at 200, summary2 at 400, new summary) + result, _ := s.GetContextItems(ctx, conv.ConversationID) + if len(result) != 3 { + t.Fatalf("expected 3 items after replace, got %d", len(result)) + } + + // Verify message at ordinal 200 is preserved + messagePreserved := false + for _, item := range result { + if item.ItemType == "message" && item.MessageID == msgs[1] { + messagePreserved = true + break + } + } + if !messagePreserved { + t.Error("message at ordinal 200 should have been preserved") + } + + // Verify summary2 at ordinal 400 is preserved + summary2Preserved := false + for _, item := range result { + if item.ItemType == "summary" && item.SummaryID == summaries[2] { + summary2Preserved = true + break + } + } + if !summary2Preserved { + t.Error("summary2 at ordinal 400 should have been preserved") + } + + // Verify new summary exists + newSummaryFound := false + for _, item := range result { + if item.ItemType == "summary" && item.SummaryID == newSummary.SummaryID { + newSummaryFound = true + break + } + } + if !newSummaryFound { + t.Error("new summary should exist") + } + + // Verify no duplicate ordinals + ordinalSet := make(map[int]bool) + for _, item := range result { + if ordinalSet[item.Ordinal] { + t.Errorf("duplicate ordinal %d detected", item.Ordinal) + } + ordinalSet[item.Ordinal] = true + } +} diff --git a/pkg/seahorse/tool_expand.go b/pkg/seahorse/tool_expand.go new file mode 100644 index 000000000..749c9cd6c --- /dev/null +++ b/pkg/seahorse/tool_expand.go @@ -0,0 +1,129 @@ +package seahorse + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/sipeed/picoclaw/pkg/tools" +) + +// ExpandTool recovers full message content by ID. +type ExpandTool struct { + engine *RetrievalEngine +} + +func NewExpandTool(engine *RetrievalEngine) *ExpandTool { + return &ExpandTool{engine: engine} +} + +func (t *ExpandTool) Name() string { + return "short_expand" +} + +func (t *ExpandTool) Description() string { + return `Get full message content by ID. + +Use when short_grep returns messages and you need complete content (not just snippet). + +Parameters: +- message_ids (required): Array of message ID strings (from short_grep results) + +Returns message with: +- content: Full text content +- parts: Structured content + - text: Full text + - tool_use: name, arguments, toolCallId + - tool_result: toolCallId only (content omitted - re-run tool if needed) + - media: mediaUri (file path), mimeType + +Notes: +- tool_result content is not returned (can be large). Re-run the tool if you need the result. +- Media files are stored on disk at mediaUri path, use bash to access. + +Example: + {"message_ids": ["10", "25"]}` +} + +func (t *ExpandTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "message_ids": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "Message IDs to expand (from short_grep results, e.g., [\"10\", \"25\"])", + }, + }, + "required": []string{"message_ids"}, + } +} + +func (t *ExpandTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + idsRaw, ok := args["message_ids"].([]any) + if !ok || len(idsRaw) == 0 { + return tools.ErrorResult( + "Missing required 'message_ids' argument. " + + "Example: {\"message_ids\": [\"10\", \"25\"]}") + } + + // Parse message IDs + messageIDs := make([]int64, 0, len(idsRaw)) + for _, id := range idsRaw { + switch v := id.(type) { + case string: + var n int64 + if _, err := fmt.Sscanf(v, "%d", &n); err != nil { + return tools.ErrorResult(fmt.Sprintf("Invalid message_id %q: %v", v, err)) + } + messageIDs = append(messageIDs, n) + case float64: + messageIDs = append(messageIDs, int64(v)) + } + } + + result, err := t.engine.ExpandMessages(ctx, messageIDs) + if err != nil { + return tools.ErrorResult("Expand failed: " + err.Error()) + } + + // Build response with filtered parts + messages := make([]map[string]any, 0, len(result.Messages)) + for _, msg := range result.Messages { + parts := make([]map[string]any, 0, len(msg.Parts)) + for _, p := range msg.Parts { + part := map[string]any{"type": p.Type} + switch p.Type { + case "text": + part["text"] = p.Text + case "tool_use": + part["name"] = p.Name + part["arguments"] = p.Arguments + part["toolCallId"] = p.ToolCallID + case "tool_result": + // Omit content - can be large, re-run tool if needed + part["toolCallId"] = p.ToolCallID + case "media": + part["mediaUri"] = p.MediaURI + part["mimeType"] = p.MimeType + } + parts = append(parts, part) + } + + messages = append(messages, map[string]any{ + "id": fmt.Sprintf("%d", msg.ID), + "role": msg.Role, + "content": msg.Content, + "parts": parts, + "conversationId": msg.ConversationID, + }) + } + + output := map[string]any{ + "success": true, + "tokenCount": result.TokenCount, + "messages": messages, + } + data, _ := json.Marshal(output) + return tools.NewToolResult(string(data)) +} diff --git a/pkg/seahorse/tool_expand_test.go b/pkg/seahorse/tool_expand_test.go new file mode 100644 index 000000000..fc726a7a0 --- /dev/null +++ b/pkg/seahorse/tool_expand_test.go @@ -0,0 +1,136 @@ +package seahorse + +import ( + "context" + "encoding/json" + "fmt" + "testing" +) + +func TestExpandToolByMessageIDs(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "test:expand-tool") + + msg1, _ := s.AddMessage(ctx, conv.ConversationID, "user", "first message", 10) + msg2, _ := s.AddMessage(ctx, conv.ConversationID, "assistant", "second message", 10) + + re := &RetrievalEngine{store: s} + tool := NewExpandTool(re) + + result := tool.Execute(ctx, map[string]any{ + "message_ids": []any{fmt.Sprintf("%d", msg1.ID), fmt.Sprintf("%d", msg2.ID)}, + }) + + if result.IsError { + t.Fatalf("Expand failed: %s", result.ForLLM) + } + + // Parse result + var output struct { + Success bool `json:"success"` + TokenCount int `json:"tokenCount"` + Messages []map[string]any `json:"messages"` + } + if err := json.Unmarshal([]byte(result.ForLLM), &output); err != nil { + t.Fatalf("Parse result: %v", err) + } + + if !output.Success { + t.Error("expected success=true") + } + if len(output.Messages) != 2 { + t.Errorf("Messages = %d, want 2", len(output.Messages)) + } + if output.TokenCount != 20 { + t.Errorf("TokenCount = %d, want 20", output.TokenCount) + } +} + +func TestExpandToolMissingIDs(t *testing.T) { + s := openTestStore(t) + re := &RetrievalEngine{store: s} + tool := NewExpandTool(re) + + result := tool.Execute(context.Background(), map[string]any{}) + + if !result.IsError { + t.Error("expected error for missing message_ids") + } +} + +func TestExpandToolWithParts(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "test:expand-parts") + + // Create message with parts + parts := []MessagePart{ + {Type: "text", Text: "Hello"}, + {Type: "tool_use", Name: "bash", Arguments: `{"command":"ls"}`, ToolCallID: "call_123"}, + {Type: "tool_result", ToolCallID: "call_123", Text: "file1.txt\nfile2.txt"}, + } + msg, _ := s.AddMessageWithParts(ctx, conv.ConversationID, "assistant", parts, 50) + + re := &RetrievalEngine{store: s} + tool := NewExpandTool(re) + + result := tool.Execute(ctx, map[string]any{ + "message_ids": []any{fmt.Sprintf("%d", msg.ID)}, + }) + + if result.IsError { + t.Fatalf("Expand failed: %s", result.ForLLM) + } + + var output struct { + Messages []struct { + Parts []map[string]any `json:"parts"` + } `json:"messages"` + } + if err := json.Unmarshal([]byte(result.ForLLM), &output); err != nil { + t.Fatalf("Parse result: %v", err) + } + + if len(output.Messages) != 1 { + t.Fatalf("Messages = %d, want 1", len(output.Messages)) + } + + // Verify parts are filtered correctly + foundText := false + foundToolUse := false + foundToolResult := false + for _, p := range output.Messages[0].Parts { + switch p["type"].(string) { + case "text": + foundText = true + if p["text"] != "Hello" { + t.Errorf("text = %v, want Hello", p["text"]) + } + case "tool_use": + foundToolUse = true + if p["name"] != "bash" { + t.Errorf("name = %v, want bash", p["name"]) + } + case "tool_result": + foundToolResult = true + // tool_result should NOT have content + if _, hasContent := p["content"]; hasContent { + t.Error("tool_result should not have content field") + } + if p["toolCallId"] != "call_123" { + t.Errorf("toolCallId = %v, want call_123", p["toolCallId"]) + } + } + } + + if !foundText { + t.Error("missing text part") + } + if !foundToolUse { + t.Error("missing tool_use part") + } + if !foundToolResult { + t.Error("missing tool_result part") + } +} diff --git a/pkg/seahorse/tool_grep.go b/pkg/seahorse/tool_grep.go new file mode 100644 index 000000000..9671d2a7f --- /dev/null +++ b/pkg/seahorse/tool_grep.go @@ -0,0 +1,172 @@ +package seahorse + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/sipeed/picoclaw/pkg/tools" +) + +// GrepTool searches summaries and messages for matching content. +type GrepTool struct { + engine *RetrievalEngine +} + +func NewGrepTool(engine *RetrievalEngine) *GrepTool { + return &GrepTool{engine: engine} +} + +func (t *GrepTool) Name() string { + return "short_grep" +} + +func (t *GrepTool) Description() string { + return `Search summaries and messages for matching content. + +Pattern syntax: +- Words: "authentication" - matches content containing this word +- AND: "auth AND login" - matches content with both words +- OR: "auth OR signin" - matches content with either word +- NOT: "bug NOT fixed" - matches "bug" but excludes "fixed" +- Wildcard: "%auth%" - matches any text containing "auth" (e.g., "auth", "authentication") + +Each summary has a "depth" field: +- depth 0: Created from messages, most detailed +- depth 1+: Created from other summaries, more compressed but covers longer time + +Parameters: +- pattern (required): Search pattern +- scope: "both" (default), "summary", or "message" - what to search +- role: "user", "assistant", or omit for all - filter by message role +- last: Time shortcut like "6h", "7d", "2w", "1m" (hours/days/weeks/months) +- all_conversations: Search all conversations (default: current only) +- since: ISO8601 timestamp, content after this time +- before: ISO8601 timestamp, content before this time +- limit: Max results (default: 20) + +Returns: +{ + "success": true, + "summaries": [{"id": "sum_abc", "content": "...", "depth": 0, "kind": "leaf", "conversationId": 1, "rank": -0.5}], + "messages": [{"id": "10", "snippet": "...matched...", "role": "user", "conversationId": 1, "rank": -1.2}], + "totalSummaries": 5, + "totalMessages": 10, + "hint": "No matches. Try: %keyword% for fuzzy search" +} + +Rank field (FTS5 mode only): bm25 relevance score, negative value where more negative = higher relevance. +Examples: -5=excellent, -2=good, -0.5=partial. LIKE mode (%pattern%) has no rank. + +Examples: + {"pattern": "authentication"} + {"pattern": "bug AND login"} + {"pattern": "%snake%"} + {"pattern": "project", "scope": "summary"} + {"pattern": "error", "role": "assistant", "last": "7d"} + {"pattern": "error", "all_conversations": true}` +} + +func (t *GrepTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "pattern": map[string]any{ + "type": "string", + "description": "Search pattern. Supports: words, AND/OR/NOT operators, % wildcard", + }, + "scope": map[string]any{ + "type": "string", + "enum": []string{"both", "summary", "message"}, + "description": "What to search: 'both' (default), 'summary', or 'message'", + }, + "role": map[string]any{ + "type": "string", + "enum": []string{"user", "assistant"}, + "description": "Filter by message role (default: all roles)", + }, + "last": map[string]any{ + "type": "string", + "description": "Time shortcut: '6h' (6 hours), '7d' (7 days), '2w' (2 weeks), '1m' (1 month)", + }, + "all_conversations": map[string]any{ + "type": "boolean", + "description": "Search across all conversations (default: searches current conversation only)", + }, + "since": map[string]any{ + "type": "string", + "description": "ISO8601 timestamp, only return content after this time", + }, + "before": map[string]any{ + "type": "string", + "description": "ISO8601 timestamp, only return content before this time", + }, + "limit": map[string]any{ + "type": "integer", + "description": "Maximum number of results (default: 20)", + }, + }, + "required": []string{"pattern"}, + } +} + +func (t *GrepTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + pattern, ok := args["pattern"].(string) + if !ok || pattern == "" { + return tools.ErrorResult("Missing required 'pattern' argument. Example: {\"pattern\": \"authentication\"}") + } + + input := GrepInput{Pattern: pattern} + + if scope, ok := args["scope"].(string); ok && scope != "" { + input.Scope = scope + } + if role, ok := args["role"].(string); ok && role != "" { + input.Role = role + } + if last, ok := args["last"].(string); ok && last != "" { + input.Last = last + } + if allConv, ok := args["all_conversations"].(bool); ok { + input.AllConversations = allConv + } + if limit, ok := args["limit"].(float64); ok { + input.Limit = int(limit) + } + if sinceStr, ok := args["since"].(string); ok && sinceStr != "" { + parsed, err := time.Parse(time.RFC3339, sinceStr) + if err != nil { + return tools.ErrorResult(fmt.Sprintf( + "Invalid 'since' timestamp. Use RFC3339 format like '2024-01-15T10:00:00Z'. Error: %v", err)) + } + input.Since = &parsed + } + if beforeStr, ok := args["before"].(string); ok && beforeStr != "" { + parsed, err := time.Parse(time.RFC3339, beforeStr) + if err != nil { + return tools.ErrorResult(fmt.Sprintf("Invalid 'before' timestamp format: %v", err)) + } + input.Before = &parsed + } + + result, err := t.engine.Grep(ctx, input) + if err != nil { + return tools.ErrorResult("Grep failed: " + err.Error()) + } + + // Build response + output := map[string]any{ + "success": result.Success, + "summaries": result.Summaries, + "messages": result.Messages, + } + + // Add hint if provided + if result.Hint != "" { + output["hint"] = result.Hint + } + + data, _ := json.Marshal(output) + return tools.NewToolResult(string(data)) +} diff --git a/pkg/seahorse/tool_grep_test.go b/pkg/seahorse/tool_grep_test.go new file mode 100644 index 000000000..050d9deeb --- /dev/null +++ b/pkg/seahorse/tool_grep_test.go @@ -0,0 +1,72 @@ +package seahorse + +import ( + "context" + "testing" +) + +func TestGrepSearchSummaries(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "test:grep-tool") + + s.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "database connection pool configuration", + TokenCount: 50, + }) + + re := &RetrievalEngine{store: s} + results, err := re.Grep(ctx, GrepInput{ + Pattern: "database", + }) + if err != nil { + t.Fatalf("Grep: %v", err) + } + if len(results.Summaries) == 0 { + t.Error("expected at least 1 summary result") + } +} + +func TestGrepSearchMessages(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + conv, _ := s.GetOrCreateConversation(ctx, "test:grep-msg") + + s.AddMessage(ctx, conv.ConversationID, "user", "find this message about testing", 5) + s.AddMessage(ctx, conv.ConversationID, "user", "unrelated content", 3) + + re := &RetrievalEngine{store: s} + results, err := re.Grep(ctx, GrepInput{ + Pattern: "testing", + }) + if err != nil { + t.Fatalf("Grep messages: %v", err) + } + if len(results.Messages) == 0 { + t.Error("expected at least 1 message result") + } +} + +func TestGrepMissingPattern(t *testing.T) { + s := openTestStore(t) + re := &RetrievalEngine{store: s} + _, err := re.Grep(context.Background(), GrepInput{}) + if err == nil { + t.Error("expected error for missing pattern") + } +} + +func TestGrepToolSupportsAllConversations(t *testing.T) { + s := openTestStore(t) + tool := NewGrepTool(&RetrievalEngine{store: s}) + params := tool.Parameters() + props := params["properties"].(map[string]any) + + // GrepTool should accept all_conversations parameter + if _, ok := props["all_conversations"]; !ok { + t.Error("Parameters missing 'all_conversations' field") + } +} diff --git a/pkg/seahorse/types.go b/pkg/seahorse/types.go new file mode 100644 index 000000000..2bc7f931f --- /dev/null +++ b/pkg/seahorse/types.go @@ -0,0 +1,161 @@ +package seahorse + +import ( + "time" + + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tokenizer" +) + +// SummaryKind distinguishes leaf summaries (from raw messages) vs condensed +// summaries (from other summaries). +type SummaryKind string + +const ( + SummaryKindLeaf SummaryKind = "leaf" + SummaryKindCondensed SummaryKind = "condensed" +) + +// Message represents a single chat message with role and content. +type Message struct { + ID int64 `json:"id"` + ConversationID int64 `json:"conversationId"` + Role string `json:"role"` + Content string `json:"content"` + ReasoningContent string `json:"reasoningContent,omitempty"` + TokenCount int `json:"tokenCount"` + CreatedAt time.Time `json:"createdAt"` + Parts []MessagePart `json:"parts,omitempty"` +} + +// MessagePart holds structured content (tool calls, media, etc.) +type MessagePart struct { + ID int64 `json:"id"` + MessageID int64 `json:"messageId"` + Type string `json:"type"` // "text", "tool_use", "tool_result", "media" + Text string `json:"text"` + Name string `json:"name"` + Arguments string `json:"arguments"` + ToolCallID string `json:"toolCallId"` + MediaURI string `json:"mediaUri"` + MimeType string `json:"mimeType"` +} + +// Summary represents a compressed representation of messages or other summaries. +type Summary struct { + SummaryID string `json:"summaryId"` + ConversationID int64 `json:"conversationId"` + Kind SummaryKind `json:"kind"` + Depth int `json:"depth"` + Content string `json:"content"` + TokenCount int `json:"tokenCount"` + EarliestAt *time.Time `json:"earliestAt,omitempty"` + LatestAt *time.Time `json:"latestAt,omitempty"` + DescendantCount int `json:"descendantCount"` + DescendantTokenCount int `json:"descendantTokenCount"` + SourceMessageTokenCount int `json:"sourceMessageTokenCount"` + Model string `json:"model"` + CreatedAt time.Time `json:"createdAt"` +} + +// SummaryNode is a Summary with graph relationships for tree traversal. +type SummaryNode struct { + Summary + Children []string `json:"children"` // Child summary IDs + Expanded bool `json:"expanded"` // UI state for expansion +} + +// Conversation represents a session's conversation with metadata. +type Conversation struct { + ConversationID int64 `json:"conversationId"` + SessionKey string `json:"sessionKey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// SessionStatus contains status information for a session. +type SessionStatus struct { + SessionKey string `json:"sessionKey"` + ConversationID int64 `json:"conversationId"` + Messages int `json:"messages"` + TotalTokens int `json:"totalTokens"` + Summaries int `json:"summaries"` + OldestAt time.Time `json:"oldestAt"` + NewestAt time.Time `json:"newestAt"` +} + +// ContextItem represents one item in the assembled context window. +type ContextItem struct { + ConversationID int64 `json:"conversationId"` + Ordinal int `json:"ordinal"` + ItemType string `json:"itemType"` // "summary" or "message" + SummaryID string `json:"summaryId,omitempty"` + MessageID int64 `json:"messageId,omitempty"` + TokenCount int `json:"tokenCount"` + CreatedAt time.Time `json:"createdAt"` +} + +// SummarySubtreeNode is a node in a summary DAG subtree. +type SummarySubtreeNode struct { + SummaryID string `json:"summaryId"` + DepthFromRoot int `json:"depthFromRoot"` +} + +// SearchInput controls summary search. +type SearchInput struct { + Pattern string `json:"pattern"` + Mode string `json:"mode"` // "like" (LIKE search) or "full_text" (FTS5, default) + Scope string `json:"scope,omitempty"` // "messages", "summaries", "both" + Role string `json:"role,omitempty"` // "user", "assistant", or "" (all) + Since *time.Time `json:"since,omitempty"` + Before *time.Time `json:"before,omitempty"` + Limit int `json:"limit,omitempty"` + ConversationID int64 `json:"conversationId,omitempty"` + AllConversations bool `json:"allConversations,omitempty"` +} + +// SearchResult is a search match. +type SearchResult struct { + SummaryID string `json:"summaryId,omitempty"` + MessageID int64 `json:"messageId,omitempty"` + ConversationID int64 `json:"conversationId"` + Kind SummaryKind `json:"kind,omitempty"` + Depth int `json:"depth,omitempty"` + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` // Full content for summaries + Snippet string `json:"snippet"` + CreatedAt time.Time `json:"createdAt"` + Rank float64 `json:"rank,omitempty"` + TotalCount int `json:"totalCount,omitempty"` // Total matching rows (from window function) +} + +// EstimateMessageTokens estimates token count for a full message using the +// shared tokenizer package for consistency with agent.context_budget. +func EstimateMessageTokens(msg Message) int { + pm := providers.Message{ + Role: msg.Role, + Content: msg.Content, + ReasoningContent: msg.ReasoningContent, + } + + // Convert MessageParts to ToolCalls / ToolCallID / Media + for _, part := range msg.Parts { + switch part.Type { + case "tool_use": + pm.ToolCalls = append(pm.ToolCalls, providers.ToolCall{ + ID: part.ToolCallID, + Type: "function", + Function: &providers.FunctionCall{ + Name: part.Name, + Arguments: part.Arguments, + }, + }) + case "tool_result": + pm.ToolCallID = part.ToolCallID + case "media": + pm.Media = append(pm.Media, part.MediaURI) + } + } + + return tokenizer.EstimateMessageTokens(pm) +} diff --git a/pkg/seahorse/types_test.go b/pkg/seahorse/types_test.go new file mode 100644 index 000000000..b7467005f --- /dev/null +++ b/pkg/seahorse/types_test.go @@ -0,0 +1,54 @@ +package seahorse + +import ( + "testing" +) + +func TestSummaryKindValues(t *testing.T) { + if SummaryKindLeaf != "leaf" { + t.Errorf("expected SummaryKindLeaf = 'leaf', got %q", SummaryKindLeaf) + } + if SummaryKindCondensed != "condensed" { + t.Errorf("expected SummaryKindCondensed = 'condensed', got %q", SummaryKindCondensed) + } +} + +func TestConstants(t *testing.T) { + // Ordinal gap step + if OrdinalStep != 100 { + t.Errorf("expected OrdinalStep = 100, got %d", OrdinalStep) + } + + // Compaction triggers + if ContextThreshold != 0.75 { + t.Errorf("expected ContextThreshold = 0.75, got %f", ContextThreshold) + } + if FreshTailCount != 32 { + t.Errorf("expected FreshTailCount = 32, got %d", FreshTailCount) + } + + // Fanout + if LeafMinFanout != 8 { + t.Errorf("expected LeafMinFanout = 8, got %d", LeafMinFanout) + } + if CondensedMinFanout != 4 { + t.Errorf("expected CondensedMinFanout = 4, got %d", CondensedMinFanout) + } + if CondensedMinFanoutHard != 2 { + t.Errorf("expected CondensedMinFanoutHard = 2, got %d", CondensedMinFanoutHard) + } + + // Token targets + if LeafChunkTokens != 20000 { + t.Errorf("expected LeafChunkTokens = 20000, got %d", LeafChunkTokens) + } + if LeafTargetTokens != 1200 { + t.Errorf("expected LeafTargetTokens = 1200, got %d", LeafTargetTokens) + } + if CondensedTargetTokens != 2000 { + t.Errorf("expected CondensedTargetTokens = 2000, got %d", CondensedTargetTokens) + } + if MaxExpandTokens != 4000 { + t.Errorf("expected MaxExpandTokens = 4000, got %d", MaxExpandTokens) + } +} diff --git a/pkg/session/allocator.go b/pkg/session/allocator.go new file mode 100644 index 000000000..509550cb2 --- /dev/null +++ b/pkg/session/allocator.go @@ -0,0 +1,213 @@ +package session + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/routing" +) + +// Allocation contains the concrete session keys selected for a routed turn. +// The current implementation intentionally preserves the legacy session-key +// layout while moving key construction out of the router. +type Allocation struct { + Scope SessionScope + SessionKey string + SessionAliases []string + MainSessionKey string + MainAliases []string +} + +// AllocationInput contains the routing result and peer context needed to +// derive the session keys for a turn. +type AllocationInput struct { + AgentID string + Context bus.InboundContext + SessionPolicy routing.SessionPolicy +} + +// AllocateRouteSession maps a route decision onto a structured scope and the +// current opaque session-key format. +func AllocateRouteSession(input AllocationInput) Allocation { + scope := buildSessionScope(input) + legacySessionAliases := buildLegacySessionAliases(input) + legacyMainSessionKey := strings.ToLower(BuildLegacyMainAlias(input.AgentID)) + return Allocation{ + Scope: scope, + SessionKey: BuildSessionKey(scope), + SessionAliases: legacySessionAliases, + MainSessionKey: BuildOpaqueSessionKey(legacyMainSessionKey), + MainAliases: []string{legacyMainSessionKey}, + } +} + +func buildSessionScope(input AllocationInput) SessionScope { + inbound := input.Context + includeTopicInChatDimension := shouldPreserveTelegramForumIsolation(input) + scope := SessionScope{ + Version: ScopeVersionV1, + AgentID: routing.NormalizeAgentID(input.AgentID), + Channel: strings.ToLower(strings.TrimSpace(inbound.Channel)), + Account: routing.NormalizeAccountID(inbound.Account), + } + if scope.Channel == "" { + scope.Channel = "unknown" + } + + dimensions := make([]string, 0, len(input.SessionPolicy.Dimensions)) + values := make(map[string]string, len(input.SessionPolicy.Dimensions)) + + for _, dimension := range input.SessionPolicy.Dimensions { + switch dimension { + case "space": + if spaceID := strings.TrimSpace(inbound.SpaceID); spaceID != "" { + spaceType := strings.ToLower(strings.TrimSpace(inbound.SpaceType)) + if spaceType == "" { + spaceType = "space" + } + dimensions = append(dimensions, "space") + values["space"] = fmt.Sprintf("%s:%s", spaceType, strings.ToLower(spaceID)) + } + case "chat": + chatID := strings.TrimSpace(inbound.ChatID) + if chatID == "" { + continue + } + if includeTopicInChatDimension { + if topicID := strings.TrimSpace(inbound.TopicID); topicID != "" { + chatID = chatID + "/" + topicID + } + } + chatType := strings.ToLower(strings.TrimSpace(inbound.ChatType)) + if chatType == "" { + chatType = "direct" + } + dimensions = append(dimensions, "chat") + values["chat"] = fmt.Sprintf("%s:%s", chatType, strings.ToLower(chatID)) + case "topic": + if topicID := strings.TrimSpace(inbound.TopicID); topicID != "" { + dimensions = append(dimensions, "topic") + values["topic"] = "topic:" + strings.ToLower(topicID) + } + case "sender": + senderID := CanonicalSessionIdentityID( + inbound.Channel, + inbound.SenderID, + input.SessionPolicy.IdentityLinks, + ) + if senderID == "" { + continue + } + dimensions = append(dimensions, "sender") + values["sender"] = senderID + } + } + + if len(dimensions) > 0 { + scope.Dimensions = dimensions + scope.Values = values + } + + return scope +} + +func buildLegacySessionAliases(input AllocationInput) []string { + aliases := []string{strings.ToLower(BuildLegacyMainAlias(input.AgentID))} + inbound := input.Context + + if strings.EqualFold(strings.TrimSpace(inbound.ChatType), "direct") { + peerIDs := buildLegacyDirectPeerIDs(input) + if len(peerIDs) == 0 { + return uniqueAliases(aliases) + } + for _, peerID := range peerIDs { + aliases = append( + aliases, + BuildLegacyDirectAliases(input.AgentID, inbound.Channel, inbound.Account, peerID)..., + ) + } + return uniqueAliases(aliases) + } + + peerID := strings.TrimSpace(inbound.ChatID) + if peerID == "" { + return uniqueAliases(aliases) + } + if topicID := strings.TrimSpace(inbound.TopicID); topicID != "" { + peerID = peerID + "/" + topicID + } + aliases = append(aliases, BuildLegacyPeerAlias( + input.AgentID, + inbound.Channel, + strings.ToLower(strings.TrimSpace(inbound.ChatType)), + peerID, + )) + + return uniqueAliases(aliases) +} + +func shouldPreserveTelegramForumIsolation(input AllocationInput) bool { + inbound := input.Context + if !strings.EqualFold(strings.TrimSpace(inbound.Channel), "telegram") { + return false + } + if strings.TrimSpace(inbound.TopicID) == "" { + return false + } + for _, dimension := range input.SessionPolicy.Dimensions { + if strings.EqualFold(strings.TrimSpace(dimension), "topic") { + return false + } + } + return true +} + +func buildLegacyDirectPeerIDs(input AllocationInput) []string { + inbound := input.Context + peerIDs := make([]string, 0, 3) + + rawSenderID := strings.TrimSpace(inbound.SenderID) + if rawSenderID != "" { + peerIDs = append(peerIDs, strings.ToLower(rawSenderID)) + } + + canonicalSenderID := CanonicalSessionIdentityID( + inbound.Channel, + inbound.SenderID, + input.SessionPolicy.IdentityLinks, + ) + if canonicalSenderID != "" { + peerIDs = append(peerIDs, canonicalSenderID) + } + + chatID := strings.TrimSpace(inbound.ChatID) + if chatID != "" { + peerIDs = append(peerIDs, strings.ToLower(chatID)) + } + + return uniqueAliases(peerIDs) +} + +func uniqueAliases(aliases []string) []string { + if len(aliases) == 0 { + return nil + } + normalized := make([]string, 0, len(aliases)) + seen := make(map[string]struct{}, len(aliases)) + for _, alias := range aliases { + alias = strings.TrimSpace(strings.ToLower(alias)) + if alias == "" { + continue + } + if _, ok := seen[alias]; ok { + continue + } + seen[alias] = struct{}{} + normalized = append(normalized, alias) + } + if len(normalized) == 0 { + return nil + } + return normalized +} diff --git a/pkg/session/allocator_test.go b/pkg/session/allocator_test.go new file mode 100644 index 000000000..9750ffc39 --- /dev/null +++ b/pkg/session/allocator_test.go @@ -0,0 +1,160 @@ +package session + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/routing" +) + +func TestAllocateRouteSession_PerPeerDM(t *testing.T) { + allocation := AllocateRouteSession(AllocationInput{ + AgentID: "main", + Context: bus.InboundContext{ + Channel: "telegram", + Account: "default", + ChatID: "dm-123", + ChatType: "direct", + SenderID: "User123", + }, + SessionPolicy: routing.SessionPolicy{ + Dimensions: []string{"sender"}, + }, + }) + + if allocation.SessionKey == "" || !IsOpaqueSessionKey(allocation.SessionKey) { + t.Fatalf("SessionKey = %q, want opaque session key", allocation.SessionKey) + } + if !containsAlias(allocation.SessionAliases, "agent:main:direct:user123") { + t.Fatalf("SessionAliases = %v, want to contain agent:main:direct:user123", allocation.SessionAliases) + } + if allocation.MainSessionKey == "" || !IsOpaqueSessionKey(allocation.MainSessionKey) { + t.Fatalf("MainSessionKey = %q, want opaque session key", allocation.MainSessionKey) + } + if len(allocation.MainAliases) != 1 || allocation.MainAliases[0] != "agent:main:main" { + t.Fatalf("MainAliases = %v, want [agent:main:main]", allocation.MainAliases) + } + if allocation.Scope.Version != ScopeVersionV1 { + t.Fatalf("Scope.Version = %d, want %d", allocation.Scope.Version, ScopeVersionV1) + } + if len(allocation.Scope.Dimensions) != 1 || allocation.Scope.Dimensions[0] != "sender" { + t.Fatalf("Scope.Dimensions = %v, want [sender]", allocation.Scope.Dimensions) + } + if allocation.Scope.Values["sender"] != "user123" { + t.Fatalf("Scope.Values[sender] = %q, want user123", allocation.Scope.Values["sender"]) + } +} + +func TestAllocateRouteSession_GroupPeer(t *testing.T) { + allocation := AllocateRouteSession(AllocationInput{ + AgentID: "main", + Context: bus.InboundContext{ + Channel: "slack", + Account: "workspace-a", + ChatID: "C001", + ChatType: "channel", + SenderID: "U001", + }, + SessionPolicy: routing.SessionPolicy{ + Dimensions: []string{"chat"}, + }, + }) + + if allocation.SessionKey == "" || !IsOpaqueSessionKey(allocation.SessionKey) { + t.Fatalf("SessionKey = %q, want opaque session key", allocation.SessionKey) + } + if !containsAlias(allocation.SessionAliases, "agent:main:slack:channel:c001") { + t.Fatalf("SessionAliases = %v, want to contain agent:main:slack:channel:c001", allocation.SessionAliases) + } + if allocation.MainSessionKey == "" || !IsOpaqueSessionKey(allocation.MainSessionKey) { + t.Fatalf("MainSessionKey = %q, want opaque session key", allocation.MainSessionKey) + } + if len(allocation.MainAliases) != 1 || allocation.MainAliases[0] != "agent:main:main" { + t.Fatalf("MainAliases = %v, want [agent:main:main]", allocation.MainAliases) + } + if len(allocation.Scope.Dimensions) != 1 || allocation.Scope.Dimensions[0] != "chat" { + t.Fatalf("Scope.Dimensions = %v, want [chat]", allocation.Scope.Dimensions) + } + if allocation.Scope.Values["chat"] != "channel:c001" { + t.Fatalf("Scope.Values[chat] = %q, want channel:c001", allocation.Scope.Values["chat"]) + } +} + +func TestAllocateRouteSession_TelegramForumTopicsRemainIsolatedByDefault(t *testing.T) { + first := AllocateRouteSession(AllocationInput{ + AgentID: "main", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "-1001234567890", + ChatType: "group", + TopicID: "42", + SenderID: "7", + }, + SessionPolicy: routing.SessionPolicy{ + Dimensions: []string{"chat"}, + }, + }) + second := AllocateRouteSession(AllocationInput{ + AgentID: "main", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "-1001234567890", + ChatType: "group", + TopicID: "99", + SenderID: "7", + }, + SessionPolicy: routing.SessionPolicy{ + Dimensions: []string{"chat"}, + }, + }) + + if first.SessionKey == second.SessionKey { + t.Fatalf("forum topics should not share default session key: %q", first.SessionKey) + } + if got := first.Scope.Values["chat"]; got != "group:-1001234567890/42" { + t.Fatalf("first.Scope.Values[chat] = %q, want %q", got, "group:-1001234567890/42") + } + if got := second.Scope.Values["chat"]; got != "group:-1001234567890/99" { + t.Fatalf("second.Scope.Values[chat] = %q, want %q", got, "group:-1001234567890/99") + } +} + +func TestAllocateRouteSession_PicoDirectAliasesIncludeLegacyChatKey(t *testing.T) { + allocation := AllocateRouteSession(AllocationInput{ + AgentID: "main", + Context: bus.InboundContext{ + Channel: "pico", + Account: "default", + ChatID: "pico:session-123", + ChatType: "direct", + SenderID: "pico-user", + }, + SessionPolicy: routing.SessionPolicy{ + Dimensions: []string{"sender"}, + }, + }) + + if !containsAlias(allocation.SessionAliases, "agent:main:pico:direct:pico:session-123") { + t.Fatalf("SessionAliases = %v, want pico legacy alias", allocation.SessionAliases) + } +} + +func TestBuildOpaqueSessionKey_IsStable(t *testing.T) { + first := BuildOpaqueSessionKey("agent:main:direct:user123") + second := BuildOpaqueSessionKey("agent:main:direct:user123") + if first != second { + t.Fatalf("BuildOpaqueSessionKey() mismatch: %q != %q", first, second) + } + if !IsOpaqueSessionKey(first) { + t.Fatalf("expected opaque session key, got %q", first) + } +} + +func containsAlias(aliases []string, want string) bool { + for _, alias := range aliases { + if alias == want { + return true + } + } + return false +} diff --git a/pkg/session/jsonl_backend.go b/pkg/session/jsonl_backend.go new file mode 100644 index 000000000..68ef2d753 --- /dev/null +++ b/pkg/session/jsonl_backend.go @@ -0,0 +1,192 @@ +package session + +import ( + "context" + "encoding/json" + "log" + "strings" + + "github.com/sipeed/picoclaw/pkg/memory" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// JSONLBackend adapts a memory.Store into the SessionStore interface. +// Write errors are logged rather than returned, matching the fire-and-forget +// contract of SessionManager that the agent loop relies on. +type JSONLBackend struct { + store memory.Store +} + +type metaAwareStore interface { + GetSessionMeta(ctx context.Context, sessionKey string) (memory.SessionMeta, error) + UpsertSessionMeta(ctx context.Context, sessionKey string, scope json.RawMessage, aliases []string) error + ResolveSessionKey(ctx context.Context, sessionKey string) (string, bool, error) +} + +type aliasPromotingStore interface { + PromoteAliasHistory(ctx context.Context, sessionKey string, scope json.RawMessage, aliases []string) (bool, error) +} + +// MetadataAwareSessionStore exposes structured session metadata operations. +type MetadataAwareSessionStore interface { + EnsureSessionMetadata(sessionKey string, scope *SessionScope, aliases []string) + ResolveSessionKey(sessionKey string) string + GetSessionScope(sessionKey string) *SessionScope +} + +// NewJSONLBackend wraps a memory.Store for use as a SessionStore. +func NewJSONLBackend(store memory.Store) *JSONLBackend { + return &JSONLBackend{store: store} +} + +func (b *JSONLBackend) resolveSessionKey(sessionKey string) string { + metaStore, ok := b.store.(metaAwareStore) + if !ok { + return sessionKey + } + resolved, found, err := metaStore.ResolveSessionKey(context.Background(), sessionKey) + if err != nil { + log.Printf("session: resolve session key: %v", err) + return sessionKey + } + if found && resolved != "" { + return resolved + } + return sessionKey +} + +// ResolveSessionKey maps aliases onto their canonical session key when the +// underlying store supports structured metadata. Unknown aliases fall back to +// the original input so existing callers remain compatible. +func (b *JSONLBackend) ResolveSessionKey(sessionKey string) string { + return b.resolveSessionKey(sessionKey) +} + +// EnsureSessionMetadata persists scope and alias metadata for a session. +func (b *JSONLBackend) EnsureSessionMetadata(sessionKey string, scope *SessionScope, aliases []string) { + metaStore, ok := b.store.(metaAwareStore) + if !ok { + return + } + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return + } + + var rawScope json.RawMessage + if scope != nil { + data, err := json.Marshal(scope) + if err != nil { + log.Printf("session: encode session scope: %v", err) + return + } + rawScope = data + } + ctx := context.Background() + if err := metaStore.UpsertSessionMeta(ctx, sessionKey, rawScope, aliases); err != nil { + log.Printf("session: upsert session metadata: %v", err) + return + } + + if promotingStore, ok := b.store.(aliasPromotingStore); ok { + if _, err := promotingStore.PromoteAliasHistory(ctx, sessionKey, rawScope, aliases); err != nil { + log.Printf("session: promote alias history: %v", err) + } + } +} + +// GetSessionScope reads structured scope metadata for a session key or alias. +func (b *JSONLBackend) GetSessionScope(sessionKey string) *SessionScope { + metaStore, ok := b.store.(metaAwareStore) + if !ok { + return nil + } + sessionKey = b.resolveSessionKey(sessionKey) + meta, err := metaStore.GetSessionMeta(context.Background(), sessionKey) + if err != nil { + log.Printf("session: get session metadata: %v", err) + return nil + } + if len(meta.Scope) == 0 { + return nil + } + var scope SessionScope + if err := json.Unmarshal(meta.Scope, &scope); err != nil { + log.Printf("session: decode session scope: %v", err) + return nil + } + return CloneScope(&scope) +} + +func (b *JSONLBackend) AddMessage(sessionKey, role, content string) { + sessionKey = b.resolveSessionKey(sessionKey) + if err := b.store.AddMessage(context.Background(), sessionKey, role, content); err != nil { + log.Printf("session: add message: %v", err) + } +} + +func (b *JSONLBackend) AddFullMessage(sessionKey string, msg providers.Message) { + sessionKey = b.resolveSessionKey(sessionKey) + if err := b.store.AddFullMessage(context.Background(), sessionKey, msg); err != nil { + log.Printf("session: add full message: %v", err) + } +} + +func (b *JSONLBackend) GetHistory(key string) []providers.Message { + key = b.resolveSessionKey(key) + msgs, err := b.store.GetHistory(context.Background(), key) + if err != nil { + log.Printf("session: get history: %v", err) + return []providers.Message{} + } + return msgs +} + +func (b *JSONLBackend) GetSummary(key string) string { + key = b.resolveSessionKey(key) + summary, err := b.store.GetSummary(context.Background(), key) + if err != nil { + log.Printf("session: get summary: %v", err) + return "" + } + return summary +} + +func (b *JSONLBackend) SetSummary(key, summary string) { + key = b.resolveSessionKey(key) + if err := b.store.SetSummary(context.Background(), key, summary); err != nil { + log.Printf("session: set summary: %v", err) + } +} + +func (b *JSONLBackend) SetHistory(key string, history []providers.Message) { + key = b.resolveSessionKey(key) + if err := b.store.SetHistory(context.Background(), key, history); err != nil { + log.Printf("session: set history: %v", err) + } +} + +func (b *JSONLBackend) TruncateHistory(key string, keepLast int) { + key = b.resolveSessionKey(key) + if err := b.store.TruncateHistory(context.Background(), key, keepLast); err != nil { + log.Printf("session: truncate history: %v", err) + } +} + +// Save persists session state. Since the JSONL store fsyncs every write +// immediately, the data is already durable. Save runs compaction to reclaim +// space from logically truncated messages (no-op when there are none). +func (b *JSONLBackend) Save(key string) error { + key = b.resolveSessionKey(key) + return b.store.Compact(context.Background(), key) +} + +// Close releases resources held by the underlying store. +func (b *JSONLBackend) Close() error { + return b.store.Close() +} + +// ListSessions returns all known session keys. +func (b *JSONLBackend) ListSessions() []string { + return b.store.ListSessions() +} diff --git a/pkg/session/jsonl_backend_test.go b/pkg/session/jsonl_backend_test.go new file mode 100644 index 000000000..0b79ad84d --- /dev/null +++ b/pkg/session/jsonl_backend_test.go @@ -0,0 +1,304 @@ +package session_test + +import ( + "fmt" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/memory" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" +) + +// Compile-time interface satisfaction checks. +var ( + _ session.SessionStore = (*session.SessionManager)(nil) + _ session.SessionStore = (*session.JSONLBackend)(nil) +) + +func newBackend(t *testing.T) *session.JSONLBackend { + t.Helper() + store, err := memory.NewJSONLStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + return session.NewJSONLBackend(store) +} + +func TestJSONLBackend_AddAndGetHistory(t *testing.T) { + b := newBackend(t) + + b.AddMessage("s1", "user", "hello") + b.AddMessage("s1", "assistant", "hi") + + history := b.GetHistory("s1") + if len(history) != 2 { + t.Fatalf("got %d messages, want 2", len(history)) + } + if history[0].Role != "user" || history[0].Content != "hello" { + t.Errorf("msg[0] = %+v", history[0]) + } + if history[1].Role != "assistant" || history[1].Content != "hi" { + t.Errorf("msg[1] = %+v", history[1]) + } +} + +func TestJSONLBackend_AddFullMessage(t *testing.T) { + b := newBackend(t) + + msg := providers.Message{ + Role: "assistant", + Content: "done", + ToolCalls: []providers.ToolCall{ + {ID: "tc1", Function: &providers.FunctionCall{Name: "read_file", Arguments: `{"path":"x"}`}}, + }, + } + b.AddFullMessage("s1", msg) + + history := b.GetHistory("s1") + if len(history) != 1 { + t.Fatalf("got %d, want 1", len(history)) + } + if len(history[0].ToolCalls) != 1 || history[0].ToolCalls[0].ID != "tc1" { + t.Errorf("tool calls = %+v", history[0].ToolCalls) + } +} + +func TestJSONLBackend_Summary(t *testing.T) { + b := newBackend(t) + + if got := b.GetSummary("s1"); got != "" { + t.Errorf("got %q, want empty", got) + } + + b.SetSummary("s1", "test summary") + if got := b.GetSummary("s1"); got != "test summary" { + t.Errorf("got %q, want %q", got, "test summary") + } +} + +func TestJSONLBackend_TruncateAndSave(t *testing.T) { + b := newBackend(t) + + for i := 0; i < 10; i++ { + b.AddMessage("s1", "user", fmt.Sprintf("msg %d", i)) + } + b.TruncateHistory("s1", 3) + + history := b.GetHistory("s1") + if len(history) != 3 { + t.Fatalf("got %d, want 3", len(history)) + } + if history[0].Content != "msg 7" { + t.Errorf("got %q, want %q", history[0].Content, "msg 7") + } + + // Save triggers compaction. + if err := b.Save("s1"); err != nil { + t.Fatal(err) + } + + // Messages still accessible after compaction. + history = b.GetHistory("s1") + if len(history) != 3 { + t.Fatalf("after save: got %d, want 3", len(history)) + } +} + +func TestJSONLBackend_SetHistory(t *testing.T) { + b := newBackend(t) + b.AddMessage("s1", "user", "old") + + b.SetHistory("s1", []providers.Message{ + {Role: "user", Content: "new1"}, + {Role: "assistant", Content: "new2"}, + }) + + history := b.GetHistory("s1") + if len(history) != 2 { + t.Fatalf("got %d, want 2", len(history)) + } + if history[0].Content != "new1" { + t.Errorf("got %q, want %q", history[0].Content, "new1") + } +} + +func TestJSONLBackend_EmptySession(t *testing.T) { + b := newBackend(t) + + history := b.GetHistory("nonexistent") + if history == nil { + t.Fatal("got nil, want empty slice") + } + if len(history) != 0 { + t.Errorf("got %d, want 0", len(history)) + } +} + +func TestJSONLBackend_SessionIsolation(t *testing.T) { + b := newBackend(t) + b.AddMessage("s1", "user", "session1") + b.AddMessage("s2", "user", "session2") + + h1 := b.GetHistory("s1") + h2 := b.GetHistory("s2") + + if len(h1) != 1 || h1[0].Content != "session1" { + t.Errorf("s1: %+v", h1) + } + if len(h2) != 1 || h2[0].Content != "session2" { + t.Errorf("s2: %+v", h2) + } +} + +func TestJSONLBackend_SummarizeFlow(t *testing.T) { + // Simulates the real summarization flow in the agent loop: + // SetSummary → TruncateHistory → Save + b := newBackend(t) + + for i := 0; i < 20; i++ { + b.AddMessage("s1", "user", fmt.Sprintf("msg %d", i)) + } + + b.SetSummary("s1", "conversation about testing") + b.TruncateHistory("s1", 4) + if err := b.Save("s1"); err != nil { + t.Fatal(err) + } + + if got := b.GetSummary("s1"); got != "conversation about testing" { + t.Errorf("summary = %q", got) + } + history := b.GetHistory("s1") + if len(history) != 4 { + t.Fatalf("got %d messages, want 4", len(history)) + } + if history[0].Content != "msg 16" { + t.Errorf("first message = %q, want %q", history[0].Content, "msg 16") + } +} + +func TestJSONLBackend_ResolveAliasAndPersistMetadata(t *testing.T) { + b := newBackend(t) + + scope := &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "main", + Channel: "telegram", + Account: "default", + Dimensions: []string{"chat"}, + Values: map[string]string{ + "chat": "group:c1", + }, + } + b.EnsureSessionMetadata("canonical", scope, []string{"legacy"}) + + if got := b.ResolveSessionKey("legacy"); got != "canonical" { + t.Fatalf("ResolveSessionKey() = %q, want %q", got, "canonical") + } + + b.AddMessage("legacy", "user", "hello through alias") + history := b.GetHistory("canonical") + if len(history) != 1 { + t.Fatalf("len(history) = %d, want 1", len(history)) + } + if history[0].Content != "hello through alias" { + t.Fatalf("history[0].Content = %q, want %q", history[0].Content, "hello through alias") + } + + resolvedScope := b.GetSessionScope("legacy") + if resolvedScope == nil { + t.Fatal("GetSessionScope() returned nil") + } + if resolvedScope.AgentID != scope.AgentID || resolvedScope.Values["chat"] != scope.Values["chat"] { + t.Fatalf("GetSessionScope() = %+v, want %+v", resolvedScope, scope) + } +} + +func TestJSONLBackend_EnsureSessionMetadata_PromotesLegacyAliasHistory(t *testing.T) { + b := newBackend(t) + + legacyKey := "agent:main:direct:legacy-user" + b.AddMessage(legacyKey, "user", "legacy history") + b.SetSummary(legacyKey, "legacy summary") + + canonicalKey := session.BuildOpaqueSessionKey(legacyKey) + b.EnsureSessionMetadata(canonicalKey, &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "main", + }, []string{legacyKey}) + + if got := b.ResolveSessionKey(legacyKey); got != canonicalKey { + t.Fatalf("ResolveSessionKey() = %q, want %q", got, canonicalKey) + } + history := b.GetHistory(canonicalKey) + if len(history) != 1 || history[0].Content != "legacy history" { + t.Fatalf("promoted history = %+v", history) + } + if summary := b.GetSummary(canonicalKey); summary != "legacy summary" { + t.Fatalf("promoted summary = %q, want %q", summary, "legacy summary") + } +} + +func TestJSONLBackend_EnsureSessionMetadata_PromotesLegacyPicoDirectAliasHistory(t *testing.T) { + b := newBackend(t) + + legacyKey := "agent:main:pico:direct:pico:session-123" + b.AddMessage(legacyKey, "user", "legacy pico history") + + scope := &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "main", + Channel: "pico", + Account: "default", + Dimensions: []string{"sender"}, + Values: map[string]string{ + "sender": "pico-user", + }, + } + allocation := session.AllocateRouteSession(session.AllocationInput{ + AgentID: "main", + Context: bus.InboundContext{ + Channel: "pico", + Account: "default", + ChatID: "pico:session-123", + ChatType: "direct", + SenderID: "pico-user", + }, + SessionPolicy: routing.SessionPolicy{ + Dimensions: []string{"sender"}, + }, + }) + + b.EnsureSessionMetadata(allocation.SessionKey, scope, allocation.SessionAliases) + + if got := b.ResolveSessionKey(legacyKey); got != allocation.SessionKey { + t.Fatalf("ResolveSessionKey() = %q, want %q", got, allocation.SessionKey) + } + history := b.GetHistory(allocation.SessionKey) + if len(history) != 1 || history[0].Content != "legacy pico history" { + t.Fatalf("promoted history = %+v", history) + } +} + +func TestJSONLBackend_EnsureSessionMetadata_DoesNotOverwriteNonEmptyCanonicalHistory(t *testing.T) { + b := newBackend(t) + + canonicalKey := session.BuildOpaqueSessionKey("agent:main:direct:current-user") + legacyKey := "agent:main:direct:legacy-user" + + b.AddMessage(canonicalKey, "user", "current canonical history") + b.AddMessage(legacyKey, "user", "legacy history") + + b.EnsureSessionMetadata(canonicalKey, &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "main", + }, []string{legacyKey}) + + history := b.GetHistory(canonicalKey) + if len(history) != 1 || history[0].Content != "current canonical history" { + t.Fatalf("canonical history overwritten: %+v", history) + } +} diff --git a/pkg/session/key.go b/pkg/session/key.go new file mode 100644 index 000000000..fb0836bc1 --- /dev/null +++ b/pkg/session/key.go @@ -0,0 +1,205 @@ +package session + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/routing" +) + +const ( + sessionKeyV1Prefix = "sk_v1_" + legacyAgentSessionKeyPrefix = "agent:" +) + +type ParsedLegacySessionKey struct { + AgentID string + Rest string +} + +// BuildOpaqueSessionKey returns a stable opaque session key derived from a +// canonical alias string. The alias remains available through metadata for +// compatibility and migration purposes. +func BuildOpaqueSessionKey(alias string) string { + normalized := strings.TrimSpace(strings.ToLower(alias)) + if normalized == "" { + return "" + } + sum := sha256.Sum256([]byte(normalized)) + return sessionKeyV1Prefix + hex.EncodeToString(sum[:]) +} + +// IsOpaqueSessionKey returns true when the key matches the current opaque +// session-key format. +func IsOpaqueSessionKey(key string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(key)), sessionKeyV1Prefix) +} + +func IsLegacyAgentSessionKey(key string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(key)), legacyAgentSessionKeyPrefix) +} + +func IsExplicitSessionKey(key string) bool { + return IsOpaqueSessionKey(key) || IsLegacyAgentSessionKey(key) +} + +func ParseLegacyAgentSessionKey(sessionKey string) *ParsedLegacySessionKey { + raw := strings.TrimSpace(sessionKey) + if raw == "" { + return nil + } + parts := strings.SplitN(raw, ":", 3) + if len(parts) < 3 || parts[0] != "agent" { + return nil + } + agentID := strings.TrimSpace(parts[1]) + rest := parts[2] + if agentID == "" || rest == "" { + return nil + } + return &ParsedLegacySessionKey{AgentID: agentID, Rest: rest} +} + +// ResolveAgentID returns the routed agent ID associated with a session. It +// prefers structured session scope metadata when available and falls back to +// legacy agent-scoped session keys for compatibility. +func ResolveAgentID(store any, sessionKey string) string { + if scopeReader, ok := store.(interface { + GetSessionScope(sessionKey string) *SessionScope + }); ok { + scope := scopeReader.GetSessionScope(sessionKey) + if scope != nil && strings.TrimSpace(scope.AgentID) != "" { + return routing.NormalizeAgentID(scope.AgentID) + } + } + + if parsed := ParseLegacyAgentSessionKey(sessionKey); parsed != nil { + return routing.NormalizeAgentID(parsed.AgentID) + } + + return "" +} + +func BuildLegacyMainAlias(agentID string) string { + return fmt.Sprintf("agent:%s:main", routing.NormalizeAgentID(agentID)) +} + +// BuildMainSessionKey returns the canonical opaque main-session key for an +// agent. The corresponding legacy alias remains available via +// BuildLegacyMainAlias for compatibility and migration logic. +func BuildMainSessionKey(agentID string) string { + return BuildOpaqueSessionKey(BuildLegacyMainAlias(agentID)) +} + +func BuildLegacyDirectAliases(agentID, channel, account, peerID string) []string { + agentID = routing.NormalizeAgentID(agentID) + channel = normalizeLegacyChannel(channel) + account = routing.NormalizeAccountID(account) + peerID = strings.ToLower(strings.TrimSpace(peerID)) + if peerID == "" { + return nil + } + return []string{ + fmt.Sprintf("agent:%s:direct:%s", agentID, peerID), + fmt.Sprintf("agent:%s:%s:direct:%s", agentID, channel, peerID), + fmt.Sprintf("agent:%s:%s:%s:direct:%s", agentID, channel, account, peerID), + } +} + +func BuildLegacyPeerAlias(agentID, channel, peerKind, peerID string) string { + agentID = routing.NormalizeAgentID(agentID) + channel = normalizeLegacyChannel(channel) + peerKind = strings.ToLower(strings.TrimSpace(peerKind)) + if peerKind == "" { + peerKind = "unknown" + } + peerID = strings.ToLower(strings.TrimSpace(peerID)) + if peerID == "" { + peerID = "unknown" + } + return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, peerKind, peerID) +} + +// CanonicalSessionIdentityID collapses an identity using identity_links when +// possible, then returns a normalized lowercase identifier. +func CanonicalSessionIdentityID(channel, rawID string, identityLinks map[string][]string) string { + normalizedID := strings.TrimSpace(rawID) + if normalizedID == "" { + return "" + } + if linked := resolveLinkedPeerID(identityLinks, channel, normalizedID); linked != "" { + normalizedID = linked + } + return strings.ToLower(normalizedID) +} + +func normalizeLegacyChannel(channel string) string { + channel = strings.ToLower(strings.TrimSpace(channel)) + if channel == "" { + return "unknown" + } + return channel +} + +func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID string) string { + if len(identityLinks) == 0 { + return "" + } + peerID = strings.TrimSpace(peerID) + if peerID == "" { + return "" + } + + candidates := make(map[string]bool) + rawCandidate := strings.ToLower(peerID) + if rawCandidate != "" { + candidates[rawCandidate] = true + } + channel = strings.ToLower(strings.TrimSpace(channel)) + if channel != "" { + candidates[fmt.Sprintf("%s:%s", channel, rawCandidate)] = true + } + if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 { + candidates[rawCandidate[idx+1:]] = true + } + + for canonical, ids := range identityLinks { + canonicalName := strings.TrimSpace(canonical) + if canonicalName == "" { + continue + } + for _, id := range ids { + normalized := strings.ToLower(strings.TrimSpace(id)) + if normalized != "" && candidates[normalized] { + return canonicalName + } + } + } + return "" +} + +// CanonicalScopeSignature returns a stable serialized representation of scope. +func CanonicalScopeSignature(scope SessionScope) string { + parts := []string{ + fmt.Sprintf("v=%d", scope.Version), + fmt.Sprintf("agent=%s", strings.TrimSpace(strings.ToLower(scope.AgentID))), + fmt.Sprintf("channel=%s", strings.TrimSpace(strings.ToLower(scope.Channel))), + fmt.Sprintf("account=%s", strings.TrimSpace(strings.ToLower(scope.Account))), + } + for _, dimension := range scope.Dimensions { + dimension = strings.TrimSpace(strings.ToLower(dimension)) + if dimension == "" { + continue + } + value := strings.TrimSpace(strings.ToLower(scope.Values[dimension])) + parts = append(parts, fmt.Sprintf("%s=%s", dimension, value)) + } + return strings.Join(parts, "|") +} + +// BuildSessionKey returns the current opaque key for a structured session scope. +func BuildSessionKey(scope SessionScope) string { + return BuildOpaqueSessionKey(CanonicalScopeSignature(scope)) +} diff --git a/pkg/session/key_test.go b/pkg/session/key_test.go new file mode 100644 index 000000000..6cdf397e1 --- /dev/null +++ b/pkg/session/key_test.go @@ -0,0 +1,100 @@ +package session + +import "testing" + +type testScopeReader struct { + scope *SessionScope +} + +func (r testScopeReader) GetSessionScope(sessionKey string) *SessionScope { + return CloneScope(r.scope) +} + +func TestIsExplicitSessionKey(t *testing.T) { + tests := []struct { + key string + want bool + }{ + {"sk_v1_abc", true}, + {"agent:main:direct:user123", true}, + {"custom-key", false}, + {"", false}, + } + + for _, tt := range tests { + if got := IsExplicitSessionKey(tt.key); got != tt.want { + t.Fatalf("IsExplicitSessionKey(%q) = %v, want %v", tt.key, got, tt.want) + } + } +} + +func TestParseLegacyAgentSessionKey(t *testing.T) { + parsed := ParseLegacyAgentSessionKey("agent:sales:telegram:direct:user123") + if parsed == nil { + t.Fatal("expected parsed legacy key, got nil") + } + if parsed.AgentID != "sales" { + t.Fatalf("AgentID = %q, want sales", parsed.AgentID) + } + if parsed.Rest != "telegram:direct:user123" { + t.Fatalf("Rest = %q, want telegram:direct:user123", parsed.Rest) + } + + if got := ParseLegacyAgentSessionKey("sk_v1_abc"); got != nil { + t.Fatalf("expected nil for opaque key, got %+v", got) + } +} + +func TestBuildLegacyDirectAliases(t *testing.T) { + aliases := BuildLegacyDirectAliases("Main", "Telegram", "BotA", "User123") + want := []string{ + "agent:main:direct:user123", + "agent:main:telegram:direct:user123", + "agent:main:telegram:bota:direct:user123", + } + if len(aliases) != len(want) { + t.Fatalf("len(aliases) = %d, want %d", len(aliases), len(want)) + } + for i := range want { + if aliases[i] != want[i] { + t.Fatalf("aliases[%d] = %q, want %q", i, aliases[i], want[i]) + } + } +} + +func TestBuildLegacyPeerAlias(t *testing.T) { + got := BuildLegacyPeerAlias("Main", "Slack", "channel", "C001") + if got != "agent:main:slack:channel:c001" { + t.Fatalf("BuildLegacyPeerAlias() = %q", got) + } +} + +func TestBuildMainSessionKey(t *testing.T) { + got := BuildMainSessionKey("Main") + if !IsOpaqueSessionKey(got) { + t.Fatalf("BuildMainSessionKey() = %q, want opaque key", got) + } + if got != BuildOpaqueSessionKey("agent:main:main") { + t.Fatalf("BuildMainSessionKey() = %q, want stable main-key hash", got) + } +} + +func TestResolveAgentID_PrefersSessionScope(t *testing.T) { + store := testScopeReader{ + scope: &SessionScope{ + Version: ScopeVersionV1, + AgentID: "Support", + Channel: "slack", + }, + } + + if got := ResolveAgentID(store, "sk_v1_anything"); got != "support" { + t.Fatalf("ResolveAgentID() = %q, want support", got) + } +} + +func TestResolveAgentID_FallsBackToLegacyKey(t *testing.T) { + if got := ResolveAgentID(nil, "agent:Sales:telegram:direct:user123"); got != "sales" { + t.Fatalf("ResolveAgentID() = %q, want sales", got) + } +} diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 08f0b0ad2..1d6fa3106 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -9,6 +9,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" ) type Session struct { @@ -32,7 +33,7 @@ func NewSessionManager(storage string) *SessionManager { } if storage != "" { - os.MkdirAll(storage, 0o755) + os.MkdirAll(storage, 0o700) sm.loadSessions() } @@ -69,6 +70,10 @@ func (sm *SessionManager) AddMessage(sessionKey, role, content string) { // AddFullMessage adds a complete message with tool calls and tool call ID to the session. // This is used to save the full conversation flow including tool calls and tool results. func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) { + if messageutil.IsTransientAssistantThoughtMessage(msg) { + return + } + sm.mu.Lock() defer sm.mu.Unlock() @@ -145,13 +150,26 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) { session.Updated = time.Now() } +func (sm *SessionManager) ListSessions() []string { + sm.mu.RLock() + defer sm.mu.RUnlock() + keys := make([]string, 0, len(sm.sessions)) + for k := range sm.sessions { + keys = append(keys, k) + } + return keys +} + // sanitizeFilename converts a session key into a cross-platform safe filename. -// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the -// volume separator on Windows, so filepath.Base would misinterpret the key. -// We replace it with '_'. The original key is preserved inside the JSON file, -// so loadSessions still maps back to the right in-memory key. +// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' so +// composite IDs (e.g. Telegram forum "chatID/threadID") do not create +// subdirectories or break on Windows. The original key is preserved inside +// the JSON file, so loadSessions still maps back to the right in-memory key. func sanitizeFilename(key string) string { - return strings.ReplaceAll(key, ":", "_") + s := strings.ReplaceAll(key, ":", "_") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + return s } func (sm *SessionManager) Save(key string) error { @@ -162,10 +180,9 @@ func (sm *SessionManager) Save(key string) error { filename := sanitizeFilename(key) // filepath.IsLocal rejects empty names, "..", absolute paths, and - // OS-reserved device names (NUL, COM1 … on Windows). - // The extra checks reject "." and any directory separators so that - // the session file is always written directly inside sm.storage. - if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) { + // OS-reserved device names (NUL, COM1 … on Windows). sanitizeFilename + // already replaced '/' and '\' with '_', so no subdirs are created. + if filename == "." || !filepath.IsLocal(filename) { return os.ErrInvalid } @@ -184,8 +201,7 @@ func (sm *SessionManager) Save(key string) error { Updated: stored.Updated, } if len(stored.Messages) > 0 { - snapshot.Messages = make([]providers.Message, len(stored.Messages)) - copy(snapshot.Messages, stored.Messages) + snapshot.Messages = messageutil.FilterInvalidHistoryMessages(stored.Messages) } else { snapshot.Messages = []providers.Message{} } @@ -214,7 +230,7 @@ func (sm *SessionManager) Save(key string) error { _ = tmpFile.Close() return err } - if err := tmpFile.Chmod(0o644); err != nil { + if err := tmpFile.Chmod(0o600); err != nil { _ = tmpFile.Close() return err } @@ -258,6 +274,7 @@ func (sm *SessionManager) loadSessions() error { if err := json.Unmarshal(data, &session); err != nil { continue } + session.Messages = messageutil.FilterInvalidHistoryMessages(session.Messages) sm.sessions[session.Key] = &session } @@ -265,6 +282,12 @@ func (sm *SessionManager) loadSessions() error { return nil } +// Close is a no-op for the in-memory SessionManager; it satisfies the +// SessionStore interface so callers can release resources uniformly. +func (sm *SessionManager) Close() error { + return nil +} + // SetHistory updates the messages of a session. func (sm *SessionManager) SetHistory(key string, history []providers.Message) { sm.mu.Lock() @@ -272,6 +295,7 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) { session, ok := sm.sessions[key] if ok { + history = messageutil.FilterInvalidHistoryMessages(history) // Create a deep copy to strictly isolate internal state // from the caller's slice. msgs := make([]providers.Message, len(history)) diff --git a/pkg/session/manager_test.go b/pkg/session/manager_test.go index 5ef5f4349..bc5615966 100644 --- a/pkg/session/manager_test.go +++ b/pkg/session/manager_test.go @@ -17,6 +17,7 @@ func TestSanitizeFilename(t *testing.T) { {"slack:C01234", "slack_C01234"}, {"no-colons-here", "no-colons-here"}, {"multiple:colons:here", "multiple_colons_here"}, + {"agent:main:telegram:group:-1003822706455/12", "agent_main_telegram_group_-1003822706455_12"}, } for _, tt := range tests { @@ -64,11 +65,21 @@ func TestSave_RejectsPathTraversal(t *testing.T) { tmpDir := t.TempDir() sm := NewSessionManager(tmpDir) - badKeys := []string{"", ".", "..", "foo/bar", "foo\\bar"} + // Invalid names that must still be rejected. + badKeys := []string{"", ".", ".."} for _, key := range badKeys { sm.GetOrCreate(key) if err := sm.Save(key); err == nil { t.Errorf("Save(%q) should have failed but didn't", key) } } + + // Keys containing path separators are sanitized (no subdirs created). + sm.GetOrCreate("foo/bar") + if err := sm.Save("foo/bar"); err != nil { + t.Fatalf("Save(\"foo/bar\") after sanitize should succeed: %v", err) + } + if _, err := os.Stat(filepath.Join(tmpDir, "foo_bar.json")); os.IsNotExist(err) { + t.Errorf("expected foo_bar.json in storage (sanitized from foo/bar)") + } } diff --git a/pkg/session/scope.go b/pkg/session/scope.go new file mode 100644 index 000000000..efb026ea3 --- /dev/null +++ b/pkg/session/scope.go @@ -0,0 +1,32 @@ +package session + +// ScopeVersionV1 is the first structured session-scope schema version. +const ScopeVersionV1 = 1 + +// SessionScope describes the semantic session partition selected for a turn. +type SessionScope struct { + Version int `json:"version"` + AgentID string `json:"agent_id"` + Channel string `json:"channel"` + Account string `json:"account"` + Dimensions []string `json:"dimensions"` + Values map[string]string `json:"values"` +} + +// CloneScope returns a deep copy of scope. +func CloneScope(scope *SessionScope) *SessionScope { + if scope == nil { + return nil + } + cloned := *scope + if len(scope.Dimensions) > 0 { + cloned.Dimensions = append([]string(nil), scope.Dimensions...) + } + if len(scope.Values) > 0 { + cloned.Values = make(map[string]string, len(scope.Values)) + for key, value := range scope.Values { + cloned.Values[key] = value + } + } + return &cloned +} diff --git a/pkg/session/session_store.go b/pkg/session/session_store.go new file mode 100644 index 000000000..2ba2a974d --- /dev/null +++ b/pkg/session/session_store.go @@ -0,0 +1,34 @@ +package session + +import "github.com/sipeed/picoclaw/pkg/providers" + +// SessionStore defines the persistence operations used by the agent loop. +// Both SessionManager (legacy JSON backend) and JSONLBackend satisfy this +// interface, allowing the storage layer to be swapped without touching the +// agent loop code. +// +// Write methods (Add*, Set*, Truncate*) are fire-and-forget: they do not +// return errors. Implementations should log failures internally. This +// matches the original SessionManager contract that the agent loop relies on. +type SessionStore interface { + // AddMessage appends a simple role/content message to the session. + AddMessage(sessionKey, role, content string) + // AddFullMessage appends a complete message including tool calls. + AddFullMessage(sessionKey string, msg providers.Message) + // GetHistory returns the full message history for the session. + GetHistory(key string) []providers.Message + // GetSummary returns the conversation summary, or "" if none. + GetSummary(key string) string + // SetSummary replaces the conversation summary. + SetSummary(key, summary string) + // SetHistory replaces the full message history. + SetHistory(key string, history []providers.Message) + // TruncateHistory keeps only the last keepLast messages. + TruncateHistory(key string, keepLast int) + // Save persists any pending state to durable storage. + Save(key string) error + // ListSessions returns all known session keys. + ListSessions() []string + // Close releases resources held by the store. + Close() error +} diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go index e1f1068de..723cc983e 100644 --- a/pkg/skills/clawhub_registry.go +++ b/pkg/skills/clawhub_registry.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -21,6 +22,35 @@ const ( defaultMaxResponseSize = 2 * 1024 * 1024 // 2 MB ) +func init() { + RegisterRegistryProviderBuilder("clawhub", func(_ string, cfg config.SkillRegistryConfig) RegistryProvider { + privateCfg := clawHubRegistryPrivateConfig{} + if err := cfg.DecodeParam(&privateCfg); err != nil { + slog.Warn("invalid clawhub private config", "error", err) + } + return ClawHubConfig{ + Enabled: cfg.Enabled, + BaseURL: cfg.BaseURL, + AuthToken: cfg.AuthToken.String(), + SearchPath: privateCfg.SearchPath, + SkillsPath: privateCfg.SkillsPath, + DownloadPath: privateCfg.DownloadPath, + Timeout: privateCfg.Timeout, + MaxZipSize: privateCfg.MaxZipSize, + MaxResponseSize: privateCfg.MaxResponseSize, + } + }) +} + +type clawHubRegistryPrivateConfig struct { + SearchPath string `json:"search_path"` + SkillsPath string `json:"skills_path"` + DownloadPath string `json:"download_path"` + Timeout int `json:"timeout"` + MaxZipSize int `json:"max_zip_size"` + MaxResponseSize int `json:"max_response_size"` +} + // ClawHubRegistry implements SkillRegistry for the ClawHub platform. type ClawHubRegistry struct { baseURL string @@ -102,6 +132,28 @@ func (c *ClawHubRegistry) Name() string { return "clawhub" } +func (c *ClawHubRegistry) ResolveInstallDirName(target string) (string, error) { + if err := utils.ValidateSkillIdentifier(target); err != nil { + return "", err + } + return target, nil +} + +func (c *ClawHubRegistry) SkillURL(slug, _ string) string { + if slug == "" { + return "" + } + return c.baseURL + "/skills/" + url.PathEscape(slug) +} + +func (c ClawHubConfig) IsEnabled() bool { + return c.Enabled +} + +func (c ClawHubConfig) BuildRegistry() SkillRegistry { + return NewClawHubRegistry(c) +} + // --- Search --- type clawhubSearchResponse struct { diff --git a/pkg/skills/config_bridge.go b/pkg/skills/config_bridge.go new file mode 100644 index 000000000..5302db196 --- /dev/null +++ b/pkg/skills/config_bridge.go @@ -0,0 +1,136 @@ +package skills + +import "github.com/sipeed/picoclaw/pkg/config" + +const defaultGitHubRegistryBaseURL = "https://github.com" + +func effectiveRegistryConfigsFromToolsConfig(cfg config.SkillsToolsConfig) []config.SkillRegistryConfig { + effective := make([]config.SkillRegistryConfig, 0, len(cfg.Registries)+1) + seen := map[string]struct{}{} + + for _, registryCfg := range cfg.Registries { + if registryCfg == nil || registryCfg.Name == "" { + continue + } + resolved := *registryCfg + if resolved.Name == "github" { + resolved = applyLegacyGithubRegistryCompatibility(cfg, resolved) + } + effective = append(effective, resolved) + seen[resolved.Name] = struct{}{} + } + + if _, ok := seen["github"]; ok { + return effective + } + + legacyGithubConfigured := cfg.Github.BaseURL != "" || cfg.Github.Token.String() != "" || cfg.Github.Proxy != "" + if !legacyGithubConfigured { + return effective + } + + effective = append(effective, applyLegacyGithubRegistryCompatibility(cfg, config.SkillRegistryConfig{ + Name: "github", + Enabled: true, + })) + return effective +} + +func applyLegacyGithubRegistryCompatibility( + cfg config.SkillsToolsConfig, + registryCfg config.SkillRegistryConfig, +) config.SkillRegistryConfig { + if registryCfg.Name != "github" { + return registryCfg + } + if registryCfg.Param == nil { + registryCfg.Param = map[string]any{} + } + if registryCfg.BaseURL == "" || + (registryCfg.BaseURL == defaultGitHubRegistryBaseURL && + cfg.Github.BaseURL != "" && + cfg.Github.BaseURL != defaultGitHubRegistryBaseURL) { + registryCfg.BaseURL = cfg.Github.BaseURL + } + if registryCfg.AuthToken.String() == "" { + registryCfg.AuthToken = cfg.Github.Token + } + if _, ok := registryCfg.Param["proxy"]; !ok && cfg.Github.Proxy != "" { + registryCfg.Param["proxy"] = cfg.Github.Proxy + } + return registryCfg +} + +func registryProvidersFromToolsConfig(cfg config.SkillsToolsConfig) []RegistryProvider { + registryConfigs := effectiveRegistryConfigsFromToolsConfig(cfg) + providers := make([]RegistryProvider, 0, len(registryConfigs)) + for _, registryCfg := range registryConfigs { + provider := buildRegistryProvider(registryCfg.Name, registryCfg) + if provider == nil { + continue + } + providers = append(providers, provider) + } + return providers +} + +func NewRegistryManagerFromToolsConfig(cfg config.SkillsToolsConfig) *RegistryManager { + return NewRegistryManagerFromConfig(RegistryConfig{ + Providers: registryProvidersFromToolsConfig(cfg), + MaxConcurrentSearches: cfg.MaxConcurrentSearches, + }) +} + +func LookupRegistryFromToolsConfig(cfg config.SkillsToolsConfig, name string) SkillRegistry { + for _, provider := range registryProvidersFromToolsConfig(cfg) { + if provider == nil { + continue + } + registry := provider.BuildRegistry() + if registry == nil || registry.Name() != name { + continue + } + return registry + } + return nil +} + +func GitHubInstallDirNameFromToolsConfig(cfg config.SkillsToolsConfig, target string) (string, error) { + registryCfg, ok := cfg.Registries.Get("github") + if ok { + registryCfg = applyLegacyGithubRegistryCompatibility(cfg, registryCfg) + return githubInstallDirNameWithBaseURL(target, registryCfg.BaseURL) + } + return githubInstallDirNameWithBaseURL(target, cfg.Github.BaseURL) +} + +func NormalizeInstallTargetForRegistry(cfg config.SkillsToolsConfig, registryName, target string) string { + if registryName == "" || target == "" { + return target + } + registry := LookupRegistryFromToolsConfig(cfg, registryName) + if registry == nil { + return target + } + ghRegistry, ok := registry.(*GitHubRegistry) + if !ok { + return target + } + normalized, err := canonicalGitHubRegistrySlugWithBaseURL(target, ghRegistry.webBase) + if err != nil || normalized == "" { + return target + } + return normalized +} + +func BuildInstallMetadataForRegistryInstance(registry SkillRegistry, target, version string) (string, string) { + normalizedTarget := NormalizeInstallTargetForRegistryInstance(registry, target) + if registry == nil { + return normalizedTarget, "" + } + registryURL := registry.SkillURL(target, version) + if registryURL == "" { + registryURL = registry.SkillURL(normalizedTarget, version) + } + return normalizedTarget, registryURL +} diff --git a/pkg/skills/github_registry.go b/pkg/skills/github_registry.go new file mode 100644 index 000000000..de2dd9697 --- /dev/null +++ b/pkg/skills/github_registry.go @@ -0,0 +1,305 @@ +package skills + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + RegisterRegistryProviderBuilder("github", func(_ string, cfg config.SkillRegistryConfig) RegistryProvider { + privateCfg := githubRegistryPrivateConfig{} + if err := cfg.DecodeParam(&privateCfg); err != nil { + slog.Warn("invalid github private config", "error", err) + } + return GitHubRegistryConfig{ + Enabled: cfg.Enabled, + BaseURL: cfg.BaseURL, + AuthToken: cfg.AuthToken.String(), + Proxy: privateCfg.Proxy, + } + }) +} + +type githubRegistryPrivateConfig struct { + Proxy string `json:"proxy"` +} + +type GitHubRegistryConfig struct { + Enabled bool + BaseURL string + AuthToken string + Proxy string +} + +type GitHubRegistry struct { + installer *SkillInstaller + webBase string +} + +const githubAuthTokenHelp = "configure registries.github.auth_token" + +func (c GitHubRegistryConfig) IsEnabled() bool { + return c.Enabled +} + +func (c GitHubRegistryConfig) BuildRegistry() SkillRegistry { + installer, err := NewSkillInstallerWithBaseURL("", c.BaseURL, c.AuthToken, c.Proxy) + if err != nil { + slog.Warn("failed to create github registry installer", "error", err) + return nil + } + return &GitHubRegistry{ + installer: installer, + webBase: installer.githubBaseURL, + } +} + +func (r *GitHubRegistry) Name() string { + return "github" +} + +func (r *GitHubRegistry) ResolveInstallDirName(target string) (string, error) { + return githubInstallDirNameWithBaseURL(target, r.webBase) +} + +func (r *GitHubRegistry) NormalizeInstallTarget(target string) string { + normalized, err := canonicalGitHubRegistrySlugWithBaseURL(target, r.webBase) + if err != nil { + return target + } + return normalized +} + +func (r *GitHubRegistry) SkillURL(target, version string) string { + defaultRef := strings.TrimSpace(version) + parsedTarget, err := parseGitHubTargetWithBaseURL(target, r.webBase, defaultRef) + if err != nil { + return "" + } + ref := parsedTarget.Ref + base := strings.TrimRight(parsedTarget.Endpoints.WebBaseURL, "/") + urlPath := path.Join(ref.Owner, ref.RepoName) + if ref.SubPath != "" { + if ref.Ref == "" { + return "" + } + viewKind := "tree" + if isSkillMarkdownPath(ref.SubPath) { + viewKind = "blob" + } + return fmt.Sprintf("%s/%s/%s/%s/%s", base, urlPath, viewKind, ref.Ref, ref.SubPath) + } + if ref.Ref == "" { + return fmt.Sprintf("%s/%s", base, urlPath) + } + if ref.Ref != "main" { + return fmt.Sprintf("%s/%s/tree/%s", base, urlPath, ref.Ref) + } + return fmt.Sprintf("%s/%s", base, urlPath) +} + +type gitHubCodeSearchResponse struct { + Items []gitHubCodeSearchItem `json:"items"` +} + +type gitHubCodeSearchItem struct { + Path string `json:"path"` + HTMLURL string `json:"html_url"` + Score float64 `json:"score"` + Repository struct { + FullName string `json:"full_name"` + Name string `json:"name"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + } `json:"repository"` +} + +func (r *GitHubRegistry) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) { + query = strings.TrimSpace(query) + if query == "" { + return nil, nil + } + if limit <= 0 { + limit = 5 + } + + u, err := url.Parse(strings.TrimRight(r.installer.githubAPIBaseURL, "/") + "/search/code") + if err != nil { + return nil, fmt.Errorf("invalid github api base url: %w", err) + } + q := u.Query() + q.Set("q", fmt.Sprintf("%s filename:SKILL.md", query)) + q.Set("per_page", fmt.Sprintf("%d", limit)) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + if r.installer.githubToken != "" { + req.Header.Set("Authorization", "Bearer "+r.installer.githubToken) + } + + resp, err := r.installer.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + return nil, fmt.Errorf("failed to read github search response: %w", err) + } + if resp.StatusCode == http.StatusUnauthorized && r.installer.githubToken == "" && isGitHubAuthRequiredError(body) { + slog.Warn("github search requires authentication; returning no results", "help", githubAuthTokenHelp) + return []SearchResult{}, nil + } + if resp.StatusCode == http.StatusForbidden && r.installer.githubToken == "" && isGitHubRateLimitError(body) { + slog.Warn("github search hit unauthenticated rate limit; returning no results", "help", githubAuthTokenHelp) + return []SearchResult{}, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("github search failed: HTTP %d: %s", resp.StatusCode, string(body)) + } + + var parsed gitHubCodeSearchResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("failed to parse github search response: %w", err) + } + + resultsBySlug := map[string]SearchResult{} + for _, item := range parsed.Items { + slug, ok := githubSearchSlug(item) + if !ok { + continue + } + result := SearchResult{ + Score: item.Score, + Slug: slug, + DisplayName: githubSearchDisplayName(item), + Summary: strings.TrimSpace(item.Repository.Description), + Version: strings.TrimSpace(item.Repository.DefaultBranch), + RegistryName: r.Name(), + } + if existing, exists := resultsBySlug[slug]; exists && existing.Score >= result.Score { + continue + } + resultsBySlug[slug] = result + } + + results := make([]SearchResult, 0, len(resultsBySlug)) + for _, result := range resultsBySlug { + results = append(results, result) + } + sort.Slice(results, func(i, j int) bool { + if results[i].Score == results[j].Score { + return results[i].Slug < results[j].Slug + } + return results[i].Score > results[j].Score + }) + if len(results) > limit { + results = results[:limit] + } + return results, nil +} + +func isGitHubRateLimitError(body []byte) bool { + message := strings.ToLower(string(body)) + return strings.Contains(message, "rate limit exceeded") +} + +func isGitHubAuthRequiredError(body []byte) bool { + message := strings.ToLower(string(body)) + return strings.Contains(message, "requires authentication") || + strings.Contains(message, "must be authenticated to access the code search api") +} + +func githubSearchSlug(item gitHubCodeSearchItem) (string, bool) { + fullName := strings.TrimSpace(item.Repository.FullName) + if fullName == "" { + return "", false + } + cleanPath := strings.Trim(strings.TrimSpace(item.Path), "/") + if cleanPath == "" || filepath.Base(cleanPath) != "SKILL.md" { + return "", false + } + dir := path.Dir(cleanPath) + if dir == "." || dir == "" { + return fullName, true + } + return fullName + "/" + dir, true +} + +func githubSearchDisplayName(item gitHubCodeSearchItem) string { + cleanPath := strings.Trim(strings.TrimSpace(item.Path), "/") + if cleanPath != "" { + dir := path.Dir(cleanPath) + if dir != "." && dir != "" { + return path.Base(dir) + } + } + if name := strings.TrimSpace(item.Repository.Name); name != "" { + return name + } + return strings.TrimSpace(item.Repository.FullName) +} + +func canonicalGitHubRegistrySlugWithBaseURL(target, githubBaseURL string) (string, error) { + ref, err := parseGitHubRefWithBaseURL(target, githubBaseURL, "") + if err != nil { + return "", err + } + slug := path.Join(ref.Owner, ref.RepoName) + if ref.SubPath != "" { + slug = path.Join(slug, ref.SubPath) + } + return slug, nil +} + +func (r *GitHubRegistry) GetSkillMeta(ctx context.Context, target string) (*SkillMeta, error) { + slug, err := canonicalGitHubRegistrySlugWithBaseURL(target, r.webBase) + if err != nil { + return nil, err + } + parsedTarget, err := parseGitHubTargetWithBaseURL(target, r.webBase, "") + if err != nil { + return nil, err + } + ref := parsedTarget.Ref + if ref.Ref == "" { + ref.Ref, err = r.installer.fetchDefaultBranchWithAPIBaseURL( + ctx, + parsedTarget.Endpoints.APIBaseURL, + ref.Owner, + ref.RepoName, + ) + if err != nil { + return nil, err + } + } + return &SkillMeta{ + Slug: slug, + DisplayName: ref.RepoName, + LatestVersion: ref.Ref, + RegistryName: r.Name(), + }, nil +} + +func (r *GitHubRegistry) DownloadAndInstall( + ctx context.Context, + target, version, targetDir string, +) (*InstallResult, error) { + return r.installer.InstallFromGitHubToDir(ctx, target, version, targetDir) +} diff --git a/pkg/skills/github_registry_test.go b/pkg/skills/github_registry_test.go new file mode 100644 index 000000000..3ac309700 --- /dev/null +++ b/pkg/skills/github_registry_test.go @@ -0,0 +1,218 @@ +package skills + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestGitHubRegistrySearch(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v3/search/code", r.URL.Path) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + assert.Equal(t, "skill search filename:SKILL.md", r.URL.Query().Get("q")) + assert.Equal(t, "2", r.URL.Query().Get("per_page")) + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(gitHubCodeSearchResponse{ + Items: []gitHubCodeSearchItem{ + { + Path: "skills/pr-review/SKILL.md", + Score: 10, + HTMLURL: server.URL + "/foo/bar/blob/main/skills/pr-review/SKILL.md", + Repository: struct { + FullName string `json:"full_name"` + Name string `json:"name"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + }{ + FullName: "foo/bar", + Name: "bar", + Description: "Review pull requests", + DefaultBranch: "main", + }, + }, + { + Path: "SKILL.md", + Score: 5, + HTMLURL: server.URL + "/foo/root/blob/main/SKILL.md", + Repository: struct { + FullName string `json:"full_name"` + Name string `json:"name"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + }{ + FullName: "foo/root", + Name: "root", + Description: "Root skill", + DefaultBranch: "master", + }, + }, + }, + })) + })) + defer server.Close() + + provider := GitHubRegistryConfig{ + Enabled: true, + BaseURL: server.URL, + AuthToken: "test-token", + } + registry := provider.BuildRegistry() + require.NotNil(t, registry) + + results, err := registry.Search(context.Background(), "skill search", 2) + require.NoError(t, err) + require.Len(t, results, 2) + + assert.Equal(t, "foo/bar/skills/pr-review", results[0].Slug) + assert.Equal(t, "pr-review", results[0].DisplayName) + assert.Equal(t, "Review pull requests", results[0].Summary) + assert.Equal(t, "main", results[0].Version) + assert.Equal(t, "github", results[0].RegistryName) + + assert.Equal(t, "foo/root", results[1].Slug) + assert.Equal(t, "root", results[1].DisplayName) + assert.Equal(t, "master", results[1].Version) +} + +func TestGitHubRegistryProviderDecodesProxyParam(t *testing.T) { + builder := buildRegistryProvider("github", config.SkillRegistryConfig{ + Name: "github", + Enabled: true, + BaseURL: "https://github.com", + AuthToken: *config.NewSecureString("test-token"), + Param: map[string]any{ + "proxy": "http://127.0.0.1:7890", + }, + }) + require.NotNil(t, builder) + + registry := builder.BuildRegistry() + require.NotNil(t, registry) + ghRegistry, ok := registry.(*GitHubRegistry) + require.True(t, ok) + assert.Equal(t, "http://127.0.0.1:7890", ghRegistry.installer.proxy) +} + +func TestGitHubRegistrySearchReturnsNoResultsOnUnauthenticatedRateLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"API rate limit exceeded for 1.2.3.4"}`)) + })) + defer server.Close() + + registry := GitHubRegistryConfig{Enabled: true, BaseURL: server.URL}.BuildRegistry() + require.NotNil(t, registry) + + results, err := registry.Search(context.Background(), "pr review", 5) + require.NoError(t, err) + assert.Empty(t, results) +} + +func TestGitHubRegistrySearchReturnsNoResultsOnUnauthenticatedAuthRequired(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte( + `{"message":"Requires authentication","errors":[{"message":"Must be authenticated to access the code search API"}]}`, + )) + })) + defer server.Close() + + registry := GitHubRegistryConfig{Enabled: true, BaseURL: server.URL}.BuildRegistry() + require.NotNil(t, registry) + + results, err := registry.Search(context.Background(), "pr review", 5) + require.NoError(t, err) + assert.Empty(t, results) +} + +func TestGitHubRegistryGetSkillMetaCanonicalizesURLSlug(t *testing.T) { + registry := GitHubRegistryConfig{ + Enabled: true, + BaseURL: "https://ghe.example.com/git", + }.BuildRegistry() + require.NotNil(t, registry) + + meta, err := registry.GetSkillMeta( + context.Background(), + "https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review", + ) + require.NoError(t, err) + require.NotNil(t, meta) + assert.Equal(t, "org/repo/skills/pr-review", meta.Slug) + assert.Equal(t, "dev", meta.LatestVersion) +} + +func TestGitHubRegistrySkillURLUsesProvidedVersionAndBasePath(t *testing.T) { + registry := GitHubRegistryConfig{ + Enabled: true, + BaseURL: "https://ghe.example.com/git", + }.BuildRegistry() + require.NotNil(t, registry) + + assert.Equal( + t, + "https://ghe.example.com/git/org/repo/tree/master/skills/pr-review", + registry.SkillURL("org/repo/skills/pr-review", "master"), + ) + assert.Equal( + t, + "https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review", + registry.SkillURL("https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review", ""), + ) + assert.Equal( + t, + "https://ghe.example.com/git/org/repo/tree/feature/skills-registry/skills/pr-review", + registry.SkillURL("org/repo/skills/pr-review", "feature/skills-registry"), + ) + assert.Equal( + t, + "https://ghe.example.com/git/org/repo/blob/main/.agents/skills/pr-review/SKILL.md", + registry.SkillURL("https://ghe.example.com/git/org/repo/blob/main/.agents/skills/pr-review/SKILL.md", ""), + ) + assert.Equal( + t, + "https://github.com/org/repo/tree/main/.agents/skills/pr-review", + registry.SkillURL("https://github.com/org/repo/tree/main/.agents/skills/pr-review", ""), + ) + assert.Empty(t, registry.SkillURL("org/repo/.agents/skills/pr-review", "")) +} + +func TestGitHubRegistryResolveInstallDirNameSupportsFullURLs(t *testing.T) { + registry := GitHubRegistryConfig{ + Enabled: true, + BaseURL: "https://ghe.example.com/git", + }.BuildRegistry() + require.NotNil(t, registry) + + dirName, err := registry.ResolveInstallDirName("https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review") + require.NoError(t, err) + assert.Equal(t, "pr-review", dirName) + + dirName, err = registry.ResolveInstallDirName("https://github.com/org/repo/tree/main/skills/release-checklist") + require.NoError(t, err) + assert.Equal(t, "release-checklist", dirName) + + dirName, err = registry.ResolveInstallDirName( + "https://ghe.example.com/git/org/repo/blob/dev/skills/pr-review/SKILL.md", + ) + require.NoError(t, err) + assert.Equal(t, "pr-review", dirName) + + dirName, err = registry.ResolveInstallDirName( + "https://ghe.example.com/git/org/repo/blob/dev/SKILL.md", + ) + require.NoError(t, err) + assert.Equal(t, "repo", dirName) +} diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index 783aa18b8..2f97ca8bf 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -2,89 +2,618 @@ package skills import ( "context" + "encoding/json" "fmt" "io" "net/http" + "net/url" "os" + "path" "path/filepath" - "regexp" + "strings" "time" "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/utils" ) -const maxSkillFileSize int64 = 5 << 20 // 5 MB — SKILL.md files should never approach this - -var repoPattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$`) - -type SkillInstaller struct { - workspace string +// GitHubContent represents a file or directory in GitHub API response +type GitHubContent struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` // "file" or "dir" + DownloadURL string `json:"download_url"` + URL string `json:"url"` // API URL for subdirectories } -func NewSkillInstaller(workspace string) *SkillInstaller { - return &SkillInstaller{ - workspace: workspace, +// GitHubRef represents a parsed GitHub reference +type GitHubRef struct { + Owner string // Repository owner + RepoName string // Repository name + Ref string // Git reference (branch, tag, or commit) + SubPath string // Path within the repository +} + +type gitHubTarget struct { + Ref GitHubRef + Endpoints gitHubEndpoints +} + +type SkillInstaller struct { + workspace string + client *http.Client + githubBaseURL string + githubAPIBaseURL string + githubRawBaseURL string + githubToken string + proxy string +} + +// NewSkillInstaller creates a new skill installer. +// proxy is an optional HTTP/HTTPS/SOCKS5 proxy URL for downloading skills. +func NewSkillInstaller(workspace, githubToken, proxy string) (*SkillInstaller, error) { + return NewSkillInstallerWithBaseURL(workspace, "", githubToken, proxy) +} + +// NewSkillInstallerWithBaseURL creates a new skill installer with a custom GitHub base URL. +// For github.com this can be left empty. For GitHub Enterprise, set it to the web URL. +func NewSkillInstallerWithBaseURL(workspace, githubBaseURL, githubToken, proxy string) (*SkillInstaller, error) { + client, err := utils.CreateHTTPClient(proxy, 15*time.Second) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client: %w", err) } + endpoints, err := resolveGitHubEndpoints(githubBaseURL) + if err != nil { + return nil, err + } + + return &SkillInstaller{ + workspace: workspace, + client: client, + githubBaseURL: endpoints.WebBaseURL, + githubAPIBaseURL: endpoints.APIBaseURL, + githubRawBaseURL: endpoints.RawBaseURL, + githubToken: githubToken, + proxy: proxy, + }, nil +} + +type gitHubEndpoints struct { + WebBaseURL string + APIBaseURL string + RawBaseURL string +} + +func resolveGitHubEndpoints(baseURL string) (gitHubEndpoints, error) { + trimmed := strings.TrimSpace(baseURL) + if trimmed == "" { + return gitHubEndpoints{ + WebBaseURL: "https://github.com", + APIBaseURL: "https://api.github.com", + RawBaseURL: "https://raw.githubusercontent.com", + }, nil + } + + u, err := url.Parse(trimmed) + if err != nil { + return gitHubEndpoints{}, fmt.Errorf("invalid github base url: %w", err) + } + if u.Scheme == "" || u.Host == "" { + return gitHubEndpoints{}, fmt.Errorf("invalid github base url %q", baseURL) + } + + trimmedPath := strings.TrimSuffix(u.Path, "/") + origin := u.Scheme + "://" + u.Host + + if u.Host == "api.github.com" { + return gitHubEndpoints{ + WebBaseURL: "https://github.com", + APIBaseURL: "https://api.github.com", + RawBaseURL: "https://raw.githubusercontent.com", + }, nil + } + + if strings.HasSuffix(trimmedPath, "/api/v3") { + webBaseURL := origin + strings.TrimSuffix(trimmedPath, "/api/v3") + webBaseURL = strings.TrimSuffix(webBaseURL, "/") + if webBaseURL == origin { + webBaseURL = origin + } + return gitHubEndpoints{ + WebBaseURL: webBaseURL, + APIBaseURL: origin + trimmedPath, + RawBaseURL: webBaseURL + "/raw", + }, nil + } + + webBaseURL := origin + trimmedPath + webBaseURL = strings.TrimSuffix(webBaseURL, "/") + if u.Host == "github.com" { + return gitHubEndpoints{ + WebBaseURL: "https://github.com", + APIBaseURL: "https://api.github.com", + RawBaseURL: "https://raw.githubusercontent.com", + }, nil + } + + return gitHubEndpoints{ + WebBaseURL: webBaseURL, + APIBaseURL: webBaseURL + "/api/v3", + RawBaseURL: webBaseURL + "/raw", + }, nil +} + +func parseGitHubRefPathParts(repoURL *url.URL, githubBaseURL string) []string { + parts := strings.Split(strings.Trim(repoURL.Path, "/"), "/") + if len(parts) == 0 { + return parts + } + if githubBaseURL == "" { + return parts + } + baseURL, err := url.Parse(strings.TrimSpace(githubBaseURL)) + if err != nil { + return parts + } + if !strings.EqualFold(repoURL.Host, baseURL.Host) || !strings.EqualFold(repoURL.Scheme, baseURL.Scheme) { + return parts + } + baseParts := strings.Split(strings.Trim(baseURL.Path, "/"), "/") + if len(baseParts) == 1 && baseParts[0] == "" { + baseParts = nil + } + if len(baseParts) == 0 || len(parts) < len(baseParts)+2 { + return parts + } + for i, part := range baseParts { + if parts[i] != part { + return parts + } + } + return parts[len(baseParts):] +} + +func supportedGitHubBaseURL(repoURL *url.URL, githubBaseURL string) string { + if repoURL == nil { + return "" + } + trimmedBaseURL := strings.TrimSpace(githubBaseURL) + if trimmedBaseURL != "" && matchesGitHubWebBase(repoURL, trimmedBaseURL) { + return trimmedBaseURL + } + if matchesGitHubWebBase(repoURL, "https://github.com") { + return "https://github.com" + } + return "" +} + +func matchesGitHubWebBase(repoURL *url.URL, webBaseURL string) bool { + baseURL, err := url.Parse(strings.TrimSpace(webBaseURL)) + if err != nil { + return false + } + if !strings.EqualFold(repoURL.Scheme, baseURL.Scheme) { + return false + } + if !strings.EqualFold(repoURL.Host, baseURL.Host) { + return false + } + basePath := strings.Trim(baseURL.Path, "/") + if basePath == "" { + return true + } + repoPath := strings.Trim(repoURL.Path, "/") + return repoPath == basePath || strings.HasPrefix(repoPath, basePath+"/") +} + +func splitGitHubTreeOrBlobRefPath(parts []string, defaultRef string) (string, string) { + if len(parts) == 0 { + return defaultRef, "" + } + if anchor := knownSkillSubPathAnchor(parts); anchor > 0 { + return strings.Join(parts[:anchor], "/"), strings.Join(parts[anchor:], "/") + } + if parts[len(parts)-1] == "SKILL.md" { + return strings.Join(parts[:len(parts)-1], "/"), "SKILL.md" + } + return parts[0], strings.Join(parts[1:], "/") +} + +func knownSkillSubPathAnchor(parts []string) int { + for i := 1; i < len(parts); i++ { + candidateSubPath := strings.Join(parts[i:], "/") + if strings.HasPrefix(candidateSubPath, ".agents/skills/") || strings.HasPrefix(candidateSubPath, "skills/") { + return i + } + } + return -1 +} + +func isSkillMarkdownPath(subPath string) bool { + subPath = strings.Trim(strings.TrimSpace(subPath), "/") + return subPath == "SKILL.md" || strings.HasSuffix(subPath, "/SKILL.md") +} + +// parseGitHubRef parses a GitHub reference. +// Supports: "owner/repo", "owner/repo/path", or full URL like "https://github.com/owner/repo/tree/ref/path" +func parseGitHubRef(repo string) (GitHubRef, error) { + return parseGitHubRefWithBaseURL(repo, "", "main") +} + +func parseGitHubRefWithBaseURL(repo, githubBaseURL, defaultRef string) (GitHubRef, error) { + target, err := parseGitHubTargetWithBaseURL(repo, githubBaseURL, defaultRef) + if err != nil { + return GitHubRef{}, err + } + return target.Ref, nil +} + +func parseGitHubTargetWithBaseURL(repo, githubBaseURL, defaultRef string) (gitHubTarget, error) { + repo = strings.TrimSpace(repo) + defaultRef = strings.TrimSpace(defaultRef) + + // Handle full URL + if strings.HasPrefix(repo, "http://") || strings.HasPrefix(repo, "https://") { + u, err := url.Parse(repo) + if err != nil { + return gitHubTarget{}, fmt.Errorf("invalid URL: %w", err) + } + matchedBaseURL := supportedGitHubBaseURL(u, githubBaseURL) + if matchedBaseURL == "" { + return gitHubTarget{}, fmt.Errorf("invalid GitHub URL host %q", u.Host) + } + endpoints, err := resolveGitHubEndpoints(matchedBaseURL) + if err != nil { + return gitHubTarget{}, err + } + parts := parseGitHubRefPathParts(u, matchedBaseURL) + if len(parts) < 2 { + return gitHubTarget{}, fmt.Errorf("invalid GitHub URL") + } + if len(parts) > 2 { + if parts[2] != "tree" && parts[2] != "blob" { + return gitHubTarget{}, fmt.Errorf("invalid GitHub repository URL path %q", u.Path) + } + if len(parts) < 4 { + return gitHubTarget{}, fmt.Errorf("invalid GitHub %s URL path %q", parts[2], u.Path) + } + } + ref := GitHubRef{ + Owner: parts[0], + RepoName: parts[1], + Ref: defaultRef, + } + // Look for /tree/ or /blob/ in the path + for i := 2; i < len(parts); i++ { + if parts[i] == "tree" || parts[i] == "blob" { + if i+1 < len(parts) { + ref.Ref, ref.SubPath = splitGitHubTreeOrBlobRefPath(parts[i+1:], defaultRef) + } + break + } + } + return gitHubTarget{Ref: ref, Endpoints: endpoints}, nil + } + + endpoints, err := resolveGitHubEndpoints(githubBaseURL) + if err != nil { + return gitHubTarget{}, err + } + + // Handle shorthand format + parts := strings.Split(strings.Trim(repo, "/"), "/") + if len(parts) < 2 { + return gitHubTarget{}, fmt.Errorf("invalid format %q: expected 'owner/repo'", repo) + } + ref := GitHubRef{ + Owner: parts[0], + RepoName: parts[1], + Ref: defaultRef, + } + if len(parts) > 2 { + ref.SubPath = strings.Join(parts[2:], "/") + } + return gitHubTarget{Ref: ref, Endpoints: endpoints}, nil +} + +type gitHubRepository struct { + DefaultBranch string `json:"default_branch"` +} + +func (si *SkillInstaller) resolveGitHubTarget(ctx context.Context, repo, version string) (gitHubTarget, error) { + target, err := parseGitHubTargetWithBaseURL(repo, si.githubBaseURL, "") + if err != nil { + return gitHubTarget{}, err + } + if version != "" { + target.Ref.Ref = version + return target, nil + } + if target.Ref.Ref != "" { + return target, nil + } + defaultBranch, err := si.fetchDefaultBranchWithAPIBaseURL( + ctx, + target.Endpoints.APIBaseURL, + target.Ref.Owner, + target.Ref.RepoName, + ) + if err != nil { + return gitHubTarget{}, err + } + target.Ref.Ref = defaultBranch + return target, nil +} + +func (si *SkillInstaller) fetchDefaultBranchWithAPIBaseURL( + ctx context.Context, + apiBaseURL, owner, repo string, +) (string, error) { + apiURL := fmt.Sprintf("%s/repos/%s/%s", strings.TrimRight(apiBaseURL, "/"), owner, repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", err + } + if si.githubToken != "" { + req.Header.Set("Authorization", "Bearer "+si.githubToken) + } + + resp, err := utils.DoRequestWithRetry(si.client, req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("failed to read repository metadata: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to resolve default branch: HTTP %d: %s", resp.StatusCode, string(body)) + } + + var repository gitHubRepository + if err := json.Unmarshal(body, &repository); err != nil { + return "", fmt.Errorf("failed to parse repository metadata: %w", err) + } + if strings.TrimSpace(repository.DefaultBranch) == "" { + return "", fmt.Errorf("repository %s/%s did not report a default branch", owner, repo) + } + return repository.DefaultBranch, nil +} + +func githubInstallDirNameWithBaseURL(repo, githubBaseURL string) (string, error) { + if !strings.HasPrefix(repo, "http://") && !strings.HasPrefix(repo, "https://") { + if err := ValidateInstallTarget(repo); err != nil { + return "", err + } + } + ref, err := parseGitHubRefWithBaseURL(repo, githubBaseURL, "main") + if err != nil { + return "", err + } + if ref.SubPath != "" { + if isSkillMarkdownPath(ref.SubPath) { + skillDir := path.Dir(strings.Trim(ref.SubPath, "/")) + if skillDir == "." || skillDir == "" { + return ref.RepoName, nil + } + return path.Base(skillDir), nil + } + return filepath.Base(ref.SubPath), nil + } + return ref.RepoName, nil } func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error { - if !repoPattern.MatchString(repo) { - return fmt.Errorf("invalid repository format %q: must be 'owner/repo'", repo) + skillName, err := githubInstallDirNameWithBaseURL(repo, si.githubBaseURL) + if err != nil { + return err + } + skillDirectory := filepath.Join(si.workspace, "skills", skillName) + + if _, statErr := os.Stat(skillDirectory); statErr == nil { + return fmt.Errorf("skill '%s' already exists", skillName) + } + _, err = si.InstallFromGitHubToDir(ctx, repo, "", skillDirectory) + return err +} + +func (si *SkillInstaller) InstallFromGitHubToDir( + ctx context.Context, + repo, version, skillDirectory string, +) (*InstallResult, error) { + target, err := si.resolveGitHubTarget(ctx, repo, version) + if err != nil { + return nil, err + } + ref := target.Ref + apiSubPath := strings.Trim(ref.SubPath, "/") + if isSkillMarkdownPath(apiSubPath) { + if dir := path.Dir(apiSubPath); dir == "." { + apiSubPath = "" + } else { + apiSubPath = dir + } } - skillDir := filepath.Join(si.workspace, "skills", filepath.Base(repo)) + // Build GitHub API URL + apiPath := path.Join(ref.Owner, ref.RepoName, "contents") + if apiSubPath != "" { + apiPath = path.Join(apiPath, apiSubPath) + } + apiURL := fmt.Sprintf("%s/repos/%s?ref=%s", target.Endpoints.APIBaseURL, apiPath, url.QueryEscape(ref.Ref)) - if _, err := os.Stat(skillDir); err == nil { - return fmt.Errorf("skill '%s' already exists", filepath.Base(repo)) + if err := si.getGithubDirAllFiles(ctx, apiURL, skillDirectory, true); err != nil { + // Fallback to raw download + if downloadErr := si.downloadRaw( + ctx, + target.Endpoints.RawBaseURL, + ref.Owner, + ref.RepoName, + ref.Ref, + ref.SubPath, + skillDirectory, + ); downloadErr != nil { + return nil, downloadErr + } + } else if _, err := os.Stat(filepath.Join(skillDirectory, "SKILL.md")); err != nil { + return nil, fmt.Errorf("SKILL.md not found in repository") } - url := fmt.Sprintf("https://raw.githubusercontent.com/%s/main/SKILL.md", repo) + return &InstallResult{Version: ref.Ref}, nil +} + +// downloadDir recursively downloads a directory from GitHub API +// isRoot: true if this is the skill root directory (only download SKILL.md at root) +func (si *SkillInstaller) getGithubDirAllFiles(ctx context.Context, apiURL, localDir string, isRoot bool) error { + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return err + } + if si.githubToken != "" { + req.Header.Set("Authorization", "Bearer "+si.githubToken) + } + + resp, err := utils.DoRequestWithRetry(si.client, req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var items []GitHubContent + if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { + return err + } + + for _, item := range items { + localPath := filepath.Join(localDir, item.Name) + + switch item.Type { + case "file": + if !shouldDownload(item.Name, isRoot) { + continue + } + if err := si.downloadFile(ctx, item.DownloadURL, localPath); err != nil { + return fmt.Errorf("download %s: %w", item.Name, err) + } + case "dir": + if !isSkillDirectory(item.Name) { + continue + } + if err := si.getGithubDirAllFiles(ctx, item.URL, localPath, false); err != nil { + return err + } + } + } + return nil +} + +// downloadRaw is a fallback that downloads just SKILL.md from raw.githubusercontent.com +func (si *SkillInstaller) downloadRaw( + ctx context.Context, + rawBaseURL, owner, repo, ref, subPath, localDir string, +) error { + urlPath := path.Join(owner, repo, ref) + if subPath != "" { + if isSkillMarkdownPath(subPath) { + urlPath = strings.TrimSuffix(path.Join(urlPath, subPath), "/SKILL.md") + } else { + urlPath = path.Join(urlPath, subPath) + } + } + url := fmt.Sprintf("%s/%s/SKILL.md", strings.TrimRight(rawBaseURL, "/"), urlPath) - client := &http.Client{Timeout: 15 * time.Second} req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } - resp, err := utils.DoRequestWithRetry(client, req) + // Use chunked download to temporary file. + tmpPath, err := utils.DownloadToFile(ctx, si.client, req, 0) if err != nil { return fmt.Errorf("failed to fetch skill: %w", err) } - defer resp.Body.Close() + defer os.Remove(tmpPath) - if resp.StatusCode != 200 { - return fmt.Errorf("failed to fetch skill: HTTP %d", resp.StatusCode) - } - - body, err := io.ReadAll(io.LimitReader(resp.Body, maxSkillFileSize)) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - if err := os.MkdirAll(skillDir, 0o755); err != nil { + if err := os.MkdirAll(localDir, 0o755); err != nil { return fmt.Errorf("failed to create skill directory: %w", err) } - skillPath := filepath.Join(skillDir, "SKILL.md") + localPath := filepath.Join(localDir, "SKILL.md") - // Use unified atomic write utility with explicit sync for flash storage reliability. - if err := fileutil.WriteFileAtomic(skillPath, body, 0o600); err != nil { + if err := fileutil.CopyFile(tmpPath, localPath, 0o600); err != nil { return fmt.Errorf("failed to write skill file: %w", err) } - return nil } +func (si *SkillInstaller) downloadFile(ctx context.Context, url, localPath string) error { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return err + } + + // Use chunked download to temporary file, then move atomically to target. + tmpPath, err := utils.DownloadToFile(ctx, si.client, req, 0) + if err != nil { + return err + } + defer os.Remove(tmpPath) + + if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { + return err + } + + if err := fileutil.CopyFile(tmpPath, localPath, 0o600); err != nil { + return fmt.Errorf("failed to move downloaded file: %w", err) + } + return nil +} + +// shouldDownload determines if a file should be downloaded +// root: true if we're at the skill root directory +func shouldDownload(name string, root bool) bool { + if root { + return name == "SKILL.md" + } + return true +} + +// isSkillDir checks if a directory is a standard skill resource directory +func isSkillDirectory(name string) bool { + switch name { + case "scripts", "references", "assets", "templates", "docs": + return true + } + return false +} + func (si *SkillInstaller) Uninstall(skillName string) error { - skillDir := filepath.Join(si.workspace, "skills", skillName) + parts := strings.Split(skillName, "/") + var finalSkillName string + for i := len(parts) - 1; i >= 0; i-- { + if parts[i] != "" { + finalSkillName = parts[i] + break + } + } + if finalSkillName == "" { + finalSkillName = skillName + } + + skillDir := filepath.Join(si.workspace, "skills", finalSkillName) if _, err := os.Stat(skillDir); os.IsNotExist(err) { - return fmt.Errorf("skill '%s' not found", skillName) + return fmt.Errorf("skill '%s' not found (processed as '%s')", skillName, finalSkillName) } if err := os.RemoveAll(skillDir); err != nil { - return fmt.Errorf("failed to remove skill: %w", err) + return fmt.Errorf("failed to remove skill '%s': %w", finalSkillName, err) } return nil diff --git a/pkg/skills/installer_test.go b/pkg/skills/installer_test.go new file mode 100644 index 000000000..9691a5312 --- /dev/null +++ b/pkg/skills/installer_test.go @@ -0,0 +1,961 @@ +package skills + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestParseGitHubRef(t *testing.T) { + tests := []struct { + name string + repo string + wantOwner string + wantRepoName string + wantRef string + wantSubPath string + wantErr bool + wantErrContain string + }{ + { + name: "simple owner/repo", + repo: "sipeed/picoclaw", + wantOwner: "sipeed", + wantRepoName: "picoclaw", + wantRef: "main", + wantSubPath: "", + }, + { + name: "owner/repo with subpath", + repo: "sipeed/picoclaw/skills/test", + wantOwner: "sipeed", + wantRepoName: "picoclaw", + wantRef: "main", + wantSubPath: "skills/test", + }, + { + name: "full URL with tree", + repo: "https://github.com/sipeed/picoclaw/tree/dev/skills/test", + wantOwner: "sipeed", + wantRepoName: "picoclaw", + wantRef: "dev", + wantSubPath: "skills/test", + }, + { + name: "full URL with blob", + repo: "https://github.com/sipeed/picoclaw/blob/main/README.md", + wantOwner: "sipeed", + wantRepoName: "picoclaw", + wantRef: "main", + wantSubPath: "README.md", + }, + { + name: "full URL without ref", + repo: "https://github.com/sipeed/picoclaw", + wantOwner: "sipeed", + wantRepoName: "picoclaw", + wantRef: "main", + wantSubPath: "", + }, + { + name: "invalid format - single part", + repo: "sipeed", + wantErr: true, + wantErrContain: "expected 'owner/repo'", + }, + { + name: "invalid URL", + repo: "http://[invalid", + wantErr: true, + wantErrContain: "invalid URL", + }, + { + name: "invalid GitHub URL - only one path part", + repo: "https://github.com/sipeed", + wantErr: true, + wantErrContain: "invalid GitHub URL", + }, + { + name: "with whitespace", + repo: " sipeed/picoclaw ", + wantOwner: "sipeed", + wantRepoName: "picoclaw", + wantRef: "main", + wantSubPath: "", + }, + { + name: "invalid non github host", + repo: "https://gitlab.com/sipeed/picoclaw/-/tree/main/skills/test", + wantErr: true, + wantErrContain: `invalid GitHub URL host "gitlab.com"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ref, err := parseGitHubRef(tt.repo) + + if tt.wantErr { + if err == nil { + t.Errorf("parseGitHubRef() error = nil, wantErr = true") + return + } + if tt.wantErrContain != "" && !strings.Contains(err.Error(), tt.wantErrContain) { + t.Errorf("parseGitHubRef() error = %v, want error containing %v", err, tt.wantErrContain) + } + return + } + + if err != nil { + t.Errorf("parseGitHubRef() unexpected error = %v", err) + return + } + + if ref.Owner != tt.wantOwner { + t.Errorf("parseGitHubRef() owner = %v, want %v", ref.Owner, tt.wantOwner) + } + if ref.RepoName != tt.wantRepoName { + t.Errorf("parseGitHubRef() repoName = %v, want %v", ref.RepoName, tt.wantRepoName) + } + if ref.Ref != tt.wantRef { + t.Errorf("parseGitHubRef() ref = %v, want %v", ref.Ref, tt.wantRef) + } + if ref.SubPath != tt.wantSubPath { + t.Errorf("parseGitHubRef() subPath = %v, want %v", ref.SubPath, tt.wantSubPath) + } + }) + } +} + +func TestParseGitHubRefWithBaseURL(t *testing.T) { + ref, err := parseGitHubRefWithBaseURL( + "https://ghe.example.com/git/org/repo/tree/dev/skills/test", + "https://ghe.example.com/git", + "main", + ) + if err != nil { + t.Fatalf("parseGitHubRefWithBaseURL() unexpected error = %v", err) + } + if ref.Owner != "org" { + t.Fatalf("owner = %q, want org", ref.Owner) + } + if ref.RepoName != "repo" { + t.Fatalf("repo = %q, want repo", ref.RepoName) + } + if ref.Ref != "dev" { + t.Fatalf("ref = %q, want dev", ref.Ref) + } + if ref.SubPath != "skills/test" { + t.Fatalf("subPath = %q, want skills/test", ref.SubPath) + } + + dirName, err := githubInstallDirNameWithBaseURL( + "https://ghe.example.com/git/org/repo/tree/dev/skills/test", + "https://ghe.example.com/git", + ) + if err != nil { + t.Fatalf("githubInstallDirNameWithBaseURL() unexpected error = %v", err) + } + if dirName != "test" { + t.Fatalf("dirName = %q, want test", dirName) + } + + dirName, err = githubInstallDirNameWithBaseURL( + "https://ghe.example.com/git/org/repo/blob/dev/skills/test/SKILL.md", + "https://ghe.example.com/git", + ) + if err != nil { + t.Fatalf("githubInstallDirNameWithBaseURL() unexpected error for blob skill url = %v", err) + } + if dirName != "test" { + t.Fatalf("dirName for nested blob skill = %q, want test", dirName) + } + + dirName, err = githubInstallDirNameWithBaseURL( + "https://ghe.example.com/git/org/repo/blob/dev/SKILL.md", + "https://ghe.example.com/git", + ) + if err != nil { + t.Fatalf("githubInstallDirNameWithBaseURL() unexpected error for repo root blob skill = %v", err) + } + if dirName != "repo" { + t.Fatalf("dirName for repo root blob skill = %q, want repo", dirName) + } + + ref, err = parseGitHubRefWithBaseURL("https://ghe.example.com/git/org/repo", "https://ghe.example.com/git", "") + if err != nil { + t.Fatalf("parseGitHubRefWithBaseURL() unexpected error = %v", err) + } + if ref.Ref != "" { + t.Fatalf("ref = %q, want empty", ref.Ref) + } + + ref, err = parseGitHubRefWithBaseURL( + "https://github.com/org/repo/tree/feature/skills-registry/.agents/skills/pr-review", + "", + "main", + ) + if err != nil { + t.Fatalf("parseGitHubRefWithBaseURL() unexpected error for slash branch = %v", err) + } + if ref.Ref != "feature/skills-registry" { + t.Fatalf("ref = %q, want feature/skills-registry", ref.Ref) + } + if ref.SubPath != ".agents/skills/pr-review" { + t.Fatalf("subPath = %q, want .agents/skills/pr-review", ref.SubPath) + } + + _, err = parseGitHubRefWithBaseURL( + "https://gitlab.example.com/org/repo/-/tree/dev/skills/test", + "https://ghe.example.com/git", + "main", + ) + if err == nil { + t.Fatal("parseGitHubRefWithBaseURL() error = nil, want invalid host error") + } + if !strings.Contains(err.Error(), `invalid GitHub URL host "gitlab.example.com"`) { + t.Fatalf("unexpected error = %v", err) + } + + _, err = parseGitHubRefWithBaseURL( + "http://ghe.example.com/git/org/repo/tree/dev/skills/test", + "https://ghe.example.com/git", + "main", + ) + if err == nil { + t.Fatal("parseGitHubRefWithBaseURL() error = nil, want invalid host error for scheme mismatch") + } + if !strings.Contains(err.Error(), `invalid GitHub URL host "ghe.example.com"`) { + t.Fatalf("unexpected scheme mismatch error = %v", err) + } + + _, err = parseGitHubRefWithBaseURL( + "https://github.com/org/repo/pull/2442", + "", + "main", + ) + if err == nil { + t.Fatal("parseGitHubRefWithBaseURL() error = nil, want invalid repository URL path error") + } + if !strings.Contains(err.Error(), `invalid GitHub repository URL path "/org/repo/pull/2442"`) { + t.Fatalf("unexpected PR URL error = %v", err) + } + + _, err = parseGitHubRefWithBaseURL( + "https://github.com/org/repo/tree", + "", + "main", + ) + if err == nil { + t.Fatal("parseGitHubRefWithBaseURL() error = nil, want invalid tree URL path error") + } + if !strings.Contains(err.Error(), `invalid GitHub tree URL path "/org/repo/tree"`) { + t.Fatalf("unexpected short tree URL error = %v", err) + } +} + +func TestParseGitHubTargetWithBaseURLPreservesSourceEndpoints(t *testing.T) { + target, err := parseGitHubTargetWithBaseURL( + "https://github.com/org/repo/tree/main/.agents/skills/pr-review", + "https://ghe.example.com/git", + "", + ) + if err != nil { + t.Fatalf("parseGitHubTargetWithBaseURL() unexpected error = %v", err) + } + if target.Endpoints.WebBaseURL != "https://github.com" { + t.Fatalf("web base = %q, want https://github.com", target.Endpoints.WebBaseURL) + } + if target.Endpoints.APIBaseURL != "https://api.github.com" { + t.Fatalf("api base = %q, want https://api.github.com", target.Endpoints.APIBaseURL) + } + if target.Endpoints.RawBaseURL != "https://raw.githubusercontent.com" { + t.Fatalf("raw base = %q, want https://raw.githubusercontent.com", target.Endpoints.RawBaseURL) + } + if target.Ref.Owner != "org" || target.Ref.RepoName != "repo" { + t.Fatalf("unexpected ref = %+v", target.Ref) + } + if target.Ref.Ref != "main" { + t.Fatalf("ref = %q, want main", target.Ref.Ref) + } + if target.Ref.SubPath != ".agents/skills/pr-review" { + t.Fatalf("subPath = %q, want .agents/skills/pr-review", target.Ref.SubPath) + } +} + +func TestParseGitHubTargetWithBaseURLPreservesSlashBranchForRepoRootBlobSkill(t *testing.T) { + target, err := parseGitHubTargetWithBaseURL( + "https://github.com/org/repo/blob/feature/skills-registry/SKILL.md", + "", + "", + ) + if err != nil { + t.Fatalf("parseGitHubTargetWithBaseURL() unexpected error = %v", err) + } + if target.Ref.Ref != "feature/skills-registry" { + t.Fatalf("ref = %q, want feature/skills-registry", target.Ref.Ref) + } + if target.Ref.SubPath != "SKILL.md" { + t.Fatalf("subPath = %q, want SKILL.md", target.Ref.SubPath) + } +} + +func TestSkillInstallerResolveGitHubRefUsesDefaultBranch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/repos/org/repo": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"default_branch":"master"}`)) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + installer, err := NewSkillInstallerWithBaseURL(t.TempDir(), server.URL, "", "") + if err != nil { + t.Fatalf("NewSkillInstallerWithBaseURL() error = %v", err) + } + + target, err := installer.resolveGitHubTarget(context.Background(), "org/repo/skills/test", "") + if err != nil { + t.Fatalf("resolveGitHubTarget() error = %v", err) + } + ref := target.Ref + if ref.Ref != "master" { + t.Fatalf("ref = %q, want master", ref.Ref) + } + if ref.SubPath != "skills/test" { + t.Fatalf("subPath = %q, want skills/test", ref.SubPath) + } +} + +func TestSkillInstallerInstallFromGitHubToDirSupportsBlobSkillURL(t *testing.T) { + tmpDir := t.TempDir() + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/repos/org/repo/contents/.agents/skills/pr-review": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"type":"file","name":"SKILL.md","download_url":"` + server.URL + `/raw/org/repo/main/.agents/skills/pr-review/SKILL.md"}, + {"type":"dir","name":"scripts","url":"` + server.URL + `/api/v3/repos/org/repo/contents/.agents/skills/pr-review/scripts?ref=main"} + ]`)) + case "/api/v3/repos/org/repo/contents/.agents/skills/pr-review/scripts": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"type":"file","name":"check.sh","download_url":"` + server.URL + `/raw/org/repo/main/.agents/skills/pr-review/scripts/check.sh"} + ]`)) + case "/raw/org/repo/main/.agents/skills/pr-review/SKILL.md": + _, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n")) + case "/raw/org/repo/main/.agents/skills/pr-review/scripts/check.sh": + _, _ = w.Write([]byte("#!/bin/sh\nexit 0\n")) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + installer, err := NewSkillInstallerWithBaseURL(tmpDir, server.URL, "", "") + if err != nil { + t.Fatalf("NewSkillInstallerWithBaseURL() error = %v", err) + } + + targetDir := filepath.Join(tmpDir, "skills", "pr-review") + result, err := installer.InstallFromGitHubToDir( + context.Background(), + server.URL+"/org/repo/blob/main/.agents/skills/pr-review/SKILL.md", + "", + targetDir, + ) + if err != nil { + t.Fatalf("InstallFromGitHubToDir() error = %v", err) + } + if result.Version != "main" { + t.Fatalf("version = %q, want main", result.Version) + } + + content, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md")) + if err != nil { + t.Fatalf("ReadFile(SKILL.md) error = %v", err) + } + if !strings.Contains(string(content), "name: pr-review") { + t.Fatalf("SKILL.md content = %q, want skill metadata", string(content)) + } + + scriptPath := filepath.Join(targetDir, "scripts", "check.sh") + if _, err := os.Stat(scriptPath); err != nil { + t.Fatalf("Stat(scripts/check.sh) error = %v", err) + } +} + +func TestShouldDownload(t *testing.T) { + tests := []struct { + name string + file string + root bool + want bool + }{ + {"SKILL.md at root", "SKILL.md", true, true}, + {"other file at root", "README.md", true, false}, + {"script at root", "script.py", true, false}, + {"SKILL.md not at root", "SKILL.md", false, true}, + {"any file not at root", "any.txt", false, true}, + {"script not at root", "script.py", false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldDownload(tt.file, tt.root) + if got != tt.want { + t.Errorf("shouldDownload(%q, %v) = %v, want %v", tt.file, tt.root, got, tt.want) + } + }) + } +} + +func TestIsSkillDirectory(t *testing.T) { + tests := []struct { + name string + dir string + want bool + }{ + {"scripts dir", "scripts", true}, + {"references dir", "references", true}, + {"assets dir", "assets", true}, + {"templates dir", "templates", true}, + {"docs dir", "docs", true}, + {"other dir", "other", false}, + {"src dir", "src", false}, + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isSkillDirectory(tt.dir) + if got != tt.want { + t.Errorf("isSkillDirectory(%q) = %v, want %v", tt.dir, got, tt.want) + } + }) + } +} + +func TestNewSkillInstaller(t *testing.T) { + tmpDir := t.TempDir() + installer, err := NewSkillInstaller(tmpDir, "test-token", "") + if err != nil { + t.Fatalf("NewSkillInstaller() error = %v", err) + } + + if installer == nil { + t.Fatal("NewSkillInstaller() returned nil") + } + + if installer.workspace != tmpDir { + t.Errorf("workspace = %v, want %v", installer.workspace, tmpDir) + } + + if installer.githubToken != "test-token" { + t.Errorf("githubToken = %v, want 'test-token'", installer.githubToken) + } + + if installer.githubBaseURL != "https://github.com" { + t.Errorf("githubBaseURL = %v, want https://github.com", installer.githubBaseURL) + } + if installer.githubAPIBaseURL != "https://api.github.com" { + t.Errorf("githubAPIBaseURL = %v, want https://api.github.com", installer.githubAPIBaseURL) + } + if installer.githubRawBaseURL != "https://raw.githubusercontent.com" { + t.Errorf("githubRawBaseURL = %v, want https://raw.githubusercontent.com", installer.githubRawBaseURL) + } + + if installer.proxy != "" { + t.Errorf("proxy = %v, want empty", installer.proxy) + } + + if installer.client == nil { + t.Error("client is nil") + } else if installer.client.Timeout != 15*time.Second { + t.Errorf("client.Timeout = %v, want 15s", installer.client.Timeout) + } +} + +func TestNewSkillInstaller_WithProxy(t *testing.T) { + tmpDir := t.TempDir() + installer, err := NewSkillInstaller(tmpDir, "test-token", "http://127.0.0.1:7890") + if err != nil { + t.Fatalf("NewSkillInstaller() error = %v", err) + } + + if installer.proxy != "http://127.0.0.1:7890" { + t.Errorf("proxy = %v, want 'http://127.0.0.1:7890'", installer.proxy) + } + + if installer.client == nil { + t.Fatal("client is nil") + } + + // Verify the transport has proxy configured + transport, ok := installer.client.Transport.(*http.Transport) + if !ok { + t.Fatal("client.Transport is not *http.Transport") + } + + if transport.Proxy == nil { + t.Error("transport.Proxy is nil, expected non-nil") + } +} + +func TestNewSkillInstaller_WithBaseURL(t *testing.T) { + tmpDir := t.TempDir() + installer, err := NewSkillInstallerWithBaseURL(tmpDir, "https://github.example.com", "test-token", "") + if err != nil { + t.Fatalf("NewSkillInstallerWithBaseURL() error = %v", err) + } + + if installer.githubBaseURL != "https://github.example.com" { + t.Errorf("githubBaseURL = %v, want https://github.example.com", installer.githubBaseURL) + } + if installer.githubAPIBaseURL != "https://github.example.com/api/v3" { + t.Errorf("githubAPIBaseURL = %v, want https://github.example.com/api/v3", installer.githubAPIBaseURL) + } + if installer.githubRawBaseURL != "https://github.example.com/raw" { + t.Errorf("githubRawBaseURL = %v, want https://github.example.com/raw", installer.githubRawBaseURL) + } +} + +func TestNewSkillInstaller_InvalidProxy(t *testing.T) { + tmpDir := t.TempDir() + installer, err := NewSkillInstaller(tmpDir, "test-token", "://invalid-proxy") + if err == nil { + t.Error("NewSkillInstaller() expected error for invalid proxy, got nil") + } + if installer != nil { + t.Error("expected nil installer on error") + } +} + +func TestSkillInstaller_DownloadFile(t *testing.T) { + // Create a test server that serves files + content := "test file content for skill download" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(content)) + })) + defer server.Close() + + tmpDir := t.TempDir() + installer, err := NewSkillInstaller(tmpDir, "", "") + if err != nil { + t.Fatalf("NewSkillInstaller() error = %v", err) + } + + t.Run("successful download", func(t *testing.T) { + localPath := filepath.Join(tmpDir, "test-skill", "SKILL.md") + err := installer.downloadFile(context.Background(), server.URL, localPath) + if err != nil { + t.Errorf("downloadFile() error = %v", err) + return + } + + // Verify file was downloaded + data, err := os.ReadFile(localPath) + if err != nil { + t.Errorf("failed to read downloaded file: %v", err) + return + } + + if string(data) != content { + t.Errorf("downloaded content = %q, want %q", string(data), content) + } + + // Check file permissions + info, err := os.Stat(localPath) + if err != nil { + t.Errorf("failed to stat file: %v", err) + return + } + + if info.Mode().Perm() != 0o600 { + t.Errorf("file permissions = %o, want %o", info.Mode().Perm(), 0o600) + } + }) + + t.Run("http error", func(t *testing.T) { + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("not found")) + })) + defer errorServer.Close() + + localPath := filepath.Join(tmpDir, "error-test", "SKILL.md") + err := installer.downloadFile(context.Background(), errorServer.URL, localPath) + if err == nil { + t.Error("downloadFile() expected error for 404, got nil") + } + }) +} + +func TestSkillInstaller_DownloadRaw(t *testing.T) { + content := "raw skill content" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(content)) + })) + defer server.Close() + + tmpDir := t.TempDir() + installer, err := NewSkillInstaller(tmpDir, "", "") + if err != nil { + t.Fatalf("NewSkillInstaller() error = %v", err) + } + + // Replace the client with one that points to our test server + // We need to modify the URL in the function, so we'll test indirectly + + localDir := filepath.Join(tmpDir, "raw-test") + ctx := context.Background() + + // Create a simple test by calling downloadFile directly since downloadRaw + // constructs its own URL + testFile := filepath.Join(localDir, "SKILL.md") + err = installer.downloadFile(ctx, server.URL, testFile) + if err != nil { + t.Errorf("downloadFile() error = %v", err) + } + + // Verify file content + data, err := os.ReadFile(testFile) + if err != nil { + t.Errorf("failed to read file: %v", err) + return + } + + if string(data) != content { + t.Errorf("content = %q, want %q", string(data), content) + } +} + +func TestSkillInstaller_Uninstall(t *testing.T) { + tmpDir := t.TempDir() + skillsDir := filepath.Join(tmpDir, "skills") + os.MkdirAll(skillsDir, 0o755) + + installer, err := NewSkillInstaller(tmpDir, "", "") + if err != nil { + t.Fatalf("NewSkillInstaller() error = %v", err) + } + + t.Run("uninstall existing skill", func(t *testing.T) { + skillName := "test-skill" + skillDir := filepath.Join(skillsDir, skillName) + + // Create skill directory with a file + os.MkdirAll(skillDir, 0o755) + os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0o644) + + if err := installer.Uninstall(skillName); err != nil { + t.Errorf("Uninstall() error = %v", err) + } + + // Verify directory was removed + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Error("skill directory still exists after uninstall") + } + }) + + t.Run("uninstall non-existent skill", func(t *testing.T) { + if err := installer.Uninstall("non-existent-skill"); err == nil { + t.Error("Uninstall() expected error for non-existent skill, got nil") + } else if !strings.Contains(err.Error(), "not found") { + t.Errorf("error message = %q, want 'not found'", err.Error()) + } + }) + + t.Run("uninstall with path separator", func(t *testing.T) { + skillName := "owner/repo/skill-name" + skillDir := filepath.Join(skillsDir, "skill-name") + + // Create skill directory + os.MkdirAll(skillDir, 0o755) + os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0o644) + + if err := installer.Uninstall(skillName); err != nil { + t.Errorf("Uninstall() error = %v", err) + } + + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Error("skill directory still exists after uninstall") + } + }) + + t.Run("uninstall with trailing slash", func(t *testing.T) { + skillName := "skill-name/" + skillDir := filepath.Join(skillsDir, "skill-name") + + // Create skill directory + os.MkdirAll(skillDir, 0o755) + os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0o644) + + if err := installer.Uninstall(skillName); err != nil { + t.Errorf("Uninstall() error = %v", err) + } + + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Error("skill directory still exists after uninstall") + } + }) +} + +func TestSkillInstaller_InstallFromGitHub_SkillAlreadyExists(t *testing.T) { + tmpDir := t.TempDir() + skillsDir := filepath.Join(tmpDir, "skills") + os.MkdirAll(skillsDir, 0o755) + + installer, err := NewSkillInstaller(tmpDir, "", "") + if err != nil { + t.Fatalf("NewSkillInstaller() error = %v", err) + } + + // Create an existing skill directory + existingSkill := filepath.Join(skillsDir, "picoclaw") + os.MkdirAll(existingSkill, 0o755) + os.WriteFile(filepath.Join(existingSkill, "SKILL.md"), []byte("existing"), 0o644) + + // Try to install the same skill - should fail + err = installer.InstallFromGitHub(context.Background(), "sipeed/picoclaw") + if err == nil { + t.Error("InstallFromGitHub() expected error for existing skill, got nil") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("error message = %q, want 'already exists'", err.Error()) + } +} + +func TestGitHubContent_Struct(t *testing.T) { + // Test that GitHubContent struct can be properly unmarshaled + jsonData := `{ + "name": "test.md", + "path": "skills/test.md", + "type": "file", + "download_url": "https://example.com/download", + "url": "https://api.github.com/contents/skills/test.md" + }` + + var content GitHubContent + err := json.Unmarshal([]byte(jsonData), &content) + if err != nil { + t.Errorf("failed to unmarshal GitHubContent: %v", err) + } + + if content.Name != "test.md" { + t.Errorf("Name = %q, want 'test.md'", content.Name) + } + if content.Type != "file" { + t.Errorf("Type = %q, want 'file'", content.Type) + } + if content.DownloadURL != "https://example.com/download" { + t.Errorf("DownloadURL = %q, want 'https://example.com/download'", content.DownloadURL) + } +} + +func TestSkillInstaller_GetGithubDirAllFiles(t *testing.T) { + tmpDir := t.TempDir() + installer, err := NewSkillInstaller(tmpDir, "", "") + if err != nil { + t.Fatalf("NewSkillInstaller() error = %v", err) + } + + // Create a test server that mimics GitHub API + fileContent := "skill file content" + var serverURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check for authorization header + authHeader := r.Header.Get("Authorization") + if authHeader != "" && !strings.HasPrefix(authHeader, "Bearer ") { + t.Errorf("expected Bearer token, got: %s", authHeader) + } + + // Return different responses based on path + if strings.Contains(r.URL.Path, "/contents") { + // API response for directory listing + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + items := []map[string]any{ + { + "name": "SKILL.md", + "path": "SKILL.md", + "type": "file", + "download_url": serverURL + "/download/SKILL.md", + }, + { + "name": "scripts", + "path": "scripts", + "type": "dir", + "url": serverURL + "/api/scripts", + }, + } + json.NewEncoder(w).Encode(items) + } else if strings.Contains(r.URL.Path, "/api/scripts") { + // API response for scripts subdirectory + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + items := []map[string]any{ + { + "name": "test.py", + "path": "scripts/test.py", + "type": "file", + "download_url": serverURL + "/download/test.py", + }, + } + json.NewEncoder(w).Encode(items) + } else if strings.Contains(r.URL.Path, "/download/") { + // Raw file download + w.WriteHeader(http.StatusOK) + w.Write([]byte(fileContent)) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + serverURL = server.URL + defer server.Close() + + localDir := filepath.Join(tmpDir, "test-skill") + + t.Run("download from GitHub API", func(t *testing.T) { + err := installer.getGithubDirAllFiles(context.Background(), server.URL+"/contents", localDir, true) + if err != nil { + t.Errorf("getGithubDirAllFiles() error = %v", err) + return + } + + // Verify SKILL.md was downloaded + skillMd := filepath.Join(localDir, "SKILL.md") + data, err := os.ReadFile(skillMd) + if err != nil { + t.Errorf("failed to read SKILL.md: %v", err) + return + } + if string(data) != fileContent { + t.Errorf("SKILL.md content = %q, want %q", string(data), fileContent) + } + + // Verify scripts directory and file + scriptFile := filepath.Join(localDir, "scripts", "test.py") + data, err = os.ReadFile(scriptFile) + if err != nil { + t.Errorf("failed to read test.py: %v", err) + return + } + if string(data) != fileContent { + t.Errorf("test.py content = %q, want %q", string(data), fileContent) + } + }) + + t.Run("http error response", func(t *testing.T) { + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer errorServer.Close() + + err := installer.getGithubDirAllFiles( + context.Background(), + errorServer.URL, + filepath.Join(tmpDir, "error-test"), + true, + ) + if err == nil { + t.Error("getGithubDirAllFiles() expected error for 403, got nil") + } + }) +} + +func TestSkillInstaller_InstallFromGitHub_WithToken(t *testing.T) { + tmpDir := t.TempDir() + skillsDir := filepath.Join(tmpDir, "skills") + os.MkdirAll(skillsDir, 0o755) + + var serverURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Capture the authorization header + authHeader := r.Header.Get("Authorization") + if authHeader != "" { + tokenReceived := strings.TrimPrefix(authHeader, "Bearer ") + t.Fatalf("github token is %s", tokenReceived) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + items := []map[string]any{ + { + "name": "SKILL.md", + "path": "SKILL.md", + "type": "file", + "download_url": serverURL + "/download/SKILL.md", + }, + } + json.NewEncoder(w).Encode(items) + })) + serverURL = server.URL + defer server.Close() + + installer, err := NewSkillInstaller(tmpDir, "test-github-token", "") + if err != nil { + t.Fatalf("NewSkillInstaller() error = %v", err) + } + + // We need to test the token is passed - the actual install will fail + // because we're not fully mocking the download, but we can verify + // the token is sent in the request + + // Use a simple context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // The install will fail because download URL isn't properly set up, + // but the token should be sent in the API request + _ = installer.InstallFromGitHub(ctx, "owner/repo") + + // Note: We can't easily intercept the download request since it's a different URL, + // but the fact that the API request was made verifies the token flow + // In a real scenario, the token would be sent to both API and raw downloads +} + +func TestSkillInstaller_ContextCancellation(t *testing.T) { + tmpDir := t.TempDir() + installer, err := NewSkillInstaller(tmpDir, "", "") + if err != nil { + t.Fatalf("NewSkillInstaller() error = %v", err) + } + + // Create a slow server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + w.Write([]byte("response")) + })) + defer server.Close() + + // Create a canceled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + localPath := filepath.Join(tmpDir, "cancel-test", "file.txt") + err = installer.downloadFile(ctx, server.URL, localPath) + + if err == nil { + t.Error("downloadFile() expected error for canceled context, got nil") + } +} diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index 7323d6686..a02b042e4 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -10,14 +10,15 @@ import ( "regexp" "strings" + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/ast" + "github.com/gomarkdown/markdown/parser" + "gopkg.in/yaml.v3" + "github.com/sipeed/picoclaw/pkg/logger" ) -var ( - namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`) - reFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`) - reStripFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) -) +var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`) const ( MaxNameLength = 64 @@ -264,11 +265,20 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata { return nil } - frontmatter := sl.extractFrontmatter(string(content)) + frontmatter, bodyContent := splitFrontmatter(string(content)) + dirName := filepath.Base(filepath.Dir(skillPath)) + title, bodyDescription := extractMarkdownMetadata(bodyContent) + + metadata := &SkillMetadata{ + Name: dirName, + Description: bodyDescription, + } + if title != "" && namePattern.MatchString(title) && len(title) <= MaxNameLength { + metadata.Name = title + } + if frontmatter == "" { - return &SkillMetadata{ - Name: filepath.Base(filepath.Dir(skillPath)), - } + return metadata } // Try JSON first (for backward compatibility) @@ -277,60 +287,133 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata { Description string `json:"description"` } if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil { - return &SkillMetadata{ - Name: jsonMeta.Name, - Description: jsonMeta.Description, + if jsonMeta.Name != "" { + metadata.Name = jsonMeta.Name } + if jsonMeta.Description != "" { + metadata.Description = jsonMeta.Description + } + return metadata } // Fall back to simple YAML parsing yamlMeta := sl.parseSimpleYAML(frontmatter) - return &SkillMetadata{ - Name: yamlMeta["name"], - Description: yamlMeta["description"], + if name := yamlMeta["name"]; name != "" { + metadata.Name = name } + if description := yamlMeta["description"]; description != "" { + metadata.Description = description + } + return metadata } -// parseSimpleYAML parses simple key: value YAML format -// Example: name: github\n description: "..." -// Normalizes line endings to handle \n (Unix), \r\n (Windows), and \r (classic Mac) +func extractMarkdownMetadata(content string) (title, description string) { + p := parser.NewWithExtensions(parser.CommonExtensions) + doc := markdown.Parse([]byte(content), p) + if doc == nil { + return "", "" + } + + ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus { + if !entering { + return ast.GoToNext + } + + switch n := node.(type) { + case *ast.Heading: + if title == "" && n.Level == 1 { + title = nodeText(n) + if title != "" && description != "" { + return ast.Terminate + } + } + case *ast.Paragraph: + if description == "" { + description = nodeText(n) + if title != "" && description != "" { + return ast.Terminate + } + } + } + return ast.GoToNext + }) + + return title, description +} + +func nodeText(n ast.Node) string { + var b strings.Builder + ast.WalkFunc(n, func(node ast.Node, entering bool) ast.WalkStatus { + if !entering { + return ast.GoToNext + } + + switch t := node.(type) { + case *ast.Text: + b.Write(t.Literal) + case *ast.Code: + b.Write(t.Literal) + case *ast.Softbreak, *ast.Hardbreak, *ast.NonBlockingSpace: + b.WriteByte(' ') + } + return ast.GoToNext + }) + return strings.Join(strings.Fields(b.String()), " ") +} + +// parseSimpleYAML parses YAML frontmatter and extracts known metadata fields. func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string { result := make(map[string]string) - // Normalize line endings: convert \r\n and \r to \n - normalized := strings.ReplaceAll(content, "\r\n", "\n") - normalized = strings.ReplaceAll(normalized, "\r", "\n") - - for line := range strings.SplitSeq(normalized, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - // Remove quotes if present - value = strings.Trim(value, "\"'") - result[key] = value - } + var meta struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + } + if err := yaml.Unmarshal([]byte(content), &meta); err != nil { + return result + } + if meta.Name != "" { + result["name"] = meta.Name + } + if meta.Description != "" { + result["description"] = meta.Description } return result } func (sl *SkillsLoader) extractFrontmatter(content string) string { - // Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks - match := reFrontmatter.FindStringSubmatch(content) - if len(match) > 1 { - return match[1] - } - return "" + frontmatter, _ := splitFrontmatter(content) + return frontmatter } func (sl *SkillsLoader) stripFrontmatter(content string) string { - return reStripFrontmatter.ReplaceAllString(content, "") + _, body := splitFrontmatter(content) + return body +} + +func splitFrontmatter(content string) (frontmatter, body string) { + normalized := string(parser.NormalizeNewlines([]byte(content))) + lines := strings.Split(normalized, "\n") + if len(lines) == 0 || lines[0] != "---" { + return "", content + } + + end := -1 + for i := 1; i < len(lines); i++ { + if lines[i] == "---" { + end = i + break + } + } + if end == -1 { + return "", content + } + + frontmatter = strings.Join(lines[1:end], "\n") + body = strings.Join(lines[end+1:], "\n") + body = strings.TrimLeft(body, "\n") + return frontmatter, body } func escapeXML(s string) string { diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go index 5cc28fda8..1de1c499c 100644 --- a/pkg/skills/loader_test.go +++ b/pkg/skills/loader_test.go @@ -342,3 +342,78 @@ func TestSkillRootsTrimsWhitespaceAndDedups(t *testing.T) { builtin, }, roots) } + +func TestGetSkillMetadata_UsesMarkdownParagraphWhenNoFrontmatter(t *testing.T) { + tmp := t.TempDir() + skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + + content := "# Plain Skill\n\nThis is parsed from markdown paragraph.\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + sl := &SkillsLoader{} + meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) + require.NotNil(t, meta) + assert.Equal(t, "plain-skill", meta.Name) + assert.Equal(t, "This is parsed from markdown paragraph.", meta.Description) +} + +func TestGetSkillMetadata_FrontmatterOverridesMarkdown(t *testing.T) { + tmp := t.TempDir() + skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + + content := "---\nname: frontmatter-skill\ndescription: frontmatter description\n---\n\n# Plain Skill\n\nBody description.\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + sl := &SkillsLoader{} + meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) + require.NotNil(t, meta) + assert.Equal(t, "frontmatter-skill", meta.Name) + assert.Equal(t, "frontmatter description", meta.Description) +} + +func TestGetSkillMetadata_YAMLMultilineDescription(t *testing.T) { + tmp := t.TempDir() + skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + + content := "---\nname: frontmatter-skill\ndescription: |\n line 1: with colon\n line 2\n---\n\n# Plain Skill\n\nBody description.\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + sl := &SkillsLoader{} + meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) + require.NotNil(t, meta) + assert.Equal(t, "frontmatter-skill", meta.Name) + assert.Equal(t, "line 1: with colon\nline 2", meta.Description) +} + +func TestGetSkillMetadata_InvalidHeadingNameFallsBackToDirName(t *testing.T) { + tmp := t.TempDir() + skillDir := filepath.Join(tmp, "workspace", "skills", "valid-name") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + + content := "# Invalid Heading Name\n\nBody description.\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + sl := &SkillsLoader{} + meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) + require.NotNil(t, meta) + assert.Equal(t, "valid-name", meta.Name) + assert.Equal(t, "Body description.", meta.Description) +} + +func TestGetSkillMetadata_IgnoresHTMLCommentBlocks(t *testing.T) { + tmp := t.TempDir() + skillDir := filepath.Join(tmp, "workspace", "skills", "biomed-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + + content := "<!--\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" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + sl := &SkillsLoader{} + meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) + require.NotNil(t, meta) + assert.Equal(t, "biomed-skill", meta.Name) + assert.Equal(t, "Summarize biomedical papers.", meta.Description) +} diff --git a/pkg/skills/provider_factory.go b/pkg/skills/provider_factory.go new file mode 100644 index 000000000..fe2849e1e --- /dev/null +++ b/pkg/skills/provider_factory.go @@ -0,0 +1,33 @@ +package skills + +import ( + "sync" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type RegistryProviderBuilder func(name string, cfg config.SkillRegistryConfig) RegistryProvider + +var ( + registryProviderBuildersMu sync.RWMutex + registryProviderBuilders = map[string]RegistryProviderBuilder{} +) + +func RegisterRegistryProviderBuilder(name string, builder RegistryProviderBuilder) { + if name == "" || builder == nil { + return + } + registryProviderBuildersMu.Lock() + defer registryProviderBuildersMu.Unlock() + registryProviderBuilders[name] = builder +} + +func buildRegistryProvider(name string, cfg config.SkillRegistryConfig) RegistryProvider { + registryProviderBuildersMu.RLock() + defer registryProviderBuildersMu.RUnlock() + builder := registryProviderBuilders[name] + if builder == nil { + return nil + } + return builder(name, cfg) +} diff --git a/pkg/skills/registry.go b/pkg/skills/registry.go index 8032c4f8d..e142f7ca5 100644 --- a/pkg/skills/registry.go +++ b/pkg/skills/registry.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log/slog" + "path" + "strings" "sync" "time" ) @@ -43,11 +45,25 @@ type InstallResult struct { MetadataAvailable bool } +// RegistryProvider creates a registry instance from configuration. +// Different hubs can implement this to plug into the shared manager. +type RegistryProvider interface { + IsEnabled() bool + BuildRegistry() SkillRegistry +} + // SkillRegistry is the interface that all skill registries must implement. // Each registry represents a different source of skills (e.g., clawhub.ai) type SkillRegistry interface { // Name returns the unique name of this registry (e.g., "clawhub"). Name() string + // ResolveInstallDirName returns the directory name to use under workspace/skills + // for a given install target. Different registries can interpret the target + // differently (for example, a slug vs owner/repo/path). + ResolveInstallDirName(target string) (string, error) + // SkillURL returns the web URL for a skill slug if the registry exposes one. + // version is optional and can be used by registries whose URLs depend on a ref. + SkillURL(slug, version string) string // Search searches the registry for skills matching the query. Search(ctx context.Context, query string, limit int) ([]SearchResult, error) // GetSkillMeta retrieves metadata for a specific skill by slug. @@ -58,10 +74,31 @@ type SkillRegistry interface { DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error) } +// InstallTargetNormalizer is implemented by registries that can canonicalize +// user-provided install targets into a stable slug for origin metadata. +type InstallTargetNormalizer interface { + NormalizeInstallTarget(target string) string +} + +func NormalizeInstallTargetForRegistryInstance(registry SkillRegistry, target string) string { + if registry == nil || target == "" { + return target + } + normalizer, ok := registry.(InstallTargetNormalizer) + if !ok { + return target + } + normalized := normalizer.NormalizeInstallTarget(target) + if normalized == "" { + return target + } + return normalized +} + // RegistryConfig holds configuration for all skill registries. // This is the input to NewRegistryManagerFromConfig. type RegistryConfig struct { - ClawHub ClawHubConfig + Providers []RegistryProvider MaxConcurrentSearches int } @@ -86,6 +123,29 @@ type RegistryManager struct { mu sync.RWMutex } +func ValidateInstallTarget(target string) error { + target = strings.TrimSpace(target) + if target == "" { + return fmt.Errorf("identifier is required and must be a non-empty string") + } + if strings.Contains(target, "\\") { + return fmt.Errorf("identifier %q contains invalid path separators", target) + } + clean := path.Clean("/" + target) + if clean == "/" || strings.HasPrefix(clean, "/../") || clean == "/.." { + return fmt.Errorf("identifier %q contains invalid path traversal", target) + } + if strings.Contains(target, "//") { + return fmt.Errorf("identifier %q contains empty path segments", target) + } + for _, segment := range strings.Split(strings.Trim(target, "/"), "/") { + if segment == "." || segment == ".." || segment == "" { + return fmt.Errorf("identifier %q contains invalid path segments", target) + } + } + return nil +} + // NewRegistryManager creates an empty RegistryManager. func NewRegistryManager() *RegistryManager { return &RegistryManager{ @@ -101,8 +161,15 @@ func NewRegistryManagerFromConfig(cfg RegistryConfig) *RegistryManager { if cfg.MaxConcurrentSearches > 0 { rm.maxConcurrent = cfg.MaxConcurrentSearches } - if cfg.ClawHub.Enabled { - rm.AddRegistry(NewClawHubRegistry(cfg.ClawHub)) + for _, provider := range cfg.Providers { + if provider == nil || !provider.IsEnabled() { + continue + } + registry := provider.BuildRegistry() + if registry == nil { + continue + } + rm.AddRegistry(registry) } return rm } diff --git a/pkg/skills/registry_test.go b/pkg/skills/registry_test.go index a4694bd43..6ac5ffbf3 100644 --- a/pkg/skills/registry_test.go +++ b/pkg/skills/registry_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -24,6 +25,10 @@ type mockRegistry struct { func (m *mockRegistry) Name() string { return m.name } +func (m *mockRegistry) ResolveInstallDirName(target string) (string, error) { return target, nil } + +func (m *mockRegistry) SkillURL(slug, _ string) string { return "https://example.com/skills/" + slug } + func (m *mockRegistry) Search(_ context.Context, _ string, _ int) ([]SearchResult, error) { return m.searchResults, m.searchErr } @@ -170,6 +175,31 @@ func TestSortByScoreDesc(t *testing.T) { assert.Equal(t, "c", results[2].Slug) } +type mockProvider struct { + enabled bool + registry SkillRegistry +} + +func (m mockProvider) IsEnabled() bool { + return m.enabled +} + +func (m mockProvider) BuildRegistry() SkillRegistry { + return m.registry +} + +func TestNewRegistryManagerFromConfigProviders(t *testing.T) { + mgr := NewRegistryManagerFromConfig(RegistryConfig{ + Providers: []RegistryProvider{ + mockProvider{enabled: true, registry: &mockRegistry{name: "alpha"}}, + mockProvider{enabled: false, registry: &mockRegistry{name: "beta"}}, + }, + }) + + assert.NotNil(t, mgr.GetRegistry("alpha")) + assert.Nil(t, mgr.GetRegistry("beta")) +} + func TestIsSafeSlug(t *testing.T) { assert.NoError(t, utils.ValidateSkillIdentifier("github")) assert.NoError(t, utils.ValidateSkillIdentifier("docker-compose")) @@ -178,3 +208,50 @@ func TestIsSafeSlug(t *testing.T) { assert.Error(t, utils.ValidateSkillIdentifier("path/traversal")) assert.Error(t, utils.ValidateSkillIdentifier("path\\traversal")) } + +func TestLegacyGithubBaseURLOverridesDefaultRegistryBaseURL(t *testing.T) { + cfg := config.DefaultConfig().Tools.Skills + cfg.Github.BaseURL = "https://ghe.example.com/git" + + registry := LookupRegistryFromToolsConfig(cfg, "github") + assert.NotNil(t, registry) + + ghRegistry, ok := registry.(*GitHubRegistry) + assert.True(t, ok) + assert.Equal(t, "https://ghe.example.com/git", ghRegistry.webBase) +} + +func TestExplicitGithubRegistryBaseURLBeatsLegacyCompat(t *testing.T) { + cfg := config.DefaultConfig().Tools.Skills + cfg.Github.BaseURL = "https://ghe-legacy.example.com/git" + cfg.Registries.Set("github", config.SkillRegistryConfig{ + Name: "github", + Enabled: true, + BaseURL: "https://ghe-explicit.example.com/scm", + Param: map[string]any{}, + }) + + registry := LookupRegistryFromToolsConfig(cfg, "github") + assert.NotNil(t, registry) + + ghRegistry, ok := registry.(*GitHubRegistry) + assert.True(t, ok) + assert.Equal(t, "https://ghe-explicit.example.com/scm", ghRegistry.webBase) +} + +func TestNormalizeInstallTargetForRegistryCanonicalizesGitHubURLs(t *testing.T) { + cfg := config.DefaultConfig().Tools.Skills + cfg.Registries.Set("github", config.SkillRegistryConfig{ + Name: "github", + Enabled: true, + BaseURL: "https://ghe.example.com/git", + Param: map[string]any{}, + }) + + got := NormalizeInstallTargetForRegistry( + cfg, + "github", + "https://ghe.example.com/git/org/repo/tree/dev/skills/pr-review", + ) + assert.Equal(t, "org/repo/skills/pr-review", got) +} diff --git a/pkg/state/state.go b/pkg/state/state.go index 57f371f12..5da7bbde1 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -40,8 +40,8 @@ func NewManager(workspace string) *Manager { oldStateFile := filepath.Join(workspace, "state.json") // Create state directory if it doesn't exist - if err := os.MkdirAll(stateDir, 0o755); err != nil { - log.Fatalf("[FATAL] state: failed to create state directory: %v", err) + if err := os.MkdirAll(stateDir, 0o700); err != nil { + log.Printf("[WARN] state: failed to create state directory %s: %v", stateDir, err) } sm := &Manager{ diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index e5e116ef6..3924e5533 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -2,7 +2,6 @@ package state import ( "encoding/json" - "errors" "fmt" "os" "os/exec" @@ -217,10 +216,7 @@ func TestNewManager_EmptyWorkspace(t *testing.T) { } } -func TestNewManager_MkdirFailureCrashes(t *testing.T) { - // Since log.Fatalf calls os.Exit(1), we cannot test it normally - // Otherwise, the test suite would stop altogether. - // We use the standard pattern of Go: rerun this test in a subprocess. +func TestNewManager_MkdirFailureDoesNotCrash(t *testing.T) { if os.Getenv("BE_CRASHER") == "1" { tmpDir := os.Getenv("CRASH_DIR") @@ -240,15 +236,11 @@ func TestNewManager_MkdirFailureCrashes(t *testing.T) { } defer os.RemoveAll(tmpDir) - cmd := exec.Command(os.Args[0], "-test.run=TestNewManager_MkdirFailureCrashes") + cmd := exec.Command(os.Args[0], "-test.run=TestNewManager_MkdirFailureDoesNotCrash") cmd.Env = append(os.Environ(), "BE_CRASHER=1", "CRASH_DIR="+tmpDir) err = cmd.Run() - - var e *exec.ExitError - if errors.As(err, &e) && !e.Success() { - return + if err != nil { + t.Fatalf("NewManager should not crash when state dir creation fails, got: %v", err) } - - t.Fatalf("The process ended without error, a crash was expected via os.Exit(1). Err: %v", err) } diff --git a/pkg/tokenizer/estimator.go b/pkg/tokenizer/estimator.go new file mode 100644 index 000000000..3265edaa8 --- /dev/null +++ b/pkg/tokenizer/estimator.go @@ -0,0 +1,91 @@ +package tokenizer + +import ( + "encoding/json" + "unicode/utf8" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +// EstimateMessageTokens estimates the token count for a single message, +// including Content, ReasoningContent, ToolCalls arguments, ToolCallID +// metadata, and Media items. Uses a heuristic of 2.5 characters per token. +func EstimateMessageTokens(msg providers.Message) int { + contentChars := utf8.RuneCountInString(msg.Content) + + // SystemParts are structured system blocks used for cache-aware adapters. + // They carry the same content as Content, but in multiple blocks. + // We estimate them as an alternative representation, not additive. + systemPartsChars := 0 + if len(msg.SystemParts) > 0 { + for _, part := range msg.SystemParts { + systemPartsChars += utf8.RuneCountInString(part.Text) + } + // Per-part overhead for JSON structure (type, text, cache_control). + const perPartOverhead = 20 + systemPartsChars += len(msg.SystemParts) * perPartOverhead + } + + // Use the larger of the two representations to stay conservative. + chars := contentChars + if systemPartsChars > chars { + chars = systemPartsChars + } + + chars += utf8.RuneCountInString(msg.ReasoningContent) + + for _, tc := range msg.ToolCalls { + chars += len(tc.ID) + len(tc.Type) + if tc.Function != nil { + // Count function name + arguments (the wire format for most providers). + // tc.Name mirrors tc.Function.Name — count only once to avoid double-counting. + chars += len(tc.Function.Name) + len(tc.Function.Arguments) + } else { + // Fallback: some provider formats use top-level Name without Function. + chars += len(tc.Name) + } + } + + if msg.ToolCallID != "" { + chars += len(msg.ToolCallID) + } + + // Per-message overhead for role label, JSON structure, separators. + const messageOverhead = 12 + chars += messageOverhead + + tokens := chars * 2 / 5 + + // Media items (images, files) are serialized by provider adapters into + // multipart or image_url payloads. Add a fixed per-item token estimate + // directly (not through the chars heuristic) since actual cost depends + // on resolution and provider-specific image tokenization. + const mediaTokensPerItem = 256 + tokens += len(msg.Media) * mediaTokensPerItem + + return tokens +} + +// EstimateToolDefsTokens estimates the total token cost of tool definitions +// as they appear in the LLM request. +func EstimateToolDefsTokens(defs []providers.ToolDefinition) int { + if len(defs) == 0 { + return 0 + } + + totalChars := 0 + for _, d := range defs { + totalChars += len(d.Function.Name) + len(d.Function.Description) + + if d.Function.Parameters != nil { + if paramJSON, err := json.Marshal(d.Function.Parameters); err == nil { + totalChars += len(paramJSON) + } + } + + // Per-tool overhead: type field, JSON structure, separators. + totalChars += 20 + } + + return totalChars * 2 / 5 +} diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 435c55a3a..f30850d43 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -6,8 +6,11 @@ import ( "strings" "time" + "github.com/google/uuid" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -15,14 +18,19 @@ import ( // JobExecutor is the interface for executing cron jobs through the agent type JobExecutor interface { ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) + // PublishResponseIfNeeded sends response to the outbound bus only when the + // agent did not already deliver content through the message tool in this round. + PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) } // CronTool provides scheduling capabilities for the agent type CronTool struct { - cronService *cron.CronService - executor JobExecutor - msgBus *bus.MessageBus - execTool *ExecTool + cronService *cron.CronService + executor JobExecutor + msgBus *bus.MessageBus + execTool *ExecTool + allowCommand bool + execEnabled bool } // NewCronTool creates a new CronTool @@ -31,17 +39,32 @@ func NewCronTool( cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config, ) (*CronTool, error) { - execTool, err := NewExecToolWithConfig(workspace, restrict, config) - if err != nil { - return nil, fmt.Errorf("unable to configure exec tool: %w", err) + allowCommand := true + execEnabled := true + if config != nil { + allowCommand = config.Tools.Cron.AllowCommand + execEnabled = config.Tools.Exec.Enabled } - execTool.SetTimeout(execTimeout) + var execTool *ExecTool + if execEnabled { + var err error + execTool, err = NewExecToolWithConfig(workspace, restrict, config) + if err != nil { + return nil, fmt.Errorf("unable to configure exec tool: %w", err) + } + } + + if execTool != nil { + execTool.SetTimeout(execTimeout) + } return &CronTool{ - cronService: cronService, - executor: executor, - msgBus: msgBus, - execTool: execTool, + cronService: cronService, + executor: executor, + msgBus: msgBus, + execTool: execTool, + allowCommand: allowCommand, + execEnabled: execEnabled, }, nil } @@ -71,7 +94,11 @@ func (t *CronTool) Parameters() map[string]any { }, "command": map[string]any{ "type": "string", - "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.", + "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.", + }, + "command_confirm": map[string]any{ + "type": "boolean", + "description": "Optional explicit confirmation flag for scheduling a shell command. Command execution must also be enabled via tools.cron.allow_command.", }, "at_seconds": map[string]any{ "type": "integer", @@ -89,10 +116,6 @@ func (t *CronTool) Parameters() map[string]any { "type": "string", "description": "Job ID (for remove/enable/disable)", }, - "deliver": map[string]any{ - "type": "boolean", - "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: true", - }, }, "required": []string{"action"}, } @@ -169,19 +192,21 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required") } - // Read deliver parameter, default to true - deliver := true - if d, ok := args["deliver"].(bool); ok { - deliver = d - } - + // GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When + // allow_command is disabled, explicit confirmation is required as an override. + // Non-command reminders remain open to all channels. command, _ := args["command"].(string) + commandConfirm, _ := args["command_confirm"].(bool) if command != "" { - // Commands must be processed by agent/exec tool, so deliver must be false (or handled specifically) - // Actually, let's keep deliver=false to let the system know it's not a simple chat message - // But for our new logic in ExecuteJob, we can handle it regardless of deliver flag if Payload.Command is set. - // However, logically, it's not "delivered" to chat directly as is. - deliver = false + if !t.execEnabled { + return ErrorResult("command execution is disabled") + } + if !constants.IsInternalChannel(channel) { + return ErrorResult("scheduling command execution is restricted to internal channels") + } + if !t.allowCommand && !commandConfirm { + return ErrorResult("command_confirm=true is required when allow_command is disabled") + } } // Truncate message for job name (max 30 chars) @@ -191,7 +216,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult messagePreview, schedule, message, - deliver, channel, chatID, ) @@ -199,9 +223,13 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult return ErrorResult(fmt.Sprintf("Error adding job: %v", err)) } + // Apply optional payload fields and persist in a single UpdateJob call + needsUpdate := false if command != "" { job.Payload.Command = command - // Need to save the updated payload + needsUpdate = true + } + if needsUpdate { t.cronService.UpdateJob(job) } @@ -280,8 +308,21 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { // Execute command if present if job.Payload.Command != "" { + if !t.execEnabled || t.execTool == nil { + output := "Error executing scheduled command: command execution is disabled" + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Context: bus.NewOutboundContext(channel, chatID, ""), + Content: output, + }) + return "ok" + } + args := map[string]any{ - "command": job.Payload.Command, + "command": job.Payload.Command, + "__channel": channel, + "__chat_id": chatID, } result := t.execTool.Execute(ctx, args) @@ -295,29 +336,14 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, + Context: bus.NewOutboundContext(channel, chatID, ""), Content: output, }) return "ok" } - // If deliver=true, send message directly without agent processing - if job.Payload.Deliver { - pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: job.Payload.Message, - }) - return "ok" - } + sessionKey := fmt.Sprintf("agent:cron-%s-%s", job.ID, uuid.New().String()) - // For deliver=false, process through agent (for complex tasks) - sessionKey := fmt.Sprintf("agent:main:cron:%s", job.ID) - - // Call agent with job's message response, err := t.executor.ProcessDirectWithChannel( ctx, job.Payload.Message, @@ -329,7 +355,8 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { return fmt.Sprintf("Error: %v", err) } - // Response is automatically sent via MessageBus by AgentLoop - _ = response // Will be sent by AgentLoop + if response != "" { + t.executor.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, response) + } return "ok" } diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go new file mode 100644 index 000000000..0e527c98a --- /dev/null +++ b/pkg/tools/cron_test.go @@ -0,0 +1,352 @@ +package tools + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/cron" +) + +type stubJobExecutor struct { + response string + err error + alreadySent bool // simulate message tool having already sent in this round + lastPrompt string + lastKey string + lastChan string + lastChatID string + publishedResp string + publishedChan string + publishedChatID string + publishedKey string +} + +func (s *stubJobExecutor) ProcessDirectWithChannel( + _ context.Context, + content, sessionKey, channel, chatID string, +) (string, error) { + s.lastPrompt = content + s.lastKey = sessionKey + s.lastChan = channel + s.lastChatID = chatID + return s.response, s.err +} + +func (s *stubJobExecutor) PublishResponseIfNeeded( + _ context.Context, + channel, chatID, sessionKey, response string, +) { + if s.alreadySent { + return + } + s.publishedResp = response + s.publishedChan = channel + s.publishedChatID = chatID + s.publishedKey = sessionKey +} + +func newTestCronToolWithExecutorAndConfig(t *testing.T, executor JobExecutor, cfg *config.Config) *CronTool { + t.Helper() + storePath := filepath.Join(t.TempDir(), "cron.json") + cronService := cron.NewCronService(storePath, nil) + msgBus := bus.NewMessageBus() + tool, err := NewCronTool(cronService, executor, msgBus, t.TempDir(), true, 0, cfg) + if err != nil { + t.Fatalf("NewCronTool() error: %v", err) + } + return tool +} + +func newTestCronToolWithConfig(t *testing.T, cfg *config.Config) *CronTool { + t.Helper() + return newTestCronToolWithExecutorAndConfig(t, nil, cfg) +} + +func newTestCronTool(t *testing.T) *CronTool { + t.Helper() + return newTestCronToolWithConfig(t, config.DefaultConfig()) +} + +// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels +func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) { + tool := newTestCronTool(t) + ctx := WithToolContext(context.Background(), "telegram", "chat-1") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "check disk", + "command": "df -h", + "command_confirm": true, + "at_seconds": float64(60), + }) + + if !result.IsError { + t.Fatal("expected command scheduling to be blocked from remote channel") + } + if !strings.Contains(result.ForLLM, "restricted to internal channels") { + t.Errorf("expected 'restricted to internal channels', got: %s", result.ForLLM) + } +} + +func TestCronTool_CommandDoesNotRequireConfirmByDefault(t *testing.T) { + tool := newTestCronTool(t) + ctx := WithToolContext(context.Background(), "cli", "direct") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "check disk", + "command": "df -h", + "at_seconds": float64(60), + }) + + if result.IsError { + t.Fatalf("expected command scheduling without confirm to succeed by default, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Cron job added") { + t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) + } +} + +func TestCronTool_CommandRequiresConfirmWhenAllowCommandDisabled(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Cron.AllowCommand = false + + tool := newTestCronToolWithConfig(t, cfg) + ctx := WithToolContext(context.Background(), "cli", "direct") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "check disk", + "command": "df -h", + "at_seconds": float64(60), + }) + + if !result.IsError { + t.Fatal("expected command scheduling to require confirm when allow_command is disabled") + } + if !strings.Contains(result.ForLLM, "command_confirm=true") { + t.Errorf("expected command_confirm requirement message, got: %s", result.ForLLM) + } +} + +func TestCronTool_CommandAllowedWithConfirmWhenAllowCommandDisabled(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Cron.AllowCommand = false + + tool := newTestCronToolWithConfig(t, cfg) + ctx := WithToolContext(context.Background(), "cli", "direct") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "check disk", + "command": "df -h", + "command_confirm": true, + "at_seconds": float64(60), + }) + + if result.IsError { + t.Fatalf( + "expected command scheduling with confirm to succeed when allow_command is disabled, got: %s", + result.ForLLM, + ) + } + if !strings.Contains(result.ForLLM, "Cron job added") { + t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) + } +} + +func TestCronTool_CommandBlockedWhenExecDisabled(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Exec.Enabled = false + + tool := newTestCronToolWithConfig(t, cfg) + ctx := WithToolContext(context.Background(), "cli", "direct") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "check disk", + "command": "df -h", + "command_confirm": true, + "at_seconds": float64(60), + }) + + if !result.IsError { + t.Fatal("expected command scheduling to be blocked when exec is disabled") + } + if !strings.Contains(result.ForLLM, "command execution is disabled") { + t.Errorf("expected exec disabled message, got: %s", result.ForLLM) + } +} + +// TestCronTool_CommandAllowedFromInternalChannel verifies command scheduling works from internal channels +func TestCronTool_CommandAllowedFromInternalChannel(t *testing.T) { + tool := newTestCronTool(t) + ctx := WithToolContext(context.Background(), "cli", "direct") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "check disk", + "command": "df -h", + "command_confirm": true, + "at_seconds": float64(60), + }) + + if result.IsError { + t.Fatalf("expected command scheduling to succeed from internal channel, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Cron job added") { + t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) + } +} + +// TestCronTool_AddJobRequiresSessionContext verifies fail-closed when channel/chatID missing +func TestCronTool_AddJobRequiresSessionContext(t *testing.T) { + tool := newTestCronTool(t) + result := tool.Execute(context.Background(), map[string]any{ + "action": "add", + "message": "reminder", + "at_seconds": float64(60), + }) + + if !result.IsError { + t.Fatal("expected error when session context is missing") + } + if !strings.Contains(result.ForLLM, "no session context") { + t.Errorf("expected 'no session context' message, got: %s", result.ForLLM) + } +} + +// TestCronTool_NonCommandJobAllowedFromRemoteChannel verifies regular reminders work from any channel +func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) { + tool := newTestCronTool(t) + ctx := WithToolContext(context.Background(), "telegram", "chat-1") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "time to stretch", + "at_seconds": float64(600), + }) + + if result.IsError { + t.Fatalf("expected non-command reminder to succeed from remote channel, got: %s", result.ForLLM) + } +} + +func TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Exec.Enabled = false + + tool := newTestCronToolWithConfig(t, cfg) + job := &cron.CronJob{} + job.Payload.Channel = "cli" + job.Payload.To = "direct" + job.Payload.Command = "df -h" + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + var msg bus.OutboundMessage + select { + case msg = <-tool.msgBus.OutboundChan(): + // got message + case <-ctx.Done(): + t.Fatal("timeout waiting for outbound message") + } + if !strings.Contains(msg.Content, "command execution is disabled") { + t.Fatalf("expected exec disabled message, got: %s", msg.Content) + } +} + +func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) { + executor := &stubJobExecutor{response: "generated reply"} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-1"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "send me a poem" + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + if !strings.HasPrefix(executor.lastKey, "agent:cron-job-1-") { + t.Fatalf("sessionKey = %q, want agent:cron-job-1-{uuid}", executor.lastKey) + } + if executor.lastChan != "telegram" || executor.lastChatID != "chat-1" { + t.Fatalf("executor target = %s/%s, want telegram/chat-1", executor.lastChan, executor.lastChatID) + } + if executor.lastPrompt != "send me a poem" { + t.Fatalf("prompt = %q, want original message", executor.lastPrompt) + } + if executor.publishedResp != "generated reply" { + t.Fatalf("published response = %q, want generated reply", executor.publishedResp) + } + if executor.publishedKey != executor.lastKey { + t.Fatalf("published sessionKey = %q, want %q", executor.publishedKey, executor.lastKey) + } + if executor.publishedChan != "telegram" || executor.publishedChatID != "chat-1" { + t.Fatalf("published target = %s/%s, want telegram/chat-1", executor.publishedChan, executor.publishedChatID) + } +} + +func TestCronTool_ExecuteJobSkipsEmptyAgentResponse(t *testing.T) { + executor := &stubJobExecutor{} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-empty"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "say nothing" + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + if executor.publishedResp != "" { + t.Fatalf("unexpected published response: %q", executor.publishedResp) + } +} + +func TestCronTool_ExecuteJobSkipsWhenMessageToolAlreadySent(t *testing.T) { + executor := &stubJobExecutor{response: "Sent.", alreadySent: true} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-msg-sent"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "send weather" + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + if executor.publishedResp != "" { + t.Fatalf("expected no published response when message tool already sent, got: %q", executor.publishedResp) + } +} + +func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) { + executor := &stubJobExecutor{ + response: "this response must not be published", + err: fmt.Errorf("agent failure"), + } + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-err"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "do something" + + got := tool.ExecuteJob(context.Background(), job) + if !strings.Contains(got, "agent failure") { + t.Fatalf("ExecuteJob() = %q, want error message", got) + } + + if executor.publishedResp != "" { + t.Fatalf("unexpected publish on error path: %q", executor.publishedResp) + } +} diff --git a/pkg/tools/delegate.go b/pkg/tools/delegate.go new file mode 100644 index 000000000..dcde27718 --- /dev/null +++ b/pkg/tools/delegate.go @@ -0,0 +1,104 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/routing" +) + +// DelegateTool delegates a task to a specific named agent and waits for +// the result. Unlike spawn (async, fire-and-forget) or subagent (sync but +// generic), delegate targets a named agent and runs the task using that +// agent's own workspace, model, and tools. +type DelegateTool struct { + spawner SubTurnSpawner + allowlistCheck func(targetAgentID string) bool + selfAgentID string +} + +func NewDelegateTool() *DelegateTool { + return &DelegateTool{} +} + +func (t *DelegateTool) SetSpawner(spawner SubTurnSpawner) { + t.spawner = spawner +} + +func (t *DelegateTool) SetAllowlistChecker(check func(targetAgentID string) bool) { + t.allowlistCheck = check +} + +func (t *DelegateTool) SetSelfAgentID(id string) { + t.selfAgentID = id +} + +func (t *DelegateTool) Name() string { + return "delegate" +} + +func (t *DelegateTool) Description() string { + return "Delegate a task to another agent and wait for the result. " + + "Use this when another agent is better suited to handle a specific task " + + "based on their capabilities. The target agent runs with its own workspace, " + + "model, and tools." +} + +func (t *DelegateTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "agent_id": map[string]any{ + "type": "string", + "description": "The ID of the target agent to delegate the task to", + }, + "task": map[string]any{ + "type": "string", + "description": "Clear description of the task to delegate", + }, + }, + "required": []string{"agent_id", "task"}, + } +} + +func (t *DelegateTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + rawAgentID, _ := args["agent_id"].(string) + if strings.TrimSpace(rawAgentID) == "" { + return ErrorResult("agent_id is required and must be a non-empty string") + } + agentID := routing.NormalizeAgentID(rawAgentID) + + task, _ := args["task"].(string) + if strings.TrimSpace(task) == "" { + return ErrorResult("task is required and must be a non-empty string") + } + + if t.selfAgentID != "" && agentID == t.selfAgentID { + return ErrorResult("cannot delegate to self") + } + + if t.allowlistCheck != nil && !t.allowlistCheck(agentID) { + return ErrorResult(fmt.Sprintf("not allowed to delegate to agent %q", agentID)) + } + + if t.spawner == nil { + return ErrorResult("delegate tool not configured") + } + + result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{ + TargetAgentID: agentID, + SystemPrompt: task, + Async: false, + }) + if err != nil { + return ErrorResult(fmt.Sprintf("delegation to agent %q failed: %v", agentID, err)).WithError(err) + } + if result == nil { + return ErrorResult(fmt.Sprintf("delegation to agent %q returned no result", agentID)) + } + + result.ForLLM = fmt.Sprintf("[Response from agent %q]\n%s", agentID, result.ForLLM) + + return result +} diff --git a/pkg/tools/delegate_test.go b/pkg/tools/delegate_test.go new file mode 100644 index 000000000..729c524a7 --- /dev/null +++ b/pkg/tools/delegate_test.go @@ -0,0 +1,300 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "testing" +) + +// delegateMockSpawner records the config and returns a canned result. +type delegateMockSpawner struct { + lastCfg SubTurnConfig + result *ToolResult + err error +} + +func (m *delegateMockSpawner) SpawnSubTurn(_ context.Context, cfg SubTurnConfig) (*ToolResult, error) { + m.lastCfg = cfg + if m.err != nil { + return nil, m.err + } + if m.result != nil { + return m.result, nil + } + return &ToolResult{ + ForLLM: "completed: " + cfg.SystemPrompt, + ForUser: "completed", + }, nil +} + +func TestDelegateTool_Name(t *testing.T) { + tool := NewDelegateTool() + if tool.Name() != "delegate" { + t.Errorf("Name() = %q, want %q", tool.Name(), "delegate") + } +} + +func TestDelegateTool_Parameters(t *testing.T) { + tool := NewDelegateTool() + params := tool.Parameters() + + props, ok := params["properties"].(map[string]any) + if !ok { + t.Fatal("properties should be a map") + } + _, hasAgentID := props["agent_id"] + if !hasAgentID { + t.Error("agent_id parameter should exist") + } + _, hasTask := props["task"] + if !hasTask { + t.Error("task parameter should exist") + } + + required, ok := params["required"].([]string) + if !ok { + t.Fatal("required should be a string array") + } + if len(required) != 2 { + t.Fatalf("required should have 2 entries, got %d", len(required)) + } +} + +func TestDelegateTool_Execute_Success(t *testing.T) { + spawner := &delegateMockSpawner{} + tool := NewDelegateTool() + tool.SetSpawner(spawner) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "researcher", + "task": "summarize the logs", + }) + + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, `[Response from agent "researcher"]`) { + t.Errorf("result should contain attribution, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "summarize the logs") { + t.Errorf("result should contain task output, got: %s", result.ForLLM) + } + + // Verify spawner received correct config + if spawner.lastCfg.TargetAgentID != "researcher" { + t.Errorf("TargetAgentID = %q, want %q", spawner.lastCfg.TargetAgentID, "researcher") + } + if spawner.lastCfg.Async { + t.Error("delegate should be synchronous (Async=false)") + } + if spawner.lastCfg.SystemPrompt != "summarize the logs" { + t.Errorf("SystemPrompt = %q, want %q", spawner.lastCfg.SystemPrompt, "summarize the logs") + } +} + +func TestDelegateTool_Execute_EmptyAgentID(t *testing.T) { + tests := []struct { + name string + args map[string]any + }{ + {"missing", map[string]any{"task": "test"}}, + {"empty string", map[string]any{"agent_id": "", "task": "test"}}, + {"whitespace only", map[string]any{"agent_id": " ", "task": "test"}}, + {"wrong type", map[string]any{"agent_id": 123, "task": "test"}}, + } + + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tool.Execute(context.Background(), tt.args) + if !result.IsError { + t.Error("expected error for invalid agent_id") + } + if !strings.Contains(result.ForLLM, "agent_id is required") { + t.Errorf("error should mention agent_id, got: %s", result.ForLLM) + } + }) + } +} + +func TestDelegateTool_Execute_EmptyTask(t *testing.T) { + tests := []struct { + name string + args map[string]any + }{ + {"missing", map[string]any{"agent_id": "a"}}, + {"empty string", map[string]any{"agent_id": "a", "task": ""}}, + {"whitespace only", map[string]any{"agent_id": "a", "task": "\t\n"}}, + } + + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tool.Execute(context.Background(), tt.args) + if !result.IsError { + t.Error("expected error for invalid task") + } + if !strings.Contains(result.ForLLM, "task is required") { + t.Errorf("error should mention task, got: %s", result.ForLLM) + } + }) + } +} + +func TestDelegateTool_Execute_PermissionDenied(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetAllowlistChecker(func(targetAgentID string) bool { + return targetAgentID == "allowed-agent" + }) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "forbidden-agent", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error for denied agent") + } + if !strings.Contains(result.ForLLM, "not allowed to delegate") { + t.Errorf("error should mention permission, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_PermissionAllowed(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetAllowlistChecker(func(targetAgentID string) bool { + return targetAgentID == "allowed-agent" + }) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "allowed-agent", + "task": "test", + }) + + if result.IsError { + t.Errorf("expected success for allowed agent, got error: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_NoSpawner(t *testing.T) { + tool := NewDelegateTool() + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "a", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error when spawner is nil") + } + if !strings.Contains(result.ForLLM, "not configured") { + t.Errorf("error should mention not configured, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_SpawnerError(t *testing.T) { + spawner := &delegateMockSpawner{ + err: fmt.Errorf("context deadline exceeded"), + } + tool := NewDelegateTool() + tool.SetSpawner(spawner) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "researcher", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error when spawner fails") + } + if !strings.Contains(result.ForLLM, "delegation to agent") { + t.Errorf("error should mention delegation failure, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "context deadline exceeded") { + t.Errorf("error should propagate cause, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_NoAllowlistCheck(t *testing.T) { + // When no allowlist checker is set, all agents are allowed + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "any-agent", + "task": "test", + }) + + if result.IsError { + t.Errorf("expected success without allowlist, got error: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_NilResult(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&nilResultSpawner{}) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "researcher", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error for nil result") + } + if !strings.Contains(result.ForLLM, "returned no result") { + t.Errorf("error should mention no result, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_SelfDelegation(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetSelfAgentID("alpha") + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "alpha", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error for self-delegation") + } + if !strings.Contains(result.ForLLM, "cannot delegate to self") { + t.Errorf("error should mention self-delegation, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_SelfDelegation_Normalized(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetSelfAgentID("alpha") // stored normalized + + // Case-insensitive and whitespace variants should still be caught + variants := []string{"ALPHA", " Alpha ", " alpha "} + for _, v := range variants { + t.Run(v, func(t *testing.T) { + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": v, + "task": "test", + }) + if !result.IsError { + t.Errorf("agent_id=%q should be caught as self-delegation", v) + } + }) + } +} + +// nilResultSpawner always returns (nil, nil). +type nilResultSpawner struct{} + +func (m *nilResultSpawner) SpawnSubTurn(_ context.Context, _ SubTurnConfig) (*ToolResult, error) { + return nil, nil +} diff --git a/pkg/tools/facade_compat_test.go b/pkg/tools/facade_compat_test.go new file mode 100644 index 000000000..378462512 --- /dev/null +++ b/pkg/tools/facade_compat_test.go @@ -0,0 +1,18 @@ +package tools + +import "testing" + +func TestFacadeConstructorsRemainAvailable(t *testing.T) { + if NewI2CTool() == nil { + t.Fatal("NewI2CTool should return a tool") + } + if NewSPITool() == nil { + t.Fatal("NewSPITool should return a tool") + } + if NewSerialTool() == nil { + t.Fatal("NewSerialTool should return a tool") + } + if NewMessageTool() == nil { + t.Fatal("NewMessageTool should return a tool") + } +} diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go deleted file mode 100644 index e1a470d6e..000000000 --- a/pkg/tools/filesystem.go +++ /dev/null @@ -1,474 +0,0 @@ -package tools - -import ( - "context" - "crypto/rand" - "encoding/hex" - "fmt" - "io/fs" - "os" - "path/filepath" - "regexp" - "strings" - - "github.com/sipeed/picoclaw/pkg/fileutil" -) - -const maxWriteSize = 20 * 1024 * 1024 // 20 MB — limit for file writes via the write tool - -// validatePath ensures the given path is within the workspace if restrict is true. -func validatePath(path, workspace string, restrict bool) (string, error) { - if workspace == "" { - return path, fmt.Errorf("workspace is not defined") - } - - absWorkspace, err := filepath.Abs(workspace) - if err != nil { - return "", fmt.Errorf("failed to resolve workspace path: %w", err) - } - - var absPath string - if filepath.IsAbs(path) { - absPath = filepath.Clean(path) - } else { - absPath, err = filepath.Abs(filepath.Join(absWorkspace, path)) - if err != nil { - return "", fmt.Errorf("failed to resolve file path: %w", err) - } - } - - if restrict { - if !isWithinWorkspace(absPath, absWorkspace) { - return "", fmt.Errorf("access denied: path is outside the workspace") - } - - var resolved string - workspaceReal := absWorkspace - if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil { - workspaceReal = resolved - } - - if resolved, err = filepath.EvalSymlinks(absPath); err == nil { - if !isWithinWorkspace(resolved, workspaceReal) { - return "", fmt.Errorf("access denied: symlink resolves outside workspace") - } - } else if os.IsNotExist(err) { - var parentResolved string - if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil { - if !isWithinWorkspace(parentResolved, workspaceReal) { - return "", fmt.Errorf("access denied: symlink resolves outside workspace") - } - } else if !os.IsNotExist(err) { - return "", fmt.Errorf("failed to resolve path: %w", err) - } - } else { - return "", fmt.Errorf("failed to resolve path: %w", err) - } - } - - return absPath, nil -} - -func resolveExistingAncestor(path string) (string, error) { - for current := filepath.Clean(path); ; current = filepath.Dir(current) { - if resolved, err := filepath.EvalSymlinks(current); err == nil { - return resolved, nil - } else if !os.IsNotExist(err) { - return "", err - } - if filepath.Dir(current) == current { - return "", os.ErrNotExist - } - } -} - -func isWithinWorkspace(candidate, workspace string) bool { - rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate)) - return err == nil && filepath.IsLocal(rel) -} - -type ReadFileTool struct { - fs fileSystem -} - -func NewReadFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ReadFileTool { - var patterns []*regexp.Regexp - if len(allowPaths) > 0 { - patterns = allowPaths[0] - } - return &ReadFileTool{fs: buildFs(workspace, restrict, patterns)} -} - -func (t *ReadFileTool) Name() string { - return "read_file" -} - -func (t *ReadFileTool) Description() string { - return "Read the contents of a file" -} - -func (t *ReadFileTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "path": map[string]any{ - "type": "string", - "description": "Path to the file to read", - }, - }, - "required": []string{"path"}, - } -} - -func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { - path, ok := args["path"].(string) - if !ok { - return ErrorResult("path is required") - } - - content, err := t.fs.ReadFile(path) - if err != nil { - return ErrorResult(err.Error()) - } - return NewToolResult(string(content)) -} - -type WriteFileTool struct { - fs fileSystem -} - -func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool { - var patterns []*regexp.Regexp - if len(allowPaths) > 0 { - patterns = allowPaths[0] - } - return &WriteFileTool{fs: buildFs(workspace, restrict, patterns)} -} - -func (t *WriteFileTool) Name() string { - return "write_file" -} - -func (t *WriteFileTool) Description() string { - return "Write content to a file" -} - -func (t *WriteFileTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "path": map[string]any{ - "type": "string", - "description": "Path to the file to write", - }, - "content": map[string]any{ - "type": "string", - "description": "Content to write to the file", - }, - }, - "required": []string{"path", "content"}, - } -} - -func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { - path, ok := args["path"].(string) - if !ok { - return ErrorResult("path is required") - } - - content, ok := args["content"].(string) - if !ok { - return ErrorResult("content is required") - } - - if len(content) > maxWriteSize { - return ErrorResult(fmt.Sprintf("content too large: %d bytes exceeds %d byte limit", len(content), maxWriteSize)) - } - - if err := t.fs.WriteFile(path, []byte(content)); err != nil { - return ErrorResult(err.Error()) - } - - return SilentResult(fmt.Sprintf("File written: %s", path)) -} - -type ListDirTool struct { - fs fileSystem -} - -func NewListDirTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ListDirTool { - var patterns []*regexp.Regexp - if len(allowPaths) > 0 { - patterns = allowPaths[0] - } - return &ListDirTool{fs: buildFs(workspace, restrict, patterns)} -} - -func (t *ListDirTool) Name() string { - return "list_dir" -} - -func (t *ListDirTool) Description() string { - return "List files and directories in a path" -} - -func (t *ListDirTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "path": map[string]any{ - "type": "string", - "description": "Path to list", - }, - }, - "required": []string{"path"}, - } -} - -func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult { - path, ok := args["path"].(string) - if !ok { - path = "." - } - - entries, err := t.fs.ReadDir(path) - if err != nil { - return ErrorResult(fmt.Sprintf("failed to read directory: %v", err)) - } - return formatDirEntries(entries) -} - -func formatDirEntries(entries []os.DirEntry) *ToolResult { - var result strings.Builder - for _, entry := range entries { - if entry.IsDir() { - result.WriteString("DIR: " + entry.Name() + "\n") - } else { - result.WriteString("FILE: " + entry.Name() + "\n") - } - } - return NewToolResult(result.String()) -} - -// fileSystem abstracts reading, writing, and listing files, allowing both -// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface. -type fileSystem interface { - ReadFile(path string) ([]byte, error) - WriteFile(path string, data []byte) error - ReadDir(path string) ([]os.DirEntry, error) -} - -// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem. -type hostFs struct{} - -func (h *hostFs) ReadFile(path string) ([]byte, error) { - content, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("failed to read file: file not found: %w", err) - } - if os.IsPermission(err) { - return nil, fmt.Errorf("failed to read file: access denied: %w", err) - } - return nil, fmt.Errorf("failed to read file: %w", err) - } - return content, nil -} - -func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) { - return os.ReadDir(path) -} - -func (h *hostFs) WriteFile(path string, data []byte) error { - // Use unified atomic write utility with explicit sync for flash storage reliability. - // Using 0o600 (owner read/write only) for secure default permissions. - return fileutil.WriteFileAtomic(path, data, 0o600) -} - -// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. -type sandboxFs struct { - workspace string -} - -func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error { - if r.workspace == "" { - return fmt.Errorf("workspace is not defined") - } - - root, err := os.OpenRoot(r.workspace) - if err != nil { - return fmt.Errorf("failed to open workspace: %w", err) - } - defer root.Close() - - relPath, err := getSafeRelPath(r.workspace, path) - if err != nil { - return err - } - - return fn(root, relPath) -} - -func (r *sandboxFs) ReadFile(path string) ([]byte, error) { - var content []byte - err := r.execute(path, func(root *os.Root, relPath string) error { - fileContent, err := root.ReadFile(relPath) - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("failed to read file: file not found: %w", err) - } - // os.Root returns "escapes from parent" for paths outside the root - if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") || - strings.Contains(err.Error(), "permission denied") { - return fmt.Errorf("failed to read file: access denied: %w", err) - } - return fmt.Errorf("failed to read file: %w", err) - } - content = fileContent - return nil - }) - return content, err -} - -func (r *sandboxFs) WriteFile(path string, data []byte) error { - return r.execute(path, func(root *os.Root, relPath string) error { - dir := filepath.Dir(relPath) - if dir != "." && dir != "/" { - if err := root.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("failed to create parent directories: %w", err) - } - } - - // Use atomic write pattern with explicit sync for flash storage reliability. - // Using 0o600 (owner read/write only) for secure default permissions. - randBytes := make([]byte, 8) - if _, err := rand.Read(randBytes); err != nil { - return fmt.Errorf("failed to generate random bytes for temp file: %w", err) - } - tmpRelPath := fmt.Sprintf(".tmp-%s", hex.EncodeToString(randBytes)) - - tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - root.Remove(tmpRelPath) - return fmt.Errorf("failed to open temp file: %w", err) - } - - if _, err := tmpFile.Write(data); err != nil { - tmpFile.Close() - root.Remove(tmpRelPath) - return fmt.Errorf("failed to write temp file: %w", err) - } - - // CRITICAL: Force sync to storage medium before rename. - // This ensures data is physically written to disk, not just cached. - if err := tmpFile.Sync(); err != nil { - tmpFile.Close() - root.Remove(tmpRelPath) - return fmt.Errorf("failed to sync temp file: %w", err) - } - - if err := tmpFile.Close(); err != nil { - root.Remove(tmpRelPath) - return fmt.Errorf("failed to close temp file: %w", err) - } - - if err := root.Rename(tmpRelPath, relPath); err != nil { - root.Remove(tmpRelPath) - return fmt.Errorf("failed to rename temp file over target: %w", err) - } - - // Sync directory to ensure rename is durable - if dirFile, err := root.Open("."); err == nil { - _ = dirFile.Sync() - dirFile.Close() - } - - return nil - }) -} - -func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) { - var entries []os.DirEntry - err := r.execute(path, func(root *os.Root, relPath string) error { - dirEntries, err := fs.ReadDir(root.FS(), relPath) - if err != nil { - return err - } - entries = dirEntries - return nil - }) - return entries, err -} - -// whitelistFs wraps a sandboxFs and allows access to specific paths outside -// the workspace when they match any of the provided patterns. -type whitelistFs struct { - sandbox *sandboxFs - host hostFs - patterns []*regexp.Regexp -} - -func (w *whitelistFs) matches(path string) bool { - for _, p := range w.patterns { - if p.MatchString(path) { - return true - } - } - return false -} - -func (w *whitelistFs) ReadFile(path string) ([]byte, error) { - if w.matches(path) { - return w.host.ReadFile(path) - } - return w.sandbox.ReadFile(path) -} - -func (w *whitelistFs) WriteFile(path string, data []byte) error { - if w.matches(path) { - return w.host.WriteFile(path, data) - } - return w.sandbox.WriteFile(path, data) -} - -func (w *whitelistFs) ReadDir(path string) ([]os.DirEntry, error) { - if w.matches(path) { - return w.host.ReadDir(path) - } - return w.sandbox.ReadDir(path) -} - -// buildFs returns the appropriate fileSystem implementation based on restriction -// settings and optional path whitelist patterns. -func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem { - if !restrict { - return &hostFs{} - } - sandbox := &sandboxFs{workspace: workspace} - if len(patterns) > 0 { - return &whitelistFs{sandbox: sandbox, patterns: patterns} - } - return sandbox -} - -// Helper to get a safe relative path for os.Root usage -func getSafeRelPath(workspace, path string) (string, error) { - if workspace == "" { - return "", fmt.Errorf("workspace is not defined") - } - - rel := filepath.Clean(path) - if filepath.IsAbs(rel) { - var err error - rel, err = filepath.Rel(workspace, rel) - if err != nil { - return "", fmt.Errorf("failed to calculate relative path: %w", err) - } - } - - if !filepath.IsLocal(rel) { - return "", fmt.Errorf("path escapes workspace: %s", path) - } - - return rel, nil -} diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go deleted file mode 100644 index 666004cd4..000000000 --- a/pkg/tools/filesystem_test.go +++ /dev/null @@ -1,522 +0,0 @@ -package tools - -import ( - "context" - "io" - "os" - "path/filepath" - "regexp" - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -// TestFilesystemTool_ReadFile_Success verifies successful file reading -func TestFilesystemTool_ReadFile_Success(t *testing.T) { - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("test content"), 0o644) - - tool := NewReadFileTool("", false) - ctx := context.Background() - args := map[string]any{ - "path": testFile, - } - - result := tool.Execute(ctx, args) - - // Success should not be an error - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) - } - - // ForLLM should contain file content - if !strings.Contains(result.ForLLM, "test content") { - t.Errorf("Expected ForLLM to contain 'test content', got: %s", result.ForLLM) - } - - // ReadFile returns NewToolResult which only sets ForLLM, not ForUser - // This is the expected behavior - file content goes to LLM, not directly to user - if result.ForUser != "" { - t.Errorf("Expected ForUser to be empty for NewToolResult, got: %s", result.ForUser) - } -} - -// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file -func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { - tool := NewReadFileTool("", false) - ctx := context.Background() - args := map[string]any{ - "path": "/nonexistent_file_12345.txt", - } - - result := tool.Execute(ctx, args) - - // Failure should be marked as error - if !result.IsError { - t.Errorf("Expected error for missing file, got IsError=false") - } - - // Should contain error message - if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { - t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) - } -} - -// TestFilesystemTool_ReadFile_MissingPath verifies error handling for missing path -func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) { - tool := &ReadFileTool{} - ctx := context.Background() - args := map[string]any{} - - result := tool.Execute(ctx, args) - - // Should return error result - if !result.IsError { - t.Errorf("Expected error when path is missing") - } - - // Should mention required parameter - if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") { - t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM) - } -} - -// TestFilesystemTool_WriteFile_Success verifies successful file writing -func TestFilesystemTool_WriteFile_Success(t *testing.T) { - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "newfile.txt") - - tool := NewWriteFileTool("", false) - ctx := context.Background() - args := map[string]any{ - "path": testFile, - "content": "hello world", - } - - result := tool.Execute(ctx, args) - - // Success should not be an error - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) - } - - // WriteFile returns SilentResult - if !result.Silent { - t.Errorf("Expected Silent=true for WriteFile, got false") - } - - // ForUser should be empty (silent result) - if result.ForUser != "" { - t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser) - } - - // Verify file was actually written - content, err := os.ReadFile(testFile) - if err != nil { - t.Fatalf("Failed to read written file: %v", err) - } - if string(content) != "hello world" { - t.Errorf("Expected file content 'hello world', got: %s", string(content)) - } -} - -// TestFilesystemTool_WriteFile_CreateDir verifies directory creation -func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) { - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "subdir", "newfile.txt") - - tool := NewWriteFileTool("", false) - ctx := context.Background() - args := map[string]any{ - "path": testFile, - "content": "test", - } - - result := tool.Execute(ctx, args) - - // Success should not be an error - if result.IsError { - t.Errorf("Expected success with directory creation, got IsError=true: %s", result.ForLLM) - } - - // Verify directory was created and file written - content, err := os.ReadFile(testFile) - if err != nil { - t.Fatalf("Failed to read written file: %v", err) - } - if string(content) != "test" { - t.Errorf("Expected file content 'test', got: %s", string(content)) - } -} - -// TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path -func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) { - tool := NewWriteFileTool("", false) - ctx := context.Background() - args := map[string]any{ - "content": "test", - } - - result := tool.Execute(ctx, args) - - // Should return error result - if !result.IsError { - t.Errorf("Expected error when path is missing") - } -} - -// TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content -func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) { - tool := NewWriteFileTool("", false) - ctx := context.Background() - args := map[string]any{ - "path": "/tmp/test.txt", - } - - result := tool.Execute(ctx, args) - - // Should return error result - if !result.IsError { - t.Errorf("Expected error when content is missing") - } - - // Should mention required parameter - if !strings.Contains(result.ForLLM, "content is required") && - !strings.Contains(result.ForUser, "content is required") { - t.Errorf("Expected 'content is required' message, got ForLLM: %s", result.ForLLM) - } -} - -// TestFilesystemTool_ListDir_Success verifies successful directory listing -func TestFilesystemTool_ListDir_Success(t *testing.T) { - tmpDir := t.TempDir() - os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0o644) - os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644) - os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755) - - tool := NewListDirTool("", false) - ctx := context.Background() - args := map[string]any{ - "path": tmpDir, - } - - result := tool.Execute(ctx, args) - - // Success should not be an error - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) - } - - // Should list files and directories - if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") { - t.Errorf("Expected files in listing, got: %s", result.ForLLM) - } - if !strings.Contains(result.ForLLM, "subdir") { - t.Errorf("Expected subdir in listing, got: %s", result.ForLLM) - } -} - -// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory -func TestFilesystemTool_ListDir_NotFound(t *testing.T) { - tool := NewListDirTool("", false) - ctx := context.Background() - args := map[string]any{ - "path": "/nonexistent_directory_12345", - } - - result := tool.Execute(ctx, args) - - // Failure should be marked as error - if !result.IsError { - t.Errorf("Expected error for non-existent directory, got IsError=false") - } - - // Should contain error message - if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { - t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) - } -} - -// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory -func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) { - tool := NewListDirTool("", false) - ctx := context.Background() - args := map[string]any{} - - result := tool.Execute(ctx, args) - - // Should use "." as default path - if result.IsError { - t.Errorf("Expected success with default path '.', got IsError=true: %s", result.ForLLM) - } -} - -// Block paths that look inside workspace but point outside via symlink. -func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { - root := t.TempDir() - workspace := filepath.Join(root, "workspace") - if err := os.MkdirAll(workspace, 0o755); err != nil { - t.Fatalf("failed to create workspace: %v", err) - } - - secret := filepath.Join(root, "secret.txt") - if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil { - t.Fatalf("failed to write secret file: %v", err) - } - - link := filepath.Join(workspace, "leak.txt") - if err := os.Symlink(secret, link); err != nil { - t.Skipf("symlink not supported in this environment: %v", err) - } - - tool := NewReadFileTool(workspace, true) - result := tool.Execute(context.Background(), map[string]any{ - "path": link, - }) - - if !result.IsError { - t.Fatalf("expected symlink escape to be blocked") - } - // os.Root might return different errors depending on platform/implementation - // but it definitely should error. - // Our wrapper returns "access denied or file not found" - if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") && - !strings.Contains(result.ForLLM, "no such file") { - t.Fatalf("expected symlink escape error, got: %s", result.ForLLM) - } -} - -func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { - tool := NewReadFileTool("", true) // restrict=true but workspace="" - - // Try to read a sensitive file (simulated by a temp file outside workspace) - tmpDir := t.TempDir() - secretFile := filepath.Join(tmpDir, "shadow") - os.WriteFile(secretFile, []byte("secret data"), 0o600) - - result := tool.Execute(context.Background(), map[string]any{ - "path": secretFile, - }) - - // We EXPECT IsError=true (access blocked due to empty workspace) - assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM) - - // Verify it failed for the right reason - assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error") -} - -// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases: -// single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path. -func TestRootMkdirAll(t *testing.T) { - workspace := t.TempDir() - root, err := os.OpenRoot(workspace) - if err != nil { - t.Fatalf("failed to open root: %v", err) - } - defer root.Close() - - // Case 1: Single directory - err = root.MkdirAll("dir1", 0o755) - assert.NoError(t, err) - _, err = os.Stat(filepath.Join(workspace, "dir1")) - assert.NoError(t, err) - - // Case 2: Deeply nested directory - err = root.MkdirAll("a/b/c/d", 0o755) - assert.NoError(t, err) - _, err = os.Stat(filepath.Join(workspace, "a/b/c/d")) - assert.NoError(t, err) - - // Case 3: Already exists — must be idempotent - err = root.MkdirAll("a/b/c/d", 0o755) - assert.NoError(t, err) - - // Case 4: A regular file blocks directory creation — must error - err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0o644) - assert.NoError(t, err) - err = root.MkdirAll("file_exists", 0o755) - assert.Error(t, err, "expected error when a file exists at the directory path") -} - -func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) { - workspace := t.TempDir() - tool := NewWriteFileTool(workspace, true) - ctx := context.Background() - - testFile := "deep/nested/path/to/file.txt" - content := "deep content" - args := map[string]any{ - "path": testFile, - "content": content, - } - - result := tool.Execute(ctx, args) - assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM) - - // Verify file content - actualPath := filepath.Join(workspace, testFile) - data, err := os.ReadFile(actualPath) - assert.NoError(t, err) - assert.Equal(t, content, string(data)) -} - -// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors. -func TestHostRW_Read_PermissionDenied(t *testing.T) { - if os.Getuid() == 0 { - t.Skip("skipping permission test: running as root") - } - tmpDir := t.TempDir() - protected := filepath.Join(tmpDir, "protected.txt") - err := os.WriteFile(protected, []byte("secret"), 0o000) - assert.NoError(t, err) - defer os.Chmod(protected, 0o644) // ensure cleanup - - _, err = (&hostFs{}).ReadFile(protected) - assert.Error(t, err) - assert.Contains(t, err.Error(), "access denied") -} - -// TestHostRW_Read_Directory verifies that hostRW.Read returns an error when given a directory path. -func TestHostRW_Read_Directory(t *testing.T) { - tmpDir := t.TempDir() - - _, err := (&hostFs{}).ReadFile(tmpDir) - assert.Error(t, err, "expected error when reading a directory as a file") -} - -// TestRootRW_Read_Directory verifies that rootRW.Read returns an error when given a directory. -func TestRootRW_Read_Directory(t *testing.T) { - workspace := t.TempDir() - root, err := os.OpenRoot(workspace) - assert.NoError(t, err) - defer root.Close() - - // Create a subdirectory - err = root.Mkdir("subdir", 0o755) - assert.NoError(t, err) - - _, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir") - assert.Error(t, err, "expected error when reading a directory as a file") -} - -// TestHostRW_Write_ParentDirMissing verifies that hostRW.Write creates parent dirs automatically. -func TestHostRW_Write_ParentDirMissing(t *testing.T) { - tmpDir := t.TempDir() - target := filepath.Join(tmpDir, "a", "b", "c", "file.txt") - - err := (&hostFs{}).WriteFile(target, []byte("hello")) - assert.NoError(t, err) - - data, err := os.ReadFile(target) - assert.NoError(t, err) - assert.Equal(t, "hello", string(data)) -} - -// TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates -// nested parent directories automatically within the sandbox. -func TestRootRW_Write_ParentDirMissing(t *testing.T) { - workspace := t.TempDir() - - relPath := "x/y/z/file.txt" - err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested")) - assert.NoError(t, err) - - data, err := os.ReadFile(filepath.Join(workspace, relPath)) - assert.NoError(t, err) - assert.Equal(t, "nested", string(data)) -} - -// TestHostRW_Write verifies the hostRW.Write helper function -func TestHostRW_Write(t *testing.T) { - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "atomic_test.txt") - testData := []byte("atomic test content") - - err := (&hostFs{}).WriteFile(testFile, testData) - assert.NoError(t, err) - - content, err := os.ReadFile(testFile) - assert.NoError(t, err) - assert.Equal(t, testData, content) - - // Verify it overwrites correctly - newData := []byte("new atomic content") - err = (&hostFs{}).WriteFile(testFile, newData) - assert.NoError(t, err) - - content, err = os.ReadFile(testFile) - assert.NoError(t, err) - assert.Equal(t, newData, content) -} - -// TestRootRW_Write verifies the rootRW.Write helper function -func TestRootRW_Write(t *testing.T) { - tmpDir := t.TempDir() - - relPath := "atomic_root_test.txt" - testData := []byte("atomic root test content") - - erw := &sandboxFs{workspace: tmpDir} - err := erw.WriteFile(relPath, testData) - assert.NoError(t, err) - - root, err := os.OpenRoot(tmpDir) - assert.NoError(t, err) - defer root.Close() - - f, err := root.Open(relPath) - assert.NoError(t, err) - defer f.Close() - - content, err := io.ReadAll(f) - assert.NoError(t, err) - assert.Equal(t, testData, content) - - // Verify it overwrites correctly - newData := []byte("new root atomic content") - err = erw.WriteFile(relPath, newData) - assert.NoError(t, err) - - f2, err := root.Open(relPath) - assert.NoError(t, err) - defer f2.Close() - - content, err = io.ReadAll(f2) - assert.NoError(t, err) - assert.Equal(t, newData, content) -} - -// TestWhitelistFs_AllowsMatchingPaths verifies that whitelistFs allows access to -// paths matching the whitelist patterns while blocking non-matching paths. -func TestWhitelistFs_AllowsMatchingPaths(t *testing.T) { - workspace := t.TempDir() - outsideDir := t.TempDir() - outsideFile := filepath.Join(outsideDir, "allowed.txt") - os.WriteFile(outsideFile, []byte("outside content"), 0o644) - - // Pattern allows access to the outsideDir. - patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(outsideDir))} - - tool := NewReadFileTool(workspace, true, patterns) - - // Read from whitelisted path should succeed. - result := tool.Execute(context.Background(), map[string]any{"path": outsideFile}) - if result.IsError { - t.Errorf("expected whitelisted path to be readable, got: %s", result.ForLLM) - } - if !strings.Contains(result.ForLLM, "outside content") { - t.Errorf("expected file content, got: %s", result.ForLLM) - } - - // Read from non-whitelisted path outside workspace should fail. - otherDir := t.TempDir() - otherFile := filepath.Join(otherDir, "blocked.txt") - os.WriteFile(otherFile, []byte("blocked"), 0o644) - - result = tool.Execute(context.Background(), map[string]any{"path": otherFile}) - if !result.IsError { - t.Errorf("expected non-whitelisted path to be blocked, got: %s", result.ForLLM) - } -} diff --git a/pkg/tools/edit.go b/pkg/tools/fs/edit.go similarity index 86% rename from pkg/tools/edit.go rename to pkg/tools/fs/edit.go index d5bebf4a2..827ea50c8 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/fs/edit.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" @@ -29,7 +29,7 @@ func (t *EditFileTool) Name() string { } func (t *EditFileTool) Description() string { - return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file." + return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n." } func (t *EditFileTool) Parameters() map[string]any { @@ -42,11 +42,11 @@ func (t *EditFileTool) Parameters() map[string]any { }, "old_text": map[string]any{ "type": "string", - "description": "The exact text to find and replace", + "description": "The exact text to find and replace. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.", }, "new_text": map[string]any{ "type": "string", - "description": "The text to replace with", + "description": "The text to replace with. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.", }, }, "required": []string{"path", "old_text", "new_text"}, @@ -92,7 +92,7 @@ func (t *AppendFileTool) Name() string { } func (t *AppendFileTool) Description() string { - return "Append content to the end of a file" + return "Append content to the end of a file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n." } func (t *AppendFileTool) Parameters() map[string]any { @@ -105,7 +105,7 @@ func (t *AppendFileTool) Parameters() map[string]any { }, "content": map[string]any{ "type": "string", - "description": "The content to append", + "description": "The content to append. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.", }, }, "required": []string{"path", "content"}, diff --git a/pkg/tools/edit_test.go b/pkg/tools/fs/edit_test.go similarity index 99% rename from pkg/tools/edit_test.go rename to pkg/tools/fs/edit_test.go index 83a7e778c..4c25322ef 100644 --- a/pkg/tools/edit_test.go +++ b/pkg/tools/fs/edit_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/fs/filesystem.go b/pkg/tools/fs/filesystem.go new file mode 100644 index 000000000..262d88d99 --- /dev/null +++ b/pkg/tools/fs/filesystem.go @@ -0,0 +1,1250 @@ +package fstools + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "math" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow + +func ValidatePathWithAllowPaths( + path, workspace string, + restrict bool, + patterns []*regexp.Regexp, +) (string, error) { + return validatePathWithAllowPaths(path, workspace, restrict, patterns) +} + +func IsAllowedPath(path string, patterns []*regexp.Regexp) bool { + return isAllowedPath(path, patterns) +} + +func validatePathWithAllowPaths( + path, workspace string, + restrict bool, + patterns []*regexp.Regexp, +) (string, error) { + if workspace == "" { + return path, fmt.Errorf("workspace is not defined") + } + + absWorkspace, err := filepath.Abs(workspace) + if err != nil { + return "", fmt.Errorf("failed to resolve workspace path: %w", err) + } + + var absPath string + if filepath.IsAbs(path) { + absPath = filepath.Clean(path) + } else { + absPath, err = filepath.Abs(filepath.Join(absWorkspace, path)) + if err != nil { + return "", fmt.Errorf("failed to resolve file path: %w", err) + } + } + + if restrict { + if isAllowedPath(absPath, patterns) { + return absPath, nil + } + + if !isWithinWorkspace(absPath, absWorkspace) { + return "", fmt.Errorf("access denied: path is outside the workspace") + } + + var resolved string + workspaceReal := absWorkspace + if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil { + workspaceReal = resolved + } + + if resolved, err = filepath.EvalSymlinks(absPath); err == nil { + if !isWithinWorkspace(resolved, workspaceReal) { + return "", fmt.Errorf("access denied: symlink resolves outside workspace") + } + } else if os.IsNotExist(err) { + var parentResolved string + if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil { + if !isWithinWorkspace(parentResolved, workspaceReal) { + return "", fmt.Errorf("access denied: symlink resolves outside workspace") + } + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + } else { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + } + + return absPath, nil +} + +func isAllowedPath(path string, patterns []*regexp.Regexp) bool { + if len(patterns) == 0 { + return false + } + + cleaned := filepath.Clean(path) + if !filepath.IsAbs(cleaned) { + return false + } + if !matchesAllowedPath(cleaned, patterns) { + return false + } + + resolved, err := resolvePathAgainstExistingAncestor(cleaned) + if err != nil { + return false + } + + return matchesAllowedPath(resolved, patterns) +} + +func matchesAllowedPath(path string, patterns []*regexp.Regexp) bool { + cleaned := filepath.Clean(path) + for _, pattern := range patterns { + if pattern.MatchString(cleaned) { + return true + } + if root, ok := extractAllowedPathRoot(pattern); ok && isWithinAllowedRoot(cleaned, root) { + return true + } + } + return false +} + +func extractAllowedPathRoot(pattern *regexp.Regexp) (string, bool) { + raw := pattern.String() + if !strings.HasPrefix(raw, "^") { + return "", false + } + + literal := strings.TrimPrefix(raw, "^") + + // Recognize the common "directory prefix" form: ^<literal>(?:/|$) + literal = strings.TrimSuffix(literal, "(?:/|$)") + literal = strings.TrimSuffix(literal, `(?:\\|$)`) + + // Reject patterns that still contain regex operators after removing the + // optional anchored-directory suffix. That keeps arbitrary regex behavior + // unchanged and only enables normalized prefix matching for literal paths. + if containsUnescapedRegexMeta(literal) { + return "", false + } + + unescaped, ok := unescapeRegexLiteral(literal) + if !ok || unescaped == "" { + return "", false + } + + return filepath.Clean(unescaped), filepath.IsAbs(unescaped) +} + +func appendUniquePath(paths []string, path string) []string { + for _, existing := range paths { + if existing == path { + return paths + } + } + return append(paths, path) +} + +func containsUnescapedRegexMeta(s string) bool { + escaped := false + for _, r := range s { + if escaped { + escaped = false + continue + } + if r == '\\' { + escaped = true + continue + } + switch r { + case '.', '+', '*', '?', '(', ')', '[', ']', '{', '}', '|': + return true + } + } + return escaped +} + +func unescapeRegexLiteral(s string) (string, bool) { + var b strings.Builder + b.Grow(len(s)) + + escaped := false + for _, r := range s { + if escaped { + b.WriteRune(r) + escaped = false + continue + } + if r == '\\' { + escaped = true + continue + } + b.WriteRune(r) + } + + if escaped { + return "", false + } + + return b.String(), true +} + +func isWithinAllowedRoot(path, root string) bool { + candidate := filepath.Clean(path) + allowedVariants := []string{filepath.Clean(root)} + + if resolvedRoot, err := resolvePathAgainstExistingAncestor(root); err == nil { + allowedVariants = appendUniquePath(allowedVariants, filepath.Clean(resolvedRoot)) + } + + for _, allowedRoot := range allowedVariants { + if isWithinWorkspace(candidate, allowedRoot) { + return true + } + } + + return false +} + +func resolveExistingAncestor(path string) (string, error) { + for current := filepath.Clean(path); ; current = filepath.Dir(current) { + if resolved, err := filepath.EvalSymlinks(current); err == nil { + return resolved, nil + } else if !os.IsNotExist(err) { + return "", err + } + if filepath.Dir(current) == current { + return "", os.ErrNotExist + } + } +} + +func resolvePathAgainstExistingAncestor(path string) (string, error) { + cleaned := filepath.Clean(path) + for current := cleaned; ; current = filepath.Dir(current) { + resolved, err := filepath.EvalSymlinks(current) + if err == nil { + suffix, relErr := filepath.Rel(current, cleaned) + if relErr != nil { + return "", relErr + } + if suffix == "." { + return filepath.Clean(resolved), nil + } + return filepath.Clean(filepath.Join(resolved, suffix)), nil + } + if !os.IsNotExist(err) { + return "", err + } + if filepath.Dir(current) == current { + return "", os.ErrNotExist + } + } +} + +func isWithinWorkspace(candidate, workspace string) bool { + rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate)) + return err == nil && (rel == "." || filepath.IsLocal(rel)) +} + +type ReadFileTool struct { + fs fileSystem + maxSize int64 +} + +type ReadFileLinesTool struct { + fs fileSystem + maxSize int64 +} + +func NewReadFileTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } + + maxSize := int64(maxReadFileSize) + if maxSize <= 0 { + maxSize = MaxReadFileSize + } + + return &ReadFileTool{ + fs: buildFs(workspace, restrict, patterns), + maxSize: maxSize, + } +} + +func NewReadFileBytesTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileTool { + return NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewReadFileLinesTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileLinesTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } + + maxSize := int64(maxReadFileSize) + if maxSize <= 0 { + maxSize = MaxReadFileSize + } + + return &ReadFileLinesTool{ + fs: buildFs(workspace, restrict, patterns), + maxSize: maxSize, + } +} + +func (t *ReadFileTool) Name() string { + return "read_file" +} + +func (t *ReadFileLinesTool) Name() string { + return "read_file" +} + +func (t *ReadFileTool) Description() string { + return "Read the contents of a file. Supports pagination via `offset` and `length`." +} + +func (t *ReadFileLinesTool) Description() string { + return "Read a UTF-8 text file from the filesystem. Output always includes line numbers in the format `LINE_NUMBER|LINE_CONTENT` (1-indexed). Supports partial reads via `start_line` and `max_lines` for large text files." +} + +func (t *ReadFileTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to the file to read.", + }, + "offset": map[string]any{ + "type": "integer", + "description": "Byte offset to start reading from.", + "default": 0, + }, + "length": map[string]any{ + "type": "integer", + "description": "Maximum number of bytes to read.", + "default": t.maxSize, + }, + }, + "required": []string{"path"}, + } +} + +func (t *ReadFileLinesTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to the file to read.", + }, + "start_line": map[string]any{ + "type": "integer", + "description": "Line number to start reading from (1-indexed, inclusive).", + "default": 1, + }, + "max_lines": map[string]any{ + "type": "integer", + "description": "Maximum number of lines to read.", + }, + }, + "required": []string{"path"}, + } +} + +func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, ok := args["path"].(string) + if !ok { + return ErrorResult("path is required") + } + + // offset (optional, default 0) + offset, err := getInt64Arg(args, "offset", 0) + if err != nil { + return ErrorResult(err.Error()) + } + if offset < 0 { + return ErrorResult("offset must be >= 0") + } + + // length (optional, capped at MaxReadFileSize) + length, err := getInt64Arg(args, "length", t.maxSize) + if err != nil { + return ErrorResult(err.Error()) + } + if length <= 0 { + return ErrorResult("length must be > 0") + } + if length > t.maxSize { + length = t.maxSize + } + + file, err := t.fs.Open(path) + if err != nil { + return ErrorResult(err.Error()) + } + defer file.Close() + + // measure total size + totalSize := int64(-1) // -1 means unknown + if info, statErr := file.Stat(); statErr == nil { + totalSize = info.Size() + } + + // sniff the first 512 bytes to detect binary content before loading + // it into the LLM context. Seeking back to 0 afterwards restores state. + sniff := make([]byte, 512) + sniffN, _ := file.Read(sniff) + + // Reset read position to beginning before applying the caller's offset. + if seeker, ok := file.(io.Seeker); ok { + _, err = seeker.Seek(0, io.SeekStart) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to reset file position after sniff: %v", err)) + } + } else { + // Non-seekable: we consumed sniffN bytes above; account for them when + // discarding to reach the requested offset below. + // If offset < sniffN the data we already read covers it, which we + // cannot replay on a non-seekable stream — return a clear error. + if offset < int64(sniffN) && offset > 0 { + return ErrorResult( + "non-seekable file: cannot seek to an offset within the first 512 bytes after binary detection", + ) + } + } + + // Seek to the requested offset. + if seeker, ok := file.(io.Seeker); ok { + _, err = seeker.Seek(offset, io.SeekStart) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to seek to offset %d: %v", offset, err)) + } + } else if offset > 0 { + // Fallback for non-seekable streams: discard leading bytes. + // sniffN bytes were already consumed above, so subtract them. + remaining := offset - int64(sniffN) + if remaining > 0 { + _, err = io.CopyN(io.Discard, file, remaining) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to advance to offset %d: %v", offset, err)) + } + } + } + + // read length+1 bytes to reliably detect whether more content exists + // without relying on totalSize (which may be -1 for non-seekable streams). + // This avoids the false-positive TRUNCATED message on the last page. + probe := make([]byte, length+1) + n, err := io.ReadFull(file, probe) + // FIX: io.ReadFull returns io.ErrUnexpectedEOF for partial reads (0 < n < len), + // and io.EOF only when n == 0. Both are normal terminal conditions — only + // other errors are genuine failures. + if err != nil && err != io.EOF && !errors.Is(err, io.ErrUnexpectedEOF) { + return ErrorResult(fmt.Sprintf("failed to read file content: %v", err)) + } + + // hasMore is true only when we actually got the extra probe byte. + hasMore := int64(n) > length + data := probe[:min(int64(n), length)] + + if len(data) == 0 { + return NewToolResult("[END OF FILE - no content at this offset]") + } + + // Build metadata header. + // use filepath.Base(path) instead of the raw path to avoid leaking + // internal filesystem structure into the LLM context. + readEnd := offset + int64(len(data)) + // use ASCII hyphen-minus instead of en-dash (U+2013) to keep the + // header parseable by downstream tools and log processors. + readRange := fmt.Sprintf("bytes %d-%d", offset, readEnd-1) + + displayPath := filepath.Base(path) + var header string + if totalSize >= 0 { + header = fmt.Sprintf( + "[file: %s | total: %d bytes | read: %s]", + displayPath, totalSize, readRange, + ) + } else { + header = fmt.Sprintf( + "[file: %s | read: %s | total size unknown]", + displayPath, readRange, + ) + } + + if hasMore { + header += fmt.Sprintf( + "\n[TRUNCATED - file has more content. Call read_file again with offset=%d to continue.]", + readEnd, + ) + } else { + header += "\n[END OF FILE - no further content.]" + } + + logger.DebugCF("tool", "ReadFileTool execution completed successfully", + map[string]any{ + "path": path, + "bytes_read": len(data), + "has_more": hasMore, + }) + + return NewToolResult(header + "\n\n" + string(data)) +} + +func (t *ReadFileLinesTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, ok := args["path"].(string) + if !ok { + return ErrorResult("path is required") + } + + startLine, err := getInt64Arg(args, "start_line", 1) + if err != nil { + return ErrorResult(err.Error()) + } + if startLine < 1 { + return ErrorResult("start_line must be >= 1") + } + if _, exists := args["offset"]; exists { + return ErrorResult("offset is not supported in line mode; use start_line") + } + if _, exists := args["length"]; exists { + return ErrorResult("length is not supported in line mode; use max_lines") + } + if _, exists := args["limit"]; exists { + return ErrorResult("limit is not supported in line mode; use max_lines") + } + + limit := int64(-1) + if raw, exists := args["max_lines"]; exists && raw != nil { + limit, err = getInt64Arg(args, "max_lines", -1) + if err != nil { + return ErrorResult(err.Error()) + } + if limit <= 0 { + return ErrorResult("max_lines, if provided, must be > 0") + } + } + + file, err := t.fs.Open(path) + if err != nil { + return ErrorResult(err.Error()) + } + defer file.Close() + + if info, statErr := file.Stat(); statErr == nil && info.IsDir() { + return ErrorResult(fmt.Sprintf("failed to open file: path is a directory: %s", path)) + } + + sample := make([]byte, 512) + sampleN, readErr := file.Read(sample) + if readErr != nil && readErr != io.EOF { + return ErrorResult(fmt.Sprintf("failed to read file: %v", readErr)) + } + sample = sample[:sampleN] + if isBinaryReadFileData(sample) { + return ErrorResult("file appears to be binary; switch read_file mode to 'bytes' for byte-based inspection") + } + + reader := bufio.NewReaderSize(io.MultiReader(bytes.NewReader(sample), file), 32*1024) + + var content strings.Builder + lineIndex := int64(1) + var linesRead int64 + var fileBytesRead int64 + var outputBytesRead int64 + var reachedEOF bool + var byteBudgetTruncated bool + var lineTruncated bool + + for lineIndex < startLine { + hasLine, consumeErr := consumeNextLine(reader) + if consumeErr != nil { + return ErrorResult(fmt.Sprintf("failed to read file content: %v", consumeErr)) + } + if !hasLine { + reachedEOF = true + break + } + lineIndex++ + } + + for !reachedEOF && (limit < 0 || linesRead < limit) { + prefix := formatReadFileLinePrefix(lineIndex) + remaining := t.maxSize - outputBytesRead - int64(len(prefix)) + if remaining <= 0 { + byteBudgetTruncated = true + break + } + + line, complete, hasLine, readLineErr := readNextLinePrefix(reader, remaining) + if readLineErr != nil { + return ErrorResult(fmt.Sprintf("failed to read file content: %v", readLineErr)) + } + if !hasLine { + reachedEOF = true + break + } + + content.WriteString(prefix) + content.Write(line) + fileBytesRead += int64(len(line)) + outputBytesRead += int64(len(prefix) + len(line)) + linesRead++ + lineIndex++ + + if !complete { + byteBudgetTruncated = true + lineTruncated = true + break + } + } + + if !reachedEOF && !lineTruncated { + hasMoreContent, peekErr := readerHasMoreContent(reader) + if peekErr != nil { + return ErrorResult(fmt.Sprintf("failed to inspect remaining file content: %v", peekErr)) + } + if !hasMoreContent { + reachedEOF = true + byteBudgetTruncated = false + } + } + + if linesRead == 0 && content.Len() == 0 { + return NewToolResult(fmt.Sprintf("[END OF FILE - no content at or after start_line=%d]", startLine)) + } + + start := startLine + endLine := startLine + linesRead - 1 + displayPath := filepath.Base(path) + header := fmt.Sprintf( + "[file: %s | read: lines %d-%d (1-indexed) | file_bytes: %d | output_bytes: %d]", + displayPath, start, endLine, fileBytesRead, outputBytesRead, + ) + + switch { + case lineTruncated: + header += fmt.Sprintf( + "\n[TRUNCATED - line %d exceeded the %d byte read budget and was cut mid-line.]", + endLine, + t.maxSize, + ) + case byteBudgetTruncated: + if limit > 0 { + header += fmt.Sprintf( + "\n[TRUNCATED - byte budget reached. Call read_file again with start_line=%d and max_lines=%d to continue at the next line.]", + startLine+linesRead, + limit, + ) + } else { + header += fmt.Sprintf( + "\n[TRUNCATED - byte budget reached. Call read_file again with start_line=%d to continue at the next line.]", + startLine+linesRead, + ) + } + case !reachedEOF && limit > 0 && linesRead >= limit: + header += fmt.Sprintf( + "\n[PARTIAL - more content remains. Call read_file again with start_line=%d and max_lines=%d to continue.]", + startLine+linesRead, + limit, + ) + default: + header += "\n[END OF FILE - no further content.]" + } + + logger.DebugCF("tool", "ReadFileTool execution completed successfully", + map[string]any{ + "path": path, + "lines_read": linesRead, + "file_bytes_read": fileBytesRead, + "output_bytes_read": outputBytesRead, + "truncated": byteBudgetTruncated, + "tool": t.Name(), + }) + + return NewToolResult(header + "\n\n" + content.String()) +} + +func formatReadFileLinePrefix(lineNumber int64) string { + return strconv.FormatInt(lineNumber, 10) + "|" +} + +func isBinaryReadFileData(data []byte) bool { + if len(data) == 0 { + return false + } + + sample := data + if len(sample) > 512 { + sample = sample[:512] + } + + if bytes.IndexByte(sample, 0) >= 0 { + return true + } + + contentType := http.DetectContentType(sample) + if strings.HasPrefix(contentType, "text/") { + return false + } + if strings.HasSuffix(contentType, "/json") || + strings.HasSuffix(contentType, "+json") || + strings.HasSuffix(contentType, "/xml") || + strings.HasSuffix(contentType, "+xml") || + strings.Contains(contentType, "javascript") { + return false + } + + if !utf8.Valid(sample) { + return true + } + + controlChars := 0 + for _, b := range sample { + if b < 0x20 && b != '\n' && b != '\r' && b != '\t' && b != '\f' && b != '\b' { + controlChars++ + } + } + + return float64(controlChars)/float64(len(sample)) > 0.1 +} + +func consumeNextLine(reader *bufio.Reader) (bool, error) { + sawData := false + + for { + fragment, err := reader.ReadSlice('\n') + if len(fragment) > 0 { + sawData = true + } + + switch { + case err == nil: + return true, nil + case errors.Is(err, bufio.ErrBufferFull): + continue + case errors.Is(err, io.EOF): + return sawData, nil + default: + return false, err + } + } +} + +func readNextLinePrefix(reader *bufio.Reader, maxBytes int64) ([]byte, bool, bool, error) { + if maxBytes <= 0 { + return nil, false, false, nil + } + + var out bytes.Buffer + sawData := false + complete := true + + for { + fragment, err := reader.ReadSlice('\n') + if len(fragment) > 0 { + sawData = true + if remaining := maxBytes - int64(out.Len()); remaining > 0 { + take := len(fragment) + if int64(take) > remaining { + take = int(remaining) + complete = false + } + out.Write(fragment[:take]) + } else { + complete = false + } + } + + switch { + case err == nil: + return out.Bytes(), complete, sawData, nil + case errors.Is(err, bufio.ErrBufferFull): + if !complete { + return out.Bytes(), false, true, nil + } + continue + case errors.Is(err, io.EOF): + if !sawData { + return nil, true, false, nil + } + return out.Bytes(), complete, true, nil + default: + return nil, false, false, err + } + } +} + +func readerHasMoreContent(reader *bufio.Reader) (bool, error) { + _, err := reader.Peek(1) + switch { + case err == nil: + return true, nil + case errors.Is(err, io.EOF): + return false, nil + default: + return false, err + } +} + +// getInt64Arg extracts an integer argument from the args map, returning the +// provided default if the key is absent. +func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) { + raw, exists := args[key] + if !exists { + return defaultVal, nil + } + + switch v := raw.(type) { + case float64: + if v != math.Trunc(v) { + return 0, fmt.Errorf("%s must be an integer, got float %v", key, v) + } + if v > math.MaxInt64 || v < math.MinInt64 { + return 0, fmt.Errorf("%s value %v overflows int64", key, v) + } + return int64(v), nil + case int: + return int64(v), nil + case int64: + return v, nil + case string: + parsed, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid integer format for %s parameter: %w", key, err) + } + return parsed, nil + default: + return 0, fmt.Errorf("unsupported type %T for %s parameter", raw, key) + } +} + +type WriteFileTool struct { + fs fileSystem +} + +func NewWriteFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *WriteFileTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } + return &WriteFileTool{fs: buildFs(workspace, restrict, patterns)} +} + +func (t *WriteFileTool) Name() string { + return "write_file" +} + +func (t *WriteFileTool) Description() string { + return "Write content to a file. Content is written byte-for-byte after argument decoding. Standard JSON escaping applies: \\n for newline and \\\\n for a literal backslash-n sequence. If the file already exists, you must set overwrite=true to replace it." +} + +func (t *WriteFileTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to the file to write", + }, + "content": map[string]any{ + "type": "string", + "description": "Content to write to the file. Standard JSON escaping applies: \\n for newline and \\\\n for literal backslash-n.", + }, + "overwrite": map[string]any{ + "type": "boolean", + "description": "Must be set to true to overwrite an existing file.", + "default": false, + }, + }, + "required": []string{"path", "content"}, + } +} + +func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, ok := args["path"].(string) + if !ok { + return ErrorResult("path is required") + } + + content, ok := args["content"].(string) + if !ok { + return ErrorResult("content is required") + } + + overwrite, _ := args["overwrite"].(bool) + + if !overwrite { + if _, err := t.fs.Open(path); err == nil { + return ErrorResult( + fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path), + ) + } + } + + if err := t.fs.WriteFile(path, []byte(content)); err != nil { + return ErrorResult(err.Error()) + } + + return SilentResult(fmt.Sprintf("File written: %s", path)) +} + +type ListDirTool struct { + fs fileSystem +} + +func NewListDirTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ListDirTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } + return &ListDirTool{fs: buildFs(workspace, restrict, patterns)} +} + +func (t *ListDirTool) Name() string { + return "list_dir" +} + +func (t *ListDirTool) Description() string { + return "List files and directories in a path" +} + +func (t *ListDirTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to list", + }, + }, + "required": []string{"path"}, + } +} + +func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, ok := args["path"].(string) + if !ok { + path = "." + } + + entries, err := t.fs.ReadDir(path) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read directory: %v", err)) + } + return formatDirEntries(entries) +} + +func formatDirEntries(entries []os.DirEntry) *ToolResult { + var result strings.Builder + for _, entry := range entries { + if entry.IsDir() { + result.WriteString("DIR: " + entry.Name() + "\n") + } else { + result.WriteString("FILE: " + entry.Name() + "\n") + } + } + return NewToolResult(result.String()) +} + +// fileSystem abstracts reading, writing, and listing files, allowing both +// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface. +type fileSystem interface { + ReadFile(path string) ([]byte, error) + WriteFile(path string, data []byte) error + ReadDir(path string) ([]os.DirEntry, error) + Open(path string) (fs.File, error) +} + +// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem. +type hostFs struct{} + +func (h *hostFs) ReadFile(path string) ([]byte, error) { + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("failed to read file: file not found: %w", err) + } + if os.IsPermission(err) { + return nil, fmt.Errorf("failed to read file: access denied: %w", err) + } + return nil, fmt.Errorf("failed to read file: %w", err) + } + return content, nil +} + +func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) { + return os.ReadDir(path) +} + +func (h *hostFs) WriteFile(path string, data []byte) error { + // Use unified atomic write utility with explicit sync for flash storage reliability. + // Using 0o600 (owner read/write only) for secure default permissions. + return fileutil.WriteFileAtomic(path, data, 0o600) +} + +func (h *hostFs) Open(path string) (fs.File, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("failed to open file: file not found: %w", err) + } + if os.IsPermission(err) { + return nil, fmt.Errorf("failed to open file: access denied: %w", err) + } + return nil, fmt.Errorf("failed to open file: %w", err) + } + return f, nil +} + +// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. +type sandboxFs struct { + workspace string +} + +func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error { + if r.workspace == "" { + return fmt.Errorf("workspace is not defined") + } + + root, err := os.OpenRoot(r.workspace) + if err != nil { + return fmt.Errorf("failed to open workspace: %w", err) + } + defer root.Close() + + relPath, err := getSafeRelPath(r.workspace, path) + if err != nil { + return err + } + + return fn(root, relPath) +} + +func (r *sandboxFs) ReadFile(path string) ([]byte, error) { + var content []byte + err := r.execute(path, func(root *os.Root, relPath string) error { + fileContent, err := root.ReadFile(relPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("failed to read file: file not found: %w", err) + } + // os.Root returns "escapes from parent" for paths outside the root + if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") || + strings.Contains(err.Error(), "permission denied") { + return fmt.Errorf("failed to read file: access denied: %w", err) + } + return fmt.Errorf("failed to read file: %w", err) + } + content = fileContent + return nil + }) + return content, err +} + +func (r *sandboxFs) WriteFile(path string, data []byte) error { + return r.execute(path, func(root *os.Root, relPath string) error { + dir := filepath.Dir(relPath) + if dir != "." && dir != "/" { + if err := root.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create parent directories: %w", err) + } + } + + // Use atomic write pattern with explicit sync for flash storage reliability. + // Using 0o600 (owner read/write only) for secure default permissions. + tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano()) + + tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + root.Remove(tmpRelPath) + return fmt.Errorf("failed to open temp file: %w", err) + } + + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + root.Remove(tmpRelPath) + return fmt.Errorf("failed to write temp file: %w", err) + } + + // CRITICAL: Force sync to storage medium before rename. + // This ensures data is physically written to disk, not just cached. + if err := tmpFile.Sync(); err != nil { + tmpFile.Close() + root.Remove(tmpRelPath) + return fmt.Errorf("failed to sync temp file: %w", err) + } + + if err := tmpFile.Close(); err != nil { + root.Remove(tmpRelPath) + return fmt.Errorf("failed to close temp file: %w", err) + } + + if err := root.Rename(tmpRelPath, relPath); err != nil { + root.Remove(tmpRelPath) + return fmt.Errorf("failed to rename temp file over target: %w", err) + } + + // Sync directory to ensure rename is durable + if dirFile, err := root.Open("."); err == nil { + _ = dirFile.Sync() + dirFile.Close() + } + + return nil + }) +} + +func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) { + var entries []os.DirEntry + err := r.execute(path, func(root *os.Root, relPath string) error { + dirEntries, err := fs.ReadDir(root.FS(), relPath) + if err != nil { + return err + } + entries = dirEntries + return nil + }) + return entries, err +} + +func (r *sandboxFs) Open(path string) (fs.File, error) { + var f fs.File + err := r.execute(path, func(root *os.Root, relPath string) error { + file, err := root.Open(relPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("failed to open file: file not found: %w", err) + } + if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") || + strings.Contains(err.Error(), "permission denied") { + return fmt.Errorf("failed to open file: access denied: %w", err) + } + return fmt.Errorf("failed to open file: %w", err) + } + f = file + return nil + }) + return f, err +} + +// whitelistFs wraps a sandboxFs and allows access to specific paths outside +// the workspace when they match any of the provided patterns. +type whitelistFs struct { + sandbox *sandboxFs + host hostFs + patterns []*regexp.Regexp +} + +func (w *whitelistFs) matches(path string) bool { + return isAllowedPath(path, w.patterns) +} + +func (w *whitelistFs) ReadFile(path string) ([]byte, error) { + if w.matches(path) { + return w.host.ReadFile(path) + } + return w.sandbox.ReadFile(path) +} + +func (w *whitelistFs) WriteFile(path string, data []byte) error { + if w.matches(path) { + return w.host.WriteFile(path, data) + } + return w.sandbox.WriteFile(path, data) +} + +func (w *whitelistFs) ReadDir(path string) ([]os.DirEntry, error) { + if w.matches(path) { + return w.host.ReadDir(path) + } + return w.sandbox.ReadDir(path) +} + +func (w *whitelistFs) Open(path string) (fs.File, error) { + if w.matches(path) { + return w.host.Open(path) + } + return w.sandbox.Open(path) +} + +// buildFs returns the appropriate fileSystem implementation based on restriction +// settings and optional path whitelist patterns. +func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem { + if !restrict { + return &hostFs{} + } + sandbox := &sandboxFs{workspace: workspace} + if len(patterns) > 0 { + return &whitelistFs{sandbox: sandbox, patterns: patterns} + } + return sandbox +} + +// Helper to get a safe relative path for os.Root usage +func getSafeRelPath(workspace, path string) (string, error) { + if workspace == "" { + return "", fmt.Errorf("workspace is not defined") + } + + rel := filepath.Clean(path) + if filepath.IsAbs(rel) { + var err error + rel, err = filepath.Rel(workspace, rel) + if err != nil { + return "", fmt.Errorf("failed to calculate relative path: %w", err) + } + } + + if !filepath.IsLocal(rel) { + return "", fmt.Errorf("path escapes workspace: %s", path) + } + + return rel, nil +} diff --git a/pkg/tools/fs/filesystem_test.go b/pkg/tools/fs/filesystem_test.go new file mode 100644 index 000000000..4387332be --- /dev/null +++ b/pkg/tools/fs/filesystem_test.go @@ -0,0 +1,1240 @@ +package fstools + +import ( + "context" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestFilesystemTool_ReadFile_Success verifies successful file reading +func TestFilesystemTool_ReadFile_Success(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.txt") + os.WriteFile(testFile, []byte("test content"), 0o644) + + tool := NewReadFileBytesTool("", false, MaxReadFileSize) + ctx := context.Background() + args := map[string]any{ + "path": testFile, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForLLM should contain file content + if !strings.Contains(result.ForLLM, "test content") { + t.Errorf("Expected ForLLM to contain 'test content', got: %s", result.ForLLM) + } + + // ReadFile returns NewToolResult which only sets ForLLM, not ForUser + // This is the expected behavior - file content goes to LLM, not directly to user + if result.ForUser != "" { + t.Errorf("Expected ForUser to be empty for NewToolResult, got: %s", result.ForUser) + } +} + +// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file +func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { + tool := NewReadFileBytesTool("", false, MaxReadFileSize) + ctx := context.Background() + args := map[string]any{ + "path": "/nonexistent_file_12345.txt", + } + + result := tool.Execute(ctx, args) + + // Failure should be marked as error + if !result.IsError { + t.Errorf("Expected error for missing file, got IsError=false") + } + + // Should contain error message + if !strings.Contains(result.ForLLM, "failed to open file") && + !strings.Contains(result.ForUser, "failed to open") { + t.Errorf( + "Expected error message, got ForLLM: %s, ForUser: %s", + result.ForLLM, + result.ForUser, + ) + } +} + +// TestFilesystemTool_ReadFile_MissingPath verifies error handling for missing path +func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) { + tool := &ReadFileTool{} + ctx := context.Background() + args := map[string]any{} + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when path is missing") + } + + // Should mention required parameter + if !strings.Contains(result.ForLLM, "path is required") && + !strings.Contains(result.ForUser, "path is required") { + t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM) + } +} + +// TestFilesystemTool_WriteFile_Success verifies successful file writing +func TestFilesystemTool_WriteFile_Success(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "newfile.txt") + + tool := NewWriteFileTool("", false) + ctx := context.Background() + args := map[string]any{ + "path": testFile, + "content": "hello world", + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // WriteFile returns SilentResult + if !result.Silent { + t.Errorf("Expected Silent=true for WriteFile, got false") + } + + // ForUser should be empty (silent result) + if result.ForUser != "" { + t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser) + } + + // Verify file was actually written + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read written file: %v", err) + } + if string(content) != "hello world" { + t.Errorf("Expected file content 'hello world', got: %s", string(content)) + } +} + +// TestFilesystemTool_WriteFile_LiteralBackslashN verifies write_file keeps +// literal backslash sequences unchanged when they are passed as plain text. +func TestFilesystemTool_WriteFile_LiteralBackslashN(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "literal.txt") + + tool := NewWriteFileTool("", false) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "content": `aaa\naaa`, + }) + + assert.False(t, result.IsError, "expected success, got: %s", result.ForLLM) + + data, err := os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, `aaa\naaa`, string(data)) +} + +// TestFilesystemTool_WriteFile_PreservesCRLF verifies write_file does not +// normalize line endings and writes CRLF bytes as provided. +func TestFilesystemTool_WriteFile_PreservesCRLF(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "crlf.txt") + content := "line1\r\nline2\r\n" + + tool := NewWriteFileTool("", false) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "content": content, + }) + + assert.False(t, result.IsError, "expected success, got: %s", result.ForLLM) + + data, err := os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, []byte(content), data) +} + +// TestFilesystemTool_WriteFile_CreateDir verifies directory creation +func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "subdir", "newfile.txt") + + tool := NewWriteFileTool("", false) + ctx := context.Background() + args := map[string]any{ + "path": testFile, + "content": "test", + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success with directory creation, got IsError=true: %s", result.ForLLM) + } + + // Verify directory was created and file written + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read written file: %v", err) + } + if string(content) != "test" { + t.Errorf("Expected file content 'test', got: %s", string(content)) + } +} + +// TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path +func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) { + tool := NewWriteFileTool("", false) + ctx := context.Background() + args := map[string]any{ + "content": "test", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when path is missing") + } +} + +// TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content +func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) { + tool := NewWriteFileTool("", false) + ctx := context.Background() + args := map[string]any{ + "path": "/tmp/test.txt", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when content is missing") + } + + // Should mention required parameter + if !strings.Contains(result.ForLLM, "content is required") && + !strings.Contains(result.ForUser, "content is required") { + t.Errorf("Expected 'content is required' message, got ForLLM: %s", result.ForLLM) + } +} + +// TestFilesystemTool_WriteFile_OverwriteDefaultBlocked verifies that writing to an +// existing file without overwrite=true returns an error. +func TestFilesystemTool_WriteFile_OverwriteDefaultBlocked(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "existing.txt") + os.WriteFile(testFile, []byte("original"), 0o644) + + tool := NewWriteFileTool("", false) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "content": "new content", + }) + + assert.True(t, result.IsError, "expected error when overwriting without overwrite=true") + assert.Contains(t, result.ForLLM, "already exists") + assert.Contains(t, result.ForLLM, "overwrite=true") + + // Original content must be untouched + data, err := os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, "original", string(data)) +} + +// TestFilesystemTool_WriteFile_OverwriteExplicitAllowed verifies that setting +// overwrite=true replaces the existing file. +func TestFilesystemTool_WriteFile_OverwriteExplicitAllowed(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "existing.txt") + os.WriteFile(testFile, []byte("original"), 0o644) + + tool := NewWriteFileTool("", false) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "content": "replaced", + "overwrite": true, + }) + + assert.False(t, result.IsError, "expected success with overwrite=true, got: %s", result.ForLLM) + + data, err := os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, "replaced", string(data)) +} + +// TestFilesystemTool_WriteFile_NewFileNoOverwriteFlag verifies that a new (non-existing) +// file can be written without setting overwrite=true. +func TestFilesystemTool_WriteFile_NewFileNoOverwriteFlag(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "newfile.txt") + + tool := NewWriteFileTool("", false) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "content": "brand new", + }) + + assert.False(t, result.IsError, "expected success for new file, got: %s", result.ForLLM) + + data, err := os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, "brand new", string(data)) +} + +// TestFilesystemTool_WriteFile_OverwriteFalseExplicitBlocked verifies that +// explicitly passing overwrite=false also blocks overwriting. +func TestFilesystemTool_WriteFile_OverwriteFalseExplicitBlocked(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "existing.txt") + os.WriteFile(testFile, []byte("original"), 0o644) + + tool := NewWriteFileTool("", false) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "content": "new content", + "overwrite": false, + }) + + assert.True(t, result.IsError, "expected error when overwrite=false") + assert.Contains(t, result.ForLLM, "already exists") + + data, err := os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, "original", string(data)) +} + +// TestFilesystemTool_WriteFile_OverwriteSandboxed verifies the overwrite guard +// works correctly in restricted (sandbox) mode. +func TestFilesystemTool_WriteFile_OverwriteSandboxed(t *testing.T) { + workspace := t.TempDir() + testFile := "file.txt" + os.WriteFile(filepath.Join(workspace, testFile), []byte("original"), 0o644) + + tool := NewWriteFileTool(workspace, true) + + // Without overwrite=true → blocked + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "content": "new content", + }) + assert.True(t, result.IsError, "expected error in sandbox mode without overwrite=true") + assert.Contains(t, result.ForLLM, "already exists") + + // With overwrite=true → allowed + result = tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "content": "replaced in sandbox", + "overwrite": true, + }) + assert.False( + t, + result.IsError, + "expected success in sandbox mode with overwrite=true, got: %s", + result.ForLLM, + ) + + data, err := os.ReadFile(filepath.Join(workspace, testFile)) + assert.NoError(t, err) + assert.Equal(t, "replaced in sandbox", string(data)) +} + +// TestFilesystemTool_ListDir_Success verifies successful directory listing +func TestFilesystemTool_ListDir_Success(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0o644) + os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644) + os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755) + + tool := NewListDirTool("", false) + ctx := context.Background() + args := map[string]any{ + "path": tmpDir, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // Should list files and directories + if !strings.Contains(result.ForLLM, "file1.txt") || + !strings.Contains(result.ForLLM, "file2.txt") { + t.Errorf("Expected files in listing, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "subdir") { + t.Errorf("Expected subdir in listing, got: %s", result.ForLLM) + } +} + +// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory +func TestFilesystemTool_ListDir_NotFound(t *testing.T) { + tool := NewListDirTool("", false) + ctx := context.Background() + args := map[string]any{ + "path": "/nonexistent_directory_12345", + } + + result := tool.Execute(ctx, args) + + // Failure should be marked as error + if !result.IsError { + t.Errorf("Expected error for non-existent directory, got IsError=false") + } + + // Should contain error message + if !strings.Contains(result.ForLLM, "failed to read") && + !strings.Contains(result.ForUser, "failed to read") { + t.Errorf( + "Expected error message, got ForLLM: %s, ForUser: %s", + result.ForLLM, + result.ForUser, + ) + } +} + +// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory +func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) { + tool := NewListDirTool("", false) + ctx := context.Background() + args := map[string]any{} + + result := tool.Execute(ctx, args) + + // Should use "." as default path + if result.IsError { + t.Errorf("Expected success with default path '.', got IsError=true: %s", result.ForLLM) + } +} + +// Block paths that look inside workspace but point outside via symlink. +func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { + root := t.TempDir() + workspace := filepath.Join(root, "workspace") + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatalf("failed to create workspace: %v", err) + } + + secret := filepath.Join(root, "secret.txt") + if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil { + t.Fatalf("failed to write secret file: %v", err) + } + + link := filepath.Join(workspace, "leak.txt") + if err := os.Symlink(secret, link); err != nil { + t.Skipf("symlink not supported in this environment: %v", err) + } + + tool := NewReadFileTool(workspace, true, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": link, + }) + + if !result.IsError { + t.Fatalf("expected symlink escape to be blocked") + } + // os.Root might return different errors depending on platform/implementation + // but it definitely should error. + // Our wrapper returns "access denied or file not found" + if !strings.Contains(result.ForLLM, "access denied") && + !strings.Contains(result.ForLLM, "file not found") && + !strings.Contains(result.ForLLM, "no such file") { + t.Fatalf("expected symlink escape error, got: %s", result.ForLLM) + } +} + +func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { + tool := NewReadFileTool("", true, MaxReadFileSize) // restrict=true but workspace="" + + // Try to read a sensitive file (simulated by a temp file outside workspace) + tmpDir := t.TempDir() + secretFile := filepath.Join(tmpDir, "shadow") + os.WriteFile(secretFile, []byte("secret data"), 0o600) + + result := tool.Execute(context.Background(), map[string]any{ + "path": secretFile, + }) + + // We EXPECT IsError=true (access blocked due to empty workspace) + assert.True( + t, + result.IsError, + "Security Regression: Empty workspace allowed access! content: %s", + result.ForLLM, + ) + + // Verify it failed for the right reason + assert.Contains( + t, + result.ForLLM, + "workspace is not defined", + "Expected 'workspace is not defined' error", + ) +} + +// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases: +// single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path. +func TestRootMkdirAll(t *testing.T) { + workspace := t.TempDir() + root, err := os.OpenRoot(workspace) + if err != nil { + t.Fatalf("failed to open root: %v", err) + } + defer root.Close() + + // Case 1: Single directory + err = root.MkdirAll("dir1", 0o755) + assert.NoError(t, err) + _, err = os.Stat(filepath.Join(workspace, "dir1")) + assert.NoError(t, err) + + // Case 2: Deeply nested directory + err = root.MkdirAll("a/b/c/d", 0o755) + assert.NoError(t, err) + _, err = os.Stat(filepath.Join(workspace, "a/b/c/d")) + assert.NoError(t, err) + + // Case 3: Already exists — must be idempotent + err = root.MkdirAll("a/b/c/d", 0o755) + assert.NoError(t, err) + + // Case 4: A regular file blocks directory creation — must error + err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0o644) + assert.NoError(t, err) + err = root.MkdirAll("file_exists", 0o755) + assert.Error(t, err, "expected error when a file exists at the directory path") +} + +func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) { + workspace := t.TempDir() + tool := NewWriteFileTool(workspace, true) + ctx := context.Background() + + testFile := "deep/nested/path/to/file.txt" + content := "deep content" + args := map[string]any{ + "path": testFile, + "content": content, + } + + result := tool.Execute(ctx, args) + assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM) + + // Verify file content + actualPath := filepath.Join(workspace, testFile) + data, err := os.ReadFile(actualPath) + assert.NoError(t, err) + assert.Equal(t, content, string(data)) +} + +// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors. +func TestHostRW_Read_PermissionDenied(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("skipping permission test: running as root") + } + tmpDir := t.TempDir() + protected := filepath.Join(tmpDir, "protected.txt") + err := os.WriteFile(protected, []byte("secret"), 0o000) + assert.NoError(t, err) + defer os.Chmod(protected, 0o644) // ensure cleanup + + _, err = (&hostFs{}).ReadFile(protected) + assert.Error(t, err) + assert.Contains(t, err.Error(), "access denied") +} + +// TestHostRW_Read_Directory verifies that hostRW.Read returns an error when given a directory path. +func TestHostRW_Read_Directory(t *testing.T) { + tmpDir := t.TempDir() + + _, err := (&hostFs{}).ReadFile(tmpDir) + assert.Error(t, err, "expected error when reading a directory as a file") +} + +// TestRootRW_Read_Directory verifies that rootRW.Read returns an error when given a directory. +func TestRootRW_Read_Directory(t *testing.T) { + workspace := t.TempDir() + root, err := os.OpenRoot(workspace) + assert.NoError(t, err) + defer root.Close() + + // Create a subdirectory + err = root.Mkdir("subdir", 0o755) + assert.NoError(t, err) + + _, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir") + assert.Error(t, err, "expected error when reading a directory as a file") +} + +// TestHostRW_Write_ParentDirMissing verifies that hostRW.Write creates parent dirs automatically. +func TestHostRW_Write_ParentDirMissing(t *testing.T) { + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "a", "b", "c", "file.txt") + + err := (&hostFs{}).WriteFile(target, []byte("hello")) + assert.NoError(t, err) + + data, err := os.ReadFile(target) + assert.NoError(t, err) + assert.Equal(t, "hello", string(data)) +} + +// TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates +// nested parent directories automatically within the sandbox. +func TestRootRW_Write_ParentDirMissing(t *testing.T) { + workspace := t.TempDir() + + relPath := "x/y/z/file.txt" + err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested")) + assert.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(workspace, relPath)) + assert.NoError(t, err) + assert.Equal(t, "nested", string(data)) +} + +// TestHostRW_Write verifies the hostRW.Write helper function +func TestHostRW_Write(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "atomic_test.txt") + testData := []byte("atomic test content") + + err := (&hostFs{}).WriteFile(testFile, testData) + assert.NoError(t, err) + + content, err := os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, testData, content) + + // Verify it overwrites correctly + newData := []byte("new atomic content") + err = (&hostFs{}).WriteFile(testFile, newData) + assert.NoError(t, err) + + content, err = os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, newData, content) +} + +// TestRootRW_Write verifies the rootRW.Write helper function +func TestRootRW_Write(t *testing.T) { + tmpDir := t.TempDir() + + relPath := "atomic_root_test.txt" + testData := []byte("atomic root test content") + + erw := &sandboxFs{workspace: tmpDir} + err := erw.WriteFile(relPath, testData) + assert.NoError(t, err) + + root, err := os.OpenRoot(tmpDir) + assert.NoError(t, err) + defer root.Close() + + f, err := root.Open(relPath) + assert.NoError(t, err) + defer f.Close() + + content, err := io.ReadAll(f) + assert.NoError(t, err) + assert.Equal(t, testData, content) + + // Verify it overwrites correctly + newData := []byte("new root atomic content") + err = erw.WriteFile(relPath, newData) + assert.NoError(t, err) + + f2, err := root.Open(relPath) + assert.NoError(t, err) + defer f2.Close() + + content, err = io.ReadAll(f2) + assert.NoError(t, err) + assert.Equal(t, newData, content) +} + +// TestWhitelistFs_AllowsMatchingPaths verifies that whitelistFs allows access to +// paths matching the whitelist patterns while blocking non-matching paths. +func TestWhitelistFs_AllowsMatchingPaths(t *testing.T) { + workspace := t.TempDir() + outsideDir := t.TempDir() + outsideFile := filepath.Join(outsideDir, "allowed.txt") + os.WriteFile(outsideFile, []byte("outside content"), 0o644) + + // Pattern allows access to the outsideDir. + patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(outsideDir))} + + tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns) + + // Read from whitelisted path should succeed. + result := tool.Execute(context.Background(), map[string]any{"path": outsideFile}) + if result.IsError { + t.Errorf("expected whitelisted path to be readable, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "outside content") { + t.Errorf("expected file content, got: %s", result.ForLLM) + } + + // Read from non-whitelisted path outside workspace should fail. + otherDir := t.TempDir() + otherFile := filepath.Join(otherDir, "blocked.txt") + os.WriteFile(otherFile, []byte("blocked"), 0o644) + + result = tool.Execute(context.Background(), map[string]any{"path": otherFile}) + if !result.IsError { + t.Errorf("expected non-whitelisted path to be blocked, got: %s", result.ForLLM) + } +} + +func TestWhitelistFs_BlocksSymlinkEscapeInAllowedDir(t *testing.T) { + workspace := t.TempDir() + allowedDir := t.TempDir() + secretDir := t.TempDir() + secretFile := filepath.Join(secretDir, "secret.txt") + if err := os.WriteFile(secretFile, []byte("top secret"), 0o644); err != nil { + t.Fatalf("WriteFile(secretFile) error = %v", err) + } + + linkPath := filepath.Join(allowedDir, "link_out") + if err := os.Symlink(secretDir, linkPath); err != nil { + t.Skipf("symlink not supported in this environment: %v", err) + } + + patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))} + tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns) + + result := tool.Execute( + context.Background(), + map[string]any{"path": filepath.Join(linkPath, "secret.txt")}, + ) + if !result.IsError { + t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM) + } +} + +func TestWhitelistFs_WriteAllowsNewFileUnderAllowedDir(t *testing.T) { + workspace := t.TempDir() + rootDir := t.TempDir() + allowedDir := filepath.Join(rootDir, "allowed") + targetFile := filepath.Join(allowedDir, "nested", "file.txt") + + patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))} + tool := NewWriteFileTool(workspace, true, patterns) + + result := tool.Execute(context.Background(), map[string]any{ + "path": targetFile, + "content": "outside write", + }) + if result.IsError { + t.Fatalf("expected whitelisted write to succeed, got: %s", result.ForLLM) + } + + data, err := os.ReadFile(targetFile) + if err != nil { + t.Fatalf("ReadFile(targetFile) error = %v", err) + } + if string(data) != "outside write" { + t.Fatalf("target file content = %q, want %q", string(data), "outside write") + } +} + +func TestWhitelistFs_AllowsResolvedAllowedRootAlias(t *testing.T) { + workspace := t.TempDir() + realDir := t.TempDir() + linkParent := t.TempDir() + allowedAlias := filepath.Join(linkParent, "allowed-link") + + if err := os.Symlink(realDir, allowedAlias); err != nil { + t.Skipf("symlink not supported in this environment: %v", err) + } + + targetFile := filepath.Join(allowedAlias, "nested", "alias.txt") + if err := os.MkdirAll(filepath.Dir(targetFile), 0o755); err != nil { + t.Fatalf("MkdirAll(targetFile dir) error = %v", err) + } + if err := os.WriteFile(targetFile, []byte("through alias"), 0o644); err != nil { + t.Fatalf("WriteFile(targetFile) error = %v", err) + } + + patterns := []*regexp.Regexp{ + regexp.MustCompile( + "^" + regexp.QuoteMeta(filepath.Clean(allowedAlias)) + + "(?:" + regexp.QuoteMeta(string(os.PathSeparator)) + "|$)", + ), + } + tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns) + + result := tool.Execute(context.Background(), map[string]any{"path": targetFile}) + if result.IsError { + t.Fatalf("expected symlink-backed allowed root to be readable, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "through alias") { + t.Fatalf("expected file content, got: %s", result.ForLLM) + } +} + +// TestReadFileTool_ChunkedReading verifies the pagination logic of the tool +// by reading a file in multiple chunks using 'offset' and 'length'. +func TestReadFileTool_ChunkedReading(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "pagination_test.txt") + + fullContent := "abcdefghijklmnopqrstuvwxyz" + err := os.WriteFile(testFile, []byte(fullContent), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileTool(tmpDir, false, MaxReadFileSize) + ctx := context.Background() + + // --- Step 1: Read the first chunk (10 bytes) --- + args1 := map[string]any{ + "path": testFile, + "offset": 0, + "length": 10, + } + result1 := tool.Execute(ctx, args1) + + if result1.IsError { + t.Fatalf("Chunk 1 failed: %s", result1.ForLLM) + } + + if !strings.Contains(result1.ForLLM, "abcdefghij") { + t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM) + } + if !strings.Contains(result1.ForLLM, "[TRUNCATED") { + t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM) + } + if !strings.Contains(result1.ForLLM, "offset=10") { + t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM) + } + + // Step 2: Read the second chunk (10 bytes) --- + args2 := map[string]any{ + "path": testFile, + "offset": 10, + "length": 10, + } + result2 := tool.Execute(ctx, args2) + + if result2.IsError { + t.Fatalf("Chunk 2 failed: %s", result2.ForLLM) + } + + if !strings.Contains(result2.ForLLM, "klmnopqrst") { + t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM) + } + if !strings.Contains(result2.ForLLM, "offset=20") { + t.Errorf("Chunk 2 header should suggest next offset=20, got: %s", result2.ForLLM) + } + + // Step 3: Read the final chunk (remaining 6 bytes) --- + args3 := map[string]any{ + "path": testFile, + "offset": 20, + "length": 10, + } + result3 := tool.Execute(ctx, args3) + + if result3.IsError { + t.Fatalf("Chunk 3 failed: %s", result3.ForLLM) + } + + if !strings.Contains(result3.ForLLM, "uvwxyz") { + t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM) + } + if !strings.Contains(result3.ForLLM, "[END OF FILE") { + t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM) + } + if strings.Contains(result3.ForLLM, "[TRUNCATED") { + t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM) + } +} + +// TestReadFileTool_OffsetBeyondEOF checks the behavior when requesting +// An offset that exceeds the total file size. +func TestReadFileTool_OffsetBeyondEOF(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "short.txt") + + err := os.WriteFile(testFile, []byte("12345"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileTool(tmpDir, false, MaxReadFileSize) + ctx := context.Background() + + args := map[string]any{ + "path": testFile, + "offset": int64(100), + } + + result := tool.Execute(ctx, args) + + if result.IsError { + t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM) + } + + expectedMsg := "[END OF FILE - no content at this offset]" + if result.ForLLM != expectedMsg { + t.Errorf("The message %q was expected, obtained: %q", expectedMsg, result.ForLLM) + } +} + +func TestReadFileLinesTool_ChunkedReading(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "pagination_lines.txt") + + fullContent := strings.Join([]string{ + "line 1", + "line 2", + "line 3", + "line 4", + "line 5", + "line 6", + }, "\n") + "\n" + err := os.WriteFile(testFile, []byte(fullContent), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + + result1 := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + "max_lines": 2, + }) + if result1.IsError { + t.Fatalf("Chunk 1 failed: %s", result1.ForLLM) + } + if !strings.Contains(result1.ForLLM, "1|line 1\n2|line 2\n") { + t.Fatalf("expected first two lines, got: %s", result1.ForLLM) + } + if !strings.Contains(result1.ForLLM, "lines 1-2") { + t.Fatalf("expected line range 1-2, got: %s", result1.ForLLM) + } + if !strings.Contains(result1.ForLLM, "start_line=3") { + t.Fatalf("expected continuation start_line=3, got: %s", result1.ForLLM) + } + if !strings.Contains(result1.ForLLM, "max_lines=2") { + t.Fatalf("expected continuation max_lines=2, got: %s", result1.ForLLM) + } + + result2 := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 3, + "max_lines": 2, + }) + if result2.IsError { + t.Fatalf("Chunk 2 failed: %s", result2.ForLLM) + } + if !strings.Contains(result2.ForLLM, "3|line 3\n4|line 4\n") { + t.Fatalf("expected middle chunk, got: %s", result2.ForLLM) + } + if !strings.Contains(result2.ForLLM, "start_line=5") { + t.Fatalf("expected continuation start_line=5, got: %s", result2.ForLLM) + } + if !strings.Contains(result2.ForLLM, "max_lines=2") { + t.Fatalf("expected continuation max_lines=2, got: %s", result2.ForLLM) + } + + result3 := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 5, + "max_lines": 2, + }) + if result3.IsError { + t.Fatalf("Chunk 3 failed: %s", result3.ForLLM) + } + if !strings.Contains(result3.ForLLM, "5|line 5\n6|line 6\n") { + t.Fatalf("expected final chunk, got: %s", result3.ForLLM) + } + if !strings.Contains(result3.ForLLM, "[END OF FILE") { + t.Fatalf("expected EOF marker, got: %s", result3.ForLLM) + } +} + +func TestReadFileLinesTool_DefaultOffsetAndRemainingLines(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "default_lines.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|line 1\n2|line 2\n3|line 3\n") { + t.Fatalf("expected remaining lines by default, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "lines 1-3") { + t.Fatalf("expected line range 1-3, got: %s", result.ForLLM) + } +} + +func TestReadFileTool_LegacyLengthUsesByteModeForText(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "legacy_bytes.txt") + + err := os.WriteFile(testFile, []byte("abcdefghijklmnopqrstuvwxyz"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileBytesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "offset": 10, + "length": 5, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "read: bytes 10-14") { + t.Fatalf("expected byte-based header, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "klmno") { + t.Fatalf("expected byte chunk content, got: %s", result.ForLLM) + } + if strings.Contains(result.ForLLM, "lines ") { + t.Fatalf("expected legacy byte mode, got line-based header: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_OffsetBeyondEOF(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "short_lines.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": int64(100), + }) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if result.ForLLM != "[END OF FILE - no content at or after start_line=100]" { + t.Fatalf("unexpected EOF message: %q", result.ForLLM) + } +} + +func TestReadFileLinesTool_RejectsOffset(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "legacy_offset.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + "offset": 1, + }) + if !result.IsError { + t.Fatalf("expected offset to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "offset is not supported in line mode; use start_line") { + t.Fatalf("unexpected error for offset in line mode: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_RejectsLength(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "legacy_length.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + "length": 1, + }) + if !result.IsError { + t.Fatalf("expected length to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "length is not supported in line mode; use max_lines") { + t.Fatalf("unexpected error for length in line mode: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_RejectsLimit(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "legacy_limit.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + "limit": 1, + }) + if !result.IsError { + t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "limit is not supported in line mode; use max_lines") { + t.Fatalf("unexpected error for limit in line mode: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_BinaryFileRejected(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "binary.dat") + + data := []byte{0x00, 0x01, 'A', 'B', 'C', 'D', 'E', 'F'} + err := os.WriteFile(testFile, data, 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if !result.IsError { + t.Fatalf("expected binary file rejection in line mode, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "switch read_file mode to 'bytes'") { + t.Fatalf("expected binary file rejection message, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "mode to 'bytes'") { + t.Fatalf("expected suggestion to switch read_file mode, got: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_TruncatesSingleLongLineAtByteBudget(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "long_line.txt") + + content := "first line\n" + strings.Repeat("x", 70*1024) + "\n" + err := os.WriteFile(testFile, []byte(content), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "was cut mid-line") { + t.Fatalf("expected explicit mid-line truncation warning, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|first line\n") { + t.Fatalf("expected the first line with line prefix, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "2|") { + t.Fatalf("expected line prefix for the truncated line, got: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_NoTrailingNewline(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "no_trailing_newline.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|line 1\n2|line 2") { + t.Fatalf( + "expected final line without trailing newline to be preserved, got: %s", + result.ForLLM, + ) + } + if !strings.Contains(result.ForLLM, "[END OF FILE - no further content.]") { + t.Fatalf("expected EOF marker, got: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_ExactByteBudgetBoundaryIncludesPrefix(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "exact_boundary.txt") + + err := os.WriteFile(testFile, []byte("1234567\nsecond line\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, 10) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|1234567\n") { + t.Fatalf( + "expected first line to fit exactly in the byte budget with its prefix, got: %s", + result.ForLLM, + ) + } + if strings.Contains(result.ForLLM, "2|") { + t.Fatalf( + "expected second line to be excluded once the exact output byte budget was reached, got: %s", + result.ForLLM, + ) + } + if !strings.Contains(result.ForLLM, "file_bytes: 8 | output_bytes: 10") { + t.Fatalf("expected separate file/output byte counters, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "start_line=2") { + t.Fatalf("expected continuation at line 2, got: %s", result.ForLLM) + } +} diff --git a/pkg/tools/fs/load_image.go b/pkg/tools/fs/load_image.go new file mode 100644 index 000000000..0a67fa120 --- /dev/null +++ b/pkg/tools/fs/load_image.go @@ -0,0 +1,163 @@ +package fstools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" +) + +// LoadImageTool loads a local image file into the MediaStore and returns a +// media:// reference. The agent loop's resolveMediaRefs will then base64-encode +// it and attach it as an image_url part in the next LLM request, enabling +// vision on local files — the same pipeline used when a user sends an image +// through a chat channel. +// +// This is intentionally different from SendFileTool: +// - SendFileTool → MediaResult + WithResponseHandled() → sends file to user, ends turn +// - LoadImageTool → plain ToolResult with media:// in ForLLM → LLM sees the image next turn +type LoadImageTool struct { + workspace string + restrict bool + maxFileSize int + mediaStore media.MediaStore + allowPaths []*regexp.Regexp + + defaultChannel string + defaultChatID string +} + +func NewLoadImageTool( + workspace string, + restrict bool, + maxFileSize int, + store media.MediaStore, + allowPaths ...[]*regexp.Regexp, +) *LoadImageTool { + if maxFileSize <= 0 { + maxFileSize = config.DefaultMaxMediaSize + } + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } + return &LoadImageTool{ + workspace: workspace, + restrict: restrict, + maxFileSize: maxFileSize, + mediaStore: store, + allowPaths: patterns, + } +} + +func (t *LoadImageTool) Name() string { return "load_image" } + +func (t *LoadImageTool) Description() string { + return "Load a local image file so you can analyze its contents with vision. " + + "Supported formats: JPEG, PNG, GIF, WebP, BMP. " + + "After calling this tool, describe or analyze the image in your next response." +} + +func (t *LoadImageTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to the local image file. Relative paths are resolved from workspace.", + }, + }, + "required": []string{"path"}, + } +} + +func (t *LoadImageTool) SetContext(channel, chatID string) { + t.defaultChannel = channel + t.defaultChatID = chatID +} + +func (t *LoadImageTool) SetMediaStore(store media.MediaStore) { + t.mediaStore = store +} + +func (t *LoadImageTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, _ := args["path"].(string) + if strings.TrimSpace(path) == "" { + return ErrorResult("path is required") + } + + // Prefer context-injected channel/chatID (set by ExecuteWithContext), fall back to SetContext values. + channel := ToolChannel(ctx) + if channel == "" { + channel = t.defaultChannel + } + chatID := ToolChatID(ctx) + if chatID == "" { + chatID = t.defaultChatID + } + if channel == "" || chatID == "" { + return ErrorResult("no target channel/chat available") + } + + if t.mediaStore == nil { + return ErrorResult("media store not configured") + } + + resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid path: %v", err)) + } + + info, err := os.Stat(resolved) + if err != nil { + return ErrorResult(fmt.Sprintf("file not found: %v", err)) + } + if info.IsDir() { + return ErrorResult("path is a directory, expected an image file") + } + if info.Size() > int64(t.maxFileSize) { + return ErrorResult(fmt.Sprintf( + "file too large: %d bytes (max %d bytes)", info.Size(), t.maxFileSize, + )) + } + + // Detect MIME type — reuse the helper already in send_file.go + mediaType := detectMediaType(resolved) + if !strings.HasPrefix(mediaType, "image/") { + return ErrorResult(fmt.Sprintf( + "file does not appear to be an image (detected type: %s)", mediaType, + )) + } + + filename := filepath.Base(resolved) + scope := fmt.Sprintf("tool:load_image:%s:%s", channel, chatID) + + ref, err := t.mediaStore.Store(resolved, media.MediaMeta{ + Filename: filename, + ContentType: mediaType, + Source: "tool:load_image", + CleanupPolicy: media.CleanupPolicyForgetOnly, + }, scope) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to register image in media store: %v", err)) + } + + // Build the tool result text. The media:// ref in Media will be picked + // up by resolveMediaRefs in agent_media.go and base64-encoded for tool + // result messages (role="tool"), so the LLM can see the image content. + msg := fmt.Sprintf("Image loaded: %s\n[image: photo]", filename) + + return &ToolResult{ + ForLLM: msg, + ForUser: fmt.Sprintf("Loaded image: %s", filename), + // Media refs inside ForLLM are resolved by resolveMediaRefs in the + // agent loop before the next LLM call. Do NOT use MediaResult here — + // that would send the file to the user channel instead. + Media: []string{ref}, + } +} diff --git a/pkg/tools/fs/load_image_test.go b/pkg/tools/fs/load_image_test.go new file mode 100644 index 000000000..d33db73be --- /dev/null +++ b/pkg/tools/fs/load_image_test.go @@ -0,0 +1,152 @@ +package fstools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" +) + +func TestLoadImage_PathRequired(t *testing.T) { + tool := NewLoadImageTool("/tmp", false, 0, nil) + ctx := WithToolContext(context.Background(), "test", "chat1") + result := tool.Execute(ctx, map[string]any{}) + if !result.IsError { + t.Fatal("expected error for missing path") + } +} + +func TestLoadImage_NilMediaStore(t *testing.T) { + tool := NewLoadImageTool("/tmp", false, 0, nil) + ctx := WithToolContext(context.Background(), "test", "chat1") + result := tool.Execute(ctx, map[string]any{"path": "test.png"}) + if !result.IsError || result.ForLLM != "media store not configured" { + t.Fatalf("expected media store error, got: %s", result.ForLLM) + } +} + +func TestLoadImage_NoChannelContext(t *testing.T) { + store := media.NewFileMediaStore() + tool := NewLoadImageTool("/tmp", false, 0, store) + // No WithToolContext — should fail + result := tool.Execute(context.Background(), map[string]any{"path": "test.png"}) + if !result.IsError || result.ForLLM != "no target channel/chat available" { + t.Fatalf("expected channel error, got: %s", result.ForLLM) + } +} + +func TestLoadImage_NonImageFile(t *testing.T) { + dir := t.TempDir() + txtFile := filepath.Join(dir, "readme.txt") + os.WriteFile(txtFile, []byte("hello"), 0o644) + + store := media.NewFileMediaStore() + tool := NewLoadImageTool(dir, false, 0, store) + ctx := WithToolContext(context.Background(), "test", "chat1") + result := tool.Execute(ctx, map[string]any{"path": txtFile}) + if !result.IsError { + t.Fatal("expected error for non-image file") + } +} + +func TestLoadImage_DefaultMaxSize(t *testing.T) { + tool := NewLoadImageTool("/tmp", false, 0, nil) + if tool.maxFileSize != config.DefaultMaxMediaSize { + t.Errorf("expected default max size %d, got %d", config.DefaultMaxMediaSize, tool.maxFileSize) + } +} + +func TestLoadImage_FileTooLarge(t *testing.T) { + dir := t.TempDir() + bigFile := filepath.Join(dir, "big.png") + // Create a file with PNG header but exceeding max size + data := make([]byte, 1024) + copy(data, []byte{0x89, 0x50, 0x4E, 0x47}) // PNG magic bytes + os.WriteFile(bigFile, data, 0o644) + + store := media.NewFileMediaStore() + tool := NewLoadImageTool(dir, false, 512, store) // maxSize = 512 + ctx := WithToolContext(context.Background(), "test", "chat1") + result := tool.Execute(ctx, map[string]any{"path": bigFile}) + if !result.IsError { + t.Fatal("expected error for oversized file") + } +} + +func TestLoadImage_SuccessPath(t *testing.T) { + dir := t.TempDir() + + // Create a minimal valid PNG file (8-byte signature + minimal IHDR + IEND). + // The PNG spec requires the 8-byte magic header: 0x89 P N G \r \n 0x1a \n + pngSignature := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + // IHDR chunk: length(13) + "IHDR" + 1x1 px, 8-bit RGB, no interlace + CRC + ihdr := []byte{ + 0x00, 0x00, 0x00, 0x0D, // chunk length = 13 + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x01, // width = 1 + 0x00, 0x00, 0x00, 0x01, // height = 1 + 0x08, // bit depth = 8 + 0x02, // color type = RGB + 0x00, 0x00, 0x00, // compression, filter, interlace + 0x90, 0x77, 0x53, 0xDE, // CRC (valid for this IHDR) + } + // IEND chunk + iend := []byte{ + 0x00, 0x00, 0x00, 0x00, // chunk length = 0 + 0x49, 0x45, 0x4E, 0x44, // "IEND" + 0xAE, 0x42, 0x60, 0x82, // CRC + } + + pngData := make([]byte, 0, len(pngSignature)+len(ihdr)+len(iend)) + pngData = append(pngData, pngSignature...) + pngData = append(pngData, ihdr...) + pngData = append(pngData, iend...) + + imgPath := filepath.Join(dir, "test_image.png") + if err := os.WriteFile(imgPath, pngData, 0o644); err != nil { + t.Fatalf("failed to create test PNG: %v", err) + } + + store := media.NewFileMediaStore() + tool := NewLoadImageTool(dir, false, 0, store) + ctx := WithToolContext(context.Background(), "test", "chat1") + + result := tool.Execute(ctx, map[string]any{"path": imgPath}) + + // 1. Must not be an error + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + + // 2. Media must contain exactly one media:// ref + if len(result.Media) != 1 { + t.Fatalf("expected 1 media ref, got %d", len(result.Media)) + } + if !strings.HasPrefix(result.Media[0], "media://") { + t.Errorf("expected media ref to start with 'media://', got: %s", result.Media[0]) + } + + // 3. ForLLM must contain the [image: marker + if !strings.Contains(result.ForLLM, "[image:") { + t.Errorf("expected ForLLM to contain '[image:' marker, got: %s", result.ForLLM) + } + + // 4. ForLLM should contain the generic [image: photo] placeholder + // (resolveMediaRefs will replace it with the actual path later) + if !strings.Contains(result.ForLLM, "[image: photo]") { + t.Errorf("expected ForLLM to contain '[image: photo]' placeholder, got: %s", result.ForLLM) + } + + // 5. Verify the ref is resolvable in the store + resolved, err := store.Resolve(result.Media[0]) + if err != nil { + t.Fatalf("media ref not resolvable: %v", err) + } + if resolved != imgPath { + t.Errorf("expected resolved path %q, got %q", imgPath, resolved) + } +} diff --git a/pkg/tools/send_file.go b/pkg/tools/fs/send_file.go similarity index 85% rename from pkg/tools/send_file.go rename to pkg/tools/fs/send_file.go index 1a03e58ed..e4f90bf61 100644 --- a/pkg/tools/send_file.go +++ b/pkg/tools/fs/send_file.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" @@ -6,6 +6,7 @@ import ( "mime" "os" "path/filepath" + "regexp" "strings" "github.com/h2non/filetype" @@ -21,20 +22,32 @@ type SendFileTool struct { restrict bool maxFileSize int mediaStore media.MediaStore + allowPaths []*regexp.Regexp defaultChannel string defaultChatID string } -func NewSendFileTool(workspace string, restrict bool, maxFileSize int, store media.MediaStore) *SendFileTool { +func NewSendFileTool( + workspace string, + restrict bool, + maxFileSize int, + store media.MediaStore, + allowPaths ...[]*regexp.Regexp, +) *SendFileTool { if maxFileSize <= 0 { maxFileSize = config.DefaultMaxMediaSize } + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } return &SendFileTool{ workspace: workspace, restrict: restrict, maxFileSize: maxFileSize, mediaStore: store, + allowPaths: patterns, } } @@ -92,7 +105,7 @@ func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult("media store not configured") } - resolved, err := validatePath(path, t.workspace, t.restrict) + resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths) if err != nil { return ErrorResult(fmt.Sprintf("invalid path: %v", err)) } @@ -120,15 +133,16 @@ func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe scope := fmt.Sprintf("tool:send_file:%s:%s", channel, chatID) ref, err := t.mediaStore.Store(resolved, media.MediaMeta{ - Filename: filename, - ContentType: mediaType, - Source: "tool:send_file", + Filename: filename, + ContentType: mediaType, + Source: "tool:send_file", + CleanupPolicy: media.CleanupPolicyForgetOnly, }, scope) if err != nil { return ErrorResult(fmt.Sprintf("failed to register media: %v", err)) } - return MediaResult(fmt.Sprintf("File %q sent to user", filename), []string{ref}) + return MediaResult(fmt.Sprintf("File %q sent to user", filename), []string{ref}).WithResponseHandled() } // detectMediaType determines the MIME type of a file. diff --git a/pkg/tools/send_file_test.go b/pkg/tools/fs/send_file_test.go similarity index 75% rename from pkg/tools/send_file_test.go rename to pkg/tools/fs/send_file_test.go index 08d129674..771393b75 100644 --- a/pkg/tools/send_file_test.go +++ b/pkg/tools/fs/send_file_test.go @@ -1,9 +1,10 @@ -package tools +package fstools import ( "context" "os" "path/filepath" + "regexp" "strings" "testing" @@ -103,6 +104,17 @@ func TestSendFileTool_Success(t *testing.T) { if result.Media[0][:8] != "media://" { t.Errorf("expected media:// ref, got %q", result.Media[0]) } + if !result.ResponseHandled { + t.Fatal("expected send_file success to mark response handled") + } + + _, meta, err := store.ResolveWithMeta(result.Media[0]) + if err != nil { + t.Fatalf("ResolveWithMeta failed: %v", err) + } + if meta.CleanupPolicy != media.CleanupPolicyForgetOnly { + t.Errorf("CleanupPolicy = %q, want %q", meta.CleanupPolicy, media.CleanupPolicyForgetOnly) + } } func TestSendFileTool_CustomFilename(t *testing.T) { @@ -128,6 +140,44 @@ func TestSendFileTool_CustomFilename(t *testing.T) { } } +func TestSendFileTool_AllowsWhitelistedMediaTempPath(t *testing.T) { + workspace := t.TempDir() + mediaDir := media.TempDir() + if err := os.MkdirAll(mediaDir, 0o700); err != nil { + t.Fatalf("MkdirAll(mediaDir) error = %v", err) + } + + testFile, err := os.CreateTemp(mediaDir, "send-file-*.txt") + if err != nil { + t.Fatalf("CreateTemp(mediaDir) error = %v", err) + } + testPath := testFile.Name() + if _, err := testFile.WriteString("forward me"); err != nil { + testFile.Close() + t.Fatalf("WriteString(testFile) error = %v", err) + } + if err := testFile.Close(); err != nil { + t.Fatalf("Close(testFile) error = %v", err) + } + t.Cleanup(func() { _ = os.Remove(testPath) }) + + pattern := regexp.MustCompile( + "^" + regexp.QuoteMeta(filepath.Clean(mediaDir)) + "(?:" + regexp.QuoteMeta(string(os.PathSeparator)) + "|$)", + ) + + store := media.NewFileMediaStore() + tool := NewSendFileTool(workspace, true, 0, store, []*regexp.Regexp{pattern}) + tool.SetContext("feishu", "chat123") + + result := tool.Execute(context.Background(), map[string]any{"path": testPath}) + if result.IsError { + t.Fatalf("expected whitelisted temp media file to be sendable, got: %s", result.ForLLM) + } + if len(result.Media) != 1 { + t.Fatalf("expected 1 media ref, got %d", len(result.Media)) + } +} + func TestDetectMediaType_MagicBytes(t *testing.T) { dir := t.TempDir() diff --git a/pkg/tools/fs/shared.go b/pkg/tools/fs/shared.go new file mode 100644 index 000000000..6d46e692b --- /dev/null +++ b/pkg/tools/fs/shared.go @@ -0,0 +1,37 @@ +package fstools + +import ( + "context" + + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +type ToolResult = toolshared.ToolResult + +func WithToolContext(ctx context.Context, channel, chatID string) context.Context { + return toolshared.WithToolContext(ctx, channel, chatID) +} + +func ToolChannel(ctx context.Context) string { + return toolshared.ToolChannel(ctx) +} + +func ToolChatID(ctx context.Context) string { + return toolshared.ToolChatID(ctx) +} + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func NewToolResult(forLLM string) *ToolResult { + return toolshared.NewToolResult(forLLM) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} + +func MediaResult(forLLM string, mediaRefs []string) *ToolResult { + return toolshared.MediaResult(forLLM, mediaRefs) +} diff --git a/pkg/tools/fs_facade.go b/pkg/tools/fs_facade.go new file mode 100644 index 000000000..5ed68f04c --- /dev/null +++ b/pkg/tools/fs_facade.go @@ -0,0 +1,100 @@ +package tools + +import ( + "regexp" + + "github.com/sipeed/picoclaw/pkg/media" + fstools "github.com/sipeed/picoclaw/pkg/tools/fs" +) + +type ( + ReadFileTool = fstools.ReadFileTool + ReadFileLinesTool = fstools.ReadFileLinesTool + WriteFileTool = fstools.WriteFileTool + ListDirTool = fstools.ListDirTool + EditFileTool = fstools.EditFileTool + AppendFileTool = fstools.AppendFileTool + LoadImageTool = fstools.LoadImageTool + SendFileTool = fstools.SendFileTool +) + +const MaxReadFileSize = fstools.MaxReadFileSize + +func NewReadFileTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileTool { + return fstools.NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewReadFileBytesTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileTool { + return fstools.NewReadFileBytesTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewReadFileLinesTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileLinesTool { + return fstools.NewReadFileLinesTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewWriteFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *WriteFileTool { + return fstools.NewWriteFileTool(workspace, restrict, allowPaths...) +} + +func NewListDirTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *ListDirTool { + return fstools.NewListDirTool(workspace, restrict, allowPaths...) +} + +func NewEditFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *EditFileTool { + return fstools.NewEditFileTool(workspace, restrict, allowPaths...) +} + +func NewAppendFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *AppendFileTool { + return fstools.NewAppendFileTool(workspace, restrict, allowPaths...) +} + +func NewLoadImageTool( + workspace string, + restrict bool, + maxFileSize int, + store media.MediaStore, + allowPaths ...[]*regexp.Regexp, +) *LoadImageTool { + return fstools.NewLoadImageTool(workspace, restrict, maxFileSize, store, allowPaths...) +} + +func NewSendFileTool( + workspace string, + restrict bool, + maxFileSize int, + store media.MediaStore, + allowPaths ...[]*regexp.Regexp, +) *SendFileTool { + return fstools.NewSendFileTool(workspace, restrict, maxFileSize, store, allowPaths...) +} diff --git a/pkg/tools/fs_registry_compat_test.go b/pkg/tools/fs_registry_compat_test.go new file mode 100644 index 000000000..51e080217 --- /dev/null +++ b/pkg/tools/fs_registry_compat_test.go @@ -0,0 +1,46 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadFileLinesTool_RegistryValidationSupportsMaxLinesAndRejectsLimit(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "registry_lines.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + reg := NewToolRegistry() + reg.Register(NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)) + + result := reg.Execute(context.Background(), "read_file", map[string]any{ + "path": testFile, + "start_line": 1, + "max_lines": 1, + }) + if result.IsError { + t.Fatalf("expected max_lines to pass registry validation, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|line 1\n") { + t.Fatalf("expected first line via max_lines, got: %s", result.ForLLM) + } + + result = reg.Execute(context.Background(), "read_file", map[string]any{ + "path": testFile, + "start_line": 2, + "limit": 1, + }) + if !result.IsError { + t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "unexpected property \"limit\"") { + t.Fatalf("expected registry validation error for limit, got: %s", result.ForLLM) + } +} diff --git a/pkg/tools/i2c.go b/pkg/tools/hardware/i2c.go similarity index 97% rename from pkg/tools/i2c.go rename to pkg/tools/hardware/i2c.go index 779b1d5a7..62e9557ee 100644 --- a/pkg/tools/i2c.go +++ b/pkg/tools/hardware/i2c.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "context" @@ -120,16 +120,12 @@ func (t *I2CTool) detect() *ToolResult { // Helper functions for I2C operations (used by platform-specific implementations) // isValidBusID checks that a bus identifier is a simple number (prevents path injection) -// -//nolint:unused // Used by i2c_linux.go func isValidBusID(id string) bool { matched, _ := regexp.MatchString(`^\d+$`, id) return matched } // parseI2CAddress extracts and validates an I2C address from args -// -//nolint:unused // Used by i2c_linux.go func parseI2CAddress(args map[string]any) (int, *ToolResult) { addrFloat, ok := args["address"].(float64) if !ok { @@ -143,8 +139,6 @@ func parseI2CAddress(args map[string]any) (int, *ToolResult) { } // parseI2CBus extracts and validates an I2C bus from args -// -//nolint:unused // Used by i2c_linux.go func parseI2CBus(args map[string]any) (string, *ToolResult) { bus, ok := args["bus"].(string) if !ok || bus == "" { @@ -155,3 +149,9 @@ func parseI2CBus(args map[string]any) (string, *ToolResult) { } return bus, nil } + +var ( + _ = isValidBusID + _ = parseI2CAddress + _ = parseI2CBus +) diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/hardware/i2c_linux.go similarity index 99% rename from pkg/tools/i2c_linux.go rename to pkg/tools/hardware/i2c_linux.go index 4eaaf8f09..771d11d90 100644 --- a/pkg/tools/i2c_linux.go +++ b/pkg/tools/hardware/i2c_linux.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "encoding/json" diff --git a/pkg/tools/i2c_other.go b/pkg/tools/hardware/i2c_other.go similarity index 95% rename from pkg/tools/i2c_other.go rename to pkg/tools/hardware/i2c_other.go index 7becf8339..4a0a130e0 100644 --- a/pkg/tools/i2c_other.go +++ b/pkg/tools/hardware/i2c_other.go @@ -1,6 +1,6 @@ //go:build !linux -package tools +package hardwaretools // scan is a stub for non-Linux platforms. func (t *I2CTool) scan(args map[string]any) *ToolResult { diff --git a/pkg/tools/hardware/serial.go b/pkg/tools/hardware/serial.go new file mode 100644 index 000000000..7e197a909 --- /dev/null +++ b/pkg/tools/hardware/serial.go @@ -0,0 +1,453 @@ +package hardwaretools + +import ( + "context" + "encoding/json" + "fmt" + "math" + "regexp" + "runtime" + "strings" + "time" + "unicode/utf8" +) + +const ( + defaultSerialBaud = 115200 + defaultSerialDataBits = 8 + defaultSerialStopBits = 1 + defaultSerialTimeoutMS = 1000 + maxSerialPayloadBytes = 4096 + maxSerialReadBytes = 4096 + serialPollInterval = 100 * time.Millisecond +) + +var ( + unixSerialPortPattern = regexp.MustCompile( + `^(?:/dev/)?(?:ttyS\d+|ttyUSB\d+|ttyACM\d+|ttyAMA\d+|rfcomm\d+|tty\.[A-Za-z0-9._-]+|cu\.[A-Za-z0-9._-]+)$`, + ) + windowsSerialPortPattern = regexp.MustCompile(`^(?:\\\\\.\\)?COM[1-9]\d*$`) + unixSerialBaudRates = map[int]struct{}{ + 50: {}, 75: {}, 110: {}, 134: {}, 150: {}, 200: {}, 300: {}, 600: {}, 1200: {}, 1800: {}, + 2400: {}, 4800: {}, 9600: {}, 19200: {}, 38400: {}, 57600: {}, 115200: {}, 230400: {}, + } +) + +type SerialTool struct{} + +type serialPortInfo struct { + Name string `json:"name"` + Path string `json:"path"` +} + +type serialConfig struct { + Port string + Baud int + DataBits int + Parity string + StopBits int +} + +func NewSerialTool() *SerialTool { + return &SerialTool{} +} + +func (t *SerialTool) Name() string { + return "serial" +} + +func (t *SerialTool) Description() string { + return "Interact with host serial ports. Actions: list (enumerate ports), read (receive bytes), write (send bytes with explicit confirmation). Supports Linux, macOS, and Windows." +} + +func (t *SerialTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"list", "read", "write"}, + "description": "Action to perform: list available serial ports, read bytes from a port, or write bytes to a port.", + }, + "port": map[string]any{ + "type": "string", + "description": "Serial port path or name, for example /dev/ttyUSB0, /dev/cu.usbserial-0001, or COM3. Required for read/write.", + }, + "baud": map[string]any{ + "type": "integer", + "description": "Baud rate. Default: 115200. Linux/macOS currently support standard termios rates up to 230400; Windows accepts configured rates up to 4000000.", + }, + "data_bits": map[string]any{ + "type": "integer", + "description": "Data bits. Supported values: 5, 6, 7, 8. Default: 8.", + }, + "parity": map[string]any{ + "type": "string", + "enum": []string{"none", "even", "odd"}, + "description": "Parity mode. Default: none.", + }, + "stop_bits": map[string]any{ + "type": "integer", + "description": "Stop bits. Supported values: 1, 2. Default: 1.", + }, + "timeout_ms": map[string]any{ + "type": "integer", + "description": "Read/write timeout in milliseconds. Default: 1000.", + }, + "length": map[string]any{ + "type": "integer", + "description": "Number of bytes to read. Required for read. Range: 1-4096.", + }, + "data": map[string]any{ + "type": "array", + "items": map[string]any{"type": "integer"}, + "description": "Bytes to write, each in range 0-255. Required for write unless text is provided.", + }, + "text": map[string]any{ + "type": "string", + "description": "UTF-8 text to write. Required for write if data is omitted.", + }, + "confirm": map[string]any{ + "type": "boolean", + "description": "Must be true for write operations.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *SerialTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, ok := args["action"].(string) + if !ok || strings.TrimSpace(action) == "" { + return ErrorResult("action is required") + } + + switch action { + case "list": + return t.list() + case "read": + return t.read(ctx, args) + case "write": + return t.write(ctx, args) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, read, write)", action)) + } +} + +func (t *SerialTool) list() *ToolResult { + ports, err := serialListPorts() + if err != nil { + return ErrorResult(fmt.Sprintf("failed to list serial ports: %v", err)) + } + if len(ports) == 0 { + return SilentResult("No serial ports found on this host.") + } + + result, _ := json.MarshalIndent(map[string]any{ + "ports": ports, + "count": len(ports), + }, "", " ") + return SilentResult(string(result)) +} + +func (t *SerialTool) read(ctx context.Context, args map[string]any) *ToolResult { + cfg, errResult := parseSerialConfig(args) + if errResult != nil { + return errResult + } + + length := 0 + if v, ok := args["length"].(float64); ok { + length = int(v) + } + if length < 1 || length > maxSerialReadBytes { + return ErrorResult(fmt.Sprintf("length is required for read (1-%d)", maxSerialReadBytes)) + } + + timeout, errResult := parseSerialTimeout(args) + if errResult != nil { + return errResult + } + + data, err := serialRead(ctx, cfg, length, timeout) + if err != nil { + return ErrorResult(fmt.Sprintf("serial read failed on %s: %v", cfg.Port, err)) + } + + return SilentResult(formatSerialPayload("read", cfg, data, timeout)) +} + +func (t *SerialTool) write(ctx context.Context, args map[string]any) *ToolResult { + confirm, _ := args["confirm"].(bool) + if !confirm { + return ErrorResult( + "write operations require confirm: true. Please confirm with the user before sending bytes to a serial device.", + ) + } + + cfg, errResult := parseSerialConfig(args) + if errResult != nil { + return errResult + } + timeout, errResult := parseSerialTimeout(args) + if errResult != nil { + return errResult + } + payload, errResult := parseSerialWritePayload(args) + if errResult != nil { + return errResult + } + + written, err := serialWrite(ctx, cfg, payload, timeout) + if err != nil { + return ErrorResult(fmt.Sprintf("serial write failed on %s: %v", cfg.Port, err)) + } + + result, _ := json.MarshalIndent(map[string]any{ + "action": "write", + "port": cfg.Port, + "baud": cfg.Baud, + "data_bits": cfg.DataBits, + "parity": cfg.Parity, + "stop_bits": cfg.StopBits, + "timeout_ms": timeout.Milliseconds(), + "written": written, + "payload": serialPayloadSummary(payload), + }, "", " ") + return SilentResult(string(result)) +} + +func parseSerialConfig(args map[string]any) (serialConfig, *ToolResult) { + port, ok := args["port"].(string) + port = strings.TrimSpace(port) + if !ok || port == "" { + return serialConfig{}, ErrorResult( + "port is required (for example /dev/ttyUSB0, /dev/cu.usbserial-0001, or COM3)", + ) + } + + normalizedPort, err := normalizeSerialPort(port) + if err != nil { + return serialConfig{}, ErrorResult(err.Error()) + } + + cfg := serialConfig{ + Port: normalizedPort, + Baud: defaultSerialBaud, + DataBits: defaultSerialDataBits, + Parity: "none", + StopBits: defaultSerialStopBits, + } + + if v, ok := args["baud"].(float64); ok { + cfg.Baud = int(v) + } + if err := validateSerialBaud(cfg.Baud); err != nil { + return serialConfig{}, ErrorResult(err.Error()) + } + + if v, ok := args["data_bits"].(float64); ok { + cfg.DataBits = int(v) + } + switch cfg.DataBits { + case 5, 6, 7, 8: + default: + return serialConfig{}, ErrorResult("data_bits must be one of 5, 6, 7, or 8") + } + + if v, ok := args["parity"].(string); ok && strings.TrimSpace(v) != "" { + cfg.Parity = strings.ToLower(strings.TrimSpace(v)) + } + switch cfg.Parity { + case "none", "even", "odd": + default: + return serialConfig{}, ErrorResult(`parity must be one of "none", "even", or "odd"`) + } + + if v, ok := args["stop_bits"].(float64); ok { + cfg.StopBits = int(v) + } + if cfg.StopBits != 1 && cfg.StopBits != 2 { + return serialConfig{}, ErrorResult("stop_bits must be 1 or 2") + } + + return cfg, nil +} + +func parseSerialTimeout(args map[string]any) (time.Duration, *ToolResult) { + timeoutMS := defaultSerialTimeoutMS + if v, ok := args["timeout_ms"].(float64); ok { + timeoutMS = int(v) + } + if timeoutMS < 1 || timeoutMS > 60000 { + return 0, ErrorResult("timeout_ms must be between 1 and 60000") + } + return time.Duration(timeoutMS) * time.Millisecond, nil +} + +func parseSerialWritePayload(args map[string]any) ([]byte, *ToolResult) { + if text, ok := args["text"].(string); ok && text != "" { + if !utf8.ValidString(text) { + return nil, ErrorResult("text must be valid UTF-8") + } + if len(text) > maxSerialPayloadBytes { + return nil, ErrorResult(fmt.Sprintf("text payload too large: maximum %d bytes", maxSerialPayloadBytes)) + } + return []byte(text), nil + } + + dataRaw, ok := args["data"].([]any) + if !ok || len(dataRaw) == 0 { + return nil, ErrorResult("write requires either text or data") + } + if len(dataRaw) > maxSerialPayloadBytes { + return nil, ErrorResult(fmt.Sprintf("data too long: maximum %d bytes", maxSerialPayloadBytes)) + } + + data := make([]byte, len(dataRaw)) + for i, v := range dataRaw { + f, ok := v.(float64) + if !ok { + return nil, ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i)) + } + if f != math.Trunc(f) { + return nil, ErrorResult(fmt.Sprintf("data[%d] is not an integer byte value", i)) + } + b := int(f) + if b < 0 || b > 255 { + return nil, ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b)) + } + data[i] = byte(b) + } + + return data, nil +} + +func formatSerialPayload(action string, cfg serialConfig, data []byte, timeout time.Duration) string { + result, _ := json.MarshalIndent(map[string]any{ + "action": action, + "port": cfg.Port, + "baud": cfg.Baud, + "data_bits": cfg.DataBits, + "parity": cfg.Parity, + "stop_bits": cfg.StopBits, + "timeout_ms": timeout.Milliseconds(), + "payload": serialPayloadSummary(data), + }, "", " ") + return string(result) +} + +func serialPayloadSummary(data []byte) map[string]any { + hexValues := make([]string, len(data)) + intValues := make([]int, len(data)) + for i, b := range data { + hexValues[i] = fmt.Sprintf("0x%02x", b) + intValues[i] = int(b) + } + + summary := map[string]any{ + "length": len(data), + "bytes": intValues, + "hex": hexValues, + } + if utf8.Valid(data) { + summary["text"] = string(data) + } + return summary +} + +func normalizeSerialPort(port string) (string, error) { + switch runtime.GOOS { + case "windows": + return normalizeWindowsSerialPath(port) + case "linux", "darwin": + return normalizeUnixSerialPath(port) + default: + if normalized, err := normalizeUnixSerialPath(port); err == nil { + return normalized, nil + } + return normalizeWindowsSerialPath(port) + } +} + +func normalizeUnixSerialPath(port string) (string, error) { + trimmed := strings.TrimSpace(port) + if !unixSerialPortPattern.MatchString(trimmed) { + return "", fmt.Errorf( + "invalid serial port: expected a safe Unix device name such as /dev/ttyUSB0 or /dev/cu.usbserial-0001", + ) + } + if strings.HasPrefix(trimmed, "/dev/") { + return trimmed, nil + } + return "/dev/" + trimmed, nil +} + +func normalizeWindowsSerialPath(port string) (string, error) { + trimmed := strings.ToUpper(strings.TrimSpace(port)) + if !windowsSerialPortPattern.MatchString(trimmed) { + return "", fmt.Errorf("invalid serial port: expected a COM port such as COM3") + } + if strings.HasPrefix(trimmed, `\\.\`) { + return trimmed, nil + } + return `\\.\` + trimmed, nil +} + +func validateSerialBaud(baud int) error { + if baud < 50 || baud > 4000000 { + return fmt.Errorf("baud must be between 50 and 4000000") + } + + switch runtime.GOOS { + case "linux", "darwin": + if _, ok := unixSerialBaudRates[baud]; !ok { + return fmt.Errorf("unsupported baud rate on this platform: %d (supported up to 230400)", baud) + } + } + + return nil +} + +func serialContextErr(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + return nil + } +} + +func serialWriteAll( + ctx context.Context, + data []byte, + timeout time.Duration, + now func() time.Time, + write func([]byte) (int, error), +) (int, error) { + if err := serialContextErr(ctx); err != nil { + return 0, err + } + + total := 0 + deadline := now().Add(timeout) + for total < len(data) { + if err := serialContextErr(ctx); err != nil { + return total, err + } + if deadline.Sub(now()) <= 0 { + return total, fmt.Errorf("timeout while writing serial data") + } + + n, err := write(data[total:]) + total += n + if err != nil { + return total, err + } + if n == 0 { + continue + } + } + + return total, nil +} diff --git a/pkg/tools/hardware/serial_darwin.go b/pkg/tools/hardware/serial_darwin.go new file mode 100644 index 000000000..bc019029e --- /dev/null +++ b/pkg/tools/hardware/serial_darwin.go @@ -0,0 +1,19 @@ +//go:build darwin + +package hardwaretools + +import "golang.org/x/sys/unix" + +func serialGetTermios(fd int) (*unix.Termios, error) { + return unix.IoctlGetTermios(fd, unix.TIOCGETA) +} + +func serialSetSpeed(tio *unix.Termios, speed uint32) error { + tio.Ispeed = uint64(speed) + tio.Ospeed = uint64(speed) + return nil +} + +func serialSetTermios(fd int, tio *unix.Termios) error { + return unix.IoctlSetTermios(fd, unix.TIOCSETA, tio) +} diff --git a/pkg/tools/hardware/serial_linux.go b/pkg/tools/hardware/serial_linux.go new file mode 100644 index 000000000..bad3e4cb8 --- /dev/null +++ b/pkg/tools/hardware/serial_linux.go @@ -0,0 +1,19 @@ +//go:build linux + +package hardwaretools + +import "golang.org/x/sys/unix" + +func serialGetTermios(fd int) (*unix.Termios, error) { + return unix.IoctlGetTermios(fd, unix.TCGETS) +} + +func serialSetSpeed(tio *unix.Termios, speed uint32) error { + tio.Ispeed = speed + tio.Ospeed = speed + return nil +} + +func serialSetTermios(fd int, tio *unix.Termios) error { + return unix.IoctlSetTermios(fd, unix.TCSETS, tio) +} diff --git a/pkg/tools/hardware/serial_other.go b/pkg/tools/hardware/serial_other.go new file mode 100644 index 000000000..ec72a2d2a --- /dev/null +++ b/pkg/tools/hardware/serial_other.go @@ -0,0 +1,21 @@ +//go:build !linux && !darwin && !windows + +package hardwaretools + +import ( + "context" + "fmt" + "time" +) + +func serialListPorts() ([]serialPortInfo, error) { + return nil, fmt.Errorf("serial is not supported on this platform") +} + +func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) { + return nil, fmt.Errorf("serial is not supported on this platform") +} + +func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) { + return 0, fmt.Errorf("serial is not supported on this platform") +} diff --git a/pkg/tools/hardware/serial_other_test.go b/pkg/tools/hardware/serial_other_test.go new file mode 100644 index 000000000..ef04c4062 --- /dev/null +++ b/pkg/tools/hardware/serial_other_test.go @@ -0,0 +1,18 @@ +//go:build !linux && !darwin && !windows + +package hardwaretools + +import ( + "strings" + "testing" +) + +func TestSerialListPortsUnsupportedPlatform(t *testing.T) { + _, err := serialListPorts() + if err == nil { + t.Fatal("expected unsupported platform error") + } + if !strings.Contains(err.Error(), "not supported") { + t.Fatalf("serialListPorts() error = %v, want unsupported platform message", err) + } +} diff --git a/pkg/tools/hardware/serial_test.go b/pkg/tools/hardware/serial_test.go new file mode 100644 index 000000000..6b2e9765d --- /dev/null +++ b/pkg/tools/hardware/serial_test.go @@ -0,0 +1,269 @@ +package hardwaretools + +import ( + "context" + "runtime" + "strings" + "testing" + "time" +) + +func TestParseSerialConfig(t *testing.T) { + port := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + port = "COM3" + } + + cfg, errResult := parseSerialConfig(map[string]any{ + "port": port, + "baud": float64(9600), + "data_bits": float64(7), + "parity": "even", + "stop_bits": float64(2), + }) + if errResult != nil { + t.Fatalf("parseSerialConfig() unexpected error = %v", errResult.ForLLM) + } + + wantPort := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + wantPort = `\\.\COM3` + } + if cfg.Port != wantPort || cfg.Baud != 9600 || cfg.DataBits != 7 || cfg.Parity != "even" || cfg.StopBits != 2 { + t.Fatalf("parseSerialConfig() = %#v", cfg) + } +} + +func TestParseSerialConfigRejectsInvalidParity(t *testing.T) { + port := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + port = "COM3" + } + + _, errResult := parseSerialConfig(map[string]any{ + "port": port, + "parity": "mark", + }) + if errResult == nil { + t.Fatal("expected invalid parity to fail") + } +} + +func TestParseSerialConfigRejectsUnsupportedUnixBaud(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skip("Unix baud validation only applies on Unix platforms") + } + + _, errResult := parseSerialConfig(map[string]any{ + "port": "/dev/ttyUSB0", + "baud": float64(460800), + }) + if errResult == nil { + t.Fatal("expected unsupported Unix baud rate to fail") + } +} + +func TestParseSerialWritePayloadRejectsFractionalBytes(t *testing.T) { + _, errResult := parseSerialWritePayload(map[string]any{ + "data": []any{65.9}, + }) + if errResult == nil { + t.Fatal("expected fractional byte value to fail") + } +} + +func TestValidateSerialBaud(t *testing.T) { + tests := []struct { + name string + baud int + wantErr bool + }{ + {name: "default-supported", baud: 115200}, + {name: "max-unix-supported", baud: 230400}, + {name: "too-low", baud: 49, wantErr: true}, + {name: "too-high", baud: 4000001, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSerialBaud(tt.baud) + if (err != nil) != tt.wantErr { + t.Fatalf("validateSerialBaud(%d) error = %v, wantErr %v", tt.baud, err, tt.wantErr) + } + }) + } +} + +func TestSerialReadCanceledBeforeOpen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + port := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + port = "COM3" + } + + _, err := serialRead( + ctx, + serialConfig{Port: port, Baud: 115200, DataBits: 8, Parity: "none", StopBits: 1}, + 1, + time.Second, + ) + if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) { + t.Fatalf("serialRead() error = %v, want context canceled", err) + } +} + +func TestSerialWriteCanceledBeforeOpen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + port := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + port = "COM3" + } + + _, err := serialWrite( + ctx, + serialConfig{Port: port, Baud: 115200, DataBits: 8, Parity: "none", StopBits: 1}, + []byte("AT"), + time.Second, + ) + if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) { + t.Fatalf("serialWrite() error = %v, want context canceled", err) + } +} + +func TestParseSerialConfigRejectsUnsafePortPaths(t *testing.T) { + tests := []string{ + "../../../etc/passwd", + "/etc/passwd", + `C:\temp\device.txt`, + `\\.\C:\temp\device.txt`, + } + + for _, port := range tests { + t.Run(strings.ReplaceAll(port, "/", "_"), func(t *testing.T) { + _, errResult := parseSerialConfig(map[string]any{ + "port": port, + }) + if errResult == nil { + t.Fatalf("expected unsafe port %q to be rejected", port) + } + }) + } +} + +func TestNormalizeUnixSerialPath(t *testing.T) { + tests := []struct { + port string + want string + }{ + {port: "ttyUSB0", want: "/dev/ttyUSB0"}, + {port: "/dev/ttyACM0", want: "/dev/ttyACM0"}, + {port: "/dev/cu.usbserial-0001", want: "/dev/cu.usbserial-0001"}, + } + + for _, tt := range tests { + got, err := normalizeUnixSerialPath(tt.port) + if err != nil { + t.Fatalf("normalizeUnixSerialPath(%q) unexpected error = %v", tt.port, err) + } + if got != tt.want { + t.Fatalf("normalizeUnixSerialPath(%q) = %q, want %q", tt.port, got, tt.want) + } + } +} + +func TestNormalizeUnixSerialPathRejectsInvalidNames(t *testing.T) { + tests := []string{ + "", + "ttyUSB0/../../passwd", + "/dev/../../etc/passwd", + "/tmp/ttyUSB0", + "ttyUSB", + "COM3", + } + + for _, port := range tests { + t.Run(strings.ReplaceAll(port, "/", "_"), func(t *testing.T) { + if _, err := normalizeUnixSerialPath(port); err == nil { + t.Fatalf("expected %q to be rejected", port) + } + }) + } +} + +func TestNormalizeWindowsSerialPath(t *testing.T) { + tests := []struct { + port string + want string + }{ + {port: "COM3", want: `\\.\COM3`}, + {port: "com12", want: `\\.\COM12`}, + {port: `\\.\COM7`, want: `\\.\COM7`}, + } + + for _, tt := range tests { + got, err := normalizeWindowsSerialPath(tt.port) + if err != nil { + t.Fatalf("normalizeWindowsSerialPath(%q) unexpected error = %v", tt.port, err) + } + if got != tt.want { + t.Fatalf("normalizeWindowsSerialPath(%q) = %q, want %q", tt.port, got, tt.want) + } + } +} + +func TestNormalizeWindowsSerialPathRejectsInvalidNames(t *testing.T) { + tests := []string{ + "", + "COM0", + "COM", + "/dev/ttyUSB0", + `C:\temp\device.txt`, + `\\.\C:\temp\device.txt`, + `\\server\share\COM3`, + } + + for _, port := range tests { + t.Run(strings.ReplaceAll(strings.ReplaceAll(port, `\`, "_"), "/", "_"), func(t *testing.T) { + if _, err := normalizeWindowsSerialPath(port); err == nil { + t.Fatalf("expected %q to be rejected", port) + } + }) + } +} + +func TestParseSerialTimeout(t *testing.T) { + timeout, errResult := parseSerialTimeout(map[string]any{ + "timeout_ms": float64(2500), + }) + if errResult != nil { + t.Fatalf("parseSerialTimeout() unexpected error = %v", errResult.ForLLM) + } + if timeout != 2500*time.Millisecond { + t.Fatalf("timeout = %v, want 2500ms", timeout) + } +} + +func TestParseSerialWritePayloadSupportsText(t *testing.T) { + data, errResult := parseSerialWritePayload(map[string]any{ + "text": "AT\r\n", + }) + if errResult != nil { + t.Fatalf("parseSerialWritePayload() unexpected error = %v", errResult.ForLLM) + } + if string(data) != "AT\r\n" { + t.Fatalf("payload = %q, want %q", string(data), "AT\r\n") + } +} + +func TestParseSerialWritePayloadRejectsOutOfRangeByte(t *testing.T) { + _, errResult := parseSerialWritePayload(map[string]any{ + "data": []any{float64(256)}, + }) + if errResult == nil { + t.Fatal("expected payload validation failure") + } +} diff --git a/pkg/tools/hardware/serial_unix.go b/pkg/tools/hardware/serial_unix.go new file mode 100644 index 000000000..548b8573b --- /dev/null +++ b/pkg/tools/hardware/serial_unix.go @@ -0,0 +1,286 @@ +//go:build linux || darwin + +package hardwaretools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "golang.org/x/sys/unix" +) + +var ( + unixSerialNow = time.Now + unixSerialOpenPort = openAndConfigureSerialPort + unixSerialClosePort = unix.Close + unixSerialPollRead = pollRead + unixSerialPollWrite = pollWrite +) + +func serialListPorts() ([]serialPortInfo, error) { + patterns := []string{ + "/dev/ttyS*", + "/dev/ttyUSB*", + "/dev/ttyACM*", + "/dev/ttyAMA*", + "/dev/rfcomm*", + "/dev/tty.*", + "/dev/cu.*", + } + + seen := make(map[string]struct{}) + ports := make([]serialPortInfo, 0) + for _, pattern := range patterns { + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + for _, match := range matches { + if _, ok := seen[match]; ok { + continue + } + info, err := os.Stat(match) + if err != nil || info.IsDir() { + continue + } + seen[match] = struct{}{} + ports = append(ports, serialPortInfo{ + Name: filepath.Base(match), + Path: match, + }) + } + } + + sort.Slice(ports, func(i, j int) bool { + return ports[i].Path < ports[j].Path + }) + return ports, nil +} + +func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) { + if err := serialContextErr(ctx); err != nil { + return nil, err + } + + fd, err := unixSerialOpenPort(cfg) + if err != nil { + return nil, err + } + defer unixSerialClosePort(fd) + + buf := make([]byte, length) + total := 0 + deadline := unixSerialNow().Add(timeout) + + for total < length { + if err := serialContextErr(ctx); err != nil { + return nil, err + } + + remaining := deadline.Sub(unixSerialNow()) + if remaining <= 0 { + break + } + + n, err := unixSerialPollRead(fd, buf[total:], minSerialPollTimeout(remaining)) + if err != nil { + return nil, err + } + if n == 0 { + continue + } + total += n + } + + return buf[:total], nil +} + +func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) { + if err := serialContextErr(ctx); err != nil { + return 0, err + } + + fd, err := unixSerialOpenPort(cfg) + if err != nil { + return 0, err + } + defer unixSerialClosePort(fd) + + total := 0 + deadline := unixSerialNow().Add(timeout) + for total < len(data) { + if err := serialContextErr(ctx); err != nil { + return total, err + } + + remaining := deadline.Sub(unixSerialNow()) + if remaining <= 0 { + return total, fmt.Errorf("timeout while writing serial data") + } + + n, err := unixSerialPollWrite(fd, data[total:], minSerialPollTimeout(remaining)) + if err != nil { + return total, err + } + if n == 0 { + continue + } + total += n + } + + return total, nil +} + +func openAndConfigureSerialPort(cfg serialConfig) (int, error) { + fd, err := unix.Open(cfg.Port, unix.O_RDWR|unix.O_NOCTTY|unix.O_NONBLOCK, 0) + if err != nil { + return -1, err + } + + if err := unix.SetNonblock(fd, false); err != nil { + unix.Close(fd) + return -1, err + } + + if err := configureUnixSerialPort(fd, cfg); err != nil { + unix.Close(fd) + return -1, err + } + + return fd, nil +} + +func configureUnixSerialPort(fd int, cfg serialConfig) error { + tio, err := serialGetTermios(fd) + if err != nil { + return err + } + + tio.Iflag = 0 + tio.Oflag = 0 + tio.Lflag = 0 + tio.Cflag = unix.CREAD | unix.CLOCAL + tio.Cc[unix.VMIN] = 0 + tio.Cc[unix.VTIME] = 0 + + switch cfg.DataBits { + case 5: + tio.Cflag |= unix.CS5 + case 6: + tio.Cflag |= unix.CS6 + case 7: + tio.Cflag |= unix.CS7 + default: + tio.Cflag |= unix.CS8 + } + + switch cfg.Parity { + case "even": + tio.Cflag |= unix.PARENB + case "odd": + tio.Cflag |= unix.PARENB | unix.PARODD + } + + if cfg.StopBits == 2 { + tio.Cflag |= unix.CSTOPB + } + + speed, err := serialBaudToUnix(cfg.Baud) + if err != nil { + return err + } + if err := serialSetSpeed(tio, speed); err != nil { + return err + } + + return serialSetTermios(fd, tio) +} + +func serialBaudToUnix(baud int) (uint32, error) { + switch baud { + case 50: + return unix.B50, nil + case 75: + return unix.B75, nil + case 110: + return unix.B110, nil + case 134: + return unix.B134, nil + case 150: + return unix.B150, nil + case 200: + return unix.B200, nil + case 300: + return unix.B300, nil + case 600: + return unix.B600, nil + case 1200: + return unix.B1200, nil + case 1800: + return unix.B1800, nil + case 2400: + return unix.B2400, nil + case 4800: + return unix.B4800, nil + case 9600: + return unix.B9600, nil + case 19200: + return unix.B19200, nil + case 38400: + return unix.B38400, nil + case 57600: + return unix.B57600, nil + case 115200: + return unix.B115200, nil + case 230400: + return unix.B230400, nil + default: + return 0, fmt.Errorf("unsupported baud rate on this platform: %d", baud) + } +} + +func pollRead(fd int, dst []byte, timeout time.Duration) (int, error) { + pfd := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} + n, err := unix.Poll(pfd, durationToPollTimeout(timeout)) + if err != nil { + return 0, err + } + if n == 0 { + return 0, nil + } + return unix.Read(fd, dst) +} + +func pollWrite(fd int, src []byte, timeout time.Duration) (int, error) { + pfd := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLOUT}} + n, err := unix.Poll(pfd, durationToPollTimeout(timeout)) + if err != nil { + return 0, err + } + if n == 0 { + return 0, nil + } + return unix.Write(fd, src) +} + +func durationToPollTimeout(timeout time.Duration) int { + if timeout <= 0 { + return 0 + } + ms := int(timeout / time.Millisecond) + if ms == 0 { + return 1 + } + return ms +} + +func minSerialPollTimeout(timeout time.Duration) time.Duration { + if timeout > serialPollInterval { + return serialPollInterval + } + return timeout +} diff --git a/pkg/tools/hardware/serial_unix_test.go b/pkg/tools/hardware/serial_unix_test.go new file mode 100644 index 000000000..fac2efe7f --- /dev/null +++ b/pkg/tools/hardware/serial_unix_test.go @@ -0,0 +1,140 @@ +//go:build linux || darwin + +package hardwaretools + +import ( + "context" + "errors" + "testing" + "time" +) + +func stubUnixSerialIO(t *testing.T, now *time.Time) { + t.Helper() + + prevNow := unixSerialNow + prevOpen := unixSerialOpenPort + prevClose := unixSerialClosePort + prevPollRead := unixSerialPollRead + prevPollWrite := unixSerialPollWrite + + unixSerialNow = func() time.Time { + return *now + } + unixSerialOpenPort = func(cfg serialConfig) (int, error) { + return 42, nil + } + unixSerialClosePort = func(fd int) error { + return nil + } + unixSerialPollRead = prevPollRead + unixSerialPollWrite = prevPollWrite + + t.Cleanup(func() { + unixSerialNow = prevNow + unixSerialOpenPort = prevOpen + unixSerialClosePort = prevClose + unixSerialPollRead = prevPollRead + unixSerialPollWrite = prevPollWrite + }) +} + +func TestSerialReadWaitsPastEmptyPollsUntilDeadline(t *testing.T) { + now := time.Unix(0, 0) + stubUnixSerialIO(t, &now) + + pollCalls := 0 + unixSerialPollRead = func(fd int, dst []byte, timeout time.Duration) (int, error) { + pollCalls++ + if timeout > serialPollInterval { + t.Fatalf("poll timeout = %v, want <= %v", timeout, serialPollInterval) + } + now = now.Add(timeout) + if pollCalls < 4 { + return 0, nil + } + return copy(dst, []byte("OK")), nil + } + + got, err := serialRead(context.Background(), serialConfig{}, 2, 500*time.Millisecond) + if err != nil { + t.Fatalf("serialRead() error = %v", err) + } + if string(got) != "OK" { + t.Fatalf("serialRead() = %q, want %q", got, "OK") + } + if pollCalls != 4 { + t.Fatalf("poll calls = %d, want 4", pollCalls) + } +} + +func TestSerialReadReturnsPromptlyOnContextCancelBetweenPolls(t *testing.T) { + now := time.Unix(0, 0) + stubUnixSerialIO(t, &now) + + ctx, cancel := context.WithCancel(context.Background()) + pollCalls := 0 + unixSerialPollRead = func(fd int, dst []byte, timeout time.Duration) (int, error) { + pollCalls++ + now = now.Add(timeout) + cancel() + return 0, nil + } + + _, err := serialRead(ctx, serialConfig{}, 1, time.Second) + if !errors.Is(err, context.Canceled) { + t.Fatalf("serialRead() error = %v, want context canceled", err) + } + if pollCalls != 1 { + t.Fatalf("poll calls = %d, want 1", pollCalls) + } +} + +func TestSerialWriteWaitsPastEmptyPollsUntilReady(t *testing.T) { + now := time.Unix(0, 0) + stubUnixSerialIO(t, &now) + + pollCalls := 0 + unixSerialPollWrite = func(fd int, src []byte, timeout time.Duration) (int, error) { + pollCalls++ + if timeout > serialPollInterval { + t.Fatalf("poll timeout = %v, want <= %v", timeout, serialPollInterval) + } + now = now.Add(timeout) + switch pollCalls { + case 1, 2: + return 0, nil + default: + return 1, nil + } + } + + written, err := serialWrite(context.Background(), serialConfig{}, []byte("OK"), 500*time.Millisecond) + if err != nil { + t.Fatalf("serialWrite() error = %v", err) + } + if written != 2 { + t.Fatalf("serialWrite() wrote %d bytes, want 2", written) + } + if pollCalls != 4 { + t.Fatalf("poll calls = %d, want 4", pollCalls) + } +} + +func TestSerialWriteTimesOutAfterRepeatedEmptyPolls(t *testing.T) { + now := time.Unix(0, 0) + stubUnixSerialIO(t, &now) + + unixSerialPollWrite = func(fd int, src []byte, timeout time.Duration) (int, error) { + now = now.Add(timeout) + return 0, nil + } + + written, err := serialWrite(context.Background(), serialConfig{}, []byte("A"), 250*time.Millisecond) + if err == nil || err.Error() != "timeout while writing serial data" { + t.Fatalf("serialWrite() error = %v, want timeout", err) + } + if written != 0 { + t.Fatalf("serialWrite() wrote %d bytes, want 0", written) + } +} diff --git a/pkg/tools/hardware/serial_windows.go b/pkg/tools/hardware/serial_windows.go new file mode 100644 index 000000000..31a215589 --- /dev/null +++ b/pkg/tools/hardware/serial_windows.go @@ -0,0 +1,247 @@ +//go:build windows + +package hardwaretools + +import ( + "context" + "sort" + "strings" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procGetCommState = kernel32.NewProc("GetCommState") + procSetCommState = kernel32.NewProc("SetCommState") + procSetCommTimeouts = kernel32.NewProc("SetCommTimeouts") + procPurgeComm = kernel32.NewProc("PurgeComm") +) + +const ( + purgeTxClear = 0x0004 + purgeRxClear = 0x0008 + + dcbFlagBinary = 0x00000001 + dcbFlagParity = 0x00000002 + dcbFlagOutxCtsFlow = 0x00000004 + dcbFlagOutxDsrFlow = 0x00000008 + dcbFlagDtrControlMask = 0x00000030 + dcbFlagDsrSensitivity = 0x00000040 + dcbFlagTXContinueOnXoff = 0x00000080 + dcbFlagOutX = 0x00000100 + dcbFlagInX = 0x00000200 + dcbFlagRtsControlMask = 0x00003000 +) + +type dcb struct { + DCBlength uint32 + BaudRate uint32 + Flags uint32 + Reserved uint16 + XonLim uint16 + XoffLim uint16 + ByteSize byte + Parity byte + StopBits byte + XonChar byte + XoffChar byte + ErrorChar byte + EofChar byte + EvtChar byte + wReserved1 uint16 +} + +type commTimeouts struct { + ReadIntervalTimeout uint32 + ReadTotalTimeoutMultiplier uint32 + ReadTotalTimeoutConstant uint32 + WriteTotalTimeoutMultiplier uint32 + WriteTotalTimeoutConstant uint32 +} + +func serialListPorts() ([]serialPortInfo, error) { + key, err := registry.OpenKey(registry.LOCAL_MACHINE, `HARDWARE\DEVICEMAP\SERIALCOMM`, registry.QUERY_VALUE) + if err != nil { + if err == registry.ErrNotExist { + return nil, nil + } + return nil, err + } + defer key.Close() + + names, err := key.ReadValueNames(-1) + if err != nil { + return nil, err + } + + ports := make([]serialPortInfo, 0, len(names)) + seen := make(map[string]struct{}) + for _, name := range names { + value, _, err := key.GetStringValue(name) + if err != nil { + continue + } + portName := strings.TrimSpace(value) + if portName == "" { + continue + } + normalized := strings.ToUpper(portName) + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + ports = append(ports, serialPortInfo{ + Name: normalized, + Path: normalized, + }) + } + + sort.Slice(ports, func(i, j int) bool { + return ports[i].Path < ports[j].Path + }) + return ports, nil +} + +func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) { + if err := serialContextErr(ctx); err != nil { + return nil, err + } + + handle, err := openAndConfigureWindowsSerial(cfg, timeout) + if err != nil { + return nil, err + } + defer windows.CloseHandle(handle) + + if err := serialContextErr(ctx); err != nil { + return nil, err + } + + buf := make([]byte, length) + var read uint32 + // Synchronous serial I/O on Windows cannot be interrupted once the syscall starts. + // COMMTIMEOUTS bounds how long turn cancellation may take to surface. + if err := windows.ReadFile(handle, buf, &read, nil); err != nil { + return nil, err + } + return buf[:read], nil +} + +func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) { + if err := serialContextErr(ctx); err != nil { + return 0, err + } + + handle, err := openAndConfigureWindowsSerial(cfg, timeout) + if err != nil { + return 0, err + } + defer windows.CloseHandle(handle) + + if err := serialContextErr(ctx); err != nil { + return 0, err + } + + return serialWriteAll(ctx, data, timeout, time.Now, func(chunk []byte) (int, error) { + var written uint32 + // Like ReadFile above, this synchronous WriteFile call relies on COMMTIMEOUTS + // rather than context preemption once the syscall is in flight. + if err := windows.WriteFile(handle, chunk, &written, nil); err != nil { + return int(written), err + } + return int(written), nil + }) +} + +func openAndConfigureWindowsSerial(cfg serialConfig, timeout time.Duration) (windows.Handle, error) { + handle, err := windows.CreateFile( + windows.StringToUTF16Ptr(cfg.Port), + windows.GENERIC_READ|windows.GENERIC_WRITE, + 0, + nil, + windows.OPEN_EXISTING, + 0, + 0, + ) + if err != nil { + return 0, err + } + + if err := configureWindowsSerialPort(handle, cfg, timeout); err != nil { + windows.CloseHandle(handle) + return 0, err + } + return handle, nil +} + +func configureWindowsSerialPort(handle windows.Handle, cfg serialConfig, timeout time.Duration) error { + state := dcb{DCBlength: uint32(unsafe.Sizeof(dcb{}))} + r1, _, err := procGetCommState.Call(uintptr(handle), uintptr(unsafe.Pointer(&state))) + if r1 == 0 { + return err + } + + state.BaudRate = uint32(cfg.Baud) + state.ByteSize = byte(cfg.DataBits) + state.Flags = sanitizeWindowsSerialFlags(state.Flags) + state.Flags |= dcbFlagBinary + + switch cfg.Parity { + case "even": + state.Parity = 2 + state.Flags |= dcbFlagParity + case "odd": + state.Parity = 1 + state.Flags |= dcbFlagParity + default: + state.Parity = 0 + state.Flags &^= dcbFlagParity + } + + switch cfg.StopBits { + case 2: + state.StopBits = 2 + default: + state.StopBits = 0 + } + + r1, _, err = procSetCommState.Call(uintptr(handle), uintptr(unsafe.Pointer(&state))) + if r1 == 0 { + return err + } + + timeoutMS := uint32(timeout / time.Millisecond) + if timeoutMS == 0 { + timeoutMS = 1 + } + timeouts := commTimeouts{ + ReadIntervalTimeout: timeoutMS, + ReadTotalTimeoutConstant: timeoutMS, + WriteTotalTimeoutConstant: timeoutMS, + ReadTotalTimeoutMultiplier: 0, + WriteTotalTimeoutMultiplier: 0, + } + r1, _, err = procSetCommTimeouts.Call(uintptr(handle), uintptr(unsafe.Pointer(&timeouts))) + if r1 == 0 { + return err + } + + procPurgeComm.Call(uintptr(handle), uintptr(purgeRxClear|purgeTxClear)) + return nil +} + +func sanitizeWindowsSerialFlags(flags uint32) uint32 { + flags &^= dcbFlagOutxCtsFlow | + dcbFlagOutxDsrFlow | + dcbFlagDtrControlMask | + dcbFlagDsrSensitivity | + dcbFlagTXContinueOnXoff | + dcbFlagOutX | + dcbFlagInX | + dcbFlagRtsControlMask + return flags +} diff --git a/pkg/tools/hardware/serial_windows_test.go b/pkg/tools/hardware/serial_windows_test.go new file mode 100644 index 000000000..ecb0addbd --- /dev/null +++ b/pkg/tools/hardware/serial_windows_test.go @@ -0,0 +1,39 @@ +//go:build windows + +package hardwaretools + +import "testing" + +func TestSanitizeWindowsSerialFlags(t *testing.T) { + flags := uint32( + dcbFlagBinary | + dcbFlagParity | + dcbFlagOutxCtsFlow | + dcbFlagOutxDsrFlow | + dcbFlagDtrControlMask | + dcbFlagDsrSensitivity | + dcbFlagTXContinueOnXoff | + dcbFlagOutX | + dcbFlagInX | + dcbFlagRtsControlMask, + ) + + got := sanitizeWindowsSerialFlags(flags) + + if got&dcbFlagBinary == 0 { + t.Fatal("sanitizeWindowsSerialFlags() should preserve fBinary") + } + if got&dcbFlagParity == 0 { + t.Fatal("sanitizeWindowsSerialFlags() should preserve fParity") + } + if got&(dcbFlagOutxCtsFlow| + dcbFlagOutxDsrFlow| + dcbFlagDtrControlMask| + dcbFlagDsrSensitivity| + dcbFlagTXContinueOnXoff| + dcbFlagOutX| + dcbFlagInX| + dcbFlagRtsControlMask) != 0 { + t.Fatalf("sanitizeWindowsSerialFlags() = %#x, want flow-control bits cleared", got) + } +} diff --git a/pkg/tools/hardware/serial_write_common_test.go b/pkg/tools/hardware/serial_write_common_test.go new file mode 100644 index 000000000..398c1fde5 --- /dev/null +++ b/pkg/tools/hardware/serial_write_common_test.go @@ -0,0 +1,87 @@ +package hardwaretools + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestSerialWriteAllRetriesPartialWritesUntilComplete(t *testing.T) { + now := time.Unix(0, 0) + calls := 0 + + written, err := serialWriteAll(context.Background(), []byte("PING"), time.Second, func() time.Time { + return now + }, func(chunk []byte) (int, error) { + calls++ + now = now.Add(100 * time.Millisecond) + switch calls { + case 1: + if string(chunk) != "PING" { + t.Fatalf("first chunk = %q, want %q", chunk, "PING") + } + return 2, nil + case 2: + if string(chunk) != "NG" { + t.Fatalf("second chunk = %q, want %q", chunk, "NG") + } + return 2, nil + default: + t.Fatalf("unexpected extra write call %d", calls) + return 0, nil + } + }) + if err != nil { + t.Fatalf("serialWriteAll() error = %v", err) + } + if written != 4 { + t.Fatalf("serialWriteAll() wrote %d bytes, want 4", written) + } +} + +func TestSerialWriteAllTimesOutAfterZeroByteWrites(t *testing.T) { + now := time.Unix(0, 0) + calls := 0 + + written, err := serialWriteAll(context.Background(), []byte("A"), 250*time.Millisecond, func() time.Time { + return now + }, func(chunk []byte) (int, error) { + calls++ + now = now.Add(100 * time.Millisecond) + return 0, nil + }) + if err == nil || err.Error() != "timeout while writing serial data" { + t.Fatalf("serialWriteAll() error = %v, want timeout", err) + } + if written != 0 { + t.Fatalf("serialWriteAll() wrote %d bytes, want 0", written) + } + if calls != 3 { + t.Fatalf("write calls = %d, want 3", calls) + } +} + +func TestSerialWriteAllReturnsContextCancellationAfterRetryBoundary(t *testing.T) { + now := time.Unix(0, 0) + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + + written, err := serialWriteAll(ctx, []byte("A"), time.Second, func() time.Time { + return now + }, func(chunk []byte) (int, error) { + calls++ + now = now.Add(100 * time.Millisecond) + cancel() + return 0, nil + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("serialWriteAll() error = %v, want context canceled", err) + } + if written != 0 { + t.Fatalf("serialWriteAll() wrote %d bytes, want 0", written) + } + if calls != 1 { + t.Fatalf("write calls = %d, want 1", calls) + } +} diff --git a/pkg/tools/hardware/shared.go b/pkg/tools/hardware/shared.go new file mode 100644 index 000000000..3012f3e6c --- /dev/null +++ b/pkg/tools/hardware/shared.go @@ -0,0 +1,13 @@ +package hardwaretools + +import toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" + +type ToolResult = toolshared.ToolResult + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} diff --git a/pkg/tools/spi.go b/pkg/tools/hardware/spi.go similarity index 98% rename from pkg/tools/spi.go rename to pkg/tools/hardware/spi.go index 0ca17e84f..0bc0d8f72 100644 --- a/pkg/tools/spi.go +++ b/pkg/tools/hardware/spi.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "context" @@ -122,8 +122,6 @@ func (t *SPITool) list() *ToolResult { // Helper function for SPI operations (used by platform-specific implementations) // parseSPIArgs extracts and validates common SPI parameters -// -//nolint:unused // Used by spi_linux.go func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { dev, ok := args["device"].(string) if !ok || dev == "" { @@ -160,3 +158,5 @@ func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, return dev, speed, mode, bits, "" } + +var _ = parseSPIArgs diff --git a/pkg/tools/spi_linux.go b/pkg/tools/hardware/spi_linux.go similarity index 99% rename from pkg/tools/spi_linux.go rename to pkg/tools/hardware/spi_linux.go index 9def73662..8502d6b9e 100644 --- a/pkg/tools/spi_linux.go +++ b/pkg/tools/hardware/spi_linux.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "encoding/json" diff --git a/pkg/tools/spi_other.go b/pkg/tools/hardware/spi_other.go similarity index 94% rename from pkg/tools/spi_other.go rename to pkg/tools/hardware/spi_other.go index 5d078ac3f..89fc99e67 100644 --- a/pkg/tools/spi_other.go +++ b/pkg/tools/hardware/spi_other.go @@ -1,6 +1,6 @@ //go:build !linux -package tools +package hardwaretools // transfer is a stub for non-Linux platforms. func (t *SPITool) transfer(args map[string]any) *ToolResult { diff --git a/pkg/tools/hardware_facade.go b/pkg/tools/hardware_facade.go new file mode 100644 index 000000000..b505c5a48 --- /dev/null +++ b/pkg/tools/hardware_facade.go @@ -0,0 +1,21 @@ +package tools + +import hardwaretools "github.com/sipeed/picoclaw/pkg/tools/hardware" + +type ( + I2CTool = hardwaretools.I2CTool + SerialTool = hardwaretools.SerialTool + SPITool = hardwaretools.SPITool +) + +func NewI2CTool() *I2CTool { + return hardwaretools.NewI2CTool() +} + +func NewSPITool() *SPITool { + return hardwaretools.NewSPITool() +} + +func NewSerialTool() *SerialTool { + return hardwaretools.NewSerialTool() +} diff --git a/pkg/tools/identifier_compat.go b/pkg/tools/identifier_compat.go new file mode 100644 index 000000000..c5a6d9cf3 --- /dev/null +++ b/pkg/tools/identifier_compat.go @@ -0,0 +1,48 @@ +package tools + +import "strings" + +func sanitizeIdentifierComponent(s string) string { + const maxLen = 64 + + s = strings.ToLower(s) + var b strings.Builder + b.Grow(len(s)) + + prevUnderscore := false + for _, r := range s { + isAllowed := (r >= 'a' && r <= 'z') || + (r >= '0' && r <= '9') || + r == '_' || r == '-' + + if !isAllowed { + if !prevUnderscore { + b.WriteRune('_') + prevUnderscore = true + } + continue + } + + if r == '_' { + if prevUnderscore { + continue + } + prevUnderscore = true + } else { + prevUnderscore = false + } + + b.WriteRune(r) + } + + result := strings.Trim(b.String(), "_") + if result == "" { + result = "unnamed" + } + + if len(result) > maxLen { + result = result[:maxLen] + } + + return result +} diff --git a/pkg/tools/integration/helpers.go b/pkg/tools/integration/helpers.go new file mode 100644 index 000000000..b34fbc6cd --- /dev/null +++ b/pkg/tools/integration/helpers.go @@ -0,0 +1,134 @@ +package integrationtools + +import ( + "fmt" + "math" + "mime" + "path/filepath" + "regexp" + "strconv" + "strings" + "unicode" +) + +var ( + inlineMarkdownDataURLRe = regexp.MustCompile(`!\[[^\]]*\]\((data:[^)]+)\)`) + inlineRawDataURLRe = regexp.MustCompile(`data:[^;\s]+;base64,[A-Za-z0-9+/=\r\n]+`) +) + +const ( + largeBase64OmittedMessage = "[Tool returned a large base64-like payload; omitted from model context.]" + inlineMediaOmittedMessage = "[Tool returned inline media content; omitted from model context.]" +) + +func sanitizeToolLLMContent(text string) string { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return text + } + if inlineMarkdownDataURLRe.MatchString(trimmed) || inlineRawDataURLRe.MatchString(trimmed) { + cleaned := inlineMarkdownDataURLRe.ReplaceAllString(trimmed, "") + cleaned = inlineRawDataURLRe.ReplaceAllString(cleaned, "") + cleaned = strings.TrimSpace(cleaned) + if cleaned == "" { + return inlineMediaOmittedMessage + } + return cleaned + "\n" + inlineMediaOmittedMessage + } + if looksLikeLargeBase64Payload(trimmed) { + return largeBase64OmittedMessage + } + return text +} + +func looksLikeLargeBase64Payload(text string) bool { + trimmed := strings.TrimSpace(text) + if len(trimmed) < 1024 { + return false + } + + nonSpace := 0 + base64Like := 0 + spaceCount := 0 + + for _, r := range trimmed { + if unicode.IsSpace(r) { + spaceCount++ + continue + } + nonSpace++ + if (r >= 'A' && r <= 'Z') || + (r >= 'a' && r <= 'z') || + (r >= '0' && r <= '9') || + r == '+' || r == '/' || r == '=' { + base64Like++ + } + } + + if nonSpace == 0 { + return false + } + + ratio := float64(base64Like) / float64(nonSpace) + return ratio >= 0.97 && spaceCount <= len(trimmed)/128 +} + +func extensionForMIMEType(mimeType string) string { + if mimeType == "" { + return ".bin" + } + if exts, err := mime.ExtensionsByType(mimeType); err == nil && len(exts) > 0 { + return exts[0] + } + + switch strings.ToLower(mimeType) { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "audio/wav", "audio/x-wav": + return ".wav" + case "audio/mpeg": + return ".mp3" + case "audio/ogg": + return ".ogg" + case "video/mp4": + return ".mp4" + default: + return filepath.Ext(mimeType) + } +} + +func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) { + raw, exists := args[key] + if !exists { + return defaultVal, nil + } + + switch v := raw.(type) { + case float64: + if v != math.Trunc(v) { + return 0, fmt.Errorf("%s must be an integer, got float %v", key, v) + } + if v > math.MaxInt64 || v < math.MinInt64 { + return 0, fmt.Errorf("%s value %v overflows int64", key, v) + } + return int64(v), nil + case int: + return int64(v), nil + case int64: + return v, nil + case string: + parsed, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid integer format for %s parameter: %w", key, err) + } + return parsed, nil + default: + return 0, fmt.Errorf("unsupported type %T for %s parameter", raw, key) + } +} diff --git a/pkg/tools/integration/mcp_tool.go b/pkg/tools/integration/mcp_tool.go new file mode 100644 index 000000000..8cfc1de5e --- /dev/null +++ b/pkg/tools/integration/mcp_tool.go @@ -0,0 +1,688 @@ +package integrationtools + +import ( + "context" + "encoding/json" + "fmt" + "hash/fnv" + "os" + "path/filepath" + "strings" + "time" + "unicode/utf8" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +// MCPManager defines the interface for MCP manager operations +// This allows for easier testing with mock implementations +type MCPManager interface { + CallTool( + ctx context.Context, + serverName, toolName string, + arguments map[string]any, + ) (*mcp.CallToolResult, error) +} + +// MCPTool wraps an MCP tool to implement the Tool interface +type MCPTool struct { + manager MCPManager + serverName string + tool *mcp.Tool + mediaStore media.MediaStore + workspace string + maxInlineTextRunes int + runtimeEvents runtimeevents.Bus +} + +// MCPToolCallPayload describes MCP tool execution runtime events. +type MCPToolCallPayload struct { + Server string `json:"server"` + Tool string `json:"tool"` + DurationMS int64 `json:"duration_ms,omitempty"` + IsError bool `json:"is_error,omitempty"` + Error string `json:"error,omitempty"` +} + +// NewMCPTool creates a new MCP tool wrapper +func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool { + return &MCPTool{ + manager: manager, + serverName: serverName, + tool: tool, + maxInlineTextRunes: maxMCPInlineTextRunes, + } +} + +func (t *MCPTool) SetMediaStore(store media.MediaStore) { + t.mediaStore = store +} + +func (t *MCPTool) SetWorkspace(workspace string) { + t.workspace = strings.TrimSpace(workspace) +} + +func (t *MCPTool) SetMaxInlineTextRunes(limit int) { + if limit > 0 { + t.maxInlineTextRunes = limit + } +} + +// SetEventPublisher injects the runtime event bus used for MCP tool observations. +func (t *MCPTool) SetEventPublisher(eventBus runtimeevents.Bus) { + t.runtimeEvents = eventBus +} + +const maxMCPInlineTextRunes = 16 * 1024 + +// sanitizeIdentifierComponent normalizes a string so it can be safely used +// as part of a tool/function identifier for downstream providers. +// It: +// - lowercases the string +// - replaces any character not in [a-z0-9_-] with '_' +// - collapses multiple consecutive '_' into a single '_' +// - trims leading/trailing '_' +// - falls back to "unnamed" if the result is empty +// - truncates overly long components to a reasonable length +func sanitizeIdentifierComponent(s string) string { + const maxLen = 64 + + s = strings.ToLower(s) + var b strings.Builder + b.Grow(len(s)) + + prevUnderscore := false + for _, r := range s { + isAllowed := (r >= 'a' && r <= 'z') || + (r >= '0' && r <= '9') || + r == '_' || r == '-' + + if !isAllowed { + // Normalize any disallowed character to '_' + if !prevUnderscore { + b.WriteRune('_') + prevUnderscore = true + } + continue + } + + if r == '_' { + if prevUnderscore { + continue + } + prevUnderscore = true + } else { + prevUnderscore = false + } + + b.WriteRune(r) + } + + result := strings.Trim(b.String(), "_") + if result == "" { + result = "unnamed" + } + + if len(result) > maxLen { + result = result[:maxLen] + } + + return result +} + +// Name returns the tool name, prefixed with the server name. +// The total length is capped at 64 characters (OpenAI-compatible API limit). +// A short hash of the original (unsanitized) server and tool names is appended +// whenever sanitization is lossy or the name is truncated, ensuring that two +// names which differ only in disallowed characters remain distinct after sanitization. +func (t *MCPTool) Name() string { + // Prefix with server name to avoid conflicts, and sanitize components + sanitizedServer := sanitizeIdentifierComponent(t.serverName) + sanitizedTool := sanitizeIdentifierComponent(t.tool.Name) + full := fmt.Sprintf("mcp_%s_%s", sanitizedServer, sanitizedTool) + + // Check if sanitization was lossless (only lowercasing, no char replacement/truncation) + lossless := strings.ToLower(t.serverName) == sanitizedServer && + strings.ToLower(t.tool.Name) == sanitizedTool + + const maxTotal = 64 + if lossless && len(full) <= maxTotal { + return full + } + + // Sanitization was lossy or name too long: append hash of the ORIGINAL names + // (not the sanitized names) so different originals always yield different hashes. + h := fnv.New32a() + _, _ = h.Write([]byte(t.serverName + "\x00" + t.tool.Name)) + suffix := fmt.Sprintf("%08x", h.Sum32()) // 8 chars + + base := full + if len(base) > maxTotal-9 { + base = strings.TrimRight(full[:maxTotal-9], "_") + } + return base + "_" + suffix +} + +// Description returns the tool description +func (t *MCPTool) Description() string { + desc := t.tool.Description + if desc == "" { + desc = fmt.Sprintf("MCP tool from %s server", t.serverName) + } + // Add server info to description + return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc) +} + +func (t *MCPTool) PromptMetadata() toolshared.PromptMetadata { + return toolshared.PromptMetadata{ + Layer: toolshared.ToolPromptLayerCapability, + Slot: toolshared.ToolPromptSlotMCP, + Source: "mcp:" + sanitizeIdentifierComponent(t.serverName), + } +} + +// Parameters returns the tool parameters schema +func (t *MCPTool) Parameters() map[string]any { + // The InputSchema is already a JSON Schema object + schema := t.tool.InputSchema + + // Handle nil schema + if schema == nil { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + + // Try direct conversion first (fast path) + if schemaMap, ok := schema.(map[string]any); ok { + return schemaMap + } + + // Handle json.RawMessage and []byte - unmarshal directly + var jsonData []byte + if rawMsg, ok := schema.(json.RawMessage); ok { + jsonData = rawMsg + } else if bytes, ok := schema.([]byte); ok { + jsonData = bytes + } + + if jsonData != nil { + var result map[string]any + if err := json.Unmarshal(jsonData, &result); err == nil { + return result + } + // Fallback on error + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + + // For other types (structs, etc.), convert via JSON marshal/unmarshal + var err error + jsonData, err = json.Marshal(schema) + if err != nil { + // Fallback to empty schema if marshaling fails + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + + var result map[string]any + if err := json.Unmarshal(jsonData, &result); err != nil { + // Fallback to empty schema if unmarshaling fails + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + + return result +} + +// Execute executes the MCP tool +func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + startedAt := time.Now() + t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallStart, startedAt, false, "") + + result, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args) + if err != nil { + t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, err.Error()) + return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err) + } + + if result == nil { + nilErr := fmt.Errorf("MCP tool returned nil result without error") + t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, nilErr.Error()) + return ErrorResult("MCP tool execution failed: nil result").WithError(nilErr) + } + + // Handle error result from server + if result.IsError { + errMsg := extractContentText(result.Content) + t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, errMsg) + return ErrorResult(fmt.Sprintf("MCP tool returned error: %s", errMsg)). + WithError(fmt.Errorf("MCP tool error: %s", errMsg)) + } + + t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, false, "") + return t.normalizeResultContent(ctx, result.Content) +} + +func (t *MCPTool) publishRuntimeEvent( + ctx context.Context, + kind runtimeevents.Kind, + startedAt time.Time, + isError bool, + errMsg string, +) { + if t == nil || t.runtimeEvents == nil { + return + } + + scope := runtimeevents.Scope{ + AgentID: toolshared.ToolAgentID(ctx), + SessionKey: toolshared.ToolSessionKey(ctx), + Channel: toolshared.ToolChannel(ctx), + ChatID: toolshared.ToolChatID(ctx), + MessageID: toolshared.ToolMessageID(ctx), + } + payload := MCPToolCallPayload{ + Server: t.serverName, + Tool: t.tool.Name, + DurationMS: time.Since(startedAt).Milliseconds(), + IsError: isError, + Error: errMsg, + } + severity := runtimeevents.SeverityInfo + if isError { + severity = runtimeevents.SeverityError + } + + t.runtimeEvents.PublishNonBlocking(runtimeevents.Event{ + Kind: kind, + Source: runtimeevents.Source{Component: "mcp", Name: t.serverName}, + Scope: scope, + Severity: severity, + Payload: payload, + Attrs: mcpToolCallEventAttrs(payload), + }) +} + +func mcpToolCallEventAttrs(payload MCPToolCallPayload) map[string]any { + attrs := map[string]any{ + "server": payload.Server, + "tool": payload.Tool, + "duration_ms": payload.DurationMS, + } + if payload.IsError { + attrs["is_error"] = payload.IsError + } + if payload.Error != "" { + attrs["error"] = payload.Error + } + return attrs +} + +// extractContentText extracts text from MCP content array +func extractContentText(content []mcp.Content) string { + var parts []string + for _, c := range content { + switch v := c.(type) { + case *mcp.TextContent: + parts = append(parts, sanitizeToolLLMContent(v.Text)) + case *mcp.ImageContent: + parts = append(parts, fmt.Sprintf("[Image: %s]", normalizedMIMEType(v.MIMEType))) + case *mcp.AudioContent: + parts = append(parts, fmt.Sprintf("[Audio: %s]", normalizedMIMEType(v.MIMEType))) + case *mcp.ResourceLink: + parts = append(parts, summarizeResourceLink(v)) + case *mcp.EmbeddedResource: + parts = append(parts, summarizeEmbeddedResource(v)) + default: + // For other content types, use string representation + parts = append(parts, fmt.Sprintf("[Content: %T]", v)) + } + } + return sanitizeToolLLMContent(strings.Join(parts, "\n")) +} + +func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Content) *ToolResult { + llmParts := make([]string, 0, len(content)) + rawTextParts := make([]string, 0, len(content)) + mediaRefs := make([]string, 0, len(content)) + + for _, c := range content { + switch v := c.(type) { + case *mcp.TextContent: + rawText := strings.TrimSpace(v.Text) + if rawText != "" { + rawTextParts = append(rawTextParts, rawText) + } + safeText := strings.TrimSpace(sanitizeToolLLMContent(v.Text)) + if safeText != "" { + llmParts = append(llmParts, safeText) + } + case *mcp.ImageContent: + ref, note := t.storeBinaryContent( + ctx, + "image", + normalizedMIMEType(v.MIMEType), + v.Data, + v.Annotations, + ) + if ref != "" { + mediaRefs = append(mediaRefs, ref) + } + if note != "" { + llmParts = append(llmParts, note) + } + case *mcp.AudioContent: + ref, note := t.storeBinaryContent( + ctx, + "audio", + normalizedMIMEType(v.MIMEType), + v.Data, + v.Annotations, + ) + if ref != "" { + mediaRefs = append(mediaRefs, ref) + } + if note != "" { + llmParts = append(llmParts, note) + } + case *mcp.ResourceLink: + llmParts = append(llmParts, summarizeResourceLink(v)) + case *mcp.EmbeddedResource: + ref, note, rawText := t.storeEmbeddedResource(ctx, v) + if ref != "" { + mediaRefs = append(mediaRefs, ref) + } + if rawText != "" { + rawTextParts = append(rawTextParts, rawText) + } + if note != "" { + llmParts = append(llmParts, note) + } + default: + llmParts = append(llmParts, fmt.Sprintf("[MCP returned unsupported content type %T]", v)) + } + } + + forLLM := strings.Join(compactStrings(llmParts), "\n") + rawText := strings.Join(compactStrings(rawTextParts), "\n") + if artifactResult := t.persistLargeTextArtifact(rawText); artifactResult != nil { + artifactResult.Media = mediaRefs + return artifactResult + } + + result := &ToolResult{ + ForLLM: forLLM, + Media: mediaRefs, + } + return result +} + +func (t *MCPTool) persistLargeTextArtifact(text string) *ToolResult { + text = strings.TrimSpace(text) + limit := t.maxInlineTextRunes + if limit <= 0 { + limit = maxMCPInlineTextRunes + } + size := utf8.RuneCountInString(text) + if text == "" || size <= limit || t.workspace == "" { + return nil + } + + dir := filepath.Join(t.workspace, ".artifacts", "mcp") + if err := os.MkdirAll(dir, 0o700); err != nil { + return t.largeTextArtifactFallback(text, err) + } + // TODO: Add lifecycle cleanup/retention for MCP artifact files. + + pattern := fmt.Sprintf( + "%s_%s_*.txt", + sanitizeIdentifierComponent(t.serverName), + sanitizeIdentifierComponent(t.tool.Name), + ) + tmpFile, err := os.CreateTemp(dir, pattern) + if err != nil { + return t.largeTextArtifactFallback(text, err) + } + path := tmpFile.Name() + if _, err = tmpFile.WriteString(text); err != nil { + _ = tmpFile.Close() + _ = os.Remove(path) + return t.largeTextArtifactFallback(text, err) + } + if err = tmpFile.Close(); err != nil { + _ = os.Remove(path) + return t.largeTextArtifactFallback(text, err) + } + + return &ToolResult{ + ForLLM: fmt.Sprintf( + "[MCP returned a large text result (%d chars); omitted from model context and saved as a local artifact.]", + size, + ), + ArtifactTags: []string{"[file:" + path + "]"}, + } +} + +func (t *MCPTool) largeTextArtifactFallback(text string, err error) *ToolResult { + size := utf8.RuneCountInString(text) + logger.WarnCF("tool", "Failed to persist large MCP text artifact", map[string]any{ + "server": t.serverName, + "tool": t.tool.Name, + "chars": size, + "error": err.Error(), + }) + return &ToolResult{ + ForLLM: fmt.Sprintf( + "[MCP returned a large text result (%d chars); omitted from model context because artifact persistence failed.]", + size, + ), + } +} + +func (t *MCPTool) storeEmbeddedResource(ctx context.Context, content *mcp.EmbeddedResource) (string, string, string) { + if content == nil || content.Resource == nil { + return "", "[MCP returned an embedded resource without data.]", "" + } + + resource := content.Resource + if len(resource.Blob) > 0 { + ref, note := t.storeBinaryContent( + ctx, + "resource", + normalizedMIMEType(resource.MIMEType), + resource.Blob, + content.Annotations, + ) + return ref, note, "" + } + + rawText := strings.TrimSpace(resource.Text) + if rawText != "" { + return "", sanitizeToolLLMContent(resource.Text), rawText + } + + return "", summarizeEmbeddedResource(content), "" +} + +func (t *MCPTool) storeBinaryContent( + ctx context.Context, + kind string, + mimeType string, + data []byte, + annotations *mcp.Annotations, +) (string, string) { + if len(data) == 0 { + return "", fmt.Sprintf("[MCP returned %s content (%s) but it was empty.]", kind, mimeType) + } + if !annotationsAllowUser(annotations) { + return "", fmt.Sprintf( + "[MCP returned %s content (%s) for non-user audience; omitted from model context.]", + kind, + mimeType, + ) + } + if t.mediaStore == nil { + return "", fmt.Sprintf( + "[MCP returned %s content (%s); omitted from model context because media delivery is unavailable.]", + kind, + mimeType, + ) + } + + channel := ToolChannel(ctx) + chatID := ToolChatID(ctx) + if channel == "" || chatID == "" { + return "", fmt.Sprintf( + "[MCP returned %s content (%s); omitted from model context because no target chat was available.]", + kind, + mimeType, + ) + } + + dir := media.TempDir() + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType) + } + + ext := extensionForMIMEType(mimeType) + tmpFile, err := os.CreateTemp(dir, "mcp-*"+ext) + if err != nil { + return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType) + } + tmpPath := tmpFile.Name() + if _, err = tmpFile.Write(data); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpPath) + return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType) + } + if err = tmpFile.Close(); err != nil { + _ = os.Remove(tmpPath) + return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType) + } + + scope := fmt.Sprintf( + "tool:mcp:%s:%s:%s:%d", + sanitizeIdentifierComponent(t.serverName), + channel, + chatID, + time.Now().UnixNano(), + ) + filename := fmt.Sprintf( + "%s_%s%s", + sanitizeIdentifierComponent(t.serverName), + sanitizeIdentifierComponent(t.tool.Name), + ext, + ) + + ref, err := t.mediaStore.Store(tmpPath, media.MediaMeta{ + Filename: filename, + ContentType: mimeType, + Source: fmt.Sprintf( + "tool:mcp:%s:%s", + sanitizeIdentifierComponent(t.serverName), + sanitizeIdentifierComponent(t.tool.Name), + ), + }, scope) + if err != nil { + _ = os.Remove(tmpPath) + return "", fmt.Sprintf( + "[MCP returned %s content (%s) but it could not be registered as media.]", + kind, + mimeType, + ) + } + + return ref, fmt.Sprintf( + "[MCP returned %s content (%s); omitted from model context and stored as a local media artifact.]", + kind, + mimeType, + ) +} + +func summarizeResourceLink(content *mcp.ResourceLink) string { + if content == nil { + return "[MCP returned an empty resource link.]" + } + + parts := []string{"[MCP returned resource link"} + if content.Name != "" { + parts = append(parts, fmt.Sprintf("name=%q", content.Name)) + } + if content.URI != "" { + parts = append(parts, fmt.Sprintf("uri=%q", content.URI)) + } + if content.MIMEType != "" { + parts = append(parts, fmt.Sprintf("mime=%q", content.MIMEType)) + } + if content.Description != "" { + desc := strings.TrimSpace(content.Description) + if len(desc) > 200 { + desc = desc[:200] + "..." + } + parts = append(parts, fmt.Sprintf("description=%q", desc)) + } + return strings.Join(parts, ", ") + "]" +} + +func summarizeEmbeddedResource(content *mcp.EmbeddedResource) string { + if content == nil || content.Resource == nil { + return "[MCP returned an embedded resource.]" + } + + resource := content.Resource + if resource.URI != "" { + return fmt.Sprintf( + "[MCP returned embedded resource %q (%s).]", + resource.URI, + normalizedMIMEType(resource.MIMEType), + ) + } + return fmt.Sprintf("[MCP returned embedded resource (%s).]", normalizedMIMEType(resource.MIMEType)) +} + +func annotationsAllowUser(annotations *mcp.Annotations) bool { + if annotations == nil || len(annotations.Audience) == 0 { + return true + } + for _, audience := range annotations.Audience { + if strings.EqualFold(string(audience), "user") { + return true + } + } + return false +} + +func normalizedMIMEType(mimeType string) string { + if strings.TrimSpace(mimeType) == "" { + return "application/octet-stream" + } + return mimeType +} + +func compactStrings(parts []string) []string { + compact := make([]string, 0, len(parts)) + for _, part := range parts { + if strings.TrimSpace(part) == "" { + continue + } + compact = append(compact, part) + } + return compact +} diff --git a/pkg/tools/integration/mcp_tool_test.go b/pkg/tools/integration/mcp_tool_test.go new file mode 100644 index 000000000..7c961e1e1 --- /dev/null +++ b/pkg/tools/integration/mcp_tool_test.go @@ -0,0 +1,900 @@ +package integrationtools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/media" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +// MockMCPManager is a mock implementation of MCPManager interface for testing +type MockMCPManager struct { + callToolFunc func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) +} + +func (m *MockMCPManager) CallTool( + ctx context.Context, + serverName, toolName string, + arguments map[string]any, +) (*mcp.CallToolResult, error) { + if m.callToolFunc != nil { + return m.callToolFunc(ctx, serverName, toolName, arguments) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "mock result"}, + }, + IsError: false, + }, nil +} + +// TestNewMCPTool verifies MCP tool creation +func TestNewMCPTool(t *testing.T) { + manager := &MockMCPManager{} + tool := &mcp.Tool{ + Name: "test_tool", + Description: "A test tool", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "input": map[string]any{ + "type": "string", + "description": "Test input", + }, + }, + }, + } + + mcpTool := NewMCPTool(manager, "test_server", tool) + + if mcpTool == nil { + t.Fatal("NewMCPTool should not return nil") + } + // Verify tool properties we can access + if mcpTool.Name() != "mcp_test_server_test_tool" { + t.Errorf("Expected tool name with prefix, got '%s'", mcpTool.Name()) + } +} + +// TestMCPTool_Name verifies tool name with server prefix +func TestMCPTool_Name(t *testing.T) { + tests := []struct { + name string + serverName string + toolName string + expected string + }{ + { + name: "simple name", + serverName: "github", + toolName: "create_issue", + expected: "mcp_github_create_issue", + }, + { + name: "filesystem server", + serverName: "filesystem", + toolName: "read_file", + expected: "mcp_filesystem_read_file", + }, + { + name: "remote server", + serverName: "remote-api", + toolName: "fetch_data", + expected: "mcp_remote-api_fetch_data", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &MockMCPManager{} + tool := &mcp.Tool{Name: tt.toolName} + mcpTool := NewMCPTool(manager, tt.serverName, tool) + + result := mcpTool.Name() + if result != tt.expected { + t.Errorf("Expected name '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestMCPTool_PromptMetadata(t *testing.T) { + manager := &MockMCPManager{} + tool := NewMCPTool(manager, "GitHub Server", &mcp.Tool{Name: "create_issue"}) + + metadata := tool.PromptMetadata() + if metadata.Layer != toolshared.ToolPromptLayerCapability { + t.Fatalf("metadata.Layer = %q, want %q", metadata.Layer, toolshared.ToolPromptLayerCapability) + } + if metadata.Slot != toolshared.ToolPromptSlotMCP { + t.Fatalf("metadata.Slot = %q, want %q", metadata.Slot, toolshared.ToolPromptSlotMCP) + } + if metadata.Source != "mcp:github_server" { + t.Fatalf("metadata.Source = %q, want mcp:github_server", metadata.Source) + } +} + +// TestMCPTool_Description verifies tool description generation +func TestMCPTool_Description(t *testing.T) { + tests := []struct { + name string + serverName string + toolDescription string + expectContains []string + }{ + { + name: "with description", + serverName: "github", + toolDescription: "Create a GitHub issue", + expectContains: []string{"[MCP:github]", "Create a GitHub issue"}, + }, + { + name: "empty description", + serverName: "filesystem", + toolDescription: "", + expectContains: []string{"[MCP:filesystem]", "MCP tool from filesystem server"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &MockMCPManager{} + tool := &mcp.Tool{ + Name: "test_tool", + Description: tt.toolDescription, + } + mcpTool := NewMCPTool(manager, tt.serverName, tool) + + result := mcpTool.Description() + + for _, expected := range tt.expectContains { + if !strings.Contains(result, expected) { + t.Errorf("Description should contain '%s', got: %s", expected, result) + } + } + }) + } +} + +// TestMCPTool_Parameters verifies parameter schema conversion +func TestMCPTool_Parameters(t *testing.T) { + tests := []struct { + name string + inputSchema any + expectType string + checkProperty string + expectProperty bool + }{ + { + name: "map schema", + inputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": "Search query", + }, + }, + "required": []string{"query"}, + }, + expectType: "object", + checkProperty: "query", + expectProperty: true, + }, + { + name: "nil schema", + inputSchema: nil, + expectType: "object", + expectProperty: false, + }, + { + name: "json.RawMessage schema", + inputSchema: []byte(`{ + "type": "object", + "properties": { + "repo": { + "type": "string", + "description": "Repository name" + }, + "stars": { + "type": "integer", + "description": "Minimum stars" + } + }, + "required": ["repo"] + }`), + expectType: "object", + checkProperty: "repo", + expectProperty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &MockMCPManager{} + tool := &mcp.Tool{ + Name: "test_tool", + InputSchema: tt.inputSchema, + } + mcpTool := NewMCPTool(manager, "test_server", tool) + + params := mcpTool.Parameters() + + if params == nil { + t.Fatal("Parameters should not be nil") + } + + if params["type"] != tt.expectType { + t.Errorf("Expected type '%s', got '%v'", tt.expectType, params["type"]) + } + + // Check if property exists when expected + if tt.checkProperty != "" { + properties, ok := params["properties"].(map[string]any) + if !ok && tt.expectProperty { + t.Errorf("Expected properties to be a map") + return + } + if ok { + _, hasProperty := properties[tt.checkProperty] + if hasProperty != tt.expectProperty { + t.Errorf("Expected property '%s' existence: %v, got: %v", + tt.checkProperty, tt.expectProperty, hasProperty) + } + } + } + }) + } +} + +// TestMCPTool_Execute_Success tests successful tool execution +func TestMCPTool_Execute_Success(t *testing.T) { + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + // Verify correct parameters passed + if serverName != "github" { + t.Errorf("Expected serverName 'github', got '%s'", serverName) + } + if toolName != "search_repos" { + t.Errorf("Expected toolName 'search_repos', got '%s'", toolName) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Found 3 repositories"}, + }, + IsError: false, + }, nil + }, + } + + tool := &mcp.Tool{ + Name: "search_repos", + Description: "Search GitHub repositories", + } + mcpTool := NewMCPTool(manager, "github", tool) + + ctx := context.Background() + args := map[string]any{ + "query": "golang mcp", + } + + result := mcpTool.Execute(ctx, args) + + if result == nil { + t.Fatal("Result should not be nil") + } + if result.IsError { + t.Errorf("Expected no error, got error: %s", result.ForLLM) + } + if result.ForLLM != "Found 3 repositories" { + t.Errorf("Expected 'Found 3 repositories', got '%s'", result.ForLLM) + } +} + +func TestMCPTool_Execute_PublishesRuntimeEvents(t *testing.T) { + eventBus := runtimeevents.NewBus() + defer func() { + if err := eventBus.Close(); err != nil { + t.Errorf("event bus close failed: %v", err) + } + }() + + _, eventsCh, err := eventBus.Channel().OfKind( + runtimeevents.KindMCPToolCallStart, + runtimeevents.KindMCPToolCallEnd, + ).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "mcp-tool-events", Buffer: 2}) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + manager := &MockMCPManager{} + mcpTool := NewMCPTool(manager, "github", &mcp.Tool{Name: "search_repos"}) + mcpTool.SetEventPublisher(eventBus) + + ctx := toolshared.WithToolContext(context.Background(), "telegram", "chat-1") + ctx = toolshared.WithToolMessageContext(ctx, "msg-1", "") + ctx = toolshared.WithToolSessionContext(ctx, "main", "session-1", nil) + result := mcpTool.Execute(ctx, map[string]any{"query": "picoclaw"}) + if result == nil || result.IsError { + t.Fatalf("Execute result = %+v", result) + } + + started := receiveMCPToolRuntimeEvent(t, eventsCh) + if started.Kind != runtimeevents.KindMCPToolCallStart || + started.Scope.AgentID != "main" || + started.Scope.SessionKey != "session-1" || + started.Scope.Channel != "telegram" || + started.Scope.ChatID != "chat-1" || + started.Scope.MessageID != "msg-1" { + t.Fatalf("started event = %+v", started) + } + + ended := receiveMCPToolRuntimeEvent(t, eventsCh) + if ended.Kind != runtimeevents.KindMCPToolCallEnd || ended.Severity != runtimeevents.SeverityInfo { + t.Fatalf("ended event = %+v", ended) + } + payload, ok := ended.Payload.(MCPToolCallPayload) + if !ok { + t.Fatalf("ended payload = %T, want MCPToolCallPayload", ended.Payload) + } + if payload.Server != "github" || payload.Tool != "search_repos" || payload.IsError { + t.Fatalf("ended payload = %+v", payload) + } + if ended.Attrs["server"] != "github" || + ended.Attrs["tool"] != "search_repos" || + ended.Attrs["duration_ms"] == nil { + t.Fatalf("ended attrs = %#v", ended.Attrs) + } +} + +func receiveMCPToolRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event { + t.Helper() + + select { + case evt, ok := <-ch: + if !ok { + t.Fatal("runtime event channel closed before expected event") + } + return evt + case <-time.After(time.Second): + t.Fatal("timed out waiting for runtime event") + return runtimeevents.Event{} + } +} + +// TestMCPTool_Execute_ManagerError tests execution when manager returns error +func TestMCPTool_Execute_ManagerError(t *testing.T) { + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + return nil, fmt.Errorf("connection failed") + }, + } + + tool := &mcp.Tool{Name: "test_tool"} + mcpTool := NewMCPTool(manager, "test_server", tool) + + ctx := context.Background() + result := mcpTool.Execute(ctx, map[string]any{}) + + if result == nil { + t.Fatal("Result should not be nil") + } + if !result.IsError { + t.Error("Expected IsError to be true") + } + if !strings.Contains(result.ForLLM, "MCP tool execution failed") { + t.Errorf("Error message should mention execution failure, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "connection failed") { + t.Errorf("Error message should include original error, got: %s", result.ForLLM) + } +} + +// TestMCPTool_Execute_ServerError tests execution when server returns error +func TestMCPTool_Execute_ServerError(t *testing.T) { + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Invalid API key"}, + }, + IsError: true, + }, nil + }, + } + + tool := &mcp.Tool{Name: "test_tool"} + mcpTool := NewMCPTool(manager, "test_server", tool) + + ctx := context.Background() + result := mcpTool.Execute(ctx, map[string]any{}) + + if result == nil { + t.Fatal("Result should not be nil") + } + if !result.IsError { + t.Error("Expected IsError to be true") + } + if !strings.Contains(result.ForLLM, "MCP tool returned error") { + t.Errorf("Error message should mention server error, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Invalid API key") { + t.Errorf("Error message should include server message, got: %s", result.ForLLM) + } +} + +// TestMCPTool_Execute_MultipleContent tests execution with multiple content items +func TestMCPTool_Execute_MultipleContent(t *testing.T) { + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "First line"}, + &mcp.TextContent{Text: "Second line"}, + &mcp.TextContent{Text: "Third line"}, + }, + IsError: false, + }, nil + }, + } + + tool := &mcp.Tool{Name: "multi_output"} + mcpTool := NewMCPTool(manager, "test_server", tool) + + ctx := context.Background() + result := mcpTool.Execute(ctx, map[string]any{}) + + if result.IsError { + t.Errorf("Expected no error, got: %s", result.ForLLM) + } + + expected := "First line\nSecond line\nThird line" + if result.ForLLM != expected { + t.Errorf("Expected '%s', got '%s'", expected, result.ForLLM) + } +} + +// TestExtractContentText_TextContent tests text content extraction +func TestExtractContentText_TextContent(t *testing.T) { + content := []mcp.Content{ + &mcp.TextContent{Text: "Hello World"}, + &mcp.TextContent{Text: "Second message"}, + } + + result := extractContentText(content) + expected := "Hello World\nSecond message" + + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } +} + +// TestExtractContentText_ImageContent tests image content extraction +func TestExtractContentText_ImageContent(t *testing.T) { + content := []mcp.Content{ + &mcp.ImageContent{ + Data: []byte("base64data"), + MIMEType: "image/png", + }, + } + + result := extractContentText(content) + + if !strings.Contains(result, "[Image:") { + t.Errorf("Expected image indicator, got: %s", result) + } + if !strings.Contains(result, "image/png") { + t.Errorf("Expected MIME type in output, got: %s", result) + } +} + +// TestExtractContentText_MixedContent tests mixed content types +func TestExtractContentText_MixedContent(t *testing.T) { + content := []mcp.Content{ + &mcp.TextContent{Text: "Description"}, + &mcp.ImageContent{ + Data: []byte("data"), + MIMEType: "image/jpeg", + }, + &mcp.TextContent{Text: "More text"}, + } + + result := extractContentText(content) + + if !strings.Contains(result, "Description") { + t.Errorf("Should contain text content, got: %s", result) + } + if !strings.Contains(result, "[Image:") { + t.Errorf("Should contain image indicator, got: %s", result) + } + if !strings.Contains(result, "More text") { + t.Errorf("Should contain second text, got: %s", result) + } +} + +// TestExtractContentText_EmptyContent tests empty content array +func TestExtractContentText_EmptyContent(t *testing.T) { + content := []mcp.Content{} + + result := extractContentText(content) + + if result != "" { + t.Errorf("Expected empty string for empty content, got: %s", result) + } +} + +// TestMCPTool_InterfaceCompliance verifies MCPTool implements Tool interface +func TestMCPTool_InterfaceCompliance(t *testing.T) { + manager := &MockMCPManager{} + tool := &mcp.Tool{Name: "test"} + mcpTool := NewMCPTool(manager, "test_server", tool) + + // Verify it implements Tool interface + var _ Tool = mcpTool +} + +// TestMCPTool_Parameters_MapSchema tests schema that's already a map +func TestMCPTool_Parameters_MapSchema(t *testing.T) { + manager := &MockMCPManager{} + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + "description": "The name parameter", + }, + }, + "required": []string{"name"}, + } + + tool := &mcp.Tool{ + Name: "test_tool", + InputSchema: schema, + } + mcpTool := NewMCPTool(manager, "test_server", tool) + + params := mcpTool.Parameters() + + // Should return the schema as-is when it's already a map + if params["type"] != "object" { + t.Errorf("Expected type 'object', got '%v'", params["type"]) + } + + props, ok := params["properties"].(map[string]any) + if !ok { + t.Error("Properties should be a map") + } + + nameParam, ok := props["name"].(map[string]any) + if !ok { + t.Error("Name parameter should exist") + } + + if nameParam["type"] != "string" { + t.Errorf("Name type should be 'string', got '%v'", nameParam["type"]) + } +} + +func TestMCPTool_Execute_ImageContentStoredAsMedia(t *testing.T) { + store := media.NewFileMediaStore() + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.ImageContent{ + Data: []byte("fake-image-bytes"), + MIMEType: "image/png", + }, + }, + }, nil + }, + } + + mcpTool := NewMCPTool(manager, "screenshoto", &mcp.Tool{Name: "take_screenshot"}) + mcpTool.SetMediaStore(store) + + result := mcpTool.Execute(WithToolContext(context.Background(), "telegram", "chat-42"), nil) + + if result.IsError { + t.Fatalf("expected success, got %q", result.ForLLM) + } + if len(result.Media) != 1 { + t.Fatalf("expected 1 media ref, got %d", len(result.Media)) + } + if result.ResponseHandled { + t.Fatal("expected MCP image artifact not to mark response as handled") + } + if !strings.Contains(result.ForLLM, "stored as a local media artifact") { + t.Fatalf("expected local media artifact note, got %q", result.ForLLM) + } + + path, meta, err := store.ResolveWithMeta(result.Media[0]) + if err != nil { + t.Fatalf("expected stored media ref to resolve: %v", err) + } + if meta.ContentType != "image/png" { + t.Fatalf("expected image/png content type, got %q", meta.ContentType) + } + if filepath.Ext(path) != ".png" { + t.Fatalf("expected png temp file, got %q", path) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected stored media file to be readable: %v", err) + } + if string(data) != "fake-image-bytes" { + t.Fatalf("expected stored media bytes to match input, got %q", string(data)) + } +} + +func TestMCPTool_Execute_EmbeddedResourceBlobStoredAsMedia(t *testing.T) { + store := media.NewFileMediaStore() + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "file:///tmp/report.png", + MIMEType: "image/png", + Blob: []byte("blob-bytes"), + }, + }, + }, + }, nil + }, + } + + mcpTool := NewMCPTool(manager, "grafana", &mcp.Tool{Name: "get_dashboard_image"}) + mcpTool.SetMediaStore(store) + + result := mcpTool.Execute(WithToolContext(context.Background(), "telegram", "chat-42"), nil) + + if len(result.Media) != 1 { + t.Fatalf("expected embedded resource blob to be stored as media, got %d refs", len(result.Media)) + } + path, _, err := store.ResolveWithMeta(result.Media[0]) + if err != nil { + t.Fatalf("expected stored media ref to resolve: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected stored media file to be readable: %v", err) + } + if string(data) != "blob-bytes" { + t.Fatalf("expected stored blob bytes to match input, got %q", string(data)) + } +} + +func TestMCPTool_Execute_RespectsUserAudienceForBinaryContent(t *testing.T) { + store := media.NewFileMediaStore() + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.ImageContent{ + Data: []byte("assistant-only"), + MIMEType: "image/png", + Annotations: &mcp.Annotations{Audience: []mcp.Role{"assistant"}}, + }, + }, + }, nil + }, + } + + mcpTool := NewMCPTool(manager, "screenshoto", &mcp.Tool{Name: "take_screenshot"}) + mcpTool.SetMediaStore(store) + + result := mcpTool.Execute(WithToolContext(context.Background(), "telegram", "chat-42"), nil) + + if len(result.Media) != 0 { + t.Fatalf("expected no media ref for non-user audience, got %d", len(result.Media)) + } + if !strings.Contains(result.ForLLM, "non-user audience") { + t.Fatalf("expected audience note, got %q", result.ForLLM) + } +} + +func TestMCPTool_Execute_LargeBase64TextIsOmittedFromContext(t *testing.T) { + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: strings.Repeat("QUJD", 400)}, + }, + }, nil + }, + } + + mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"}) + + result := mcpTool.Execute(context.Background(), nil) + + if result.ForLLM != largeBase64OmittedMessage { + t.Fatalf("expected sanitized large base64 note, got %q", result.ForLLM) + } +} + +func TestMCPTool_Execute_LargeBase64TextArtifactPreservesRawPayload(t *testing.T) { + workspace := t.TempDir() + largeBase64 := strings.Repeat("QUJD", 400) + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: largeBase64}, + }, + }, nil + }, + } + + mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"}) + mcpTool.SetWorkspace(workspace) + mcpTool.SetMaxInlineTextRunes(32) + + result := mcpTool.Execute(context.Background(), nil) + + if !strings.Contains(result.ForLLM, "saved as a local artifact") { + t.Fatalf("expected artifact note, got %q", result.ForLLM) + } + if result.ForLLM == largeBase64OmittedMessage { + t.Fatalf("expected artifact note instead of sanitized base64 placeholder") + } + if len(result.ArtifactTags) != 1 { + t.Fatalf("expected 1 artifact tag, got %d", len(result.ArtifactTags)) + } + tag := result.ArtifactTags[0] + const prefix = "[file:" + if !strings.HasPrefix(tag, prefix) || !strings.HasSuffix(tag, "]") { + t.Fatalf("expected file artifact tag, got %q", tag) + } + path := strings.TrimSuffix(strings.TrimPrefix(tag, prefix), "]") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected artifact file to be readable: %v", err) + } + if string(data) != largeBase64 { + t.Fatalf("expected artifact file contents to preserve raw MCP payload") + } +} + +func TestMCPTool_Execute_LargeTextStoredAsArtifact(t *testing.T) { + workspace := t.TempDir() + largeText := strings.Repeat("This is a large MCP text payload.\n", 800) + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: largeText}, + }, + }, nil + }, + } + + mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"}) + mcpTool.SetWorkspace(workspace) + + result := mcpTool.Execute(context.Background(), nil) + + if strings.Contains(result.ForLLM, "This is a large MCP text payload") { + t.Fatalf("expected large MCP text to be omitted from ForLLM, got %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "saved as a local artifact") { + t.Fatalf("expected artifact note, got %q", result.ForLLM) + } + if len(result.ArtifactTags) != 1 { + t.Fatalf("expected 1 artifact tag, got %d", len(result.ArtifactTags)) + } + tag := result.ArtifactTags[0] + const prefix = "[file:" + if !strings.HasPrefix(tag, prefix) || !strings.HasSuffix(tag, "]") { + t.Fatalf("expected file artifact tag, got %q", tag) + } + path := strings.TrimSuffix(strings.TrimPrefix(tag, prefix), "]") + if !strings.HasPrefix(path, workspace) { + t.Fatalf("expected artifact inside workspace, got %q", path) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected artifact file to be readable: %v", err) + } + if string(data) != strings.TrimSpace(largeText) { + t.Fatalf("expected artifact file contents to match source text") + } +} + +func TestMCPTool_Execute_CustomInlineTextThreshold(t *testing.T) { + workspace := t.TempDir() + text := strings.Repeat("small custom threshold text\n", 20) + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: text}, + }, + }, nil + }, + } + + mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"}) + mcpTool.SetWorkspace(workspace) + mcpTool.SetMaxInlineTextRunes(32) + + result := mcpTool.Execute(context.Background(), nil) + + if len(result.ArtifactTags) != 1 { + t.Fatalf("expected custom threshold to persist artifact, got %+v", result) + } + if strings.Contains(result.ForLLM, "small custom threshold text") { + t.Fatalf("expected text to be omitted from ForLLM, got %q", result.ForLLM) + } +} + +func TestMCPTool_Execute_LargeTextArtifactFailureStillOmitsContext(t *testing.T) { + workspaceRoot := t.TempDir() + workspaceFile := filepath.Join(workspaceRoot, "not-a-directory") + if err := os.WriteFile(workspaceFile, []byte("x"), 0o600); err != nil { + t.Fatalf("failed to create workspace file: %v", err) + } + + largeText := strings.Repeat("This is a large MCP text payload.\n", 800) + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: largeText}, + }, + }, nil + }, + } + + mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"}) + mcpTool.SetWorkspace(workspaceFile) + + result := mcpTool.Execute(context.Background(), nil) + + if strings.Contains(result.ForLLM, "This is a large MCP text payload") { + t.Fatalf("expected large MCP text to be omitted from ForLLM, got %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "artifact persistence failed") { + t.Fatalf("expected persistence failure note, got %q", result.ForLLM) + } + if len(result.ArtifactTags) != 0 { + t.Fatalf("expected no artifact tags on persistence failure, got %+v", result.ArtifactTags) + } +} + +func TestMCPTool_Execute_WhitespaceWorkspaceDisablesArtifactPersistence(t *testing.T) { + largeText := strings.Repeat("This is a large MCP text payload.\n", 800) + manager := &MockMCPManager{ + callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: largeText}, + }, + }, nil + }, + } + + mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"}) + mcpTool.SetWorkspace(" \n\t ") + + result := mcpTool.Execute(context.Background(), nil) + + if len(result.ArtifactTags) != 0 { + t.Fatalf("expected no artifact tags for whitespace workspace, got %+v", result.ArtifactTags) + } + if !strings.Contains(result.ForLLM, "This is a large MCP text payload") { + t.Fatalf("expected large text to remain inline when workspace is blank, got %q", result.ForLLM) + } +} diff --git a/pkg/tools/integration/message.go b/pkg/tools/integration/message.go new file mode 100644 index 000000000..98d87bcb3 --- /dev/null +++ b/pkg/tools/integration/message.go @@ -0,0 +1,143 @@ +package integrationtools + +import ( + "context" + "fmt" + "sync" +) + +type SendCallbackWithContext func(ctx context.Context, channel, chatID, content, replyToMessageID string) error + +// sentTarget records the channel+chatID that the message tool sent to. +type sentTarget struct { + Channel string + ChatID string +} + +type MessageTool struct { + sendCallback SendCallbackWithContext + mu sync.Mutex + // sentTargets tracks targets sent to in the current round, keyed by session key + // to support parallel turns for different sessions. + sentTargets map[string][]sentTarget +} + +func NewMessageTool() *MessageTool { + return &MessageTool{ + sentTargets: make(map[string][]sentTarget), + } +} + +func (t *MessageTool) Name() string { + return "message" +} + +func (t *MessageTool) Description() string { + return "Send a message to user on a chat channel. Use this when you want to communicate something." +} + +func (t *MessageTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "content": map[string]any{ + "type": "string", + "description": "The message content to send", + }, + "channel": map[string]any{ + "type": "string", + "description": "Optional: target channel (telegram, whatsapp, etc.)", + }, + "chat_id": map[string]any{ + "type": "string", + "description": "Optional: target chat/user ID", + }, + "reply_to_message_id": map[string]any{ + "type": "string", + "description": "Optional: reply target message ID for channels that support threaded replies", + }, + }, + "required": []string{"content"}, + } +} + +// ResetSentInRound resets the per-round send tracker for the given session key. +// Called by the agent loop at the start of each inbound message processing round. +func (t *MessageTool) ResetSentInRound(sessionKey string) { + t.mu.Lock() + defer t.mu.Unlock() + + // Delete the key entirely to prevent unbounded map growth over time + // with many unique sessions. Truncating the slice keeps the key alive. + delete(t.sentTargets, sessionKey) +} + +// HasSentInRound returns true if the message tool sent a message during the current round. +func (t *MessageTool) HasSentInRound(sessionKey string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return len(t.sentTargets[sessionKey]) > 0 +} + +// HasSentTo returns true if the message tool sent to the specific channel+chatID +// during the current round. Used by PublishResponseIfNeeded to avoid suppressing +// the final response when the message tool only sent to a different conversation. +func (t *MessageTool) HasSentTo(sessionKey, channel, chatID string) bool { + t.mu.Lock() + defer t.mu.Unlock() + for _, st := range t.sentTargets[sessionKey] { + if st.Channel == channel && st.ChatID == chatID { + return true + } + } + return false +} + +func (t *MessageTool) SetSendCallback(callback SendCallbackWithContext) { + t.sendCallback = callback +} + +func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + content, ok := args["content"].(string) + if !ok { + return &ToolResult{ForLLM: "content is required", IsError: true} + } + + channel, _ := args["channel"].(string) + chatID, _ := args["chat_id"].(string) + replyToMessageID, _ := args["reply_to_message_id"].(string) + + if channel == "" { + channel = ToolChannel(ctx) + } + if chatID == "" { + chatID = ToolChatID(ctx) + } + + if channel == "" || chatID == "" { + return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true} + } + + if t.sendCallback == nil { + return &ToolResult{ForLLM: "Message sending not configured", IsError: true} + } + + if err := t.sendCallback(ctx, channel, chatID, content, replyToMessageID); err != nil { + return &ToolResult{ + ForLLM: fmt.Sprintf("sending message: %v", err), + IsError: true, + Err: err, + } + } + + sessionKey := ToolSessionKey(ctx) + t.mu.Lock() + t.sentTargets[sessionKey] = append(t.sentTargets[sessionKey], sentTarget{Channel: channel, ChatID: chatID}) + t.mu.Unlock() + + // Silent: user already received the message directly + return &ToolResult{ + ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID), + Silent: true, + } +} diff --git a/pkg/tools/message_test.go b/pkg/tools/integration/message_test.go similarity index 68% rename from pkg/tools/message_test.go rename to pkg/tools/integration/message_test.go index 05630972e..c7b7d2b6e 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/integration/message_test.go @@ -1,19 +1,25 @@ -package tools +package integrationtools import ( "context" "errors" "testing" + + "github.com/sipeed/picoclaw/pkg/session" ) func TestMessageTool_Execute_Success(t *testing.T) { tool := NewMessageTool() var sentChannel, sentChatID, sentContent string - tool.SetSendCallback(func(channel, chatID, content string) error { + tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error { sentChannel = channel sentChatID = chatID sentContent = content + if ToolAgentID(ctx) != "" || ToolSessionKey(ctx) != "" || ToolSessionScope(ctx) != nil { + t.Fatalf("expected empty turn metadata in basic context, got agent=%q session=%q scope=%+v", + ToolAgentID(ctx), ToolSessionKey(ctx), ToolSessionScope(ctx)) + } return nil }) @@ -61,7 +67,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { tool := NewMessageTool() var sentChannel, sentChatID string - tool.SetSendCallback(func(channel, chatID, content string) error { + tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error { sentChannel = channel sentChatID = chatID return nil @@ -96,7 +102,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) { tool := NewMessageTool() sendErr := errors.New("network error") - tool.SetSendCallback(func(channel, chatID, content string) error { + tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error { return sendErr }) @@ -149,7 +155,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) { tool := NewMessageTool() // No WithToolContext — channel/chatID are empty - tool.SetSendCallback(func(channel, chatID, content string) error { + tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error { return nil }) @@ -251,4 +257,75 @@ func TestMessageTool_Parameters(t *testing.T) { if chatIDProp["type"] != "string" { t.Error("Expected chat_id type to be 'string'") } + + // Check reply_to_message_id property (optional) + replyToProp, ok := props["reply_to_message_id"].(map[string]any) + if !ok { + t.Error("Expected 'reply_to_message_id' property") + } + if replyToProp["type"] != "string" { + t.Error("Expected reply_to_message_id type to be 'string'") + } +} + +func TestMessageTool_Execute_WithReplyToMessageID(t *testing.T) { + tool := NewMessageTool() + + var sentReplyTo string + tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error { + sentReplyTo = replyToMessageID + return nil + }) + + ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") + args := map[string]any{ + "content": "Reply test", + "reply_to_message_id": "msg-123", + } + + result := tool.Execute(ctx, args) + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if sentReplyTo != "msg-123" { + t.Fatalf("expected reply_to_message_id msg-123, got %q", sentReplyTo) + } +} + +func TestMessageTool_Execute_PropagatesTurnSessionMetadata(t *testing.T) { + tool := NewMessageTool() + + var gotAgentID, gotSessionKey string + var gotScope *session.SessionScope + tool.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error { + gotAgentID = ToolAgentID(ctx) + gotSessionKey = ToolSessionKey(ctx) + gotScope = ToolSessionScope(ctx) + return nil + }) + + ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") + ctx = WithToolSessionContext(ctx, "main", "sk_v1_tool", &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "main", + Channel: "telegram", + Dimensions: []string{"chat"}, + Values: map[string]string{ + "chat": "direct:test-chat-id", + }, + }) + + result := tool.Execute(ctx, map[string]any{"content": "Hello, world!"}) + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if gotAgentID != "main" { + t.Fatalf("ToolAgentID() = %q, want main", gotAgentID) + } + if gotSessionKey != "sk_v1_tool" { + t.Fatalf("ToolSessionKey() = %q, want sk_v1_tool", gotSessionKey) + } + if gotScope == nil || gotScope.Values["chat"] != "direct:test-chat-id" { + t.Fatalf("ToolSessionScope() = %+v, want chat scope", gotScope) + } } diff --git a/pkg/tools/integration/reaction.go b/pkg/tools/integration/reaction.go new file mode 100644 index 000000000..5a8dc87be --- /dev/null +++ b/pkg/tools/integration/reaction.go @@ -0,0 +1,87 @@ +package integrationtools + +import ( + "context" + "fmt" +) + +type ReactionCallback func(ctx context.Context, channel, chatID, messageID string) error + +type ReactionTool struct { + reactionCallback ReactionCallback +} + +func NewReactionTool() *ReactionTool { + return &ReactionTool{} +} + +func (t *ReactionTool) Name() string { + return "reaction" +} + +func (t *ReactionTool) Description() string { + return "Add a reaction to a message. Defaults to the current inbound message when message_id is omitted." +} + +func (t *ReactionTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "message_id": map[string]any{ + "type": "string", + "description": "Optional: target message ID; defaults to the current inbound message", + }, + "channel": map[string]any{ + "type": "string", + "description": "Optional: target channel (telegram, whatsapp, etc.)", + }, + "chat_id": map[string]any{ + "type": "string", + "description": "Optional: target chat/user ID", + }, + }, + } +} + +func (t *ReactionTool) SetReactionCallback(callback ReactionCallback) { + t.reactionCallback = callback +} + +func (t *ReactionTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + channel, _ := args["channel"].(string) + chatID, _ := args["chat_id"].(string) + messageID, _ := args["message_id"].(string) + + if channel == "" { + channel = ToolChannel(ctx) + } + if chatID == "" { + chatID = ToolChatID(ctx) + } + if messageID == "" { + messageID = ToolMessageID(ctx) + } + + if channel == "" || chatID == "" { + return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true} + } + if messageID == "" { + return &ToolResult{ForLLM: "message_id is required", IsError: true} + } + if t.reactionCallback == nil { + return &ToolResult{ForLLM: "Reaction not configured", IsError: true} + } + + if err := t.reactionCallback(ctx, channel, chatID, messageID); err != nil { + return &ToolResult{ + ForLLM: fmt.Sprintf("adding reaction: %v", err), + IsError: true, + Err: err, + } + } + + return &ToolResult{ + ForLLM: fmt.Sprintf("Reaction added to %s:%s message %s", channel, chatID, messageID), + Silent: true, + } +} diff --git a/pkg/tools/integration/reaction_test.go b/pkg/tools/integration/reaction_test.go new file mode 100644 index 000000000..f579fd914 --- /dev/null +++ b/pkg/tools/integration/reaction_test.go @@ -0,0 +1,96 @@ +package integrationtools + +import ( + "context" + "errors" + "testing" +) + +func TestReactionTool_Execute_UsesContextMessageIDByDefault(t *testing.T) { + tool := NewReactionTool() + + var gotChannel, gotChatID, gotMessageID string + tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { + gotChannel = channel + gotChatID = chatID + gotMessageID = messageID + return nil + }) + + ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-100", "") + result := tool.Execute(ctx, map[string]any{}) + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if gotChannel != "telegram" || gotChatID != "chat-1" || gotMessageID != "msg-100" { + t.Fatalf("unexpected callback args: channel=%q chatID=%q messageID=%q", gotChannel, gotChatID, gotMessageID) + } +} + +func TestReactionTool_Execute_AllowsExplicitMessageIDOverride(t *testing.T) { + tool := NewReactionTool() + + var gotMessageID string + tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { + gotMessageID = messageID + return nil + }) + + ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-context", "") + result := tool.Execute(ctx, map[string]any{"message_id": "msg-explicit"}) + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if gotMessageID != "msg-explicit" { + t.Fatalf("expected explicit message id, got %q", gotMessageID) + } +} + +func TestReactionTool_Execute_MissingMessageID(t *testing.T) { + tool := NewReactionTool() + tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { return nil }) + + ctx := WithToolContext(context.Background(), "telegram", "chat-1") + result := tool.Execute(ctx, map[string]any{}) + if !result.IsError { + t.Fatal("expected error") + } + if result.ForLLM != "message_id is required" { + t.Fatalf("unexpected error message: %q", result.ForLLM) + } +} + +func TestReactionTool_Execute_CallbackError(t *testing.T) { + tool := NewReactionTool() + tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { + return errors.New("unsupported") + }) + + ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-100", "") + result := tool.Execute(ctx, map[string]any{}) + if !result.IsError { + t.Fatal("expected error") + } + if result.Err == nil { + t.Fatal("expected wrapped error") + } +} + +func TestReactionTool_Parameters(t *testing.T) { + tool := NewReactionTool() + params := tool.Parameters() + + props, ok := params["properties"].(map[string]any) + if !ok { + t.Fatal("expected properties map") + } + if _, ok := props["message_id"]; !ok { + t.Fatal("expected message_id parameter") + } + if _, ok := props["channel"]; !ok { + t.Fatal("expected channel parameter") + } + if _, ok := props["chat_id"]; !ok { + t.Fatal("expected chat_id parameter") + } +} diff --git a/pkg/tools/integration/shared.go b/pkg/tools/integration/shared.go new file mode 100644 index 000000000..cc6aa3f28 --- /dev/null +++ b/pkg/tools/integration/shared.go @@ -0,0 +1,77 @@ +package integrationtools + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/session" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +type ( + Tool = toolshared.Tool + ToolResult = toolshared.ToolResult + AsyncCallback = toolshared.AsyncCallback +) + +func WithToolContext(ctx context.Context, channel, chatID string) context.Context { + return toolshared.WithToolContext(ctx, channel, chatID) +} + +func WithToolInboundContext( + ctx context.Context, + channel, chatID, messageID, replyToMessageID string, +) context.Context { + return toolshared.WithToolInboundContext(ctx, channel, chatID, messageID, replyToMessageID) +} + +func WithToolSessionContext( + ctx context.Context, + agentID, sessionKey string, + scope *session.SessionScope, +) context.Context { + return toolshared.WithToolSessionContext(ctx, agentID, sessionKey, scope) +} + +func ToolChannel(ctx context.Context) string { + return toolshared.ToolChannel(ctx) +} + +func ToolChatID(ctx context.Context) string { + return toolshared.ToolChatID(ctx) +} + +func ToolMessageID(ctx context.Context) string { + return toolshared.ToolMessageID(ctx) +} + +func ToolAgentID(ctx context.Context) string { + return toolshared.ToolAgentID(ctx) +} + +func ToolSessionKey(ctx context.Context) string { + return toolshared.ToolSessionKey(ctx) +} + +func ToolSessionScope(ctx context.Context) *session.SessionScope { + return toolshared.ToolSessionScope(ctx) +} + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} + +func NewToolResult(forLLM string) *ToolResult { + return toolshared.NewToolResult(forLLM) +} + +func UserResult(content string) *ToolResult { + return toolshared.UserResult(content) +} + +func MediaResult(forLLM string, mediaRefs []string) *ToolResult { + return toolshared.MediaResult(forLLM, mediaRefs) +} diff --git a/pkg/tools/skills_install.go b/pkg/tools/integration/skills_install.go similarity index 57% rename from pkg/tools/skills_install.go rename to pkg/tools/integration/skills_install.go index ff45b8355..f88df552a 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/integration/skills_install.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "time" @@ -15,6 +16,10 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +const defaultSkillRegistryName = "github" + +var persistInstalledSkillOriginMeta = writeOriginMeta + // InstallSkillTool allows the LLM agent to install skills from registries. // It shares the same RegistryManager that FindSkillsTool uses, // so all registries configured in config are available for installation. @@ -40,7 +45,7 @@ func (t *InstallSkillTool) Name() string { } func (t *InstallSkillTool) Description() string { - return "Install a skill from a registry by slug. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills." + return "Install a skill from a registry by slug. Defaults to GitHub when registry is omitted. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills." } func (t *InstallSkillTool) Parameters() map[string]any { @@ -57,14 +62,14 @@ func (t *InstallSkillTool) Parameters() map[string]any { }, "registry": map[string]any{ "type": "string", - "description": "Registry to install from (required, e.g., 'clawhub')", + "description": "Registry to install from (optional, defaults to 'github')", }, "force": map[string]any{ "type": "boolean", "description": "Force reinstall if skill already exists (default false)", }, }, - "required": []string{"slug", "registry"}, + "required": []string{"slug"}, } } @@ -74,45 +79,86 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To t.mu.Lock() defer t.mu.Unlock() - // Validate slug slug, _ := args["slug"].(string) - if err := utils.ValidateSkillIdentifier(slug); err != nil { - return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error())) + if strings.TrimSpace(slug) == "" { + return ErrorResult("identifier is required and must be a non-empty string") } // Validate registry registryName, _ := args["registry"].(string) + if registryName == "" { + registryName = defaultSkillRegistryName + } if err := utils.ValidateSkillIdentifier(registryName); err != nil { return ErrorResult(fmt.Sprintf("invalid registry %q: error: %s", registryName, err.Error())) } - version, _ := args["version"].(string) - force, _ := args["force"].(bool) - - // Check if already installed. - skillsDir := filepath.Join(t.workspace, "skills") - targetDir := filepath.Join(skillsDir, slug) - - if !force { - if _, err := os.Stat(targetDir); err == nil { - return ErrorResult( - fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir), - ) - } - } else { - // Force: remove existing if present. - os.RemoveAll(targetDir) - } - // Resolve which registry to use. registry := t.registryMgr.GetRegistry(registryName) if registry == nil { return ErrorResult(fmt.Sprintf("registry %q not found", registryName)) } + // Validate target and resolve install directory. + dirName, err := registry.ResolveInstallDirName(slug) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error())) + } + + version, _ := args["version"].(string) + force, _ := args["force"].(bool) + + // Check if already installed. + skillsDir := filepath.Join(t.workspace, "skills") + targetDir := filepath.Join(skillsDir, dirName) + backupDir := "" + restorePreviousInstall := func() { + if backupDir == "" { + return + } + if rmErr := os.RemoveAll(targetDir); rmErr != nil { + logger.ErrorCF("tool", "Failed to remove failed install before restore", + map[string]any{ + "tool": "install_skill", + "target_dir": targetDir, + "error": rmErr.Error(), + }) + return + } + if restoreErr := os.Rename(backupDir, targetDir); restoreErr != nil { + logger.ErrorCF("tool", "Failed to restore previous install after failed reinstall", + map[string]any{ + "tool": "install_skill", + "backup_dir": backupDir, + "target_dir": targetDir, + "error": restoreErr.Error(), + }) + return + } + backupDir = "" + } + + if !force { + if _, statErr := os.Stat(targetDir); statErr == nil { + return ErrorResult( + fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir), + ) + } + } else { + if _, statErr := os.Stat(targetDir); statErr == nil { + backupDir = filepath.Join(skillsDir, fmt.Sprintf(".%s.picoclaw-backup-%d", dirName, time.Now().UnixNano())) + if renameErr := os.Rename(targetDir, backupDir); renameErr != nil { + return ErrorResult(fmt.Sprintf("failed to prepare reinstall for %q: %v", slug, renameErr)) + } + } else if !os.IsNotExist(statErr) { + return ErrorResult(fmt.Sprintf("failed to inspect existing install for %q: %v", slug, statErr)) + } + } + // Ensure skills directory exists. - if err := os.MkdirAll(skillsDir, 0o755); err != nil { - return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err)) + if mkdirErr := os.MkdirAll(skillsDir, 0o755); mkdirErr != nil { + restorePreviousInstall() + return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", mkdirErr)) } // Download and install (handles metadata, version resolution, extraction). @@ -128,6 +174,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To "error": rmErr.Error(), }) } + restorePreviousInstall() return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err)) } @@ -142,11 +189,26 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To "error": rmErr.Error(), }) } + restorePreviousInstall() return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug)) } + if !workspaceHasValidInstalledSkill(t.workspace, dirName) { + rmErr := os.RemoveAll(targetDir) + if rmErr != nil { + logger.ErrorCF("tool", "Failed to remove invalid installed skill", + map[string]any{ + "tool": "install_skill", + "target_dir": targetDir, + "error": rmErr.Error(), + }) + } + restorePreviousInstall() + return ErrorResult(fmt.Sprintf("failed to install %q: registry archive is not a valid skill", slug)) + } + // Write origin metadata. - if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil { + if err := persistInstalledSkillOriginMeta(targetDir, registry, slug, result.Version); err != nil { logger.ErrorCF("tool", "Failed to write origin metadata", map[string]any{ "tool": "install_skill", @@ -156,7 +218,27 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To "slug": slug, "version": result.Version, }) - _ = err + rmErr := os.RemoveAll(targetDir) + if rmErr != nil { + logger.ErrorCF("tool", "Failed to roll back install after metadata write failure", + map[string]any{ + "tool": "install_skill", + "target_dir": targetDir, + "error": rmErr.Error(), + }) + } + restorePreviousInstall() + return ErrorResult(fmt.Sprintf("failed to persist skill metadata for %q: %v", slug, err)) + } + if backupDir != "" { + if rmErr := os.RemoveAll(backupDir); rmErr != nil { + logger.ErrorCF("tool", "Failed to remove previous install backup after successful reinstall", + map[string]any{ + "tool": "install_skill", + "backup_dir": backupDir, + "error": rmErr.Error(), + }) + } } // Build result with moderation warnings. @@ -181,17 +263,27 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To // originMeta tracks which registry a skill was installed from. type originMeta struct { Version int `json:"version"` + OriginKind string `json:"origin_kind,omitempty"` Registry string `json:"registry"` Slug string `json:"slug"` + RegistryURL string `json:"registry_url,omitempty"` InstalledVersion string `json:"installed_version"` InstalledAt int64 `json:"installed_at"` } -func writeOriginMeta(targetDir, registryName, slug, version string) error { +func writeOriginMeta(targetDir string, registry skills.SkillRegistry, slug, version string) error { + normalizedSlug, registryURL := skills.BuildInstallMetadataForRegistryInstance(registry, slug, version) + registryName := "" + if registry != nil { + registryName = registry.Name() + } + meta := originMeta{ Version: 1, + OriginKind: "third_party", Registry: registryName, - Slug: slug, + Slug: normalizedSlug, + RegistryURL: registryURL, InstalledVersion: version, InstalledAt: time.Now().UnixMilli(), } @@ -204,3 +296,16 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error { // Use unified atomic write utility with explicit sync for flash storage reliability. return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600) } + +func workspaceHasValidInstalledSkill(workspace, directory string) bool { + loader := skills.NewSkillsLoader(workspace, "", "") + for _, skill := range loader.ListSkills() { + if skill.Source != "workspace" { + continue + } + if filepath.Base(filepath.Dir(skill.Path)) == directory { + return true + } + } + return false +} diff --git a/pkg/tools/integration/skills_install_test.go b/pkg/tools/integration/skills_install_test.go new file mode 100644 index 000000000..01d2fd2bc --- /dev/null +++ b/pkg/tools/integration/skills_install_test.go @@ -0,0 +1,423 @@ +package integrationtools + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/skills" +) + +type mockInstallRegistry struct{} + +const validSkillMarkdown = "---\nname: pr-review\ndescription: Review pull requests\n---\n# PR Review\n" + +func (m *mockInstallRegistry) Name() string { return "clawhub" } + +func (m *mockInstallRegistry) ResolveInstallDirName(target string) (string, error) { + return target, nil +} + +func (m *mockInstallRegistry) SkillURL(slug, _ string) string { return slug } + +func (m *mockInstallRegistry) Search(context.Context, string, int) ([]skills.SearchResult, error) { + return nil, nil +} + +func (m *mockInstallRegistry) GetSkillMeta(context.Context, string) (*skills.SkillMeta, error) { + return nil, nil +} + +func (m *mockInstallRegistry) DownloadAndInstall( + _ context.Context, + _ string, + _ string, + targetDir string, +) (*skills.InstallResult, error) { + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(targetDir, "SKILL.md"), []byte(validSkillMarkdown), 0o600); err != nil { + return nil, err + } + return &skills.InstallResult{Version: "test"}, nil +} + +type mockGitHubInstallRegistry struct{} + +func (m *mockGitHubInstallRegistry) Name() string { return "github" } + +func (m *mockGitHubInstallRegistry) ResolveInstallDirName(target string) (string, error) { + return "pr-review", nil +} + +func (m *mockGitHubInstallRegistry) SkillURL(slug, _ string) string { return slug } + +func (m *mockGitHubInstallRegistry) Search(context.Context, string, int) ([]skills.SearchResult, error) { + return nil, nil +} + +func (m *mockGitHubInstallRegistry) GetSkillMeta(context.Context, string) (*skills.SkillMeta, error) { + return nil, nil +} + +func (m *mockGitHubInstallRegistry) DownloadAndInstall( + _ context.Context, + _ string, + _ string, + targetDir string, +) (*skills.InstallResult, error) { + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(targetDir, "SKILL.md"), []byte(validSkillMarkdown), 0o600); err != nil { + return nil, err + } + return &skills.InstallResult{Version: "main"}, nil +} + +type stubGitHubInstallRegistry struct { + *skills.GitHubRegistry +} + +func (m *stubGitHubInstallRegistry) DownloadAndInstall( + _ context.Context, + _ string, + _ string, + targetDir string, +) (*skills.InstallResult, error) { + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(targetDir, "SKILL.md"), []byte(validSkillMarkdown), 0o600); err != nil { + return nil, err + } + return &skills.InstallResult{Version: "main"}, nil +} + +type mockInvalidInstallRegistry struct{} + +type mockFailingInstallRegistry struct{} + +func (m *mockInvalidInstallRegistry) Name() string { return "clawhub" } + +func (m *mockInvalidInstallRegistry) ResolveInstallDirName(target string) (string, error) { + return target, nil +} + +func (m *mockInvalidInstallRegistry) SkillURL(slug, _ string) string { return slug } + +func (m *mockInvalidInstallRegistry) Search(context.Context, string, int) ([]skills.SearchResult, error) { + return nil, nil +} + +func (m *mockInvalidInstallRegistry) GetSkillMeta(context.Context, string) (*skills.SkillMeta, error) { + return nil, nil +} + +func (m *mockInvalidInstallRegistry) DownloadAndInstall( + _ context.Context, + _ string, + _ string, + targetDir string, +) (*skills.InstallResult, error) { + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return nil, err + } + if err := os.WriteFile( + filepath.Join(targetDir, "SKILL.md"), + []byte("---\nname: bad_skill\ndescription: invalid name\n---\n# Invalid\n"), + 0o600, + ); err != nil { + return nil, err + } + return &skills.InstallResult{Version: "test"}, nil +} + +func (m *mockFailingInstallRegistry) Name() string { return "clawhub" } + +func (m *mockFailingInstallRegistry) ResolveInstallDirName(target string) (string, error) { + return target, nil +} + +func (m *mockFailingInstallRegistry) SkillURL(slug, _ string) string { return slug } + +func (m *mockFailingInstallRegistry) Search(context.Context, string, int) ([]skills.SearchResult, error) { + return nil, nil +} + +func (m *mockFailingInstallRegistry) GetSkillMeta(context.Context, string) (*skills.SkillMeta, error) { + return nil, nil +} + +func (m *mockFailingInstallRegistry) DownloadAndInstall( + _ context.Context, + _ string, + _ string, + _ string, +) (*skills.InstallResult, error) { + return nil, assert.AnError +} + +func TestInstallSkillToolName(t *testing.T) { + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) + assert.Equal(t, "install_skill", tool.Name()) +} + +func TestInstallSkillToolMissingSlug(t *testing.T) { + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) + result := tool.Execute(context.Background(), map[string]any{}) + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string") +} + +func TestInstallSkillToolEmptySlug(t *testing.T) { + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) + result := tool.Execute(context.Background(), map[string]any{ + "slug": " ", + }) + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string") +} + +func TestInstallSkillToolUnsafeSlug(t *testing.T) { + registryMgr := skills.NewRegistryManager() + registryMgr.AddRegistry(skills.NewClawHubRegistry(skills.ClawHubConfig{Enabled: true})) + tool := NewInstallSkillTool(registryMgr, t.TempDir()) + + cases := []string{ + "../etc/passwd", + "path/traversal", + "path\\traversal", + } + + for _, slug := range cases { + result := tool.Execute(context.Background(), map[string]any{ + "slug": slug, + "registry": "clawhub", + }) + assert.True(t, result.IsError, "slug %q should be rejected", slug) + assert.Contains(t, result.ForLLM, "invalid slug") + } +} + +func TestInstallSkillToolAlreadyExists(t *testing.T) { + workspace := t.TempDir() + skillDir := filepath.Join(workspace, "skills", "existing-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + + registryMgr := skills.NewRegistryManager() + registryMgr.AddRegistry(&mockInstallRegistry{}) + tool := NewInstallSkillTool(registryMgr, workspace) + result := tool.Execute(context.Background(), map[string]any{ + "slug": "existing-skill", + "registry": "clawhub", + }) + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "already installed") +} + +func TestInstallSkillToolRegistryNotFound(t *testing.T) { + workspace := t.TempDir() + tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace) + result := tool.Execute(context.Background(), map[string]any{ + "slug": "some-skill", + "registry": "nonexistent", + }) + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "registry") + assert.Contains(t, result.ForLLM, "not found") +} + +func TestInstallSkillToolParameters(t *testing.T) { + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) + params := tool.Parameters() + + props, ok := params["properties"].(map[string]any) + assert.True(t, ok) + assert.Contains(t, props, "slug") + assert.Contains(t, props, "version") + assert.Contains(t, props, "registry") + assert.Contains(t, props, "force") + + required, ok := params["required"].([]string) + assert.True(t, ok) + assert.Contains(t, required, "slug") + assert.NotContains(t, required, "registry") +} + +func TestInstallSkillToolMissingRegistry(t *testing.T) { + registryMgr := skills.NewRegistryManager() + registryMgr.AddRegistry(&mockGitHubInstallRegistry{}) + tool := NewInstallSkillTool(registryMgr, t.TempDir()) + result := tool.Execute(context.Background(), map[string]any{ + "slug": "some-skill", + }) + assert.False(t, result.IsError) + assert.Contains(t, result.ForLLM, `Successfully installed skill`) +} + +func TestInstallSkillToolAllowsGitHubURLSlug(t *testing.T) { + registry := skills.GitHubRegistryConfig{Enabled: true, BaseURL: "https://github.com"}.BuildRegistry() + githubRegistry, ok := registry.(*skills.GitHubRegistry) + require.True(t, ok) + + registryMgr := skills.NewRegistryManager() + registryMgr.AddRegistry(&stubGitHubInstallRegistry{GitHubRegistry: githubRegistry}) + workspace := t.TempDir() + tool := NewInstallSkillTool(registryMgr, workspace) + + slug := "https://github.com/synthetic-lab/octofriend/tree/main/.agents/skills/pr-review" + result := tool.Execute(context.Background(), map[string]any{ + "slug": slug, + "registry": "github", + }) + + assert.False(t, result.IsError) + assert.Contains(t, result.ForLLM, `Successfully installed skill`) + + data, err := os.ReadFile(filepath.Join(workspace, "skills", "pr-review", ".skill-origin.json")) + require.NoError(t, err) + + var meta originMeta + require.NoError(t, json.Unmarshal(data, &meta)) + assert.Equal(t, "third_party", meta.OriginKind) + assert.Equal(t, "github", meta.Registry) + assert.Equal(t, "synthetic-lab/octofriend/.agents/skills/pr-review", meta.Slug) + assert.Equal(t, slug, meta.RegistryURL) + assert.Equal(t, "main", meta.InstalledVersion) + assert.NotZero(t, meta.InstalledAt) +} + +func TestInstallSkillToolPreservesGitHubSourceURLWithEnterpriseRegistry(t *testing.T) { + registry := skills.GitHubRegistryConfig{Enabled: true, BaseURL: "https://ghe.example.com/git"}.BuildRegistry() + githubRegistry, ok := registry.(*skills.GitHubRegistry) + require.True(t, ok) + + registryMgr := skills.NewRegistryManager() + registryMgr.AddRegistry(&stubGitHubInstallRegistry{GitHubRegistry: githubRegistry}) + workspace := t.TempDir() + tool := NewInstallSkillTool(registryMgr, workspace) + + slug := "https://github.com/synthetic-lab/octofriend/tree/main/.agents/skills/pr-review" + result := tool.Execute(context.Background(), map[string]any{ + "slug": slug, + "registry": "github", + }) + + assert.False(t, result.IsError) + + data, err := os.ReadFile(filepath.Join(workspace, "skills", "pr-review", ".skill-origin.json")) + require.NoError(t, err) + + var meta originMeta + require.NoError(t, json.Unmarshal(data, &meta)) + assert.Equal(t, "synthetic-lab/octofriend/.agents/skills/pr-review", meta.Slug) + assert.Equal(t, slug, meta.RegistryURL) + assert.Equal(t, "main", meta.InstalledVersion) +} + +func TestInstallSkillToolRejectsInvalidInstalledSkill(t *testing.T) { + workspace := t.TempDir() + registryMgr := skills.NewRegistryManager() + registryMgr.AddRegistry(&mockInvalidInstallRegistry{}) + tool := NewInstallSkillTool(registryMgr, workspace) + + result := tool.Execute(context.Background(), map[string]any{ + "slug": "broken-skill", + "registry": "clawhub", + }) + + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "not a valid skill") + _, err := os.Stat(filepath.Join(workspace, "skills", "broken-skill")) + assert.True(t, os.IsNotExist(err)) +} + +func TestInstallSkillToolRollsBackOnOriginMetadataWriteFailure(t *testing.T) { + workspace := t.TempDir() + registryMgr := skills.NewRegistryManager() + registryMgr.AddRegistry(&mockInstallRegistry{}) + tool := NewInstallSkillTool(registryMgr, workspace) + + previousPersist := persistInstalledSkillOriginMeta + persistInstalledSkillOriginMeta = func(string, skills.SkillRegistry, string, string) error { + return assert.AnError + } + defer func() { + persistInstalledSkillOriginMeta = previousPersist + }() + + result := tool.Execute(context.Background(), map[string]any{ + "slug": "rollback-skill", + "registry": "clawhub", + }) + + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "failed to persist skill metadata") + _, err := os.Stat(filepath.Join(workspace, "skills", "rollback-skill")) + assert.True(t, os.IsNotExist(err)) +} + +func TestInstallSkillToolForceReinstallRestoresPreviousSkillAfterDownloadFailure(t *testing.T) { + workspace := t.TempDir() + skillDir := filepath.Join(workspace, "skills", "existing-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + oldContent := []byte("---\nname: existing-skill\ndescription: Existing skill\n---\n# Existing\n") + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), oldContent, 0o600)) + + registryMgr := skills.NewRegistryManager() + registryMgr.AddRegistry(&mockFailingInstallRegistry{}) + tool := NewInstallSkillTool(registryMgr, workspace) + + result := tool.Execute(context.Background(), map[string]any{ + "slug": "existing-skill", + "registry": "clawhub", + "force": true, + }) + + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "failed to install") + + gotContent, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md")) + require.NoError(t, err) + assert.Equal(t, oldContent, gotContent) +} + +func TestInstallSkillToolForceReinstallRestoresPreviousSkillAfterMetadataFailure(t *testing.T) { + workspace := t.TempDir() + skillDir := filepath.Join(workspace, "skills", "existing-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + oldContent := []byte("---\nname: existing-skill\ndescription: Existing skill\n---\n# Existing\n") + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), oldContent, 0o600)) + + registryMgr := skills.NewRegistryManager() + registryMgr.AddRegistry(&mockInstallRegistry{}) + tool := NewInstallSkillTool(registryMgr, workspace) + + previousPersist := persistInstalledSkillOriginMeta + persistInstalledSkillOriginMeta = func(string, skills.SkillRegistry, string, string) error { + return assert.AnError + } + defer func() { + persistInstalledSkillOriginMeta = previousPersist + }() + + result := tool.Execute(context.Background(), map[string]any{ + "slug": "existing-skill", + "registry": "clawhub", + "force": true, + }) + + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "failed to persist skill metadata") + + gotContent, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md")) + require.NoError(t, err) + assert.Equal(t, oldContent, gotContent) +} diff --git a/pkg/tools/skills_search.go b/pkg/tools/integration/skills_search.go similarity index 99% rename from pkg/tools/skills_search.go rename to pkg/tools/integration/skills_search.go index 2b6cffd38..f080aba95 100644 --- a/pkg/tools/skills_search.go +++ b/pkg/tools/integration/skills_search.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/skills_search_test.go b/pkg/tools/integration/skills_search_test.go similarity index 99% rename from pkg/tools/skills_search_test.go rename to pkg/tools/integration/skills_search_test.go index 0e5387cf5..fcce48b49 100644 --- a/pkg/tools/skills_search_test.go +++ b/pkg/tools/integration/skills_search_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/integration/tts_send.go b/pkg/tools/integration/tts_send.go new file mode 100644 index 000000000..6c9135624 --- /dev/null +++ b/pkg/tools/integration/tts_send.go @@ -0,0 +1,82 @@ +package integrationtools + +import ( + "context" + "strings" + + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/media" +) + +type SendTTSTool struct { + provider tts.TTSProvider + mediaStore media.MediaStore +} + +func NewSendTTSTool(provider tts.TTSProvider, store media.MediaStore) *SendTTSTool { + return &SendTTSTool{ + provider: provider, + mediaStore: store, + } +} + +func (t *SendTTSTool) Name() string { return "send_tts" } + +func (t *SendTTSTool) Description() string { + return "Synthesize speech from text and send it as an audio file to the user." +} + +func (t *SendTTSTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{ + "type": "string", + "description": "The text to synthesize into speech. NOTE: Reply in a highly concise, conversational, oral style suitable for text-to-speech. Do not use markdown, emojis, asterisks, or code blocks. Speak naturally.", + }, + "filename": map[string]any{ + "type": "string", + "description": "Optional filename for the audio file (e.g., response.ogg).", + }, + }, + "required": []string{"text"}, + } +} + +func (t *SendTTSTool) SetMediaStore(store media.MediaStore) { + t.mediaStore = store +} + +func (t *SendTTSTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + text, _ := args["text"].(string) + text = strings.TrimSpace(text) + if text == "" { + return ErrorResult("text is required") + } + + channel := ToolChannel(ctx) + chatID := ToolChatID(ctx) + filename, _ := args["filename"].(string) + + ref, err := tts.SynthesizeAndStore( + ctx, + t.provider, + t.mediaStore, + text, + filename, + channel, + chatID, + ) + if err != nil { + return ErrorResult(err.Error()).WithError(err) + } + + // Return with ForUser set to original text, Media containing the audio ref, + // and mark as ResponseHandled so the audio is sent immediately without LLM intervention. + return &ToolResult{ + ForLLM: "TTS audio sent", + ForUser: text, + Media: []string{ref}, + ResponseHandled: true, + } +} diff --git a/pkg/tools/integration/web.go b/pkg/tools/integration/web.go new file mode 100644 index 000000000..75821e40d --- /dev/null +++ b/pkg/tools/integration/web.go @@ -0,0 +1,2116 @@ +package integrationtools + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "sync/atomic" + "time" + "unicode" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +const ( + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + sogouUserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1" + userAgentHonest = "picoclaw/%s (+https://github.com/sipeed/picoclaw; AI assistant bot)" + + // HTTP client timeouts for web tool providers. + searchTimeout = 10 * time.Second // Brave, Tavily, DuckDuckGo + perplexityTimeout = 30 * time.Second // Perplexity (LLM-based, slower) + fetchTimeout = 60 * time.Second // WebFetchTool + + defaultMaxChars = 50000 + maxRedirects = 5 +) + +// Pre-compiled regexes for HTML text extraction +var ( + reScript = regexp.MustCompile(`<script[\s\S]*?</script>`) + reStyle = regexp.MustCompile(`<style[\s\S]*?</style>`) + reTags = regexp.MustCompile(`<[^>]+>`) + reWhitespace = regexp.MustCompile(`[^\S\n]+`) + reBlankLines = regexp.MustCompile(`\n{3,}`) + + // DuckDuckGo result extraction + reDDGLink = regexp.MustCompile( + `<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>`, + ) + reDDGSnippet = regexp.MustCompile( + `<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`, + ) + reSogouTitle = regexp.MustCompile( + `<a\s+class=resultLink\s+href="([^"]+)"[^>]*id="sogou_vr_\d+_\d+"[^>]*>\s*(.*?)\s*</a>`, + ) + reSogouSnippet = regexp.MustCompile(`<div class="clamp\d*">\s*(.*?)\s*</div>`) + reSogouRealURL = regexp.MustCompile(`url=([^&]+)`) +) + +type APIKeyPool struct { + keys []string + current uint32 +} + +func NewAPIKeyPool(keys []string) *APIKeyPool { + return &APIKeyPool{ + keys: keys, + } +} + +type APIKeyIterator struct { + pool *APIKeyPool + startIdx uint32 + attempt uint32 +} + +func (p *APIKeyPool) NewIterator() *APIKeyIterator { + if len(p.keys) == 0 { + return &APIKeyIterator{pool: p} + } + idx := atomic.AddUint32(&p.current, 1) - 1 + return &APIKeyIterator{ + pool: p, + startIdx: idx, + } +} + +func (it *APIKeyIterator) Next() (string, bool) { + length := uint32(len(it.pool.keys)) + if length == 0 || it.attempt >= length { + return "", false + } + key := it.pool.keys[(it.startIdx+it.attempt)%length] + it.attempt++ + return key, true +} + +type SearchProvider interface { + Search(ctx context.Context, query string, count int, rangeCode string) (string, error) +} + +type SearchResultItem struct { + Title string + URL string + Snippet string +} + +func extractSogouURL(href string) string { + match := reSogouRealURL.FindStringSubmatch(href) + if len(match) < 2 { + return "" + } + decoded, err := url.QueryUnescape(match[1]) + if err != nil { + return "" + } + return decoded +} + +func applySogouRangeHint(query string, rangeCode string) string { + switch rangeCode { + case "d": + return query + " 最近一天" + case "w": + return query + " 最近一周" + case "m": + return query + " 最近一个月" + case "y": + return query + " 最近一年" + default: + return query + } +} + +func normalizeSearchRange(raw string) (string, error) { + rangeCode := strings.ToLower(strings.TrimSpace(raw)) + switch rangeCode { + case "", "d", "w", "m", "y": + return rangeCode, nil + default: + return "", fmt.Errorf("range must be one of: d, w, m, y") + } +} + +func mapBraveFreshness(rangeCode string) string { + switch rangeCode { + case "d": + return "pd" + case "w": + return "pw" + case "m": + return "pm" + case "y": + return "py" + default: + return "" + } +} + +func mapTavilyTimeRange(rangeCode string) string { + switch rangeCode { + case "d": + return "day" + case "w": + return "week" + case "m": + return "month" + case "y": + return "year" + default: + return "" + } +} + +func mapPerplexityRecencyFilter(rangeCode string) string { + switch rangeCode { + case "d": + return "day" + case "w": + return "week" + case "m": + return "month" + case "y": + return "year" + default: + return "" + } +} + +func mapDuckDuckGoDateFilter(rangeCode string) string { + switch rangeCode { + case "d": + return "d" + case "w": + return "w" + case "m": + return "m" + case "y": + return "t" + default: + return "" + } +} + +func mapSearXNGTimeRange(rangeCode string) string { + switch rangeCode { + case "d": + return "day" + case "w": + return "week" + case "m": + return "month" + case "y": + return "year" + default: + return "" + } +} + +func mapGLMRecencyFilter(rangeCode string) string { + switch rangeCode { + case "d": + return "oneDay" + case "w": + return "oneWeek" + case "m": + return "oneMonth" + case "y": + return "oneYear" + default: + return "noLimit" + } +} + +func mapBaiduRecencyFilter(rangeCode string) string { + switch rangeCode { + case "d", "w": + // Baidu does not expose a day-level filter. Use the closest supported + // window to keep recency bias instead of silently dropping the filter. + return "week" + case "m": + return "month" + case "y": + return "year" + default: + return "" + } +} + +type BraveSearchProvider struct { + keyPool *APIKeyPool + proxy string + client *http.Client +} + +func (p *BraveSearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { + if p.keyPool == nil || len(p.keyPool.keys) == 0 { + return "", errors.New("no API key provided") + } + + searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", + url.QueryEscape(query), count) + if freshness := mapBraveFreshness(rangeCode); freshness != "" { + searchURL += "&freshness=" + url.QueryEscape(freshness) + } + + var lastErr error + iter := p.keyPool.NewIterator() + + for { + apiKey, ok := iter.Next() + if !ok { + break + } + + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Subscription-Token", apiKey) + + resp, err := p.client.Do(req) + if err != nil { + lastErr = fmt.Errorf("request failed: %w", err) + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + lastErr = fmt.Errorf("failed to read response: %w", err) + continue + } + + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + if resp.StatusCode == http.StatusTooManyRequests || + resp.StatusCode == http.StatusUnauthorized || + resp.StatusCode == http.StatusForbidden || + resp.StatusCode >= 500 { + continue + } + return "", lastErr + } + + var searchResp struct { + Web struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Description string `json:"description"` + } `json:"results"` + } `json:"web"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + // Log error body for debugging + return "", fmt.Errorf("failed to parse response: %w", err) + } + + results := searchResp.Web.Results + if len(results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + var lines []string + lines = append(lines, fmt.Sprintf("Results for: %s", query)) + for i, item := range results { + if i >= count { + break + } + lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) + if item.Description != "" { + lines = append(lines, fmt.Sprintf(" %s", item.Description)) + } + } + + return strings.Join(lines, "\n"), nil + } + + return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) +} + +type TavilySearchProvider struct { + keyPool *APIKeyPool + baseURL string + proxy string + client *http.Client +} + +func (p *TavilySearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { + if p.keyPool == nil || len(p.keyPool.keys) == 0 { + return "", errors.New("no API key provided") + } + + searchURL := p.baseURL + if searchURL == "" { + searchURL = "https://api.tavily.com/search" + } + + var lastErr error + iter := p.keyPool.NewIterator() + + for { + apiKey, ok := iter.Next() + if !ok { + break + } + + payload := map[string]any{ + "api_key": apiKey, + "query": query, + "search_depth": "advanced", + "include_answer": false, + "include_images": false, + "include_raw_content": false, + "max_results": count, + } + if timeRange := mapTavilyTimeRange(rangeCode); timeRange != "" { + payload["time_range"] = timeRange + } + + bodyBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgent) + + resp, err := p.client.Do(req) + if err != nil { + lastErr = fmt.Errorf("request failed: %w", err) + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + lastErr = fmt.Errorf("failed to read response: %w", err) + continue + } + + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body)) + if resp.StatusCode == http.StatusTooManyRequests || + resp.StatusCode == http.StatusUnauthorized || + resp.StatusCode == http.StatusForbidden || + resp.StatusCode >= 500 { + continue + } + return "", lastErr + } + + var searchResp struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + } `json:"results"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + results := searchResp.Results + if len(results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + var lines []string + lines = append(lines, fmt.Sprintf("Results for: %s (via Tavily)", query)) + for i, item := range results { + if i >= count { + break + } + lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) + if item.Content != "" { + lines = append(lines, fmt.Sprintf(" %s", item.Content)) + } + } + + return strings.Join(lines, "\n"), nil + } + + return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) +} + +type SogouSearchProvider struct { + proxy string + client *http.Client +} + +func (p *SogouSearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { + const sogouWAPURL = "https://wap.sogou.com/web/searchList.jsp" + + results := make([]SearchResultItem, 0, count) + seenURLs := make(map[string]bool) + maxPages := min(3, (count+1)/2+1) + + for page := 1; page <= maxPages && len(results) < count; page++ { + params := url.Values{} + params.Set("keyword", applySogouRangeHint(query, rangeCode)) + params.Set("v", "5") + params.Set("p", fmt.Sprintf("%d", page)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sogouWAPURL+"?"+params.Encode(), nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", sogouUserAgent) + + resp, err := p.client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + resp.Body.Close() + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Sogou returned status %d", resp.StatusCode) + } + + html := string(body) + if len(html) < 200 { + break + } + + matches := reSogouTitle.FindAllStringSubmatch(html, -1) + for _, match := range matches { + if len(match) < 3 { + continue + } + + title := stripTags(match[2]) + link := extractSogouURL(match[1]) + if title == "" || link == "" || seenURLs[link] { + continue + } + seenURLs[link] = true + + start := strings.Index(html, match[0]) + snippet := "" + if start >= 0 { + after := html[start+len(match[0]):] + if len(after) > 2000 { + after = after[:2000] + } + if snippetMatch := reSogouSnippet.FindStringSubmatch(after); len(snippetMatch) > 1 { + snippet = stripTags(snippetMatch[1]) + } + } + + results = append(results, SearchResultItem{ + Title: title, + URL: link, + Snippet: snippet, + }) + if len(results) >= count { + break + } + } + } + + if len(results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + lines := []string{fmt.Sprintf("Results for: %s (via Sogou)", query)} + for i, item := range results { + lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) + if item.Snippet != "" { + lines = append(lines, fmt.Sprintf(" %s", item.Snippet)) + } + } + return strings.Join(lines, "\n"), nil +} + +type DuckDuckGoSearchProvider struct { + proxy string + client *http.Client +} + +func (p *DuckDuckGoSearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { + searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query)) + if dateFilter := mapDuckDuckGoDateFilter(rangeCode); dateFilter != "" { + searchURL += "&df=" + url.QueryEscape(dateFilter) + } + + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", userAgent) + + resp, err := p.client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + return p.extractResults(string(body), count, query) +} + +func (p *DuckDuckGoSearchProvider) extractResults( + html string, + count int, + query string, +) (string, error) { + // Simple regex based extraction for DDG HTML + // Strategy: Find all result containers or key anchors directly + + // Try finding the result links directly first, as they are the most critical + // Pattern: <a class="result__a" href="...">Title</a> + // The previous regex was a bit strict. Let's make it more flexible for attributes order/content + matches := reDDGLink.FindAllStringSubmatch(html, count+5) + + if len(matches) == 0 { + return fmt.Sprintf("No results found or extraction failed. Query: %s", query), nil + } + + var lines []string + lines = append(lines, fmt.Sprintf("Results for: %s (via DuckDuckGo)", query)) + + // Pre-compile snippet regex to run inside the loop + // We'll search for snippets relative to the link position or just globally if needed + // But simple global search for snippets might mismatch order. + // Since we only have the raw HTML string, let's just extract snippets globally and assume order matches (risky but simple for regex) + // Or better: Let's assume the snippet follows the link in the HTML + + // A better regex approach: iterate through text and find matches in order + // But for now, let's grab all snippets too + snippetMatches := reDDGSnippet.FindAllStringSubmatch(html, count+5) + + maxItems := min(len(matches), count) + + for i := range maxItems { + urlStr := matches[i][1] + title := stripTags(matches[i][2]) + title = strings.TrimSpace(title) + + // URL decoding if needed + if strings.Contains(urlStr, "uddg=") { + if u, err := url.QueryUnescape(urlStr); err == nil { + _, after, ok := strings.Cut(u, "uddg=") + if ok { + urlStr = after + } + } + } + + lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, title, urlStr)) + + // Attempt to attach snippet if available and index aligns + if i < len(snippetMatches) { + snippet := stripTags(snippetMatches[i][1]) + snippet = strings.TrimSpace(snippet) + if snippet != "" { + lines = append(lines, fmt.Sprintf(" %s", snippet)) + } + } + } + + return strings.Join(lines, "\n"), nil +} + +func stripTags(content string) string { + return reTags.ReplaceAllString(content, "") +} + +type PerplexitySearchProvider struct { + keyPool *APIKeyPool + proxy string + client *http.Client +} + +func (p *PerplexitySearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { + if p.keyPool == nil || len(p.keyPool.keys) == 0 { + return "", errors.New("no API key provided") + } + + searchURL := "https://api.perplexity.ai/chat/completions" + + var lastErr error + iter := p.keyPool.NewIterator() + + for { + apiKey, ok := iter.Next() + if !ok { + break + } + + payload := map[string]any{ + "model": "sonar", + "messages": []map[string]string{ + { + "role": "system", + "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.", + }, + { + "role": "user", + "content": fmt.Sprintf( + "Search for: %s. Provide up to %d relevant results.", + query, + count, + ), + }, + }, + "max_tokens": 1000, + } + if recencyFilter := mapPerplexityRecencyFilter(rangeCode); recencyFilter != "" { + payload["search_recency_filter"] = recencyFilter + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, + "POST", + searchURL, + strings.NewReader(string(payloadBytes)), + ) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("User-Agent", userAgent) + + resp, err := p.client.Do(req) + if err != nil { + lastErr = fmt.Errorf("request failed: %w", err) + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + lastErr = fmt.Errorf("failed to read response: %w", err) + continue + } + + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("Perplexity API error: %s", string(body)) + if resp.StatusCode == http.StatusTooManyRequests || + resp.StatusCode == http.StatusUnauthorized || + resp.StatusCode == http.StatusForbidden || + resp.StatusCode >= 500 { + continue + } + return "", lastErr + } + + var searchResp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if len(searchResp.Choices) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + return fmt.Sprintf( + "Results for: %s (via Perplexity)\n%s", + query, + searchResp.Choices[0].Message.Content, + ), nil + } + + return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) +} + +type SearXNGSearchProvider struct { + baseURL string + proxy string + client *http.Client +} + +func (p *SearXNGSearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { + if p.baseURL == "" { + return "", errors.New("no SearXNG URL provided") + } + + searchURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=general", + strings.TrimSuffix(p.baseURL, "/"), + url.QueryEscape(query)) + if timeRange := mapSearXNGTimeRange(rangeCode); timeRange != "" { + searchURL += "&time_range=" + url.QueryEscape(timeRange) + } + + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + client := p.client + if client == nil { + client = &http.Client{Timeout: searchTimeout} + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("SearXNG returned status %d", resp.StatusCode) + } + + var result struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + Engine string `json:"engine"` + Score float64 `json:"score"` + } `json:"results"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if len(result.Results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + // Limit results to requested count + if len(result.Results) > count { + result.Results = result.Results[:count] + } + + // Format results in standard PicoClaw format + var b strings.Builder + b.WriteString(fmt.Sprintf("Results for: %s (via SearXNG)\n", query)) + for i, r := range result.Results { + b.WriteString(fmt.Sprintf("%d. %s\n", i+1, r.Title)) + b.WriteString(fmt.Sprintf(" %s\n", r.URL)) + if r.Content != "" { + b.WriteString(fmt.Sprintf(" %s\n", r.Content)) + } + } + + return b.String(), nil +} + +type GLMSearchProvider struct { + apiKey string + baseURL string + searchEngine string + proxy string + client *http.Client +} + +func (p *GLMSearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { + if p.apiKey == "" { + return "", errors.New("no API key provided") + } + + searchURL := p.baseURL + if searchURL == "" { + searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search" + } + + payload := map[string]any{ + "search_query": query, + "search_engine": p.searchEngine, + "search_intent": false, + "count": count, + "content_size": "medium", + } + if recencyFilter := mapGLMRecencyFilter(rangeCode); recencyFilter != "" { + payload["search_recency_filter"] = recencyFilter + } + + bodyBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewReader(bodyBytes)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.apiKey) + + resp, err := p.client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GLM Search API error (status %d): %s", resp.StatusCode, string(body)) + } + + var searchResp struct { + SearchResult []struct { + Title string `json:"title"` + Content string `json:"content"` + Link string `json:"link"` + } `json:"search_result"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + results := searchResp.SearchResult + if len(results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + var lines []string + lines = append(lines, fmt.Sprintf("Results for: %s (via GLM Search)", query)) + for i, item := range results { + if i >= count { + break + } + lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.Link)) + if item.Content != "" { + lines = append(lines, fmt.Sprintf(" %s", item.Content)) + } + } + + return strings.Join(lines, "\n"), nil +} + +type BaiduSearchProvider struct { + apiKey string + baseURL string + proxy string + client *http.Client +} + +func (p *BaiduSearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { + if p.apiKey == "" { + return "", errors.New("no API key provided") + } + + searchURL := p.baseURL + if searchURL == "" { + searchURL = "https://qianfan.baidubce.com/v2/ai_search/web_search" + } + + payload := map[string]any{ + "messages": []map[string]string{ + { + "role": "user", + "content": query, + }, + }, + "search_source": "baidu_search_v2", + "resource_type_filter": []map[string]any{{"type": "web", "top_k": count}}, + } + if recencyFilter := mapBaiduRecencyFilter(rangeCode); recencyFilter != "" { + payload["search_recency_filter"] = recencyFilter + } + + bodyBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewReader(bodyBytes)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.apiKey) + + resp, err := p.client.Do(req) + if err != nil { + return "", fmt.Errorf("baidu search request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("baidu search API error %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + References []struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + } `json:"references"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if len(result.References) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + lines := []string{fmt.Sprintf("Results for: %s (via Baidu Search)", query)} + for i, item := range result.References { + if i >= count { + break + } + lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) + if item.Content != "" { + lines = append(lines, fmt.Sprintf(" %s", item.Content)) + } + } + + return strings.Join(lines, "\n"), nil +} + +type WebSearchTool struct { + provider SearchProvider + maxResults int + providerResolver func(query string) (SearchProvider, int) +} + +type WebSearchToolOptions struct { + Provider string + BraveAPIKeys []string + BraveMaxResults int + BraveEnabled bool + TavilyAPIKeys []string + TavilyBaseURL string + TavilyMaxResults int + TavilyEnabled bool + SogouMaxResults int + SogouEnabled bool + DuckDuckGoMaxResults int + DuckDuckGoEnabled bool + PerplexityAPIKeys []string + PerplexityMaxResults int + PerplexityEnabled bool + SearXNGBaseURL string + SearXNGMaxResults int + SearXNGEnabled bool + GLMSearchAPIKey string + GLMSearchBaseURL string + GLMSearchEngine string + GLMSearchMaxResults int + GLMSearchEnabled bool + BaiduSearchAPIKey string + BaiduSearchBaseURL string + BaiduSearchMaxResults int + BaiduSearchEnabled bool + Proxy string +} + +func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions { + return WebSearchToolOptions{ + Provider: cfg.Tools.Web.Provider, + BraveAPIKeys: cfg.Tools.Web.Brave.APIKeys.Values(), + BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + TavilyAPIKeys: cfg.Tools.Web.Tavily.APIKeys.Values(), + TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, + TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, + TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, + SogouMaxResults: cfg.Tools.Web.Sogou.MaxResults, + SogouEnabled: cfg.Tools.Web.Sogou.Enabled, + DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, + DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(), + PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, + PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, + SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, + SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, + GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey.String(), + GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, + GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, + GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, + GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, + BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey.String(), + BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL, + BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults, + BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled, + Proxy: cfg.Tools.Web.Proxy, + } +} + +func WebSearchProviderReady(opts WebSearchToolOptions, name string) bool { + return opts.providerReady(name) +} + +func ResolveWebSearchProviderName(opts WebSearchToolOptions, query string) (string, error) { + return opts.resolveProviderName(query) +} + +var ( + knownWebSearchProviders = []string{ + "sogou", + "duckduckgo", + "brave", + "tavily", + "perplexity", + "searxng", + "glm_search", + "baidu_search", + } + autoPrimaryWebSearchProviders = []string{"perplexity", "brave", "searxng", "tavily"} + autoFallbackWebSearchProviders = []string{"baidu_search", "glm_search"} +) + +func isKnownWebSearchProvider(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + for _, known := range knownWebSearchProviders { + if name == known { + return true + } + } + return false +} + +func (opts WebSearchToolOptions) providerReady(name string) bool { + switch strings.ToLower(strings.TrimSpace(name)) { + case "sogou": + return opts.SogouEnabled + case "duckduckgo": + return opts.DuckDuckGoEnabled + case "brave": + return opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 + case "tavily": + return opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 + case "perplexity": + return opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 + case "searxng": + return opts.SearXNGEnabled && strings.TrimSpace(opts.SearXNGBaseURL) != "" + case "glm_search": + return opts.GLMSearchEnabled && strings.TrimSpace(opts.GLMSearchAPIKey) != "" + case "baidu_search": + return opts.BaiduSearchEnabled && strings.TrimSpace(opts.BaiduSearchAPIKey) != "" + default: + return false + } +} + +func (opts WebSearchToolOptions) normalizedProviderName() string { + providerName := strings.ToLower(strings.TrimSpace(opts.Provider)) + if providerName != "" && providerName != "auto" && !isKnownWebSearchProvider(providerName) { + // Tolerate stale or manually edited config values at runtime by + // treating them as "auto" and falling back to the next ready provider. + return "auto" + } + return providerName +} + +func (opts WebSearchToolOptions) resolveProviderName(query string) (string, error) { + providerName := opts.normalizedProviderName() + if providerName != "" && providerName != "auto" && opts.providerReady(providerName) { + return providerName, nil + } + + for _, name := range autoPrimaryWebSearchProviders { + if opts.providerReady(name) { + return name, nil + } + } + + sogouReady := opts.providerReady("sogou") + duckReady := opts.providerReady("duckduckgo") + if sogouReady && duckReady { + if prefersDuckDuckGoQuery(query) { + return "duckduckgo", nil + } + return "sogou", nil + } + if sogouReady { + return "sogou", nil + } + if duckReady { + return "duckduckgo", nil + } + + for _, name := range autoFallbackWebSearchProviders { + if opts.providerReady(name) { + return name, nil + } + } + + return "", nil +} + +func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, int, error) { + switch strings.ToLower(strings.TrimSpace(name)) { + case "", "auto": + return nil, 0, nil + case "sogou": + if !opts.providerReady("sogou") { + return nil, 0, nil + } + client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) + if err != nil { + return nil, 0, fmt.Errorf("failed to create HTTP client for Sogou: %w", err) + } + maxResults := 10 + if opts.SogouMaxResults > 0 { + maxResults = min(opts.SogouMaxResults, 10) + } + return &SogouSearchProvider{ + proxy: opts.Proxy, + client: client, + }, maxResults, nil + case "perplexity": + if !opts.providerReady("perplexity") { + return nil, 0, nil + } + client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout) + if err != nil { + return nil, 0, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err) + } + maxResults := 10 + if opts.PerplexityMaxResults > 0 { + maxResults = min(opts.PerplexityMaxResults, 10) + } + return &PerplexitySearchProvider{ + keyPool: NewAPIKeyPool(opts.PerplexityAPIKeys), + proxy: opts.Proxy, + client: client, + }, maxResults, nil + case "brave": + if !opts.providerReady("brave") { + return nil, 0, nil + } + client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) + if err != nil { + return nil, 0, fmt.Errorf("failed to create HTTP client for Brave: %w", err) + } + maxResults := 10 + if opts.BraveMaxResults > 0 { + maxResults = min(opts.BraveMaxResults, 10) + } + return &BraveSearchProvider{ + keyPool: NewAPIKeyPool(opts.BraveAPIKeys), + proxy: opts.Proxy, + client: client, + }, maxResults, nil + case "searxng": + if !opts.providerReady("searxng") { + return nil, 0, nil + } + client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) + if err != nil { + return nil, 0, fmt.Errorf("failed to create HTTP client for SearXNG: %w", err) + } + maxResults := 10 + if opts.SearXNGMaxResults > 0 { + maxResults = min(opts.SearXNGMaxResults, 10) + } + return &SearXNGSearchProvider{ + baseURL: opts.SearXNGBaseURL, + proxy: opts.Proxy, + client: client, + }, maxResults, nil + case "tavily": + if !opts.providerReady("tavily") { + return nil, 0, nil + } + client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) + if err != nil { + return nil, 0, fmt.Errorf("failed to create HTTP client for Tavily: %w", err) + } + maxResults := 10 + if opts.TavilyMaxResults > 0 { + maxResults = min(opts.TavilyMaxResults, 10) + } + return &TavilySearchProvider{ + keyPool: NewAPIKeyPool(opts.TavilyAPIKeys), + baseURL: opts.TavilyBaseURL, + proxy: opts.Proxy, + client: client, + }, maxResults, nil + case "duckduckgo": + if !opts.providerReady("duckduckgo") { + return nil, 0, nil + } + client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) + if err != nil { + return nil, 0, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err) + } + maxResults := 10 + if opts.DuckDuckGoMaxResults > 0 { + maxResults = min(opts.DuckDuckGoMaxResults, 10) + } + return &DuckDuckGoSearchProvider{ + proxy: opts.Proxy, + client: client, + }, maxResults, nil + case "baidu_search": + if !opts.providerReady("baidu_search") { + return nil, 0, nil + } + client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout) + if err != nil { + return nil, 0, fmt.Errorf("failed to create HTTP client for Baidu Search: %w", err) + } + maxResults := 10 + if opts.BaiduSearchMaxResults > 0 { + maxResults = min(opts.BaiduSearchMaxResults, 10) + } + return &BaiduSearchProvider{ + apiKey: opts.BaiduSearchAPIKey, + baseURL: opts.BaiduSearchBaseURL, + proxy: opts.Proxy, + client: client, + }, maxResults, nil + case "glm_search": + if !opts.providerReady("glm_search") { + return nil, 0, nil + } + client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) + if err != nil { + return nil, 0, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err) + } + searchEngine := opts.GLMSearchEngine + if searchEngine == "" { + searchEngine = "search_std" + } + maxResults := 10 + if opts.GLMSearchMaxResults > 0 { + maxResults = min(opts.GLMSearchMaxResults, 10) + } + return &GLMSearchProvider{ + apiKey: opts.GLMSearchAPIKey, + baseURL: opts.GLMSearchBaseURL, + searchEngine: searchEngine, + proxy: opts.Proxy, + client: client, + }, maxResults, nil + default: + return nil, 0, fmt.Errorf("unknown web search provider %q", name) + } +} + +func containsHan(text string) bool { + for _, r := range text { + if unicode.Is(unicode.Han, r) { + return true + } + } + return false +} + +func containsLatinLetter(text string) bool { + for _, r := range text { + if unicode.IsLetter(r) && unicode.In(r, unicode.Latin) { + return true + } + } + return false +} + +func prefersDuckDuckGoQuery(text string) bool { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return false + } + if containsHan(trimmed) { + return false + } + if containsLatinLetter(trimmed) { + return true + } + return false +} + +func (opts WebSearchToolOptions) buildProviderResolver() (func(query string) (SearchProvider, int), error) { + providersByName := make(map[string]SearchProvider, len(knownWebSearchProviders)) + maxResultsByName := make(map[string]int, len(knownWebSearchProviders)) + + for _, name := range knownWebSearchProviders { + if !opts.providerReady(name) { + continue + } + provider, maxResults, err := opts.providerByName(name) + if err != nil { + return nil, err + } + if provider == nil { + continue + } + providersByName[name] = provider + maxResultsByName[name] = maxResults + } + + return func(query string) (SearchProvider, int) { + name, err := opts.resolveProviderName(query) + if err != nil { + return nil, 0 + } + provider, ok := providersByName[name] + if !ok { + return nil, 0 + } + return provider, maxResultsByName[name] + }, nil +} + +func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { + resolver, err := opts.buildProviderResolver() + if err != nil { + return nil, err + } + provider, maxResults := resolver("") + if provider == nil { + return nil, nil + } + + return &WebSearchTool{ + provider: provider, + maxResults: maxResults, + providerResolver: resolver, + }, nil +} + +func (t *WebSearchTool) Name() string { + return "web_search" +} + +func (t *WebSearchTool) Description() string { + return "Search the web for current information. Supports query, count, and an optional temporal range filter. Returns titles, URLs, and snippets from search results." +} + +func (t *WebSearchTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": "Search query", + }, + "count": map[string]any{ + "type": "integer", + "description": "Number of results (default: 10, max: 10)", + "minimum": 1.0, + "maximum": 10.0, + }, + "range": map[string]any{ + "type": "string", + "description": "Optional time filter: d (day), w (week), m (month), y (year)", + "enum": []string{"d", "w", "m", "y"}, + }, + }, + "required": []string{"query"}, + } +} + +func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + query, ok := args["query"].(string) + if !ok || strings.TrimSpace(query) == "" { + return ErrorResult("query is required") + } + query = strings.TrimSpace(query) + + provider := t.provider + maxResults := t.maxResults + if t.providerResolver != nil { + provider, maxResults = t.providerResolver(query) + } + if provider == nil { + return ErrorResult("search provider is not configured") + } + + count64, err := getInt64Arg(args, "count", int64(maxResults)) + if err != nil { + return ErrorResult(err.Error()) + } + count := maxResults + if count64 > 0 && count64 <= 10 { + count = min(int(count64), maxResults) + } + + rangeCode, err := normalizeSearchRange("") + if err != nil { + return ErrorResult(err.Error()) + } + if rawRange, exists := args["range"]; exists { + rangeStr, ok := rawRange.(string) + if !ok { + return ErrorResult("range must be a string") + } + rangeCode, err = normalizeSearchRange(rangeStr) + if err != nil { + return ErrorResult(err.Error()) + } + } + + result, err := provider.Search(ctx, query, count, rangeCode) + if err != nil { + return ErrorResult(fmt.Sprintf("search failed: %v", err)) + } + + return &ToolResult{ + ForLLM: result, + ForUser: result, + } +} + +type WebFetchTool struct { + maxChars int + proxy string + client *http.Client + format string + fetchLimitBytes int64 + whitelist *privateHostWhitelist +} + +type privateHostWhitelist struct { + exact map[string]struct{} + cidrs []*net.IPNet +} + +type webFetchAllowedFirstHopHostKey struct{} + +func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) { + // createHTTPClient cannot fail with an empty proxy string. + return NewWebFetchToolWithConfig(maxChars, "", format, fetchLimitBytes, nil) +} + +// allowPrivateWebFetchHosts controls whether loopback/private hosts are allowed. +// This is false in normal runtime to reduce SSRF exposure, and tests can override it temporarily. +var allowPrivateWebFetchHosts atomic.Bool + +func NewWebFetchToolWithProxy( + maxChars int, + proxy string, + format string, + fetchLimitBytes int64, + privateHostWhitelist []string, +) (*WebFetchTool, error) { + return NewWebFetchToolWithConfig(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist) +} + +func NewWebFetchToolWithConfig( + maxChars int, + proxy string, + format string, + fetchLimitBytes int64, + privateHostWhitelist []string, +) (*WebFetchTool, error) { + if maxChars <= 0 { + maxChars = defaultMaxChars + } + whitelist, err := newPrivateHostWhitelist(privateHostWhitelist) + if err != nil { + return nil, fmt.Errorf("failed to parse web fetch private host whitelist: %w", err) + } + client, err := utils.CreateHTTPClient(proxy, fetchTimeout) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err) + } + if transport, ok := client.Transport.(*http.Transport); ok { + dialer := &net.Dialer{ + Timeout: 15 * time.Second, + KeepAlive: 30 * time.Second, + } + transport.DialContext = newSafeDialContext(dialer, whitelist) + } + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return fmt.Errorf("stopped after %d redirects", maxRedirects) + } + if isObviousPrivateHost(req.URL.Hostname(), whitelist) { + return fmt.Errorf("redirect target is private or local network host") + } + allowConfiguredProxyFirstHop(req, client.Transport) + return nil + } + if fetchLimitBytes <= 0 { + fetchLimitBytes = 10 * 1024 * 1024 // Security Fallback + } + return &WebFetchTool{ + maxChars: maxChars, + proxy: proxy, + client: client, + format: format, + fetchLimitBytes: fetchLimitBytes, + whitelist: whitelist, + }, nil +} + +func (t *WebFetchTool) Name() string { + return "web_fetch" +} + +func (t *WebFetchTool) Description() string { + return "Fetch a URL and extract readable content (HTML to text). Use this to get weather info, news, articles, or any web content." +} + +func (t *WebFetchTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "url": map[string]any{ + "type": "string", + "description": "URL to fetch", + }, + "maxChars": map[string]any{ + "type": "integer", + "description": "Maximum characters to extract", + "minimum": 100.0, + }, + }, + "required": []string{"url"}, + } +} + +func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + urlStr, ok := args["url"].(string) + if !ok { + return ErrorResult("url is required") + } + + parsedURL, err := url.Parse(urlStr) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid URL: %v", err)) + } + + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return ErrorResult("only http/https URLs are allowed") + } + + if parsedURL.Host == "" { + return ErrorResult("missing domain in URL") + } + + // Lightweight pre-flight: block obvious localhost/literal-IP without DNS resolution. + // The real SSRF guard is newSafeDialContext at connect time. + hostname := parsedURL.Hostname() + if isObviousPrivateHost(hostname, t.whitelist) { + return ErrorResult("fetching private or local network hosts is not allowed") + } + + maxChars := t.maxChars + if mc, ok := args["maxChars"].(float64); ok { + if int(mc) > 100 { + maxChars = int(mc) + } + } + + doFetch := func(ua string) (*http.Response, []byte, error) { + req, reqErr := http.NewRequestWithContext(ctx, "GET", urlStr, nil) + if reqErr != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", reqErr) + } + allowConfiguredProxyFirstHop(req, t.client.Transport) + req.Header.Set("User-Agent", ua) + resp, doErr := t.client.Do(req) + if doErr != nil { + return nil, nil, fmt.Errorf("request failed: %w", doErr) + } + resp.Body = http.MaxBytesReader(nil, resp.Body, t.fetchLimitBytes) + + b, readErr := io.ReadAll(resp.Body) + return resp, b, readErr + } + + resp, body, err := doFetch(userAgent) + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + + if err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + return ErrorResult( + fmt.Sprintf( + "failed to read response: size exceeded %d bytes limit", + t.fetchLimitBytes, + ), + ) + } + return ErrorResult(err.Error()) + } + + // Cloudflare (and similar WAFs) signal bot challenges with 403 + cf-mitigated: challenge. + // Retry once with an honest User-Agent that identifies picoclaw, which some + // operators explicitly allow-list for AI assistants. + if resp.StatusCode == http.StatusForbidden && resp.Header.Get("Cf-Mitigated") == "challenge" { + logger.DebugCF("tool", "Cloudflare challenge detected, retrying with honest User-Agent", + map[string]any{"url": urlStr}) + honestUA := fmt.Sprintf(userAgentHonest, config.Version) + resp2, body2, err2 := doFetch(honestUA) + if resp2 != nil && resp2.Body != nil { + defer resp2.Body.Close() + } + + if err2 == nil { + resp, body = resp2, body2 + } else { + var maxBytesErr *http.MaxBytesError + if errors.As(err2, &maxBytesErr) { + return ErrorResult( + fmt.Sprintf("failed to read response: size exceeded %d bytes limit", t.fetchLimitBytes), + ) + } + return ErrorResult(err2.Error()) + } + } + + bodyStr := string(body) + contentType := resp.Header.Get("Content-Type") + + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + // The most common error here is "mime: no media type" if the header is empty. + logger.WarnCF("tool", "Failed to parse Content-Type", map[string]any{ + "raw_header": contentType, + "error": err.Error(), + }) + + // security fallback + mediaType = "application/octet-stream" + } + + charset, hasCharset := params["charset"] + if hasCharset { + // If the charset is not utf-8, we might have to convert the bodyStr + // before passing it to the HTML/Markdown parser + if strings.ToLower(charset) != "utf-8" { + logger.WarnCF( + "tool", + "Note: the content is not in UTF-8", + map[string]any{"charset": charset}, + ) + } + } + + var text, extractor string + + switch { + case mediaType == "application/json": + var jsonData any + if err := json.Unmarshal(body, &jsonData); err != nil { + text = bodyStr + extractor = "raw" + break + } + + formatted, err := json.MarshalIndent(jsonData, "", " ") + if err != nil { + text = bodyStr + extractor = "raw" + break + } + + text = string(formatted) + extractor = "json" + + case mediaType == "text/html" || looksLikeHTML(bodyStr): + switch strings.ToLower(t.format) { + case "markdown": + var err error + text, err = utils.HtmlToMarkdown(bodyStr) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to HTML to markdown: %v", err)) + } + extractor = "markdown" + + default: + text = t.extractText(bodyStr) + extractor = "text" + } + + default: + text = bodyStr + extractor = "raw" + } + + truncated := len(text) > maxChars + if truncated { + text = text[:maxChars] + "\n[Content truncated due to size limit]" + } + + result := map[string]any{ + "url": urlStr, + "status": resp.StatusCode, + "extractor": extractor, + "truncated": truncated, + "length": len(text), + "text": text, + } + + resultJSON, _ := json.MarshalIndent(result, "", " ") + + return &ToolResult{ + ForLLM: string(resultJSON), + ForUser: fmt.Sprintf( + "Fetched %d bytes from %s (extractor: %s, truncated: %v)", + len(text), + urlStr, + extractor, + truncated, + ), + } +} + +func looksLikeHTML(body string) bool { + if body == "" { + return false + } + + lower := strings.ToLower(body) + + return strings.HasPrefix(body, "<!doctype") || + strings.HasPrefix(lower, "<html") +} + +func (t *WebFetchTool) extractText(htmlContent string) string { + result := reScript.ReplaceAllLiteralString(htmlContent, "") + result = reStyle.ReplaceAllLiteralString(result, "") + result = reTags.ReplaceAllLiteralString(result, "") + + result = strings.TrimSpace(result) + + result = reWhitespace.ReplaceAllString(result, " ") + result = reBlankLines.ReplaceAllString(result, "\n\n") + + lines := strings.Split(result, "\n") + var cleanLines []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + cleanLines = append(cleanLines, line) + } + } + + return strings.Join(cleanLines, "\n") +} + +// newSafeDialContext re-resolves DNS at connect time to mitigate DNS rebinding (TOCTOU) +// where a hostname resolves to a public IP during pre-flight but a private IP at connect time. +func newSafeDialContext( + dialer *net.Dialer, + whitelist *privateHostWhitelist, +) func(context.Context, string, string) (net.Conn, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + if allowPrivateWebFetchHosts.Load() { + return dialer.DialContext(ctx, network, address) + } + + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("invalid target address %q: %w", address, err) + } + if host == "" { + return nil, fmt.Errorf("empty target host") + } + if isAllowedFirstHopHost(ctx, host) { + return dialer.DialContext(ctx, network, address) + } + + if ip := net.ParseIP(host); ip != nil { + if shouldBlockPrivateIP(ip, whitelist) { + return nil, fmt.Errorf("blocked private or local target: %s", host) + } + return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + } + + ipAddrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, fmt.Errorf("failed to resolve %s: %w", host, err) + } + + attempted := 0 + var lastErr error + for _, ipAddr := range ipAddrs { + if shouldBlockPrivateIP(ipAddr.IP, whitelist) { + continue + } + attempted++ + conn, err := dialer.DialContext( + ctx, + network, + net.JoinHostPort(ipAddr.IP.String(), port), + ) + if err == nil { + return conn, nil + } + lastErr = err + } + + if attempted == 0 { + return nil, fmt.Errorf( + "all resolved addresses for %s are private, restricted, or not whitelisted", + host, + ) + } + if lastErr != nil { + return nil, fmt.Errorf( + "failed connecting to public addresses for %s: %w", + host, + lastErr, + ) + } + return nil, fmt.Errorf("failed connecting to public addresses for %s", host) + } +} + +func allowConfiguredProxyFirstHop(req *http.Request, rt http.RoundTripper) { + if req == nil { + return + } + + transport, ok := rt.(*http.Transport) + if !ok || transport.Proxy == nil { + return + } + + proxyURL, err := transport.Proxy(req) + if err != nil || proxyURL == nil { + return + } + + host := normalizeAllowedFirstHopHost(proxyURL.Hostname()) + if host == "" { + return + } + + *req = *req.WithContext(context.WithValue( + req.Context(), + webFetchAllowedFirstHopHostKey{}, + host, + )) +} + +func isAllowedFirstHopHost(ctx context.Context, host string) bool { + allowed, _ := ctx.Value(webFetchAllowedFirstHopHostKey{}).(string) + if allowed == "" { + return false + } + return allowed == normalizeAllowedFirstHopHost(host) +} + +func normalizeAllowedFirstHopHost(host string) string { + host = strings.ToLower(strings.TrimSpace(host)) + return strings.TrimSuffix(host, ".") +} + +func newPrivateHostWhitelist(entries []string) (*privateHostWhitelist, error) { + if len(entries) == 0 { + return nil, nil + } + + whitelist := &privateHostWhitelist{ + exact: make(map[string]struct{}), + cidrs: make([]*net.IPNet, 0, len(entries)), + } + for _, entry := range entries { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if ip := net.ParseIP(entry); ip != nil { + whitelist.exact[normalizeWhitelistIP(ip).String()] = struct{}{} + continue + } + _, network, err := net.ParseCIDR(entry) + if err != nil { + return nil, fmt.Errorf("invalid entry %q: expected IP or CIDR", entry) + } + whitelist.cidrs = append(whitelist.cidrs, network) + } + + if len(whitelist.exact) == 0 && len(whitelist.cidrs) == 0 { + return nil, nil + } + return whitelist, nil +} + +func (w *privateHostWhitelist) Contains(ip net.IP) bool { + if w == nil || ip == nil { + return false + } + + normalized := normalizeWhitelistIP(ip) + if _, ok := w.exact[normalized.String()]; ok { + return true + } + for _, network := range w.cidrs { + if network.Contains(normalized) { + return true + } + } + return false +} + +func normalizeWhitelistIP(ip net.IP) net.IP { + if ip == nil { + return nil + } + if ip4 := ip.To4(); ip4 != nil { + return ip4 + } + return ip +} + +func shouldBlockPrivateIP(ip net.IP, whitelist *privateHostWhitelist) bool { + return isPrivateOrRestrictedIP(ip) && !whitelist.Contains(ip) +} + +// isObviousPrivateHost performs a lightweight, no-DNS check for obviously private hosts. +// It catches localhost, literal private IPs, and empty hosts. It does NOT resolve DNS — +// the real SSRF guard is newSafeDialContext which checks IPs at connect time. +func isObviousPrivateHost(host string, whitelist *privateHostWhitelist) bool { + if allowPrivateWebFetchHosts.Load() { + return false + } + + h := strings.ToLower(strings.TrimSpace(host)) + h = strings.TrimSuffix(h, ".") + if h == "" { + return true + } + + if h == "localhost" || strings.HasSuffix(h, ".localhost") { + return true + } + + if ip := net.ParseIP(h); ip != nil { + return shouldBlockPrivateIP(ip, whitelist) + } + + return false +} + +// isPrivateOrRestrictedIP returns true for IPs that should never be reached via web_fetch: +// RFC 1918, loopback, link-local (incl. cloud metadata 169.254.x.x), carrier-grade NAT, +// IPv6 unique-local (fc00::/7), 6to4 (2002::/16), and Teredo (2001:0000::/32). +func isPrivateOrRestrictedIP(ip net.IP) bool { + if ip == nil { + return true + } + + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsMulticast() || ip.IsUnspecified() { + return true + } + + if ip4 := ip.To4(); ip4 != nil { + // IPv4 private, loopback, link-local, and carrier-grade NAT ranges. + if ip4[0] == 10 || + ip4[0] == 127 || + ip4[0] == 0 || + (ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) || + (ip4[0] == 192 && ip4[1] == 168) || + (ip4[0] == 169 && ip4[1] == 254) || + (ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127) { + return true + } + return false + } + + if len(ip) == net.IPv6len { + // IPv6 unique local addresses (fc00::/7) + if (ip[0] & 0xfe) == 0xfc { + return true + } + // 6to4 addresses (2002::/16): check the embedded IPv4 at bytes [2:6]. + if ip[0] == 0x20 && ip[1] == 0x02 { + embedded := net.IPv4(ip[2], ip[3], ip[4], ip[5]) + return isPrivateOrRestrictedIP(embedded) + } + // Teredo (2001:0000::/32): client IPv4 is at bytes [12:16], XOR-inverted. + if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 { + client := net.IPv4(ip[12]^0xff, ip[13]^0xff, ip[14]^0xff, ip[15]^0xff) + return isPrivateOrRestrictedIP(client) + } + } + + return false +} diff --git a/pkg/tools/integration/web_test.go b/pkg/tools/integration/web_test.go new file mode 100644 index 000000000..ba6b3da45 --- /dev/null +++ b/pkg/tools/integration/web_test.go @@ -0,0 +1,1997 @@ +package integrationtools + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +const ( + testFetchLimit = int64(10 * 1024 * 1024) + format = "plaintext" +) + +// TestWebTool_WebFetch_Success verifies successful URL fetching +func TestWebTool_WebFetch_Success(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte("<html><body><h1>Test Page</h1><p>Content here</p></body></html>")) + })) + defer server.Close() + + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + + ctx := context.Background() + args := map[string]any{ + "url": server.URL, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForLLM should contain the fetched content (full JSON result) + if !strings.Contains(result.ForLLM, "Test Page") { + t.Errorf("Expected ForLLM to contain 'Test Page', got: %s", result.ForLLM) + } + + // ForUser should contain summary + if !strings.Contains(result.ForUser, "bytes") && !strings.Contains(result.ForUser, "extractor") { + t.Errorf("Expected ForUser to contain summary, got: %s", result.ForUser) + } +} + +// TestWebTool_WebFetch_JSON verifies JSON content handling +func TestWebTool_WebFetch_JSON(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + + testData := map[string]string{"key": "value", "number": "123"} + expectedJSON, _ := json.MarshalIndent(testData, "", " ") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(expectedJSON) + })) + defer server.Close() + + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } + + ctx := context.Background() + args := map[string]any{ + "url": server.URL, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForLLM should contain formatted JSON + if !strings.Contains(result.ForLLM, "key") && !strings.Contains(result.ForLLM, "value") { + t.Errorf("Expected ForLLM to contain JSON data, got: %s", result.ForLLM) + } +} + +// TestWebTool_WebFetch_InvalidURL verifies error handling for invalid URL +func TestWebTool_WebFetch_InvalidURL(t *testing.T) { + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } + + ctx := context.Background() + args := map[string]any{ + "url": "not-a-valid-url", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error for invalid URL") + } + + // Should contain error message (either "invalid URL" or scheme error) + if !strings.Contains(result.ForLLM, "URL") && !strings.Contains(result.ForUser, "URL") { + t.Errorf("Expected error message for invalid URL, got ForLLM: %s", result.ForLLM) + } +} + +// TestWebTool_WebFetch_UnsupportedScheme verifies error handling for non-http URLs +func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } + + ctx := context.Background() + args := map[string]any{ + "url": "ftp://example.com/file.txt", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error for unsupported URL scheme") + } + + // Should mention only http/https allowed + if !strings.Contains(result.ForLLM, "http/https") && !strings.Contains(result.ForUser, "http/https") { + t.Errorf("Expected scheme error message, got ForLLM: %s", result.ForLLM) + } +} + +// TestWebTool_WebFetch_MissingURL verifies error handling for missing URL +func TestWebTool_WebFetch_MissingURL(t *testing.T) { + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } + + ctx := context.Background() + args := map[string]any{} + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when URL is missing") + } + + // Should mention URL is required + if !strings.Contains(result.ForLLM, "url is required") && !strings.Contains(result.ForUser, "url is required") { + t.Errorf("Expected 'url is required' message, got ForLLM: %s", result.ForLLM) + } +} + +// TestWebTool_WebFetch_Truncation verifies content truncation +func TestWebTool_WebFetch_Truncation(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + + longContent := strings.Repeat("x", 20000) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(longContent)) + })) + defer server.Close() + + tool, err := NewWebFetchTool(1000, format, testFetchLimit) // Limit to 1000 chars + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } + + ctx := context.Background() + args := map[string]any{ + "url": server.URL, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForLLM should contain truncated content (not the full 20000 chars) + resultMap := make(map[string]any) + json.Unmarshal([]byte(result.ForLLM), &resultMap) + if text, ok := resultMap["text"].(string); ok { + if len(text) > 1100 { // Allow some margin + t.Errorf("Expected content to be truncated to ~1000 chars, got: %d", len(text)) + } + } + + // Should be marked as truncated + if truncated, ok := resultMap["truncated"].(bool); !ok || !truncated { + t.Errorf("Expected 'truncated' to be true in result") + } + + // Text should end with the truncation notice + if text, ok := resultMap["text"].(string); ok { + if !strings.HasSuffix(text, "[Content truncated due to size limit]") { + t.Errorf("Expected text to end with truncation notice, got: %q", text[max(0, len(text)-60):]) + } + } +} + +// TestWebTool_WebFetch_TruncationNotice verifies the truncation notice is appended +// for all content formats (text/plain, text/html, markdown, application/json). +func TestWebTool_WebFetch_TruncationNotice(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + + const truncationNotice = "[Content truncated due to size limit]" + const maxChars = 100 + + tests := []struct { + name string + contentType string + body string + format string + }{ + { + name: "plain text", + contentType: "text/plain", + body: strings.Repeat("a", 500), + format: "plaintext", + }, + { + name: "html plaintext extractor", + contentType: "text/html", + body: "<html><body>" + strings.Repeat("b", 500) + "</body></html>", + format: "plaintext", + }, + { + name: "html markdown extractor", + contentType: "text/html", + body: "<html><body>" + strings.Repeat("c", 500) + "</body></html>", + format: "markdown", + }, + { + name: "json", + contentType: "application/json", + body: `"` + strings.Repeat("d", 500) + `"`, + format: "plaintext", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", tt.contentType) + w.WriteHeader(http.StatusOK) + w.Write([]byte(tt.body)) + })) + defer server.Close() + + tool, err := NewWebFetchTool(maxChars, tt.format, testFetchLimit) + if err != nil { + t.Fatalf("NewWebFetchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{"url": server.URL}) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + var resultMap map[string]any + if err := json.Unmarshal([]byte(result.ForLLM), &resultMap); err != nil { + t.Fatalf("failed to unmarshal result JSON: %v", err) + } + + text, ok := resultMap["text"].(string) + if !ok { + t.Fatal("missing 'text' field in result") + } + + if !strings.HasSuffix(text, truncationNotice) { + t.Errorf("expected text to end with %q, got suffix: %q", truncationNotice, text[max(0, len(text)-60):]) + } + + if truncated, ok := resultMap["truncated"].(bool); !ok || !truncated { + t.Errorf("expected truncated=true in result") + } + }) + } +} + +// TestWebTool_WebFetch_NoTruncationNoticeWhenFitsInLimit verifies that the notice +// is NOT appended when the content fits within the limit. +func TestWebTool_WebFetch_NoTruncationNoticeWhenFitsInLimit(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + + const truncationNotice = "[Content truncated due to size limit]" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("short content")) + })) + defer server.Close() + + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + t.Fatalf("NewWebFetchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{"url": server.URL}) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + var resultMap map[string]any + if err := json.Unmarshal([]byte(result.ForLLM), &resultMap); err != nil { + t.Fatalf("failed to unmarshal result JSON: %v", err) + } + + text, _ := resultMap["text"].(string) + if strings.Contains(text, truncationNotice) { + t.Errorf("expected no truncation notice for content within limit, got: %q", text) + } + + if truncated, _ := resultMap["truncated"].(bool); truncated { + t.Errorf("expected truncated=false for content within limit") + } +} + +func TestWebFetchTool_PayloadTooLarge(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + + // Create a mock HTTP server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + + // Generate a payload intentionally larger than our limit. + // Limit: 10 * 1024 * 1024 (10MB). We generate 10MB + 100 bytes of the letter 'A'. + largeData := bytes.Repeat([]byte("A"), int(testFetchLimit)+100) + + w.Write(largeData) + })) + // Ensure the server is shut down at the end of the test + defer ts.Close() + + // Initialize the tool + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } + + // Prepare the arguments pointing to the URL of our local mock server + args := map[string]any{ + "url": ts.URL, + } + + // Execute the tool + ctx := context.Background() + result := tool.Execute(ctx, args) + + // Assuming ErrorResult sets the ForLLM field with the error text. + if result == nil { + t.Fatal("expected a ToolResult, got nil") + } + + // Search for the exact error string we set earlier in the Execute method + expectedErrorMsg := fmt.Sprintf("size exceeded %d bytes limit", testFetchLimit) + + if !strings.Contains(result.ForLLM, expectedErrorMsg) && !strings.Contains(result.ForUser, expectedErrorMsg) { + t.Errorf("test failed: expected error %q, but got: %+v", expectedErrorMsg, result) + } +} + +// TestWebTool_WebSearch_NoApiKey verifies providers without required credentials are not registered. +func TestWebTool_WebSearch_NoApiKey(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKeys: nil}) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if tool != nil { + t.Fatalf("Expected nil tool when only enabled provider is missing credentials") + } + + // Also nil when nothing is enabled + tool, err = NewWebSearchTool(WebSearchToolOptions{}) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if tool != nil { + t.Errorf("Expected nil tool when no provider is enabled") + } +} + +// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query +func TestWebTool_WebSearch_MissingQuery(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + BraveEnabled: true, + BraveAPIKeys: []string{"test-key"}, + BraveMaxResults: 5, + }) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + ctx := context.Background() + args := map[string]any{} + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when query is missing") + } +} + +func TestNormalizeSearchRange(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "empty", input: "", want: ""}, + {name: "day", input: "d", want: "d"}, + {name: "week uppercase trimmed", input: " W ", want: "w"}, + {name: "month", input: "m", want: "m"}, + {name: "year", input: "y", want: "y"}, + {name: "invalid", input: "q", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeSearchRange(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("normalizeSearchRange(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSearchRangeMappings(t *testing.T) { + if got := mapBraveFreshness("d"); got != "pd" { + t.Fatalf("mapBraveFreshness(d) = %q, want pd", got) + } + if got := mapBraveFreshness("y"); got != "py" { + t.Fatalf("mapBraveFreshness(y) = %q, want py", got) + } + if got := mapTavilyTimeRange("w"); got != "week" { + t.Fatalf("mapTavilyTimeRange(w) = %q, want week", got) + } + if got := mapPerplexityRecencyFilter("m"); got != "month" { + t.Fatalf("mapPerplexityRecencyFilter(m) = %q, want month", got) + } + if got := mapDuckDuckGoDateFilter("y"); got != "t" { + t.Fatalf("mapDuckDuckGoDateFilter(y) = %q, want t", got) + } + if got := mapSearXNGTimeRange("d"); got != "day" { + t.Fatalf("mapSearXNGTimeRange(d) = %q, want day", got) + } + if got := mapGLMRecencyFilter("w"); got != "oneWeek" { + t.Fatalf("mapGLMRecencyFilter(w) = %q, want oneWeek", got) + } + if got := mapGLMRecencyFilter(""); got != "noLimit" { + t.Fatalf("mapGLMRecencyFilter(\"\") = %q, want noLimit", got) + } + if got := mapBaiduRecencyFilter("d"); got != "week" { + t.Fatalf("mapBaiduRecencyFilter(d) = %q, want week", got) + } + if got := mapBaiduRecencyFilter("m"); got != "month" { + t.Fatalf("mapBaiduRecencyFilter(m) = %q, want month", got) + } +} + +func TestWebTool_WebSearch_InvalidRange(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + BraveEnabled: true, + BraveAPIKeys: []string{"test-key"}, + BraveMaxResults: 5, + }) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + "range": "invalid", + }) + + if !result.IsError { + t.Fatalf("expected invalid range to return error") + } + if !strings.Contains(result.ForLLM, "range must be one of: d, w, m, y") { + t.Fatalf("unexpected error message: %q", result.ForLLM) + } +} + +// TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction +func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write( + []byte( + `<html><body><script>alert('test');</script><style>body{color:red;}</style><h1>Title</h1><p>Content</p></body></html>`, + ), + ) + })) + defer server.Close() + + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } + + ctx := context.Background() + args := map[string]any{ + "url": server.URL, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForLLM should contain extracted text (without script/style tags) + if !strings.Contains(result.ForLLM, "Title") && !strings.Contains(result.ForLLM, "Content") { + t.Errorf("Expected ForLLM to contain extracted text, got: %s", result.ForLLM) + } + + // Should NOT contain script or style tags in ForLLM + if strings.Contains(result.ForLLM, "<script>") || strings.Contains(result.ForLLM, "<style>") { + t.Errorf("Expected script/style tags to be removed, got: %s", result.ForLLM) + } +} + +// TestWebFetchTool_extractText verifies text extraction preserves newlines +func TestWebFetchTool_extractText(t *testing.T) { + tool := &WebFetchTool{} + + tests := []struct { + name string + input string + wantFunc func(t *testing.T, got string) + }{ + { + name: "preserves newlines between block elements", + input: "<html><body><h1>Title</h1>\n<p>Paragraph 1</p>\n<p>Paragraph 2</p></body></html>", + wantFunc: func(t *testing.T, got string) { + lines := strings.Split(got, "\n") + if len(lines) < 2 { + t.Errorf("Expected multiple lines, got %d: %q", len(lines), got) + } + if !strings.Contains(got, "Title") || !strings.Contains(got, "Paragraph 1") || + !strings.Contains(got, "Paragraph 2") { + t.Errorf("Missing expected text: %q", got) + } + }, + }, + { + name: "removes script and style tags", + input: "<script>alert('x');</script><style>body{}</style><p>Keep this</p>", + wantFunc: func(t *testing.T, got string) { + if strings.Contains(got, "alert") || strings.Contains(got, "body{}") { + t.Errorf("Expected script/style content removed, got: %q", got) + } + if !strings.Contains(got, "Keep this") { + t.Errorf("Expected 'Keep this' to remain, got: %q", got) + } + }, + }, + { + name: "collapses excessive blank lines", + input: "<p>A</p>\n\n\n\n\n<p>B</p>", + wantFunc: func(t *testing.T, got string) { + if strings.Contains(got, "\n\n\n") { + t.Errorf("Expected excessive blank lines collapsed, got: %q", got) + } + }, + }, + { + name: "collapses horizontal whitespace", + input: "<p>hello world</p>", + wantFunc: func(t *testing.T, got string) { + if strings.Contains(got, " ") { + t.Errorf("Expected spaces collapsed, got: %q", got) + } + if !strings.Contains(got, "hello world") { + t.Errorf("Expected 'hello world', got: %q", got) + } + }, + }, + { + name: "empty input", + input: "", + wantFunc: func(t *testing.T, got string) { + if got != "" { + t.Errorf("Expected empty string, got: %q", got) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tool.extractText(tt.input) + tt.wantFunc(t, got) + }) + } +} + +func withPrivateWebFetchHostsAllowed(t *testing.T) { + t.Helper() + previous := allowPrivateWebFetchHosts.Load() + allowPrivateWebFetchHosts.Store(true) + t.Cleanup(func() { + allowPrivateWebFetchHosts.Store(previous) + }) +} + +func serverHostAndPort(t *testing.T, rawURL string) (string, string) { + t.Helper() + hostPort := strings.TrimPrefix(rawURL, "http://") + hostPort = strings.TrimPrefix(hostPort, "https://") + host, port, err := net.SplitHostPort(hostPort) + if err != nil { + t.Fatalf("failed to split host/port from %q: %v", rawURL, err) + } + return host, port +} + +func singleHostCIDR(t *testing.T, host string) string { + t.Helper() + ip := net.ParseIP(host) + if ip == nil { + t.Fatalf("failed to parse IP %q", host) + } + if ip.To4() != nil { + return ip.String() + "/32" + } + return ip.String() + "/128" +} + +func TestWebTool_WebFetch_PrivateHostBlocked(t *testing.T) { + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + result := tool.Execute(context.Background(), map[string]any{ + "url": "http://127.0.0.1:0", + }) + + if !result.IsError { + t.Errorf("expected error for private host URL, got success") + } + if !strings.Contains(result.ForLLM, "private or local network") && + !strings.Contains(result.ForUser, "private or local network") { + t.Errorf("expected private host block message, got %q", result.ForLLM) + } +} + +func TestWebTool_WebFetch_PrivateHostAllowedByExactWhitelist(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("exact whitelist ok")) + })) + defer server.Close() + + host, _ := serverHostAndPort(t, server.URL) + tool, err := NewWebFetchToolWithConfig(50000, "", format, testFetchLimit, []string{host}) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "url": server.URL, + }) + if result.IsError { + t.Fatalf("expected success for exact whitelisted private IP, got %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "exact whitelist ok") { + t.Fatalf("expected fetched content, got %q", result.ForLLM) + } +} + +func TestWebTool_WebFetch_PrivateHostAllowedByCIDRWhitelist(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("cidr whitelist ok")) + })) + defer server.Close() + + host, _ := serverHostAndPort(t, server.URL) + tool, err := NewWebFetchToolWithConfig(50000, "", format, testFetchLimit, []string{singleHostCIDR(t, host)}) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "url": server.URL, + }) + if result.IsError { + t.Fatalf("expected success for CIDR-whitelisted private IP, got %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "cidr whitelist ok") { + t.Fatalf("expected fetched content, got %q", result.ForLLM) + } +} + +func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + })) + defer server.Close() + + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + result := tool.Execute(context.Background(), map[string]any{ + "url": server.URL, + }) + + if result.IsError { + t.Errorf("expected success when private host access is allowed in tests, got %q", result.ForLLM) + } +} + +func TestWebTool_WebFetch_AllowsLoopbackProxy(t *testing.T) { + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.String() != "http://example.com/proxied" { + t.Fatalf("proxy received URL %q, want %q", r.URL.String(), "http://example.com/proxied") + } + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("proxied content")) + })) + defer proxy.Close() + + tool, err := NewWebFetchToolWithProxy(50000, proxy.URL, format, testFetchLimit, nil) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "url": "http://example.com/proxied", + }) + if result.IsError { + t.Fatalf("expected success through loopback proxy, got %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "proxied content") { + t.Fatalf("expected proxied content, got %q", result.ForLLM) + } +} + +// TestWebFetch_BlocksIPv4MappedIPv6Loopback verifies ::ffff:127.0.0.1 is blocked +func TestWebFetch_BlocksIPv4MappedIPv6Loopback(t *testing.T) { + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + result := tool.Execute(context.Background(), map[string]any{ + "url": "http://[::ffff:127.0.0.1]:0", + }) + + if !result.IsError { + t.Error("expected error for IPv4-mapped IPv6 loopback URL, got success") + } +} + +// TestWebFetch_BlocksMetadataIP verifies 169.254.169.254 is blocked +func TestWebFetch_BlocksMetadataIP(t *testing.T) { + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + result := tool.Execute(context.Background(), map[string]any{ + "url": "http://169.254.169.254/latest/meta-data", + }) + + if !result.IsError { + t.Error("expected error for cloud metadata IP, got success") + } +} + +// TestWebFetch_BlocksIPv6UniqueLocal verifies fc00::/7 addresses are blocked +func TestWebFetch_BlocksIPv6UniqueLocal(t *testing.T) { + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + result := tool.Execute(context.Background(), map[string]any{ + "url": "http://[fd00::1]:0", + }) + + if !result.IsError { + t.Error("expected error for IPv6 unique local address, got success") + } +} + +// TestWebFetch_Blocks6to4WithPrivateEmbed verifies 6to4 with private embedded IPv4 is blocked +func TestWebFetch_Blocks6to4WithPrivateEmbed(t *testing.T) { + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + // 2002:7f00:0001::1 embeds 127.0.0.1 + result := tool.Execute(context.Background(), map[string]any{ + "url": "http://[2002:7f00:0001::1]:0", + }) + + if !result.IsError { + t.Error("expected error for 6to4 with private embedded IPv4, got success") + } +} + +// TestWebFetch_Allows6to4WithPublicEmbed verifies 6to4 with public embedded IPv4 is NOT blocked +func TestWebFetch_Allows6to4WithPublicEmbed(t *testing.T) { + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + // 2002:0801:0101::1 embeds 8.1.1.1 (public) — pre-flight should pass, + // connection will fail (no listener) but that's after the SSRF check. + result := tool.Execute(context.Background(), map[string]any{ + "url": "http://[2002:0801:0101::1]:0", + }) + + // Should NOT be blocked by SSRF check — error should be connection failure, not "private" + if result.IsError && strings.Contains(result.ForLLM, "private") { + t.Error("6to4 with public embedded IPv4 should not be blocked as private") + } +} + +// TestWebFetch_RedirectToPrivateBlocked verifies redirects to private IPs are blocked +func TestWebFetch_RedirectToPrivateBlocked(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Redirect to a private IP + http.Redirect(w, r, "http://10.0.0.1/secret", http.StatusFound) + })) + defer server.Close() + + // Temporarily disable private host allowance for the redirect check + allowPrivateWebFetchHosts.Store(false) + defer allowPrivateWebFetchHosts.Store(true) + + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + result := tool.Execute(context.Background(), map[string]any{ + "url": server.URL, + }) + + if !result.IsError { + t.Error("expected error when redirecting to private IP, got success") + } +} + +func TestNewSafeDialContext_BlocksPrivateDNSResolutionWithoutWhitelist(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on loopback: %v", err) + } + defer listener.Close() + + _, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatalf("failed to split listener address: %v", err) + } + + dialContext := newSafeDialContext(&net.Dialer{Timeout: time.Second}, nil) + _, err = dialContext(context.Background(), "tcp", net.JoinHostPort("localhost", port)) + if err == nil { + t.Fatal("expected localhost DNS resolution to be blocked without whitelist") + } + if !strings.Contains(err.Error(), "private") && !strings.Contains(err.Error(), "whitelisted") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewSafeDialContext_AllowsWhitelistedPrivateDNSResolution(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on loopback: %v", err) + } + defer listener.Close() + + accepted := make(chan struct{}, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + conn.Close() + accepted <- struct{}{} + }() + + _, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatalf("failed to split listener address: %v", err) + } + + whitelist, err := newPrivateHostWhitelist([]string{"127.0.0.0/8"}) + if err != nil { + t.Fatalf("failed to parse whitelist: %v", err) + } + + dialContext := newSafeDialContext(&net.Dialer{Timeout: time.Second}, whitelist) + conn, err := dialContext(context.Background(), "tcp", net.JoinHostPort("localhost", port)) + if err != nil { + t.Fatalf("expected localhost DNS resolution to succeed with whitelist, got %v", err) + } + conn.Close() + + select { + case <-accepted: + case <-time.After(time.Second): + t.Fatal("expected localhost listener to accept a connection") + } +} + +// TestIsPrivateOrRestrictedIP_Table tests IP classification logic +func TestIsPrivateOrRestrictedIP_Table(t *testing.T) { + tests := []struct { + ip string + blocked bool + desc string + }{ + {"127.0.0.1", true, "IPv4 loopback"}, + {"10.0.0.1", true, "IPv4 private class A"}, + {"172.16.0.1", true, "IPv4 private class B"}, + {"192.168.1.1", true, "IPv4 private class C"}, + {"169.254.169.254", true, "link-local / cloud metadata"}, + {"100.64.0.1", true, "carrier-grade NAT"}, + {"0.0.0.0", true, "unspecified"}, + {"8.8.8.8", false, "public DNS"}, + {"1.1.1.1", false, "public DNS"}, + {"::1", true, "IPv6 loopback"}, + {"::ffff:127.0.0.1", true, "IPv4-mapped IPv6 loopback"}, + {"::ffff:10.0.0.1", true, "IPv4-mapped IPv6 private"}, + {"fc00::1", true, "IPv6 unique local"}, + {"fd00::1", true, "IPv6 unique local"}, + {"2002:7f00:0001::1", true, "6to4 with embedded 127.x (private)"}, + {"2002:0a00:0001::1", true, "6to4 with embedded 10.0.0.1 (private)"}, + {"2002:0801:0101::1", false, "6to4 with embedded 8.1.1.1 (public)"}, + {"2001:0000:4136:e378:8000:63bf:f5ff:fffe", true, "Teredo with client 10.0.0.1 (private)"}, + {"2001:0000:4136:e378:8000:63bf:f7f6:fefe", false, "Teredo with client 8.9.1.1 (public)"}, + {"2607:f8b0:4004:800::200e", false, "public IPv6 (Google)"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ip := net.ParseIP(tt.ip) + if ip == nil { + t.Fatalf("failed to parse IP: %s", tt.ip) + } + got := isPrivateOrRestrictedIP(ip) + if got != tt.blocked { + t.Errorf("isPrivateOrRestrictedIP(%s) = %v, want %v", tt.ip, got, tt.blocked) + } + }) + } +} + +// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain +func TestWebTool_WebFetch_MissingDomain(t *testing.T) { + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } + + ctx := context.Background() + args := map[string]any{ + "url": "https://", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error for URL without domain") + } + + // Should mention missing domain + if !strings.Contains(result.ForLLM, "domain") && !strings.Contains(result.ForUser, "domain") { + t.Errorf("Expected domain error message, got ForLLM: %s", result.ForLLM) + } +} + +func TestNewWebFetchToolWithProxy(t *testing.T) { + tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", format, testFetchLimit, nil) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } else if tool.maxChars != 1024 { + t.Fatalf("maxChars = %d, want %d", tool.maxChars, 1024) + } + + if tool.proxy != "http://127.0.0.1:7890" { + t.Fatalf("proxy = %q, want %q", tool.proxy, "http://127.0.0.1:7890") + } + + tool, err = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890", format, testFetchLimit, nil) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } + + if tool.maxChars != 50000 { + t.Fatalf("default maxChars = %d, want %d", tool.maxChars, 50000) + } +} + +func TestNewWebFetchToolWithConfig_InvalidPrivateHostWhitelist(t *testing.T) { + _, err := NewWebFetchToolWithConfig(1024, "", format, testFetchLimit, []string{"not-an-ip-or-cidr"}) + if err == nil { + t.Fatal("expected invalid whitelist entry to fail") + } + if !strings.Contains(err.Error(), "invalid entry") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { + t.Run("perplexity", func(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + PerplexityEnabled: true, + PerplexityAPIKeys: []string{"k"}, + PerplexityMaxResults: 3, + Proxy: "http://127.0.0.1:7890", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + p, ok := tool.provider.(*PerplexitySearchProvider) + if !ok { + t.Fatalf("provider type = %T, want *PerplexitySearchProvider", tool.provider) + } + if p.proxy != "http://127.0.0.1:7890" { + t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") + } + }) + + t.Run("brave", func(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + BraveEnabled: true, + BraveAPIKeys: []string{"k"}, + BraveMaxResults: 3, + Proxy: "http://127.0.0.1:7890", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + p, ok := tool.provider.(*BraveSearchProvider) + if !ok { + t.Fatalf("provider type = %T, want *BraveSearchProvider", tool.provider) + } + if p.proxy != "http://127.0.0.1:7890" { + t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") + } + }) + + t.Run("duckduckgo", func(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + DuckDuckGoEnabled: true, + DuckDuckGoMaxResults: 3, + Proxy: "http://127.0.0.1:7890", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + p, ok := tool.provider.(*DuckDuckGoSearchProvider) + if !ok { + t.Fatalf("provider type = %T, want *DuckDuckGoSearchProvider", tool.provider) + } + if p.proxy != "http://127.0.0.1:7890" { + t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") + } + }) + + t.Run("searxng", func(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + SearXNGEnabled: true, + SearXNGBaseURL: "https://searx.example.com", + SearXNGMaxResults: 3, + Proxy: "http://127.0.0.1:7890", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + p, ok := tool.provider.(*SearXNGSearchProvider) + if !ok { + t.Fatalf("provider type = %T, want *SearXNGSearchProvider", tool.provider) + } + if p.proxy != "http://127.0.0.1:7890" { + t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") + } + tr, ok := p.client.Transport.(*http.Transport) + if !ok { + t.Fatalf("client.Transport type = %T, want *http.Transport", p.client.Transport) + } + req, err := http.NewRequest(http.MethodGet, "https://searx.example.com/search", nil) + if err != nil { + t.Fatalf("http.NewRequest() error: %v", err) + } + proxyURL, err := tr.Proxy(req) + if err != nil { + t.Fatalf("transport.Proxy(req) error: %v", err) + } + if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" { + t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890") + } + }) +} + +// TestWebTool_TavilySearch_Success verifies successful Tavily search +func TestWebTool_TavilySearch_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + } + + // Verify payload + var payload map[string]any + json.NewDecoder(r.Body).Decode(&payload) + if payload["api_key"] != "test-key" { + t.Errorf("Expected api_key test-key, got %v", payload["api_key"]) + } + if payload["query"] != "test query" { + t.Errorf("Expected query 'test query', got %v", payload["query"]) + } + + // Return mock response + response := map[string]any{ + "results": []map[string]any{ + { + "title": "Test Result 1", + "url": "https://example.com/1", + "content": "Content for result 1", + }, + { + "title": "Test Result 2", + "url": "https://example.com/2", + "content": "Content for result 2", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + TavilyEnabled: true, + TavilyAPIKeys: []string{"test-key"}, + TavilyBaseURL: server.URL, + TavilyMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + ctx := context.Background() + args := map[string]any{ + "query": "test query", + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForUser should contain result titles and URLs + if !strings.Contains(result.ForUser, "Test Result 1") || + !strings.Contains(result.ForUser, "https://example.com/1") { + t.Errorf("Expected results in output, got: %s", result.ForUser) + } + + // Should mention via Tavily + if !strings.Contains(result.ForUser, "via Tavily") { + t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser) + } +} + +func TestWebTool_TavilySearch_RangeMapping(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode payload: %v", err) + } + if payload["time_range"] != "week" { + t.Fatalf("expected time_range=week, got %v", payload["time_range"]) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + {"title": "Recent result", "url": "https://example.com/recent", "content": "snippet"}, + }, + }) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + TavilyEnabled: true, + TavilyAPIKeys: []string{"test-key"}, + TavilyBaseURL: server.URL, + TavilyMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + "range": "w", + }) + if result.IsError { + t.Fatalf("expected success, got %s", result.ForLLM) + } +} + +// TestWebFetchTool_CloudflareChallenge_RetryWithHonestUA verifies that a 403 response +// with cf-mitigated: challenge triggers a retry using the honest picoclaw User-Agent, +// and that the retry response is returned when it succeeds. +func TestWebFetchTool_CloudflareChallenge_RetryWithHonestUA(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + + requestCount := 0 + var receivedUAs []string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + receivedUAs = append(receivedUAs, r.Header.Get("User-Agent")) + + if requestCount == 1 { + // First request: simulate Cloudflare challenge + w.Header().Set("Cf-Mitigated", "challenge") + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("<html><body>Cloudflare challenge</body></html>")) + return + } + // Second request (honest UA retry): success + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("real content")) + })) + defer server.Close() + + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + t.Fatalf("NewWebFetchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{"url": server.URL}) + + if result.IsError { + t.Fatalf("expected success after retry, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "real content") { + t.Errorf("expected retry response content, got: %s", result.ForLLM) + } + if requestCount != 2 { + t.Errorf("expected exactly 2 requests, got %d", requestCount) + } + + // First request must use the generic user agent + if receivedUAs[0] != userAgent { + t.Errorf("first request UA = %q, want %q", receivedUAs[0], userAgent) + } + // Second request must use the honest picoclaw user agent + if !strings.Contains(receivedUAs[1], "picoclaw") { + t.Errorf("retry request UA = %q, want it to contain 'picoclaw'", receivedUAs[1]) + } +} + +// TestWebFetchTool_CloudflareChallenge_NoRetryOnOtherErrors verifies that a plain 403 +// (without cf-mitigated: challenge) does NOT trigger a retry. +func TestWebFetchTool_CloudflareChallenge_NoRetryOnOtherErrors(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + + requestCount := 0 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("plain forbidden")) + })) + defer server.Close() + + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + t.Fatalf("NewWebFetchTool() error: %v", err) + } + + tool.Execute(context.Background(), map[string]any{"url": server.URL}) + + if requestCount != 1 { + t.Errorf("expected exactly 1 request for plain 403, got %d", requestCount) + } +} + +// TestWebFetchTool_CloudflareChallenge_RetryFailsToo verifies that if the honest-UA +// retry also fails (e.g. still blocked), the error from the retry is returned. +func TestWebFetchTool_CloudflareChallenge_RetryFailsToo(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Always return CF challenge regardless of UA + w.Header().Set("Cf-Mitigated", "challenge") + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("<html><body>still blocked</body></html>")) + })) + defer server.Close() + + tool, err := NewWebFetchTool(50000, format, testFetchLimit) + if err != nil { + t.Fatalf("NewWebFetchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{"url": server.URL}) + + // Should not be an error — the retry response is used as-is (403 is a valid HTTP response) + if result.IsError { + t.Fatalf("expected non-error result even when retry is also blocked, got: %s", result.ForLLM) + } + // Status in the JSON result should reflect the 403 + if !strings.Contains(result.ForLLM, "403") { + t.Errorf("expected status 403 in result, got: %s", result.ForLLM) + } +} + +func TestAPIKeyPool(t *testing.T) { + pool := NewAPIKeyPool([]string{"key1", "key2", "key3"}) + if len(pool.keys) != 3 { + t.Fatalf("expected 3 keys, got %d", len(pool.keys)) + } + if pool.keys[0] != "key1" || pool.keys[1] != "key2" || pool.keys[2] != "key3" { + t.Fatalf("unexpected keys: %v", pool.keys) + } + + // Test Iterator: each iterator should cover all keys exactly once + iter := pool.NewIterator() + expected := []string{"key1", "key2", "key3"} + for i, want := range expected { + k, ok := iter.Next() + if !ok { + t.Fatalf("iter.Next() returned false at step %d", i) + } + if k != want { + t.Errorf("step %d: expected %s, got %s", i, want, k) + } + } + // Should be exhausted + if _, ok := iter.Next(); ok { + t.Errorf("expected iterator exhausted after all keys") + } + + // Second iterator starts at next position (load balancing) + iter2 := pool.NewIterator() + k, ok := iter2.Next() + if !ok { + t.Fatal("iter2.Next() returned false") + } + if k != "key2" { + t.Errorf("expected key2 (round-robin), got %s", k) + } + + // Empty pool + emptyPool := NewAPIKeyPool([]string{}) + emptyIter := emptyPool.NewIterator() + if _, ok := emptyIter.Next(); ok { + t.Errorf("expected false for empty pool") + } + + // Single key pool + singlePool := NewAPIKeyPool([]string{"single"}) + singleIter := singlePool.NewIterator() + if k, ok := singleIter.Next(); !ok || k != "single" { + t.Errorf("expected single, got %s (ok=%v)", k, ok) + } + if _, ok := singleIter.Next(); ok { + t.Errorf("expected exhausted after single key") + } +} + +func TestWebTool_TavilySearch_Failover(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode payload: %v", err) + } + + apiKey := payload["api_key"].(string) + + if apiKey == "key1" { + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte("Rate limited")) + return + } + + if apiKey == "key2" { + // Success + response := map[string]any{ + "results": []map[string]any{ + { + "title": "Success Result", + "url": "https://example.com/success", + "content": "Success content", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + return + } + + w.WriteHeader(http.StatusBadRequest) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + TavilyEnabled: true, + TavilyAPIKeys: []string{"key1", "key2"}, + TavilyBaseURL: server.URL, + TavilyMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + ctx := context.Background() + args := map[string]any{ + "query": "test query", + } + + result := tool.Execute(ctx, args) + + if result.IsError { + t.Errorf("Expected success, got Error: %s", result.ForLLM) + } + if !strings.Contains(result.ForUser, "Success Result") { + t.Errorf("Expected failover to second key and success result, got: %s", result.ForUser) + } +} + +func TestWebTool_SearXNGSearch_RangeMapping(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("time_range"); got != "year" { + t.Fatalf("expected time_range=year, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + {"title": "Recent result", "url": "https://example.com/1", "content": "snippet"}, + }, + }) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + SearXNGEnabled: true, + SearXNGBaseURL: server.URL, + SearXNGMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + "range": "y", + }) + if result.IsError { + t.Fatalf("expected success, got %s", result.ForLLM) + } +} + +func TestWebTool_GLMSearch_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + } + if r.Header.Get("Authorization") != "Bearer test-glm-key" { + t.Errorf("Expected Authorization Bearer test-glm-key, got %s", r.Header.Get("Authorization")) + } + + var payload map[string]any + json.NewDecoder(r.Body).Decode(&payload) + if payload["search_query"] != "test query" { + t.Errorf("Expected search_query 'test query', got %v", payload["search_query"]) + } + if payload["search_engine"] != "search_std" { + t.Errorf("Expected search_engine 'search_std', got %v", payload["search_engine"]) + } + + response := map[string]any{ + "id": "web-search-test", + "created": 1709568000, + "search_result": []map[string]any{ + { + "title": "Test GLM Result", + "content": "GLM search snippet", + "link": "https://example.com/glm", + "media": "Example", + "publish_date": "2026-03-04", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + GLMSearchEnabled: true, + GLMSearchAPIKey: "test-glm-key", + GLMSearchBaseURL: server.URL, + GLMSearchEngine: "search_std", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + }) + + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + if !strings.Contains(result.ForUser, "Test GLM Result") { + t.Errorf("Expected 'Test GLM Result' in output, got: %s", result.ForUser) + } + if !strings.Contains(result.ForUser, "https://example.com/glm") { + t.Errorf("Expected URL in output, got: %s", result.ForUser) + } + if !strings.Contains(result.ForUser, "via GLM Search") { + t.Errorf("Expected 'via GLM Search' in output, got: %s", result.ForUser) + } +} + +func TestWebTool_GLMSearch_RangeMapping(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode payload: %v", err) + } + if payload["search_recency_filter"] != "oneMonth" { + t.Fatalf("expected search_recency_filter=oneMonth, got %v", payload["search_recency_filter"]) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "search_result": []map[string]any{ + {"title": "Recent GLM Result", "content": "snippet", "link": "https://example.com/glm-range"}, + }, + }) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + GLMSearchEnabled: true, + GLMSearchAPIKey: "test-glm-key", + GLMSearchBaseURL: server.URL, + GLMSearchEngine: "search_std", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + "range": "m", + }) + if result.IsError { + t.Fatalf("expected success, got %s", result.ForLLM) + } +} + +func TestWebTool_BaiduSearch_RangeMapping(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode payload: %v", err) + } + if payload["search_recency_filter"] != "week" { + t.Fatalf("expected search_recency_filter=week for day fallback, got %v", payload["search_recency_filter"]) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "references": []map[string]any{ + {"title": "Recent Baidu Result", "url": "https://example.com/baidu", "content": "snippet"}, + }, + }) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + BaiduSearchEnabled: true, + BaiduSearchAPIKey: "test-baidu-key", + BaiduSearchBaseURL: server.URL, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + "range": "d", + }) + if result.IsError { + t.Fatalf("expected success, got %s", result.ForLLM) + } +} + +func TestWebTool_GLMSearch_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"invalid api key"}`)) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + GLMSearchEnabled: true, + GLMSearchAPIKey: "bad-key", + GLMSearchBaseURL: server.URL, + GLMSearchEngine: "search_std", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + }) + + if !result.IsError { + t.Errorf("Expected IsError=true for 401 response") + } + if !strings.Contains(result.ForLLM, "status 401") { + t.Errorf("Expected status 401 in error, got: %s", result.ForLLM) + } +} + +func TestWebTool_GLMSearch_Priority(t *testing.T) { + // GLM Search should only be selected when all other providers are disabled + tool, err := NewWebSearchTool(WebSearchToolOptions{ + DuckDuckGoEnabled: true, + DuckDuckGoMaxResults: 5, + GLMSearchEnabled: true, + GLMSearchAPIKey: "test-key", + GLMSearchBaseURL: "https://example.com", + GLMSearchEngine: "search_std", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + // DuckDuckGo should win over GLM Search + if _, ok := tool.provider.(*DuckDuckGoSearchProvider); !ok { + t.Errorf("Expected DuckDuckGoSearchProvider when both enabled, got %T", tool.provider) + } + + // With DuckDuckGo disabled, GLM Search should be selected + tool2, err := NewWebSearchTool(WebSearchToolOptions{ + DuckDuckGoEnabled: false, + GLMSearchEnabled: true, + GLMSearchAPIKey: "test-key", + GLMSearchBaseURL: "https://example.com", + GLMSearchEngine: "search_std", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool2.provider.(*GLMSearchProvider); !ok { + t.Errorf("Expected GLMSearchProvider when only GLM enabled, got %T", tool2.provider) + } +} + +func TestWebTool_SogouSearch_Success(t *testing.T) { + provider := &SogouSearchProvider{ + client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + fmt.Fprint(rec, `<html><body> +<a class=resultLink href="/link?url=https%3A%2F%2Fexample.com%2Fa" id="sogou_vr_0_0">Result A</a> +<div class="clamp3">Snippet A</div> +<a class=resultLink href="/link?url=https%3A%2F%2Fexample.com%2Fb" id="sogou_vr_0_1">Result B</a> +<div class="clamp3">Snippet B</div> +</body></html>`) + return rec.Result(), nil + }), + }, + } + + out, err := provider.Search(context.Background(), "test query", 2, "") + if err != nil { + t.Fatalf("Search() error: %v", err) + } + if !strings.Contains(out, "via Sogou") || !strings.Contains(out, "https://example.com/a") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestApplySogouRangeHint(t *testing.T) { + tests := []struct { + name string + query string + rangeCode string + want string + }{ + {name: "empty range", query: "golang", rangeCode: "", want: "golang"}, + {name: "day", query: "golang", rangeCode: "d", want: "golang 最近一天"}, + {name: "week", query: "golang", rangeCode: "w", want: "golang 最近一周"}, + {name: "month", query: "golang", rangeCode: "m", want: "golang 最近一个月"}, + {name: "year", query: "golang", rangeCode: "y", want: "golang 最近一年"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := applySogouRangeHint(tt.query, tt.rangeCode); got != tt.want { + t.Fatalf("applySogouRangeHint(%q, %q) = %q, want %q", tt.query, tt.rangeCode, got, tt.want) + } + }) + } +} + +func TestPrefersDuckDuckGoQuery(t *testing.T) { + tests := []struct { + name string + query string + want bool + }{ + {name: "english words", query: "golang web search", want: true}, + {name: "english with numbers", query: "OpenAI o3 price 2026", want: true}, + {name: "chinese", query: "今天上海天气", want: false}, + {name: "mixed with han", query: "golang 中文 教程", want: false}, + {name: "numbers only", query: "2026 04 15", want: false}, + {name: "blank", query: " ", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := prefersDuckDuckGoQuery(tt.query); got != tt.want { + t.Fatalf("prefersDuckDuckGoQuery(%q) = %v, want %v", tt.query, got, tt.want) + } + }) + } +} + +func TestPrefersDuckDuckGoQuery_DoesNotUseGlobalLanguageFallback(t *testing.T) { + if prefersDuckDuckGoQuery("2026 04 15") { + t.Fatal("numeric query should default to Sogou when no script-specific hint is present") + } +} + +func TestWebTool_SogouPriorityAndExplicitProvider(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + SogouEnabled: true, + SogouMaxResults: 5, + DuckDuckGoEnabled: true, + DuckDuckGoMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*SogouSearchProvider); !ok { + t.Fatalf("expected SogouSearchProvider, got %T", tool.provider) + } + + tool, err = NewWebSearchTool(WebSearchToolOptions{ + Provider: "duckduckgo", + SogouEnabled: true, + SogouMaxResults: 5, + DuckDuckGoEnabled: true, + DuckDuckGoMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*DuckDuckGoSearchProvider); !ok { + t.Fatalf("expected DuckDuckGoSearchProvider, got %T", tool.provider) + } +} + +func TestWebTool_AutoProviderPrefersConfiguredProvidersBeforeSogou(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + SogouEnabled: true, + SogouMaxResults: 5, + BraveEnabled: true, + BraveAPIKeys: []string{"brave-key"}, + BraveMaxResults: 5, + DuckDuckGoEnabled: true, + DuckDuckGoMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*BraveSearchProvider); !ok { + t.Fatalf("expected BraveSearchProvider, got %T", tool.provider) + } +} + +func TestWebTool_ExplicitProviderFallsBackWhenMissingCredentials(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + Provider: "brave", + BraveEnabled: true, + SogouEnabled: true, + SogouMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*SogouSearchProvider); !ok { + t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider) + } +} + +func TestWebTool_ExplicitProviderFallsBackWhenMissingBaseURL(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + Provider: "searxng", + SearXNGEnabled: true, + SogouEnabled: true, + SogouMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*SogouSearchProvider); !ok { + t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider) + } +} + +func TestWebTool_AutoProviderSkipsEnabledButUnreadyProviders(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + Provider: "auto", + BraveEnabled: true, + SogouEnabled: true, + SogouMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*SogouSearchProvider); !ok { + t.Fatalf("expected SogouSearchProvider when Brave has no API key, got %T", tool.provider) + } +} + +func TestResolveWebSearchProviderName_FallsBackFromExplicitUnavailableProvider(t *testing.T) { + got, err := ResolveWebSearchProviderName(WebSearchToolOptions{ + Provider: "brave", + BraveEnabled: true, + SogouEnabled: true, + SogouMaxResults: 5, + }, "") + if err != nil { + t.Fatalf("ResolveWebSearchProviderName() error: %v", err) + } + if got != "sogou" { + t.Fatalf("ResolveWebSearchProviderName() = %q, want sogou", got) + } +} + +func TestWebTool_UnknownExplicitProviderFallsBackToAuto(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + Provider: "totally_unknown", + SogouEnabled: true, + SogouMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*SogouSearchProvider); !ok { + t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider) + } +} + +func TestResolveWebSearchProviderName_FallsBackFromUnknownProvider(t *testing.T) { + got, err := ResolveWebSearchProviderName(WebSearchToolOptions{ + Provider: "totally_unknown", + SogouEnabled: true, + SogouMaxResults: 5, + }, "") + if err != nil { + t.Fatalf("ResolveWebSearchProviderName() error: %v", err) + } + if got != "sogou" { + t.Fatalf("ResolveWebSearchProviderName() = %q, want sogou", got) + } +} + +type stubSearchProvider struct { + result string + calls []string +} + +func (p *stubSearchProvider) Search( + _ context.Context, + query string, + _ int, + _ string, +) (string, error) { + p.calls = append(p.calls, query) + return p.result, nil +} + +func TestWebTool_AutoProviderRoutesQueryLanguageBetweenSogouAndDuckDuckGo(t *testing.T) { + sogouProvider := &stubSearchProvider{result: "via sogou"} + duckProvider := &stubSearchProvider{result: "via duckduckgo"} + tool := &WebSearchTool{ + provider: sogouProvider, + maxResults: 5, + providerResolver: func(query string) (SearchProvider, int) { + if prefersDuckDuckGoQuery(query) { + return duckProvider, 3 + } + return sogouProvider, 5 + }, + } + + enResult := tool.Execute(context.Background(), map[string]any{"query": "golang concurrency", "count": 10}) + if enResult.IsError { + t.Fatalf("english Execute() returned error: %s", enResult.ForLLM) + } + if len(duckProvider.calls) != 1 || duckProvider.calls[0] != "golang concurrency" { + t.Fatalf("english query should use DuckDuckGo provider, calls=%v", duckProvider.calls) + } + if len(sogouProvider.calls) != 0 { + t.Fatalf("english query should not call Sogou provider, calls=%v", sogouProvider.calls) + } + + zhResult := tool.Execute(context.Background(), map[string]any{"query": "今天上海天气"}) + if zhResult.IsError { + t.Fatalf("chinese Execute() returned error: %s", zhResult.ForLLM) + } + if len(sogouProvider.calls) != 1 || sogouProvider.calls[0] != "今天上海天气" { + t.Fatalf("chinese query should use Sogou provider, calls=%v", sogouProvider.calls) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} diff --git a/pkg/tools/integration_facade.go b/pkg/tools/integration_facade.go new file mode 100644 index 000000000..193ecd6f5 --- /dev/null +++ b/pkg/tools/integration_facade.go @@ -0,0 +1,106 @@ +package tools + +import ( + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/skills" + integrationtools "github.com/sipeed/picoclaw/pkg/tools/integration" +) + +type ( + SendCallbackWithContext = integrationtools.SendCallbackWithContext + ReactionCallback = integrationtools.ReactionCallback + MCPManager = integrationtools.MCPManager + MCPTool = integrationtools.MCPTool + FindSkillsTool = integrationtools.FindSkillsTool + InstallSkillTool = integrationtools.InstallSkillTool + MessageTool = integrationtools.MessageTool + ReactionTool = integrationtools.ReactionTool + SendTTSTool = integrationtools.SendTTSTool + APIKeyPool = integrationtools.APIKeyPool + APIKeyIterator = integrationtools.APIKeyIterator + SearchProvider = integrationtools.SearchProvider + SearchResultItem = integrationtools.SearchResultItem + BraveSearchProvider = integrationtools.BraveSearchProvider + TavilySearchProvider = integrationtools.TavilySearchProvider + SogouSearchProvider = integrationtools.SogouSearchProvider + DuckDuckGoSearchProvider = integrationtools.DuckDuckGoSearchProvider + PerplexitySearchProvider = integrationtools.PerplexitySearchProvider + SearXNGSearchProvider = integrationtools.SearXNGSearchProvider + GLMSearchProvider = integrationtools.GLMSearchProvider + BaiduSearchProvider = integrationtools.BaiduSearchProvider + WebSearchTool = integrationtools.WebSearchTool + WebSearchToolOptions = integrationtools.WebSearchToolOptions + WebFetchTool = integrationtools.WebFetchTool +) + +func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool { + return integrationtools.NewMCPTool(manager, serverName, tool) +} + +func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool { + return integrationtools.NewFindSkillsTool(registryMgr, cache) +} + +func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool { + return integrationtools.NewInstallSkillTool(registryMgr, workspace) +} + +func NewMessageTool() *MessageTool { + return integrationtools.NewMessageTool() +} + +func NewReactionTool() *ReactionTool { + return integrationtools.NewReactionTool() +} + +func NewSendTTSTool(provider tts.TTSProvider, store media.MediaStore) *SendTTSTool { + return integrationtools.NewSendTTSTool(provider, store) +} + +func NewAPIKeyPool(keys []string) *APIKeyPool { + return integrationtools.NewAPIKeyPool(keys) +} + +func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions { + return integrationtools.WebSearchToolOptionsFromConfig(cfg) +} + +func WebSearchProviderReady(opts WebSearchToolOptions, name string) bool { + return integrationtools.WebSearchProviderReady(opts, name) +} + +func ResolveWebSearchProviderName(opts WebSearchToolOptions, query string) (string, error) { + return integrationtools.ResolveWebSearchProviderName(opts, query) +} + +func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { + return integrationtools.NewWebSearchTool(opts) +} + +func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) { + return integrationtools.NewWebFetchTool(maxChars, format, fetchLimitBytes) +} + +func NewWebFetchToolWithProxy( + maxChars int, + proxy string, + format string, + fetchLimitBytes int64, + privateHostWhitelist []string, +) (*WebFetchTool, error) { + return integrationtools.NewWebFetchToolWithProxy(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist) +} + +func NewWebFetchToolWithConfig( + maxChars int, + proxy string, + format string, + fetchLimitBytes int64, + privateHostWhitelist []string, +) (*WebFetchTool, error) { + return integrationtools.NewWebFetchToolWithConfig(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist) +} diff --git a/pkg/tools/load_image_compat_test.go b/pkg/tools/load_image_compat_test.go new file mode 100644 index 000000000..a29ee2042 --- /dev/null +++ b/pkg/tools/load_image_compat_test.go @@ -0,0 +1,29 @@ +package tools + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestSubagentManager_SetMediaResolver_StoresResolver(t *testing.T) { + manager := NewSubagentManager(nil, "gpt-test", "/tmp") + + called := false + manager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { + called = true + return msgs + }) + + manager.mu.RLock() + got := manager.mediaResolver + manager.mu.RUnlock() + + if got == nil { + t.Fatal("expected mediaResolver to be set") + } + + if called { + t.Fatal("resolver should not be called during SetMediaResolver") + } +} diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/mcp_tool.go deleted file mode 100644 index 6e53cf354..000000000 --- a/pkg/tools/mcp_tool.go +++ /dev/null @@ -1,246 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "hash/fnv" - "strings" - - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// MCPManager defines the interface for MCP manager operations -// This allows for easier testing with mock implementations -type MCPManager interface { - CallTool( - ctx context.Context, - serverName, toolName string, - arguments map[string]any, - ) (*mcp.CallToolResult, error) -} - -// MCPTool wraps an MCP tool to implement the Tool interface -type MCPTool struct { - manager MCPManager - serverName string - tool *mcp.Tool -} - -// NewMCPTool creates a new MCP tool wrapper -func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool { - return &MCPTool{ - manager: manager, - serverName: serverName, - tool: tool, - } -} - -// sanitizeIdentifierComponent normalizes a string so it can be safely used -// as part of a tool/function identifier for downstream providers. -// It: -// - lowercases the string -// - replaces any character not in [a-z0-9_-] with '_' -// - collapses multiple consecutive '_' into a single '_' -// - trims leading/trailing '_' -// - falls back to "unnamed" if the result is empty -// - truncates overly long components to a reasonable length -func sanitizeIdentifierComponent(s string) string { - const maxLen = 64 - - s = strings.ToLower(s) - var b strings.Builder - b.Grow(len(s)) - - prevUnderscore := false - for _, r := range s { - isAllowed := (r >= 'a' && r <= 'z') || - (r >= '0' && r <= '9') || - r == '_' || r == '-' - - if !isAllowed { - // Normalize any disallowed character to '_' - if !prevUnderscore { - b.WriteRune('_') - prevUnderscore = true - } - continue - } - - if r == '_' { - if prevUnderscore { - continue - } - prevUnderscore = true - } else { - prevUnderscore = false - } - - b.WriteRune(r) - } - - result := strings.Trim(b.String(), "_") - if result == "" { - result = "unnamed" - } - - if len(result) > maxLen { - result = result[:maxLen] - } - - return result -} - -// Name returns the tool name, prefixed with the server name. -// The total length is capped at 64 characters (OpenAI-compatible API limit). -// A short hash of the original (unsanitized) server and tool names is appended -// whenever sanitization is lossy or the name is truncated, ensuring that two -// names which differ only in disallowed characters remain distinct after sanitization. -func (t *MCPTool) Name() string { - // Prefix with server name to avoid conflicts, and sanitize components - sanitizedServer := sanitizeIdentifierComponent(t.serverName) - sanitizedTool := sanitizeIdentifierComponent(t.tool.Name) - full := fmt.Sprintf("mcp_%s_%s", sanitizedServer, sanitizedTool) - - // Check if sanitization was lossless (only lowercasing, no char replacement/truncation) - lossless := strings.ToLower(t.serverName) == sanitizedServer && - strings.ToLower(t.tool.Name) == sanitizedTool - - const maxTotal = 64 - if lossless && len(full) <= maxTotal { - return full - } - - // Sanitization was lossy or name too long: append hash of the ORIGINAL names - // (not the sanitized names) so different originals always yield different hashes. - h := fnv.New32a() - _, _ = h.Write([]byte(t.serverName + "\x00" + t.tool.Name)) - suffix := fmt.Sprintf("%08x", h.Sum32()) // 8 chars - - base := full - if len(base) > maxTotal-9 { - base = strings.TrimRight(full[:maxTotal-9], "_") - } - return base + "_" + suffix -} - -// Description returns the tool description -func (t *MCPTool) Description() string { - desc := t.tool.Description - if desc == "" { - desc = fmt.Sprintf("MCP tool from %s server", t.serverName) - } - // Add server info to description - return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc) -} - -// Parameters returns the tool parameters schema -func (t *MCPTool) Parameters() map[string]any { - // The InputSchema is already a JSON Schema object - schema := t.tool.InputSchema - - // Handle nil schema - if schema == nil { - return map[string]any{ - "type": "object", - "properties": map[string]any{}, - "required": []string{}, - } - } - - // Try direct conversion first (fast path) - if schemaMap, ok := schema.(map[string]any); ok { - return schemaMap - } - - // Handle json.RawMessage and []byte - unmarshal directly - var jsonData []byte - if rawMsg, ok := schema.(json.RawMessage); ok { - jsonData = rawMsg - } else if bytes, ok := schema.([]byte); ok { - jsonData = bytes - } - - if jsonData != nil { - var result map[string]any - if err := json.Unmarshal(jsonData, &result); err == nil { - return result - } - // Fallback on error - return map[string]any{ - "type": "object", - "properties": map[string]any{}, - "required": []string{}, - } - } - - // For other types (structs, etc.), convert via JSON marshal/unmarshal - var err error - jsonData, err = json.Marshal(schema) - if err != nil { - // Fallback to empty schema if marshaling fails - return map[string]any{ - "type": "object", - "properties": map[string]any{}, - "required": []string{}, - } - } - - var result map[string]any - if err := json.Unmarshal(jsonData, &result); err != nil { - // Fallback to empty schema if unmarshaling fails - return map[string]any{ - "type": "object", - "properties": map[string]any{}, - "required": []string{}, - } - } - - return result -} - -// Execute executes the MCP tool -func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult { - result, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args) - if err != nil { - return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err) - } - - if result == nil { - nilErr := fmt.Errorf("MCP tool returned nil result without error") - return ErrorResult("MCP tool execution failed: nil result").WithError(nilErr) - } - - // Handle error result from server - if result.IsError { - errMsg := extractContentText(result.Content) - return ErrorResult(fmt.Sprintf("MCP tool returned error: %s", errMsg)). - WithError(fmt.Errorf("MCP tool error: %s", errMsg)) - } - - // Extract text content from result - output := extractContentText(result.Content) - - return &ToolResult{ - ForLLM: output, - IsError: false, - } -} - -// extractContentText extracts text from MCP content array -func extractContentText(content []mcp.Content) string { - var parts []string - for _, c := range content { - switch v := c.(type) { - case *mcp.TextContent: - parts = append(parts, v.Text) - case *mcp.ImageContent: - // For images, just indicate that an image was returned - parts = append(parts, fmt.Sprintf("[Image: %s]", v.MIMEType)) - default: - // For other content types, use string representation - parts = append(parts, fmt.Sprintf("[Content: %T]", v)) - } - } - return strings.Join(parts, "\n") -} diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/mcp_tool_test.go deleted file mode 100644 index 95bb0f992..000000000 --- a/pkg/tools/mcp_tool_test.go +++ /dev/null @@ -1,492 +0,0 @@ -package tools - -import ( - "context" - "fmt" - "strings" - "testing" - - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// MockMCPManager is a mock implementation of MCPManager interface for testing -type MockMCPManager struct { - callToolFunc func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) -} - -func (m *MockMCPManager) CallTool( - ctx context.Context, - serverName, toolName string, - arguments map[string]any, -) (*mcp.CallToolResult, error) { - if m.callToolFunc != nil { - return m.callToolFunc(ctx, serverName, toolName, arguments) - } - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: "mock result"}, - }, - IsError: false, - }, nil -} - -// TestNewMCPTool verifies MCP tool creation -func TestNewMCPTool(t *testing.T) { - manager := &MockMCPManager{} - tool := &mcp.Tool{ - Name: "test_tool", - Description: "A test tool", - InputSchema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "input": map[string]any{ - "type": "string", - "description": "Test input", - }, - }, - }, - } - - mcpTool := NewMCPTool(manager, "test_server", tool) - - if mcpTool == nil { - t.Fatal("NewMCPTool should not return nil") - } - // Verify tool properties we can access - if mcpTool.Name() != "mcp_test_server_test_tool" { - t.Errorf("Expected tool name with prefix, got '%s'", mcpTool.Name()) - } -} - -// TestMCPTool_Name verifies tool name with server prefix -func TestMCPTool_Name(t *testing.T) { - tests := []struct { - name string - serverName string - toolName string - expected string - }{ - { - name: "simple name", - serverName: "github", - toolName: "create_issue", - expected: "mcp_github_create_issue", - }, - { - name: "filesystem server", - serverName: "filesystem", - toolName: "read_file", - expected: "mcp_filesystem_read_file", - }, - { - name: "remote server", - serverName: "remote-api", - toolName: "fetch_data", - expected: "mcp_remote-api_fetch_data", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - manager := &MockMCPManager{} - tool := &mcp.Tool{Name: tt.toolName} - mcpTool := NewMCPTool(manager, tt.serverName, tool) - - result := mcpTool.Name() - if result != tt.expected { - t.Errorf("Expected name '%s', got '%s'", tt.expected, result) - } - }) - } -} - -// TestMCPTool_Description verifies tool description generation -func TestMCPTool_Description(t *testing.T) { - tests := []struct { - name string - serverName string - toolDescription string - expectContains []string - }{ - { - name: "with description", - serverName: "github", - toolDescription: "Create a GitHub issue", - expectContains: []string{"[MCP:github]", "Create a GitHub issue"}, - }, - { - name: "empty description", - serverName: "filesystem", - toolDescription: "", - expectContains: []string{"[MCP:filesystem]", "MCP tool from filesystem server"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - manager := &MockMCPManager{} - tool := &mcp.Tool{ - Name: "test_tool", - Description: tt.toolDescription, - } - mcpTool := NewMCPTool(manager, tt.serverName, tool) - - result := mcpTool.Description() - - for _, expected := range tt.expectContains { - if !strings.Contains(result, expected) { - t.Errorf("Description should contain '%s', got: %s", expected, result) - } - } - }) - } -} - -// TestMCPTool_Parameters verifies parameter schema conversion -func TestMCPTool_Parameters(t *testing.T) { - tests := []struct { - name string - inputSchema any - expectType string - checkProperty string - expectProperty bool - }{ - { - name: "map schema", - inputSchema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "query": map[string]any{ - "type": "string", - "description": "Search query", - }, - }, - "required": []string{"query"}, - }, - expectType: "object", - checkProperty: "query", - expectProperty: true, - }, - { - name: "nil schema", - inputSchema: nil, - expectType: "object", - expectProperty: false, - }, - { - name: "json.RawMessage schema", - inputSchema: []byte(`{ - "type": "object", - "properties": { - "repo": { - "type": "string", - "description": "Repository name" - }, - "stars": { - "type": "integer", - "description": "Minimum stars" - } - }, - "required": ["repo"] - }`), - expectType: "object", - checkProperty: "repo", - expectProperty: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - manager := &MockMCPManager{} - tool := &mcp.Tool{ - Name: "test_tool", - InputSchema: tt.inputSchema, - } - mcpTool := NewMCPTool(manager, "test_server", tool) - - params := mcpTool.Parameters() - - if params == nil { - t.Fatal("Parameters should not be nil") - } - - if params["type"] != tt.expectType { - t.Errorf("Expected type '%s', got '%v'", tt.expectType, params["type"]) - } - - // Check if property exists when expected - if tt.checkProperty != "" { - properties, ok := params["properties"].(map[string]any) - if !ok && tt.expectProperty { - t.Errorf("Expected properties to be a map") - return - } - if ok { - _, hasProperty := properties[tt.checkProperty] - if hasProperty != tt.expectProperty { - t.Errorf("Expected property '%s' existence: %v, got: %v", - tt.checkProperty, tt.expectProperty, hasProperty) - } - } - } - }) - } -} - -// TestMCPTool_Execute_Success tests successful tool execution -func TestMCPTool_Execute_Success(t *testing.T) { - manager := &MockMCPManager{ - callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { - // Verify correct parameters passed - if serverName != "github" { - t.Errorf("Expected serverName 'github', got '%s'", serverName) - } - if toolName != "search_repos" { - t.Errorf("Expected toolName 'search_repos', got '%s'", toolName) - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: "Found 3 repositories"}, - }, - IsError: false, - }, nil - }, - } - - tool := &mcp.Tool{ - Name: "search_repos", - Description: "Search GitHub repositories", - } - mcpTool := NewMCPTool(manager, "github", tool) - - ctx := context.Background() - args := map[string]any{ - "query": "golang mcp", - } - - result := mcpTool.Execute(ctx, args) - - if result == nil { - t.Fatal("Result should not be nil") - } - if result.IsError { - t.Errorf("Expected no error, got error: %s", result.ForLLM) - } - if result.ForLLM != "Found 3 repositories" { - t.Errorf("Expected 'Found 3 repositories', got '%s'", result.ForLLM) - } -} - -// TestMCPTool_Execute_ManagerError tests execution when manager returns error -func TestMCPTool_Execute_ManagerError(t *testing.T) { - manager := &MockMCPManager{ - callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { - return nil, fmt.Errorf("connection failed") - }, - } - - tool := &mcp.Tool{Name: "test_tool"} - mcpTool := NewMCPTool(manager, "test_server", tool) - - ctx := context.Background() - result := mcpTool.Execute(ctx, map[string]any{}) - - if result == nil { - t.Fatal("Result should not be nil") - } - if !result.IsError { - t.Error("Expected IsError to be true") - } - if !strings.Contains(result.ForLLM, "MCP tool execution failed") { - t.Errorf("Error message should mention execution failure, got: %s", result.ForLLM) - } - if !strings.Contains(result.ForLLM, "connection failed") { - t.Errorf("Error message should include original error, got: %s", result.ForLLM) - } -} - -// TestMCPTool_Execute_ServerError tests execution when server returns error -func TestMCPTool_Execute_ServerError(t *testing.T) { - manager := &MockMCPManager{ - callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: "Invalid API key"}, - }, - IsError: true, - }, nil - }, - } - - tool := &mcp.Tool{Name: "test_tool"} - mcpTool := NewMCPTool(manager, "test_server", tool) - - ctx := context.Background() - result := mcpTool.Execute(ctx, map[string]any{}) - - if result == nil { - t.Fatal("Result should not be nil") - } - if !result.IsError { - t.Error("Expected IsError to be true") - } - if !strings.Contains(result.ForLLM, "MCP tool returned error") { - t.Errorf("Error message should mention server error, got: %s", result.ForLLM) - } - if !strings.Contains(result.ForLLM, "Invalid API key") { - t.Errorf("Error message should include server message, got: %s", result.ForLLM) - } -} - -// TestMCPTool_Execute_MultipleContent tests execution with multiple content items -func TestMCPTool_Execute_MultipleContent(t *testing.T) { - manager := &MockMCPManager{ - callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: "First line"}, - &mcp.TextContent{Text: "Second line"}, - &mcp.TextContent{Text: "Third line"}, - }, - IsError: false, - }, nil - }, - } - - tool := &mcp.Tool{Name: "multi_output"} - mcpTool := NewMCPTool(manager, "test_server", tool) - - ctx := context.Background() - result := mcpTool.Execute(ctx, map[string]any{}) - - if result.IsError { - t.Errorf("Expected no error, got: %s", result.ForLLM) - } - - expected := "First line\nSecond line\nThird line" - if result.ForLLM != expected { - t.Errorf("Expected '%s', got '%s'", expected, result.ForLLM) - } -} - -// TestExtractContentText_TextContent tests text content extraction -func TestExtractContentText_TextContent(t *testing.T) { - content := []mcp.Content{ - &mcp.TextContent{Text: "Hello World"}, - &mcp.TextContent{Text: "Second message"}, - } - - result := extractContentText(content) - expected := "Hello World\nSecond message" - - if result != expected { - t.Errorf("Expected '%s', got '%s'", expected, result) - } -} - -// TestExtractContentText_ImageContent tests image content extraction -func TestExtractContentText_ImageContent(t *testing.T) { - content := []mcp.Content{ - &mcp.ImageContent{ - Data: []byte("base64data"), - MIMEType: "image/png", - }, - } - - result := extractContentText(content) - - if !strings.Contains(result, "[Image:") { - t.Errorf("Expected image indicator, got: %s", result) - } - if !strings.Contains(result, "image/png") { - t.Errorf("Expected MIME type in output, got: %s", result) - } -} - -// TestExtractContentText_MixedContent tests mixed content types -func TestExtractContentText_MixedContent(t *testing.T) { - content := []mcp.Content{ - &mcp.TextContent{Text: "Description"}, - &mcp.ImageContent{ - Data: []byte("data"), - MIMEType: "image/jpeg", - }, - &mcp.TextContent{Text: "More text"}, - } - - result := extractContentText(content) - - if !strings.Contains(result, "Description") { - t.Errorf("Should contain text content, got: %s", result) - } - if !strings.Contains(result, "[Image:") { - t.Errorf("Should contain image indicator, got: %s", result) - } - if !strings.Contains(result, "More text") { - t.Errorf("Should contain second text, got: %s", result) - } -} - -// TestExtractContentText_EmptyContent tests empty content array -func TestExtractContentText_EmptyContent(t *testing.T) { - content := []mcp.Content{} - - result := extractContentText(content) - - if result != "" { - t.Errorf("Expected empty string for empty content, got: %s", result) - } -} - -// TestMCPTool_InterfaceCompliance verifies MCPTool implements Tool interface -func TestMCPTool_InterfaceCompliance(t *testing.T) { - manager := &MockMCPManager{} - tool := &mcp.Tool{Name: "test"} - mcpTool := NewMCPTool(manager, "test_server", tool) - - // Verify it implements Tool interface - var _ Tool = mcpTool -} - -// TestMCPTool_Parameters_MapSchema tests schema that's already a map -func TestMCPTool_Parameters_MapSchema(t *testing.T) { - manager := &MockMCPManager{} - schema := map[string]any{ - "type": "object", - "properties": map[string]any{ - "name": map[string]any{ - "type": "string", - "description": "The name parameter", - }, - }, - "required": []string{"name"}, - } - - tool := &mcp.Tool{ - Name: "test_tool", - InputSchema: schema, - } - mcpTool := NewMCPTool(manager, "test_server", tool) - - params := mcpTool.Parameters() - - // Should return the schema as-is when it's already a map - if params["type"] != "object" { - t.Errorf("Expected type 'object', got '%v'", params["type"]) - } - - props, ok := params["properties"].(map[string]any) - if !ok { - t.Error("Properties should be a map") - } - - nameParam, ok := props["name"].(map[string]any) - if !ok { - t.Error("Name parameter should exist") - } - - if nameParam["type"] != "string" { - t.Errorf("Name type should be 'string', got '%v'", nameParam["type"]) - } -} diff --git a/pkg/tools/message.go b/pkg/tools/message.go deleted file mode 100644 index 438ceeddd..000000000 --- a/pkg/tools/message.go +++ /dev/null @@ -1,102 +0,0 @@ -package tools - -import ( - "context" - "fmt" - "sync/atomic" -) - -type SendCallback func(channel, chatID, content string) error - -type MessageTool struct { - sendCallback SendCallback - sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round -} - -func NewMessageTool() *MessageTool { - return &MessageTool{} -} - -func (t *MessageTool) Name() string { - return "message" -} - -func (t *MessageTool) Description() string { - return "Send a message to user on a chat channel. Use this when you want to communicate something." -} - -func (t *MessageTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "content": map[string]any{ - "type": "string", - "description": "The message content to send", - }, - "channel": map[string]any{ - "type": "string", - "description": "Optional: target channel (telegram, whatsapp, etc.)", - }, - "chat_id": map[string]any{ - "type": "string", - "description": "Optional: target chat/user ID", - }, - }, - "required": []string{"content"}, - } -} - -// ResetSentInRound resets the per-round send tracker. -// Called by the agent loop at the start of each inbound message processing round. -func (t *MessageTool) ResetSentInRound() { - t.sentInRound.Store(false) -} - -// HasSentInRound returns true if the message tool sent a message during the current round. -func (t *MessageTool) HasSentInRound() bool { - return t.sentInRound.Load() -} - -func (t *MessageTool) SetSendCallback(callback SendCallback) { - t.sendCallback = callback -} - -func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult { - content, ok := args["content"].(string) - if !ok { - return &ToolResult{ForLLM: "content is required", IsError: true} - } - - channel, _ := args["channel"].(string) - chatID, _ := args["chat_id"].(string) - - if channel == "" { - channel = ToolChannel(ctx) - } - if chatID == "" { - chatID = ToolChatID(ctx) - } - - if channel == "" || chatID == "" { - return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true} - } - - if t.sendCallback == nil { - return &ToolResult{ForLLM: "Message sending not configured", IsError: true} - } - - if err := t.sendCallback(channel, chatID, content); err != nil { - return &ToolResult{ - ForLLM: fmt.Sprintf("sending message: %v", err), - IsError: true, - Err: err, - } - } - - t.sentInRound.Store(true) - // Silent: user already received the message directly - return &ToolResult{ - ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID), - Silent: true, - } -} diff --git a/pkg/tools/normalization.go b/pkg/tools/normalization.go new file mode 100644 index 000000000..3a76c5d92 --- /dev/null +++ b/pkg/tools/normalization.go @@ -0,0 +1,292 @@ +package tools + +import ( + "encoding/base64" + "fmt" + "mime" + "os" + "path/filepath" + "regexp" + "strings" + "time" + "unicode" + + "github.com/sipeed/picoclaw/pkg/media" +) + +const ( + largeBase64OmittedMessage = "[Tool returned a large base64-like payload; omitted from model context.]" + inlineMediaOmittedMessage = "[Tool returned inline media content; omitted from model context.]" + inlineMediaStoredMessage = "[Tool returned inline media content (%s); omitted from model context and registered as a media attachment.]" +) + +var ( + inlineMarkdownDataURLRe = regexp.MustCompile(`!\[[^\]]*\]\((data:[^)]+)\)`) + inlineRawDataURLRe = regexp.MustCompile(`data:[^;\s]+;base64,[A-Za-z0-9+/=\r\n]+`) +) + +func normalizeToolResult( + result *ToolResult, + toolName string, + store media.MediaStore, + channel string, + chatID string, +) *ToolResult { + if result == nil { + return nil + } + + notes := make([]string, 0, 2) + seen := make(map[string]struct{}) + + if store != nil && channel != "" && chatID != "" { + var refs []string + var extractedNotes []string + + result.ForLLM, refs, extractedNotes = extractInlineMediaRefs( + result.ForLLM, + toolName, + store, + channel, + chatID, + seen, + ) + result.Media = append(result.Media, refs...) + notes = append(notes, extractedNotes...) + + result.ForUser, refs, extractedNotes = extractInlineMediaRefs( + result.ForUser, + toolName, + store, + channel, + chatID, + seen, + ) + result.Media = append(result.Media, refs...) + notes = append(notes, extractedNotes...) + } + + result.ForLLM = sanitizeToolLLMContent(result.ForLLM) + + if len(result.Media) > 0 && len(notes) > 0 { + if strings.TrimSpace(result.ForLLM) == "" { + result.ForLLM = strings.Join(notes, "\n") + } else { + result.ForLLM = strings.TrimSpace(result.ForLLM) + "\n" + strings.Join(notes, "\n") + } + } + if len(result.Media) > 0 && strings.TrimSpace(result.ForLLM) == "" { + result.ForLLM = "[Tool returned media content; omitted from model context and registered as a media attachment.]" + } + + return result +} + +func sanitizeToolLLMContent(text string) string { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return text + } + if inlineMarkdownDataURLRe.MatchString(trimmed) || inlineRawDataURLRe.MatchString(trimmed) { + cleaned := inlineMarkdownDataURLRe.ReplaceAllString(trimmed, "") + cleaned = inlineRawDataURLRe.ReplaceAllString(cleaned, "") + cleaned = strings.TrimSpace(cleaned) + if cleaned == "" { + return inlineMediaOmittedMessage + } + return cleaned + "\n" + inlineMediaOmittedMessage + } + if looksLikeLargeBase64Payload(trimmed) { + return largeBase64OmittedMessage + } + return text +} + +func looksLikeLargeBase64Payload(text string) bool { + trimmed := strings.TrimSpace(text) + if len(trimmed) < 1024 { + return false + } + + nonSpace := 0 + base64Like := 0 + spaceCount := 0 + + for _, r := range trimmed { + if unicode.IsSpace(r) { + spaceCount++ + continue + } + nonSpace++ + if (r >= 'A' && r <= 'Z') || + (r >= 'a' && r <= 'z') || + (r >= '0' && r <= '9') || + r == '+' || r == '/' || r == '=' { + base64Like++ + } + } + + if nonSpace == 0 { + return false + } + + ratio := float64(base64Like) / float64(nonSpace) + return ratio >= 0.97 && spaceCount <= len(trimmed)/128 +} + +func extractInlineMediaRefs( + text string, + toolName string, + store media.MediaStore, + channel string, + chatID string, + seen map[string]struct{}, +) (cleaned string, refs []string, notes []string) { + cleaned = text + + matches := inlineMarkdownDataURLRe.FindAllStringSubmatch(cleaned, -1) + for _, match := range matches { + if len(match) < 2 { + continue + } + dataURL := match[1] + ref, note := storeInlineDataURL(toolName, store, channel, chatID, dataURL, seen) + if ref != "" { + refs = append(refs, ref) + } + if note != "" { + notes = append(notes, note) + } + cleaned = strings.ReplaceAll(cleaned, match[0], "") + } + + rawMatches := inlineRawDataURLRe.FindAllString(cleaned, -1) + for _, dataURL := range rawMatches { + ref, note := storeInlineDataURL(toolName, store, channel, chatID, dataURL, seen) + if ref != "" { + refs = append(refs, ref) + } + if note != "" { + notes = append(notes, note) + } + cleaned = strings.ReplaceAll(cleaned, dataURL, "") + } + + return strings.TrimSpace(cleaned), refs, notes +} + +func storeInlineDataURL( + toolName string, + store media.MediaStore, + channel string, + chatID string, + dataURL string, + seen map[string]struct{}, +) (ref string, note string) { + dataURL = strings.TrimSpace(dataURL) + if _, ok := seen[dataURL]; ok { + return "", "" + } + seen[dataURL] = struct{}{} + + if !strings.HasPrefix(strings.ToLower(dataURL), "data:") { + return "", "" + } + + comma := strings.IndexByte(dataURL, ',') + if comma <= 5 { + return "", "[Tool returned inline media content that could not be parsed.]" + } + + metaPart := dataURL[:comma] + payload := dataURL[comma+1:] + if !strings.Contains(strings.ToLower(metaPart), ";base64") { + return "", "[Tool returned inline media content that was not base64-encoded.]" + } + + mimeType := strings.TrimSpace(strings.TrimPrefix(metaPart, "data:")) + if semi := strings.IndexByte(mimeType, ';'); semi >= 0 { + mimeType = mimeType[:semi] + } + if mimeType == "" { + mimeType = "application/octet-stream" + } + + payload = strings.NewReplacer("\n", "", "\r", "", "\t", "", " ", "").Replace(payload) + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return "", fmt.Sprintf("[Tool returned inline media content (%s) that could not be decoded.]", mimeType) + } + + dir := media.TempDir() + if err = os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType) + } + + ext := extensionForMIMEType(mimeType) + tmpFile, err := os.CreateTemp(dir, "tool-inline-*"+ext) + if err != nil { + return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType) + } + tmpPath := tmpFile.Name() + if _, err = tmpFile.Write(decoded); err != nil { + tmpFile.Close() + _ = os.Remove(tmpPath) + return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType) + } + if err = tmpFile.Close(); err != nil { + _ = os.Remove(tmpPath) + return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType) + } + + filename := sanitizeIdentifierComponent(toolName) + ext + scope := fmt.Sprintf( + "tool:inline:%s:%s:%s:%d", + sanitizeIdentifierComponent(toolName), + channel, + chatID, + time.Now().UnixNano(), + ) + + ref, err = store.Store(tmpPath, media.MediaMeta{ + Filename: filename, + ContentType: mimeType, + Source: fmt.Sprintf("tool:inline:%s", sanitizeIdentifierComponent(toolName)), + }, scope) + if err != nil { + _ = os.Remove(tmpPath) + return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be registered.]", mimeType) + } + + return ref, fmt.Sprintf(inlineMediaStoredMessage, mimeType) +} + +func extensionForMIMEType(mimeType string) string { + if mimeType == "" { + return ".bin" + } + if exts, err := mime.ExtensionsByType(mimeType); err == nil && len(exts) > 0 { + return exts[0] + } + + switch strings.ToLower(mimeType) { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "audio/wav", "audio/x-wav": + return ".wav" + case "audio/mpeg": + return ".mp3" + case "audio/ogg": + return ".ogg" + case "video/mp4": + return ".mp4" + default: + return filepath.Ext(mimeType) + } +} diff --git a/pkg/tools/path_compat.go b/pkg/tools/path_compat.go new file mode 100644 index 000000000..9e677cb2b --- /dev/null +++ b/pkg/tools/path_compat.go @@ -0,0 +1,19 @@ +package tools + +import ( + "regexp" + + fstools "github.com/sipeed/picoclaw/pkg/tools/fs" +) + +func validatePathWithAllowPaths( + path, workspace string, + restrict bool, + patterns []*regexp.Regexp, +) (string, error) { + return fstools.ValidatePathWithAllowPaths(path, workspace, restrict, patterns) +} + +func isAllowedPath(path string, patterns []*regexp.Regexp) bool { + return fstools.IsAllowedPath(path, patterns) +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index ca8436c67..0ff9293a3 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -5,20 +5,34 @@ import ( "fmt" "sort" "sync" + "sync/atomic" "time" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" ) +type ToolEntry struct { + Tool Tool + IsCore bool + TTL int +} + type ToolRegistry struct { - tools map[string]Tool - mu sync.RWMutex + tools map[string]*ToolEntry + mu sync.RWMutex + version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation + mediaStore media.MediaStore +} + +type mediaStoreAware interface { + SetMediaStore(store media.MediaStore) } func NewToolRegistry() *ToolRegistry { return &ToolRegistry{ - tools: make(map[string]Tool), + tools: make(map[string]*ToolEntry), } } @@ -30,14 +44,136 @@ func (r *ToolRegistry) Register(tool Tool) { logger.WarnCF("tools", "Tool registration overwrites existing tool", map[string]any{"name": name}) } - r.tools[name] = tool + r.tools[name] = &ToolEntry{ + Tool: tool, + IsCore: true, + TTL: 0, // Core tools do not use TTL + } + if aware, ok := tool.(mediaStoreAware); ok && r.mediaStore != nil { + aware.SetMediaStore(r.mediaStore) + } + r.version.Add(1) + logger.DebugCF("tools", "Registered core tool", map[string]any{"name": name}) +} + +// RegisterHidden saves hidden tools (visible only via TTL) +func (r *ToolRegistry) RegisterHidden(tool Tool) { + r.mu.Lock() + defer r.mu.Unlock() + name := tool.Name() + if _, exists := r.tools[name]; exists { + logger.WarnCF("tools", "Hidden tool registration overwrites existing tool", + map[string]any{"name": name}) + } + r.tools[name] = &ToolEntry{ + Tool: tool, + IsCore: false, + TTL: 0, + } + if aware, ok := tool.(mediaStoreAware); ok && r.mediaStore != nil { + aware.SetMediaStore(r.mediaStore) + } + r.version.Add(1) + logger.DebugCF("tools", "Registered hidden tool", map[string]any{"name": name}) +} + +// SetMediaStore injects a MediaStore into all registered tools that can +// consume it, and remembers it for future registrations. +func (r *ToolRegistry) SetMediaStore(store media.MediaStore) { + r.mu.Lock() + defer r.mu.Unlock() + + r.mediaStore = store + for _, entry := range r.tools { + if aware, ok := entry.Tool.(mediaStoreAware); ok { + aware.SetMediaStore(store) + } + } +} + +// PromoteTools atomically sets the TTL for multiple non-core tools. +// This prevents a concurrent TickTTL from decrementing between promotions. +func (r *ToolRegistry) PromoteTools(names []string, ttl int) { + r.mu.Lock() + defer r.mu.Unlock() + promoted := 0 + for _, name := range names { + if entry, exists := r.tools[name]; exists { + if !entry.IsCore { + entry.TTL = ttl + promoted++ + } + } + } + logger.DebugCF( + "tools", + "PromoteTools completed", + map[string]any{"requested": len(names), "promoted": promoted, "ttl": ttl}, + ) +} + +// TickTTL decreases TTL only for non-core tools +func (r *ToolRegistry) TickTTL() { + r.mu.Lock() + defer r.mu.Unlock() + for _, entry := range r.tools { + if !entry.IsCore && entry.TTL > 0 { + entry.TTL-- + } + } +} + +// Version returns the current registry version (atomically). +func (r *ToolRegistry) Version() uint64 { + return r.version.Load() +} + +// HiddenToolSnapshot holds a consistent snapshot of hidden tools and the +// registry version at which it was taken. Used by BM25SearchTool cache. +type HiddenToolSnapshot struct { + Docs []HiddenToolDoc + Version uint64 +} + +// HiddenToolDoc is a lightweight representation of a hidden tool for search indexing. +type HiddenToolDoc struct { + Name string + Description string +} + +// SnapshotHiddenTools returns all non-core tools and the current registry +// version under a single read-lock, guaranteeing consistency between the +// two values. +func (r *ToolRegistry) SnapshotHiddenTools() HiddenToolSnapshot { + r.mu.RLock() + defer r.mu.RUnlock() + docs := make([]HiddenToolDoc, 0, len(r.tools)) + for name, entry := range r.tools { + if !entry.IsCore { + docs = append(docs, HiddenToolDoc{ + Name: name, + Description: entry.Tool.Description(), + }) + } + } + return HiddenToolSnapshot{ + Docs: docs, + Version: r.version.Load(), + } } func (r *ToolRegistry) Get(name string) (Tool, bool) { r.mu.RLock() defer r.mu.RUnlock() - tool, ok := r.tools[name] - return tool, ok + entry, ok := r.tools[name] + if !ok { + return nil, false + } + // Hidden tools with expired TTL are not callable. + if !entry.IsCore && entry.TTL <= 0 { + return nil, false + } + return entry.Tool, true } func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult { @@ -70,6 +206,14 @@ func (r *ToolRegistry) ExecuteWithContext( return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found")) } + // Validate arguments against the tool's declared schema. + if err := validateToolArgs(tool.Parameters(), args); err != nil { + logger.WarnCF("tool", "Tool argument validation failed", + map[string]any{"tool": name, "error": err.Error()}) + return ErrorResult(fmt.Sprintf("invalid arguments for tool %q: %s", name, err)). + WithError(fmt.Errorf("argument validation failed: %w", err)) + } + // Inject channel/chatID into ctx so tools read them via ToolChannel(ctx)/ToolChatID(ctx). // Always inject — tools validate what they require. ctx = WithToolContext(ctx, channel, chatID) @@ -78,15 +222,51 @@ func (r *ToolRegistry) ExecuteWithContext( // The callback is a call parameter, not mutable state on the tool instance. var result *ToolResult start := time.Now() - if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil { - logger.DebugCF("tool", "Executing async tool via ExecuteAsync", - map[string]any{ - "tool": name, - }) - result = asyncExec.ExecuteAsync(ctx, args, asyncCallback) - } else { - result = tool.Execute(ctx, args) + + // Use recover to catch any panics during tool execution + // This prevents tool crashes from killing the entire agent + func() { + defer func() { + if re := recover(); re != nil { + logger.RecoverPanicNoExit(re) + errMsg := fmt.Sprintf("Tool '%s' crashed with panic: %v", name, re) + logger.ErrorCF("tool", "Tool execution panic recovered", + map[string]any{ + "tool": name, + "panic": fmt.Sprintf("%v", re), + }) + result = &ToolResult{ + ForLLM: errMsg, + ForUser: errMsg, + IsError: true, + Err: fmt.Errorf("panic: %v", re), + } + } + }() + + if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil { + logger.DebugCF("tool", "Executing async tool via ExecuteAsync", + map[string]any{ + "tool": name, + }) + result = asyncExec.ExecuteAsync(ctx, args, asyncCallback) + } else { + result = tool.Execute(ctx, args) + } + }() + + // Handle nil result (should not happen, but defensive) + if result == nil { + result = &ToolResult{ + ForLLM: fmt.Sprintf("Tool '%s' returned nil result unexpectedly", name), + ForUser: fmt.Sprintf("Tool '%s' returned nil result unexpectedly", name), + IsError: true, + Err: fmt.Errorf("nil result from tool"), + } } + + result = normalizeToolResult(result, name, r.mediaStore, channel, chatID) + duration := time.Since(start) // Log based on result type @@ -108,7 +288,7 @@ func (r *ToolRegistry) ExecuteWithContext( map[string]any{ "tool": name, "duration_ms": duration.Milliseconds(), - "result_length": len(result.ForLLM), + "result_length": len(result.ContentForLLM()), }) } @@ -135,7 +315,13 @@ func (r *ToolRegistry) GetDefinitions() []map[string]any { sorted := r.sortedToolNames() definitions := make([]map[string]any, 0, len(sorted)) for _, name := range sorted { - definitions = append(definitions, ToolToSchema(r.tools[name])) + entry := r.tools[name] + + if !entry.IsCore && entry.TTL <= 0 { + continue + } + + definitions = append(definitions, ToolToSchema(r.tools[name].Tool)) } return definitions } @@ -149,8 +335,13 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { sorted := r.sortedToolNames() definitions := make([]providers.ToolDefinition, 0, len(sorted)) for _, name := range sorted { - tool := r.tools[name] - schema := ToolToSchema(tool) + entry := r.tools[name] + + if !entry.IsCore && entry.TTL <= 0 { + continue + } + + schema := ToolToSchema(entry.Tool) // Safely extract nested values with type checks fn, ok := schema["function"].(map[string]any) @@ -161,6 +352,7 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { name, _ := fn["name"].(string) desc, _ := fn["description"].(string) params, _ := fn["parameters"].(map[string]any) + metadata := promptMetadataForTool(entry.Tool) definitions = append(definitions, providers.ToolDefinition{ Type: "function", @@ -169,11 +361,35 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { Description: desc, Parameters: params, }, + PromptLayer: metadata.Layer, + PromptSlot: metadata.Slot, + PromptSource: metadata.Source, }) } return definitions } +func promptMetadataForTool(tool Tool) PromptMetadata { + metadata := PromptMetadata{ + Layer: ToolPromptLayerCapability, + Slot: ToolPromptSlotTooling, + Source: ToolPromptSourceRegistry, + } + if provider, ok := tool.(PromptMetadataProvider); ok { + provided := provider.PromptMetadata() + if provided.Layer != "" { + metadata.Layer = provided.Layer + } + if provided.Slot != "" { + metadata.Slot = provided.Slot + } + if provided.Source != "" { + metadata.Source = provided.Source + } + } + return metadata +} + // List returns a list of all registered tool names. func (r *ToolRegistry) List() []string { r.mu.RLock() @@ -182,6 +398,29 @@ func (r *ToolRegistry) List() []string { return r.sortedToolNames() } +// Clone creates an independent copy of the registry containing the same tool +// entries (shallow copy of each ToolEntry). This is used to give subagents a +// snapshot of the parent agent's tools without sharing the same registry — +// tools registered on the parent after cloning (e.g. spawn, spawn_status) +// will NOT be visible to the clone, preventing recursive subagent spawning. +// The version counter is reset to 0 in the clone as it's a new independent registry. +func (r *ToolRegistry) Clone() *ToolRegistry { + r.mu.RLock() + defer r.mu.RUnlock() + clone := &ToolRegistry{ + tools: make(map[string]*ToolEntry, len(r.tools)), + mediaStore: r.mediaStore, + } + for name, entry := range r.tools { + clone.tools[name] = &ToolEntry{ + Tool: entry.Tool, + IsCore: entry.IsCore, + TTL: entry.TTL, + } + } + return clone +} + // Count returns the number of registered tools. func (r *ToolRegistry) Count() int { r.mu.RLock() @@ -198,8 +437,32 @@ func (r *ToolRegistry) GetSummaries() []string { sorted := r.sortedToolNames() summaries := make([]string, 0, len(sorted)) for _, name := range sorted { - tool := r.tools[name] - summaries = append(summaries, fmt.Sprintf("- `%s` - %s", tool.Name(), tool.Description())) + entry := r.tools[name] + + if !entry.IsCore && entry.TTL <= 0 { + continue + } + + summaries = append(summaries, fmt.Sprintf("- `%s` - %s", entry.Tool.Name(), entry.Tool.Description())) } return summaries } + +// GetAll returns all registered tools (both core and non-core with TTL > 0). +// Used by SubTurn to inherit parent's tool set. +func (r *ToolRegistry) GetAll() []Tool { + r.mu.RLock() + defer r.mu.RUnlock() + + sorted := r.sortedToolNames() + tools := make([]Tool, 0, len(sorted)) + for _, name := range sorted { + entry := r.tools[name] + + // Include core tools and non-core tools with active TTL + if entry.IsCore || entry.TTL > 0 { + tools = append(tools, entry.Tool) + } + } + return tools +} diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index 92d7d5abd..eac96382f 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -2,10 +2,14 @@ package tools import ( "context" + "errors" + "os" + "path/filepath" "strings" "sync" "testing" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -35,6 +39,15 @@ func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *T return m.result } +type mockPromptMetadataTool struct { + mockRegistryTool + metadata PromptMetadata +} + +func (m *mockPromptMetadataTool) PromptMetadata() PromptMetadata { + return m.metadata +} + type mockAsyncRegistryTool struct { mockRegistryTool lastCB AsyncCallback @@ -45,6 +58,15 @@ func (m *mockAsyncRegistryTool) ExecuteAsync(_ context.Context, args map[string] return m.result } +type mockMediaStoreAwareTool struct { + mockRegistryTool + store media.MediaStore +} + +func (m *mockMediaStoreAwareTool) SetMediaStore(store media.MediaStore) { + m.store = store +} + // --- helpers --- func newMockTool(name, desc string) *mockRegistryTool { @@ -177,6 +199,33 @@ func TestToolRegistry_ExecuteWithContext_EmptyContext(t *testing.T) { } } +func TestToolRegistry_ExecuteWithContext_PreservesMessageContext(t *testing.T) { + r := NewToolRegistry() + ct := &mockContextAwareTool{ + mockRegistryTool: *newMockTool("ctx_tool", "needs context"), + } + r.Register(ct) + + baseCtx := WithToolMessageContext(context.Background(), "msg-123", "msg-100") + r.ExecuteWithContext(baseCtx, "ctx_tool", nil, "telegram", "chat-42", nil) + + if ct.lastCtx == nil { + t.Fatal("expected Execute to be called") + } + if got := ToolChannel(ct.lastCtx); got != "telegram" { + t.Errorf("expected channel 'telegram', got %q", got) + } + if got := ToolChatID(ct.lastCtx); got != "chat-42" { + t.Errorf("expected chatID 'chat-42', got %q", got) + } + if got := ToolMessageID(ct.lastCtx); got != "msg-123" { + t.Errorf("expected messageID 'msg-123', got %q", got) + } + if got := ToolReplyToMessageID(ct.lastCtx); got != "msg-100" { + t.Errorf("expected replyToMessageID 'msg-100', got %q", got) + } +} + func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) { r := NewToolRegistry() at := &mockAsyncRegistryTool{ @@ -335,6 +384,137 @@ func TestToolToSchema(t *testing.T) { } } +func TestToolRegistry_ToProviderDefsAttachesPromptMetadata(t *testing.T) { + r := NewToolRegistry() + r.Register(newMockTool("native", "native tool")) + r.Register(&mockPromptMetadataTool{ + mockRegistryTool: mockRegistryTool{ + name: "mcp_demo", + desc: "mcp tool", + params: map[string]any{"type": "object"}, + }, + metadata: PromptMetadata{ + Layer: ToolPromptLayerCapability, + Slot: ToolPromptSlotMCP, + Source: "mcp:demo", + }, + }) + + defs := r.ToProviderDefs() + if len(defs) != 2 { + t.Fatalf("ToProviderDefs() len = %d, want 2", len(defs)) + } + + byName := make(map[string]providers.ToolDefinition, len(defs)) + for _, def := range defs { + byName[def.Function.Name] = def + } + + native := byName["native"] + if native.PromptLayer != ToolPromptLayerCapability || + native.PromptSlot != ToolPromptSlotTooling || + native.PromptSource != ToolPromptSourceRegistry { + t.Fatalf("native prompt metadata = %#v, want default tooling source", native) + } + + mcp := byName["mcp_demo"] + if mcp.PromptLayer != ToolPromptLayerCapability || + mcp.PromptSlot != ToolPromptSlotMCP || + mcp.PromptSource != "mcp:demo" { + t.Fatalf("mcp prompt metadata = %#v, want mcp source", mcp) + } +} + +func TestToolRegistry_Clone(t *testing.T) { + r := NewToolRegistry() + r.Register(newMockTool("read_file", "reads files")) + r.Register(newMockTool("exec", "runs commands")) + r.Register(newMockTool("web_search", "searches the web")) + + clone := r.Clone() + + // Clone should have the same tools + if clone.Count() != 3 { + t.Errorf("expected clone to have 3 tools, got %d", clone.Count()) + } + for _, name := range []string{"read_file", "exec", "web_search"} { + if _, ok := clone.Get(name); !ok { + t.Errorf("expected clone to have tool %q", name) + } + } + + // Registering on parent should NOT affect clone + r.Register(newMockTool("spawn", "spawns subagent")) + if r.Count() != 4 { + t.Errorf("expected parent to have 4 tools, got %d", r.Count()) + } + if clone.Count() != 3 { + t.Errorf("expected clone to still have 3 tools after parent mutation, got %d", clone.Count()) + } + if _, ok := clone.Get("spawn"); ok { + t.Error("expected clone NOT to have 'spawn' tool registered on parent after cloning") + } + + // Registering on clone should NOT affect parent + clone.Register(newMockTool("custom", "custom tool")) + if clone.Count() != 4 { + t.Errorf("expected clone to have 4 tools, got %d", clone.Count()) + } + if _, ok := r.Get("custom"); ok { + t.Error("expected parent NOT to have 'custom' tool registered on clone") + } +} + +func TestToolRegistry_Clone_Empty(t *testing.T) { + r := NewToolRegistry() + clone := r.Clone() + if clone.Count() != 0 { + t.Errorf("expected empty clone, got count %d", clone.Count()) + } +} + +func TestToolRegistry_Clone_PreservesHiddenToolState(t *testing.T) { + r := NewToolRegistry() + r.RegisterHidden(newMockTool("mcp_tool", "dynamic MCP tool")) + + clone := r.Clone() + + // Hidden tools with TTL=0 should not be gettable (same behavior as parent) + if _, ok := clone.Get("mcp_tool"); ok { + t.Error("expected hidden tool with TTL=0 to be invisible in clone") + } + + // But the entry should exist (count includes hidden tools) + if clone.Count() != 1 { + t.Errorf("expected clone count 1 (hidden entry exists), got %d", clone.Count()) + } +} + +func TestToolRegistry_Clone_PreservesTTLValue(t *testing.T) { + r := NewToolRegistry() + r.RegisterHidden(newMockTool("ttl_tool", "tool with TTL")) + + // Manually set a non-zero TTL on the entry + r.mu.RLock() + if entry, ok := r.tools["ttl_tool"]; ok { + entry.TTL = 5 + } + r.mu.RUnlock() + + clone := r.Clone() + + // Verify TTL value is preserved in the clone + clone.mu.RLock() + defer clone.mu.RUnlock() + entry, ok := clone.tools["ttl_tool"] + if !ok { + t.Fatal("expected ttl_tool to exist in clone") + } + if entry.TTL != 5 { + t.Errorf("expected TTL=5 in clone, got %d", entry.TTL) + } +} + func TestToolRegistry_ConcurrentAccess(t *testing.T) { r := NewToolRegistry() var wg sync.WaitGroup @@ -358,3 +538,274 @@ func TestToolRegistry_ConcurrentAccess(t *testing.T) { t.Error("expected tools to be registered after concurrent access") } } + +// --- Panic and abnormal exit tests --- + +// mockPanicTool is a tool that panics during execution +type mockPanicTool struct { + name string + panicValue any +} + +func (m *mockPanicTool) Name() string { return m.name } +func (m *mockPanicTool) Description() string { return "a tool that panics" } +func (m *mockPanicTool) Parameters() map[string]any { return map[string]any{"type": "object"} } +func (m *mockPanicTool) Execute(_ context.Context, _ map[string]any) *ToolResult { + panic(m.panicValue) +} + +// mockNilResultTool is a tool that returns nil +type mockNilResultTool struct { + name string +} + +func (m *mockNilResultTool) Name() string { return m.name } +func (m *mockNilResultTool) Description() string { return "a tool that returns nil" } +func (m *mockNilResultTool) Parameters() map[string]any { return map[string]any{"type": "object"} } +func (m *mockNilResultTool) Execute(_ context.Context, _ map[string]any) *ToolResult { + return nil +} + +func TestToolRegistry_Execute_PanicRecovery(t *testing.T) { + r := NewToolRegistry() + r.Register(&mockPanicTool{ + name: "panic_tool", + panicValue: "something went terribly wrong", + }) + + // Should not panic, should return error result + result := r.Execute(context.Background(), "panic_tool", nil) + + if result == nil { + t.Fatal("expected non-nil result after panic recovery") + } + if !result.IsError { + t.Error("expected IsError=true after panic") + } + if !strings.Contains(result.ForLLM, "panic") { + t.Errorf("expected 'panic' in error message, got %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "panic_tool") { + t.Errorf("expected tool name in error message, got %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "something went terribly wrong") { + t.Errorf("expected panic value in error message, got %q", result.ForLLM) + } + if result.Err == nil { + t.Error("expected Err to be set") + } +} + +func TestToolRegistry_Execute_PanicRecovery_ErrorType(t *testing.T) { + r := NewToolRegistry() + + // Test with error type panic + r.Register(&mockPanicTool{ + name: "error_panic_tool", + panicValue: errors.New("custom error panic"), + }) + + result := r.Execute(context.Background(), "error_panic_tool", nil) + + if !result.IsError { + t.Error("expected IsError=true") + } + if !strings.Contains(result.ForLLM, "custom error panic") { + t.Errorf("expected error message in ForLLM, got %q", result.ForLLM) + } +} + +func TestToolRegistry_Execute_PanicRecovery_IntType(t *testing.T) { + r := NewToolRegistry() + + // Test with int type panic + r.Register(&mockPanicTool{ + name: "int_panic_tool", + panicValue: 42, + }) + + result := r.Execute(context.Background(), "int_panic_tool", nil) + + if !result.IsError { + t.Error("expected IsError=true") + } + if !strings.Contains(result.ForLLM, "42") { + t.Errorf("expected panic value '42' in ForLLM, got %q", result.ForLLM) + } +} + +func TestToolRegistry_Execute_NilResultHandling(t *testing.T) { + r := NewToolRegistry() + r.Register(&mockNilResultTool{name: "nil_tool"}) + + result := r.Execute(context.Background(), "nil_tool", nil) + + if result == nil { + t.Fatal("expected non-nil result when tool returns nil") + } + if !result.IsError { + t.Error("expected IsError=true for nil result") + } + if !strings.Contains(result.ForLLM, "nil_tool") { + t.Errorf("expected tool name in error message, got %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "nil result") { + t.Errorf("expected 'nil result' in error message, got %q", result.ForLLM) + } + if result.Err == nil { + t.Error("expected Err to be set") + } +} + +func TestToolRegistry_ExecuteWithContext_PanicRecovery(t *testing.T) { + r := NewToolRegistry() + r.Register(&mockPanicTool{ + name: "ctx_panic_tool", + panicValue: "context panic test", + }) + + // Should not panic even with context + result := r.ExecuteWithContext( + context.Background(), + "ctx_panic_tool", + map[string]any{"key": "value"}, + "telegram", + "chat-123", + nil, + ) + + if result == nil { + t.Fatal("expected non-nil result") + } + if !result.IsError { + t.Error("expected IsError=true") + } + if !strings.Contains(result.ForLLM, "context panic test") { + t.Errorf("expected panic message, got %q", result.ForLLM) + } +} + +func TestToolRegistry_Execute_PanicDoesNotAffectOtherTools(t *testing.T) { + r := NewToolRegistry() + r.Register(&mockPanicTool{name: "bad_tool", panicValue: "boom"}) + r.Register(&mockRegistryTool{ + name: "good_tool", + desc: "works fine", + params: map[string]any{}, + result: SilentResult("success"), + }) + + // First, trigger the panic + result1 := r.Execute(context.Background(), "bad_tool", nil) + if !result1.IsError { + t.Error("expected error from panic tool") + } + + // Then, verify the good tool still works + result2 := r.Execute(context.Background(), "good_tool", nil) + if result2.IsError { + t.Errorf("expected success from good tool, got error: %s", result2.ForLLM) + } + if result2.ForLLM != "success" { + t.Errorf("expected 'success', got %q", result2.ForLLM) + } +} + +func TestToolRegistry_SetMediaStore_PropagatesToExistingAndNewTools(t *testing.T) { + r := NewToolRegistry() + store := media.NewFileMediaStore() + + existing := &mockMediaStoreAwareTool{ + mockRegistryTool: *newMockTool("existing", "existing tool"), + } + r.Register(existing) + + r.SetMediaStore(store) + if existing.store != store { + t.Fatal("expected existing tool to receive media store") + } + + later := &mockMediaStoreAwareTool{ + mockRegistryTool: *newMockTool("later", "later tool"), + } + r.Register(later) + + if later.store != store { + t.Fatal("expected newly registered tool to inherit media store") + } +} + +func TestToolRegistry_ExecuteWithContext_SanitizesLargeBase64Payload(t *testing.T) { + r := NewToolRegistry() + payload := strings.Repeat("QUJD", 400) + r.Register(&mockRegistryTool{ + name: "base64_tool", + desc: "returns huge base64", + params: map[string]any{}, + result: SilentResult(payload), + }) + + result := r.ExecuteWithContext(context.Background(), "base64_tool", nil, "telegram", "chat-1", nil) + + if result.ForLLM != largeBase64OmittedMessage { + t.Fatalf("expected sanitized payload, got %q", result.ForLLM) + } +} + +func TestToolRegistry_ExecuteWithContext_ExtractsInlineMediaDataURL(t *testing.T) { + r := NewToolRegistry() + store := media.NewFileMediaStore() + r.SetMediaStore(store) + + payload := "![screenshot](data:image/png;base64,aGVsbG8=)" + r.Register(&mockRegistryTool{ + name: "inline_media_tool", + desc: "returns inline data url", + params: map[string]any{}, + result: SilentResult(payload), + }) + + result := r.ExecuteWithContext(context.Background(), "inline_media_tool", nil, "telegram", "chat-42", nil) + + if len(result.Media) != 1 { + t.Fatalf("expected 1 media ref, got %d", len(result.Media)) + } + if strings.Contains(result.ForLLM, "data:image/png;base64") { + t.Fatalf("expected inline data URL to be stripped from ForLLM, got %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "registered as a media attachment") { + t.Fatalf("expected delivery note in ForLLM, got %q", result.ForLLM) + } + + path, err := store.Resolve(result.Media[0]) + if err != nil { + t.Fatalf("expected stored media ref to resolve: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected stored media file to exist: %v", err) + } + if filepath.Ext(path) != ".png" { + t.Fatalf("expected stored inline media to use png extension, got %q", path) + } +} + +func TestToolRegistry_ExecuteWithContext_SanitizesInlineMediaWithoutStore(t *testing.T) { + r := NewToolRegistry() + + payload := "before ![img](data:image/png;base64,aGVsbG8=) after" + r.Register(&mockRegistryTool{ + name: "inline_media_no_store", + desc: "returns inline data url without store", + params: map[string]any{}, + result: SilentResult(payload), + }) + + result := r.ExecuteWithContext(context.Background(), "inline_media_no_store", nil, "telegram", "chat-42", nil) + + if strings.Contains(result.ForLLM, "data:image/png;base64") { + t.Fatalf("expected inline data URL to be removed from ForLLM, got %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, inlineMediaOmittedMessage) { + t.Fatalf("expected inline media omission note, got %q", result.ForLLM) + } +} diff --git a/pkg/tools/result_test.go b/pkg/tools/result_test.go index a234e33f3..5f08cb4fa 100644 --- a/pkg/tools/result_test.go +++ b/pkg/tools/result_test.go @@ -3,6 +3,7 @@ package tools import ( "encoding/json" "errors" + "strings" "testing" ) @@ -227,3 +228,41 @@ func TestToolResultJSONStructure(t *testing.T) { t.Errorf("Expected silent false, got %v", parsed["silent"]) } } + +func TestToolResultContentForLLM_AppendsHandledDeliveryNote(t *testing.T) { + result := MediaResult("Screenshot attached.", []string{"media://example"}).WithResponseHandled() + + content := result.ContentForLLM() + if !strings.Contains(content, "Screenshot attached.") { + t.Fatalf("expected original content in ContentForLLM, got %q", content) + } + if !strings.Contains(content, handledToolLLMNote) { + t.Fatalf("expected handled delivery note in ContentForLLM, got %q", content) + } +} + +func TestToolResultContentForLLM_UsesHandledDeliveryNoteWhenEmpty(t *testing.T) { + result := (&ToolResult{}).WithResponseHandled() + + if got := result.ContentForLLM(); got != handledToolLLMNote { + t.Fatalf("ContentForLLM() = %q, want %q", got, handledToolLLMNote) + } +} + +func TestToolResultContentForLLM_AppendsArtifactPaths(t *testing.T) { + result := &ToolResult{ + ForLLM: "Artifact created.", + ArtifactTags: []string{"[file:/tmp/example.png]"}, + } + + content := result.ContentForLLM() + if !strings.Contains(content, "Artifact created.") { + t.Fatalf("expected original content in ContentForLLM, got %q", content) + } + if !strings.Contains(content, "Local artifact paths: [file:/tmp/example.png]") { + t.Fatalf("expected artifact path note in ContentForLLM, got %q", content) + } + if !strings.Contains(content, artifactPathsLLMNote) { + t.Fatalf("expected artifact guidance note in ContentForLLM, got %q", content) + } +} diff --git a/pkg/tools/search_tool.go b/pkg/tools/search_tool.go new file mode 100644 index 000000000..c5884c9de --- /dev/null +++ b/pkg/tools/search_tool.go @@ -0,0 +1,320 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +const ( + MaxRegexPatternLength = 200 +) + +type RegexSearchTool struct { + registry *ToolRegistry + ttl int + maxSearchResults int +} + +func NewRegexSearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *RegexSearchTool { + return &RegexSearchTool{registry: r, ttl: ttl, maxSearchResults: maxSearchResults} +} + +func (t *RegexSearchTool) Name() string { + return "tool_search_tool_regex" +} + +func (t *RegexSearchTool) Description() string { + return "Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools." +} + +func (t *RegexSearchTool) PromptMetadata() PromptMetadata { + return PromptMetadata{ + Layer: ToolPromptLayerCapability, + Slot: ToolPromptSlotTooling, + Source: ToolPromptSourceDiscovery, + } +} + +func (t *RegexSearchTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "pattern": map[string]any{ + "type": "string", + "description": "Regex pattern to match tool name or description", + }, + }, + "required": []string{"pattern"}, + } +} + +func (t *RegexSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + pattern, ok := args["pattern"].(string) + if !ok || strings.TrimSpace(pattern) == "" { + // An empty string regex (?i) will match every hidden tool, + // dumping massive payloads into the context and burning tokens. + return ErrorResult("Missing or invalid 'pattern' argument. Must be a non-empty string.") + } + + if len(pattern) > MaxRegexPatternLength { + logger.WarnCF("discovery", "Regex pattern rejected (too long)", map[string]any{"len": len(pattern)}) + return ErrorResult(fmt.Sprintf("Pattern too long: max %d characters allowed", MaxRegexPatternLength)) + } + + logger.DebugCF("discovery", "Regex search", map[string]any{"pattern": pattern}) + + res, err := t.registry.SearchRegex(pattern, t.maxSearchResults) + if err != nil { + logger.WarnCF("discovery", "Invalid regex pattern", map[string]any{"pattern": pattern, "error": err.Error()}) + return ErrorResult(fmt.Sprintf("Invalid regex pattern syntax: %v. Please fix your regex and try again.", err)) + } + + logger.InfoCF("discovery", "Regex search completed", map[string]any{"pattern": pattern, "results": len(res)}) + return formatDiscoveryResponse(t.registry, res, t.ttl) +} + +type BM25SearchTool struct { + registry *ToolRegistry + ttl int + maxSearchResults int + + // Cache: rebuilt only when the registry version changes. + cacheMu sync.Mutex + cachedEngine *bm25CachedEngine + cacheVersion uint64 +} + +func NewBM25SearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *BM25SearchTool { + return &BM25SearchTool{registry: r, ttl: ttl, maxSearchResults: maxSearchResults} +} + +func (t *BM25SearchTool) Name() string { + return "tool_search_tool_bm25" +} + +func (t *BM25SearchTool) Description() string { + return "Search available hidden tools on-demand using natural language query describing the action you need to perform. Returns JSON schemas of discovered tools." +} + +func (t *BM25SearchTool) PromptMetadata() PromptMetadata { + return PromptMetadata{ + Layer: ToolPromptLayerCapability, + Slot: ToolPromptSlotTooling, + Source: ToolPromptSourceDiscovery, + } +} + +func (t *BM25SearchTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": "Search query", + }, + }, + "required": []string{"query"}, + } +} + +func (t *BM25SearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + query, ok := args["query"].(string) + if !ok || strings.TrimSpace(query) == "" { + // An empty string query will match every hidden tool, + // dumping massive payloads into the context and burning tokens. + return ErrorResult("Missing or invalid 'query' argument. Must be a non-empty string.") + } + + logger.DebugCF("discovery", "BM25 search", map[string]any{"query": query}) + + cached := t.getOrBuildEngine() + if cached == nil { + logger.DebugCF("discovery", "BM25 search: no hidden tools available", nil) + return SilentResult("No tools found matching the query.") + } + + ranked := cached.engine.Search(query, t.maxSearchResults) + if len(ranked) == 0 { + logger.DebugCF("discovery", "BM25 search: no matches", map[string]any{"query": query}) + return SilentResult("No tools found matching the query.") + } + + results := make([]ToolSearchResult, len(ranked)) + for i, r := range ranked { + results[i] = ToolSearchResult{ + Name: r.Document.Name, + Description: r.Document.Description, + } + } + + logger.InfoCF("discovery", "BM25 search completed", map[string]any{"query": query, "results": len(results)}) + return formatDiscoveryResponse(t.registry, results, t.ttl) +} + +// ToolSearchResult represents the result returned to the LLM. +// Parameters are omitted from the JSON response to save context tokens; +// the LLM will see full schemas via ToProviderDefs after promotion. +type ToolSearchResult struct { + Name string `json:"name"` + Description string `json:"description"` +} + +func (r *ToolRegistry) SearchRegex(pattern string, maxSearchResults int) ([]ToolSearchResult, error) { + if maxSearchResults <= 0 { + return nil, nil + } + + regex, err := regexp.Compile("(?i)" + pattern) + if err != nil { + return nil, fmt.Errorf("failed to compile regex pattern %q: %w", pattern, err) + } + + r.mu.RLock() + defer r.mu.RUnlock() + + var results []ToolSearchResult + + // Iterate in sorted order for deterministic results across calls. + for _, name := range r.sortedToolNames() { + entry := r.tools[name] + // Search only among the hidden tools (Core tools are already visible) + if !entry.IsCore { + // Directly call interface methods! No reflection/unmarshalling needed. + desc := entry.Tool.Description() + + if regex.MatchString(name) || regex.MatchString(desc) { + results = append(results, ToolSearchResult{ + Name: name, + Description: desc, + }) + if len(results) >= maxSearchResults { + break // Stop searching once we hit the max! Saves CPU. + } + } + } + } + + return results, nil +} + +func formatDiscoveryResponse(registry *ToolRegistry, results []ToolSearchResult, ttl int) *ToolResult { + if len(results) == 0 { + return SilentResult("No tools found matching the query.") + } + + names := make([]string, len(results)) + for i, r := range results { + names[i] = r.Name + } + registry.PromoteTools(names, ttl) + logger.InfoCF("discovery", "Promoted tools", map[string]any{"tools": names, "ttl": ttl}) + + b, err := json.Marshal(results) + if err != nil { + return ErrorResult("Failed to format search results: " + err.Error()) + } + + msg := fmt.Sprintf( + "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", + len(results), + string(b), + ) + + return SilentResult(msg) +} + +// Lightweight internal type used as corpus document for BM25. +type searchDoc struct { + Name string + Description string +} + +// bm25CachedEngine wraps a BM25Engine with its corpus snapshot. +type bm25CachedEngine struct { + engine *utils.BM25Engine[searchDoc] +} + +// snapshotToSearchDocs converts a HiddenToolSnapshot to BM25 searchDoc slice. +func snapshotToSearchDocs(snap HiddenToolSnapshot) []searchDoc { + docs := make([]searchDoc, len(snap.Docs)) + for i, d := range snap.Docs { + docs[i] = searchDoc{Name: d.Name, Description: d.Description} + } + return docs +} + +// buildBM25Engine creates a BM25Engine from a slice of searchDocs. +func buildBM25Engine(docs []searchDoc) *utils.BM25Engine[searchDoc] { + return utils.NewBM25Engine( + docs, + func(doc searchDoc) string { + return doc.Name + " " + doc.Description + }, + ) +} + +// getOrBuildEngine returns a cached BM25 engine, rebuilding it only when +// the registry version has changed (new tools registered). +func (t *BM25SearchTool) getOrBuildEngine() *bm25CachedEngine { + // Fast path: optimistic check without locking. + if t.cachedEngine != nil && t.cacheVersion == t.registry.Version() { + return t.cachedEngine + } + + t.cacheMu.Lock() + defer t.cacheMu.Unlock() + + // Snapshot + version are read under a single registry RLock, + // guaranteeing consistency (no TOCTOU). + snap := t.registry.SnapshotHiddenTools() + + // Re-check: another goroutine may have rebuilt while we waited for cacheMu. + if t.cachedEngine != nil && t.cacheVersion == snap.Version { + return t.cachedEngine + } + + docs := snapshotToSearchDocs(snap) + if len(docs) == 0 { + t.cachedEngine = nil + t.cacheVersion = snap.Version + return nil + } + + cached := &bm25CachedEngine{engine: buildBM25Engine(docs)} + t.cachedEngine = cached + t.cacheVersion = snap.Version + logger.DebugCF("discovery", "BM25 engine rebuilt", map[string]any{"docs": len(docs), "version": snap.Version}) + return cached +} + +// SearchBM25 ranks hidden tools against query using BM25 via utils.BM25Engine. +// This non-cached variant rebuilds the engine on every call. Used by tests +// and any code that doesn't hold a BM25SearchTool instance. +func (r *ToolRegistry) SearchBM25(query string, maxSearchResults int) []ToolSearchResult { + snap := r.SnapshotHiddenTools() + docs := snapshotToSearchDocs(snap) + if len(docs) == 0 { + return nil + } + + ranked := buildBM25Engine(docs).Search(query, maxSearchResults) + if len(ranked) == 0 { + return nil + } + + out := make([]ToolSearchResult, len(ranked)) + for i, r := range ranked { + out[i] = ToolSearchResult{ + Name: r.Document.Name, + Description: r.Document.Description, + } + } + return out +} diff --git a/pkg/tools/search_tools_test.go b/pkg/tools/search_tools_test.go new file mode 100644 index 000000000..3aae941cb --- /dev/null +++ b/pkg/tools/search_tools_test.go @@ -0,0 +1,339 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "testing" +) + +// Dummy tool to fill the registry in our tests. +type mockSearchableTool struct { + name string + desc string +} + +func (m *mockSearchableTool) Name() string { return m.name } +func (m *mockSearchableTool) Description() string { return m.desc } +func (m *mockSearchableTool) Parameters() map[string]any { + return map[string]any{"type": "object"} +} + +func (m *mockSearchableTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + return SilentResult("mock executed: " + m.name) +} + +// Helper to initialize a populated ToolRegistry +func setupPopulatedRegistry() *ToolRegistry { + reg := NewToolRegistry() + + // A core tool (NOT to be found by searches) + reg.Register(&mockSearchableTool{ + name: "core_search", + desc: "I am a visible core tool for searching files", + }) + + // Hidden tools (must be found by searches) + reg.RegisterHidden(&mockSearchableTool{ + name: "mcp_read_file", + desc: "Read the contents of a system file", + }) + reg.RegisterHidden(&mockSearchableTool{ + name: "mcp_list_dir", + desc: "List directories and files in the system", + }) + reg.RegisterHidden(&mockSearchableTool{ + name: "mcp_fetch_net", + desc: "Fetch data from a network database", + }) + + return reg +} + +func TestRegexSearchTool_Execute(t *testing.T) { + reg := setupPopulatedRegistry() + tool := NewRegexSearchTool(reg, 5, 10) + ctx := context.Background() + + t.Run("Empty Pattern Error", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{}) + if !res.IsError || !strings.Contains(res.ForLLM, "Missing or invalid 'pattern'") { + t.Errorf("Expected missing pattern error, got: %v", res.ForLLM) + } + }) + + t.Run("Invalid Regex Syntax", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"pattern": "[unclosed"}) + if !res.IsError || !strings.Contains(res.ForLLM, "Invalid regex pattern syntax") { + t.Errorf("Expected regex syntax error, got: %v", res.ForLLM) + } + }) + + t.Run("No Match Found", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"pattern": "alien"}) + if res.IsError || !strings.Contains(res.ForLLM, "No tools found matching") { + t.Errorf("Expected 'no tools found' message, got: %v", res.ForLLM) + } + }) + + t.Run("Successful Match & Promotion", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"pattern": "system"}) + + if res.IsError { + t.Fatalf("Unexpected error: %v", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "SUCCESS: These tools have been temporarily UNLOCKED") { + t.Errorf("Expected success string, got: %v", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "mcp_read_file") { + t.Errorf("Expected 'mcp_read_file' in results") + } + + // Verify that the TTL has been updated for the tools found + reg.mu.RLock() + defer reg.mu.RUnlock() + if reg.tools["mcp_read_file"].TTL != 5 { + t.Errorf("Expected TTL of 'mcp_read_file' to be promoted to 5, got %d", reg.tools["mcp_read_file"].TTL) + } + if reg.tools["mcp_fetch_net"].TTL != 0 { + t.Errorf("Expected 'mcp_fetch_net' to NOT be promoted (TTL=0)") + } + }) +} + +func TestBM25SearchTool_Execute(t *testing.T) { + reg := setupPopulatedRegistry() + tool := NewBM25SearchTool(reg, 3, 10) + ctx := context.Background() + + t.Run("Empty Query Error", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"query": " "}) + if !res.IsError || !strings.Contains(res.ForLLM, "Missing or invalid 'query'") { + t.Errorf("Expected missing query error, got: %v", res.ForLLM) + } + }) + + t.Run("No Match Found", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"query": "aliens spaceships"}) + if res.IsError || !strings.Contains(res.ForLLM, "No tools found matching") { + t.Errorf("Expected 'no tools found', got: %v", res.ForLLM) + } + }) + + t.Run("Successful Match & Promotion", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"query": "read files"}) + + if res.IsError { + t.Fatalf("Unexpected error: %v", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "mcp_read_file") { + t.Errorf("Expected 'mcp_read_file' in BM25 results") + } + + reg.mu.RLock() + defer reg.mu.RUnlock() + if reg.tools["mcp_read_file"].TTL != 3 { + t.Errorf("Expected TTL of 'mcp_read_file' to be promoted to 3") + } + }) +} + +func TestRegexSearchTool_PatternTooLong(t *testing.T) { + reg := setupPopulatedRegistry() + tool := NewRegexSearchTool(reg, 5, 10) + ctx := context.Background() + + longPattern := strings.Repeat("a", MaxRegexPatternLength+1) + res := tool.Execute(ctx, map[string]any{"pattern": longPattern}) + if !res.IsError || !strings.Contains(res.ForLLM, "Pattern too long") { + t.Errorf("Expected pattern too long error, got: %v", res.ForLLM) + } +} + +func TestSearchRegex_ZeroMaxResults(t *testing.T) { + reg := setupPopulatedRegistry() + + res, err := reg.SearchRegex("mcp", 0) + if err != nil { + t.Fatalf("SearchRegex failed: %v", err) + } + if len(res) != 0 { + t.Errorf("Expected 0 results with maxSearchResults=0, got %d", len(res)) + } +} + +func TestSearchBM25_ZeroMaxResults(t *testing.T) { + reg := setupPopulatedRegistry() + + res := reg.SearchBM25("read file", 0) + if len(res) != 0 { + t.Errorf("Expected 0 results with maxSearchResults=0, got %d", len(res)) + } +} + +func TestSearchRegex_DeterministicOrder(t *testing.T) { + reg := NewToolRegistry() + for i := 0; i < 20; i++ { + reg.RegisterHidden(&mockSearchableTool{ + name: fmt.Sprintf("tool_%02d", i), + desc: "searchable tool", + }) + } + + // Run the same search multiple times and verify order is stable + var firstRun []string + for attempt := 0; attempt < 10; attempt++ { + res, err := reg.SearchRegex("searchable", 20) + if err != nil { + t.Fatalf("SearchRegex failed: %v", err) + } + + names := make([]string, len(res)) + for i, r := range res { + names[i] = r.Name + } + + if attempt == 0 { + firstRun = names + } else { + for i, name := range names { + if name != firstRun[i] { + t.Fatalf("Non-deterministic order at attempt %d, index %d: got %q, want %q", + attempt, i, name, firstRun[i]) + } + } + } + } +} + +func TestToolRegistry_SearchLimitsAndCoreFiltering(t *testing.T) { + reg := NewToolRegistry() + + // Add 1 Core and 10 Hidden, all containing the word "match" + reg.Register(&mockSearchableTool{"core_match", "I am core with match"}) + for i := 0; i < 10; i++ { + reg.RegisterHidden(&mockSearchableTool{ + name: fmt.Sprintf("hidden_match_%d", i), + desc: "this has a match", + }) + } + + t.Run("Regex limits and core filtering", func(t *testing.T) { + // Search with Regex and a limit of maxSearchResults = 4 + res, err := reg.SearchRegex("match", 4) + if err != nil { + t.Fatalf("SearchRegex failed: %v", err) + } + + if len(res) != 4 { + t.Errorf("Expected exactly 4 results due to limit, got %d", len(res)) + } + + for _, r := range res { + if r.Name == "core_match" { + t.Errorf("SearchRegex returned a Core tool, which should be excluded") + } + } + }) + + t.Run("BM25 limits and core filtering", func(t *testing.T) { + // Search with BM25 and a limit of maxSearchResults = 3 + res := reg.SearchBM25("match", 3) + + if len(res) != 3 { + t.Errorf("Expected exactly 3 results due to limit, got %d", len(res)) + } + + for _, r := range res { + if r.Name == "core_match" { + t.Errorf("SearchBM25 returned a Core tool, which should be excluded") + } + } + }) +} + +func TestGet_HiddenToolTTLLifecycle(t *testing.T) { + reg := NewToolRegistry() + reg.RegisterHidden(&mockSearchableTool{name: "hidden_tool", desc: "test"}) + + // TTL=0 at registration → not gettable + _, ok := reg.Get("hidden_tool") + if ok { + t.Error("Expected hidden tool with TTL=0 to NOT be gettable") + } + + // Promote → gettable + reg.PromoteTools([]string{"hidden_tool"}, 3) + _, ok = reg.Get("hidden_tool") + if !ok { + t.Error("Expected promoted hidden tool to be gettable") + } + + // Tick down to 0 → not gettable again + reg.TickTTL() // 3→2 + reg.TickTTL() // 2→1 + reg.TickTTL() // 1→0 + _, ok = reg.Get("hidden_tool") + if ok { + t.Error("Expected hidden tool with TTL ticked to 0 to NOT be gettable") + } + + // Core tools remain always gettable + reg.Register(&mockSearchableTool{name: "core_tool", desc: "core"}) + _, ok = reg.Get("core_tool") + if !ok { + t.Error("Expected core tool to always be gettable") + } +} + +func TestBM25CacheInvalidation(t *testing.T) { + reg := NewToolRegistry() + reg.RegisterHidden(&mockSearchableTool{name: "tool_alpha", desc: "alpha functionality"}) + + tool := NewBM25SearchTool(reg, 5, 10) + ctx := context.Background() + + // First search should find tool_alpha + res := tool.Execute(ctx, map[string]any{"query": "alpha"}) + if !strings.Contains(res.ForLLM, "tool_alpha") { + t.Fatalf("Expected 'tool_alpha' in first search, got: %v", res.ForLLM) + } + + // Register a new hidden tool + reg.RegisterHidden(&mockSearchableTool{name: "tool_beta", desc: "beta functionality"}) + + // Cache should be invalidated; new tool should be findable + res = tool.Execute(ctx, map[string]any{"query": "beta"}) + if !strings.Contains(res.ForLLM, "tool_beta") { + t.Errorf("Expected 'tool_beta' after cache invalidation, got: %v", res.ForLLM) + } +} + +func TestPromoteTools_ConcurrentWithTickTTL(t *testing.T) { + reg := NewToolRegistry() + for i := 0; i < 20; i++ { + reg.RegisterHidden(&mockSearchableTool{ + name: fmt.Sprintf("concurrent_tool_%d", i), + desc: "concurrent test tool", + }) + } + + names := make([]string, 20) + for i := 0; i < 20; i++ { + names[i] = fmt.Sprintf("concurrent_tool_%d", i) + } + + // Hammer PromoteTools and TickTTL concurrently to detect races + done := make(chan struct{}) + go func() { + for i := 0; i < 1000; i++ { + reg.PromoteTools(names, 5) + } + close(done) + }() + + for i := 0; i < 1000; i++ { + reg.TickTTL() + } + <-done +} diff --git a/pkg/tools/session.go b/pkg/tools/session.go new file mode 100644 index 000000000..8c7584254 --- /dev/null +++ b/pkg/tools/session.go @@ -0,0 +1,244 @@ +package tools + +import ( + "bytes" + "errors" + "io" + "os" + "sync" + "time" + + "github.com/google/uuid" +) + +const maxOutputBufferSize = 1 * 1024 * 1024 // 1MB + +const outputTruncateMarker = "\n... [output truncated, exceeded 1MB]\n" + +// PtyKeyMode represents arrow key encoding mode for PTY sessions. +// Programs send smkx/rmkx sequences to switch between CSI and SS3 modes. +type PtyKeyMode uint8 + +const ( + PtyKeyModeCSI PtyKeyMode = iota // triggered by rmkx (\x1b[?1l) + PtyKeyModeSS3 // triggered by smkx (\x1b[?1h) +) + +const PtyKeyModeNotFound PtyKeyMode = 255 + +var ( + ErrSessionNotFound = errors.New("session not found") + ErrSessionDone = errors.New("session already completed") + ErrPTYNotSupported = errors.New("PTY is not supported on this platform") + ErrNoStdin = errors.New("no stdin available") +) + +type ProcessSession struct { + mu sync.Mutex + ID string + PID int + Command string + PTY bool + Background bool + StartTime int64 + ExitCode int + Status string + stdinWriter io.Writer + stdoutPipe io.Reader + outputBuffer *bytes.Buffer + outputTruncated bool + ptyMaster *os.File + + // ptyKeyMode tracks arrow key encoding mode (CSI vs SS3) + ptyKeyMode PtyKeyMode +} + +func (s *ProcessSession) IsDone() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.Status == "done" || s.Status == "exited" +} + +func (s *ProcessSession) GetPtyKeyMode() PtyKeyMode { + s.mu.Lock() + defer s.mu.Unlock() + return s.ptyKeyMode +} + +func (s *ProcessSession) SetPtyKeyMode(mode PtyKeyMode) { + s.mu.Lock() + defer s.mu.Unlock() + s.ptyKeyMode = mode +} + +func (s *ProcessSession) GetStatus() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.Status +} + +func (s *ProcessSession) SetStatus(status string) { + s.mu.Lock() + defer s.mu.Unlock() + s.Status = status +} + +func (s *ProcessSession) GetExitCode() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.ExitCode +} + +func (s *ProcessSession) SetExitCode(code int) { + s.mu.Lock() + defer s.mu.Unlock() + s.ExitCode = code +} + +func (s *ProcessSession) killProcess() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.Status != "running" { + return ErrSessionDone + } + + pid := s.PID + if pid <= 0 { + return ErrSessionNotFound + } + + if err := killProcessGroup(pid); err != nil { + return err + } + + s.Status = "done" + s.ExitCode = -1 + return nil +} + +func (s *ProcessSession) Kill() error { + return s.killProcess() +} + +func (s *ProcessSession) Write(data string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.Status != "running" { + return ErrSessionDone + } + + var writer io.Writer + if s.PTY && s.ptyMaster != nil { + writer = s.ptyMaster + } else if s.stdinWriter != nil { + writer = s.stdinWriter + } else { + return ErrNoStdin + } + + _, err := writer.Write([]byte(data)) + return err +} + +func (s *ProcessSession) Read() string { + s.mu.Lock() + defer s.mu.Unlock() + + if s.outputBuffer.Len() == 0 { + return "" + } + + data := s.outputBuffer.String() + s.outputBuffer.Reset() + return data +} + +func (s *ProcessSession) ToSessionInfo() SessionInfo { + s.mu.Lock() + defer s.mu.Unlock() + + return SessionInfo{ + ID: s.ID, + Command: s.Command, + Status: s.Status, + PID: s.PID, + StartedAt: s.StartTime, + } +} + +type SessionManager struct { + mu sync.RWMutex + sessions map[string]*ProcessSession +} + +func NewSessionManager() *SessionManager { + sm := &SessionManager{ + sessions: make(map[string]*ProcessSession), + } + + // Start cleaner goroutine - runs every 5 minutes, cleans up sessions done for >30 minutes + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + sm.cleanupOldSessions() + } + }() + + return sm +} + +// cleanupOldSessions removes sessions that are done and older than 30 minutes +func (sm *SessionManager) cleanupOldSessions() { + sm.mu.Lock() + defer sm.mu.Unlock() + + cutoff := time.Now().Add(-30 * time.Minute) + for id, session := range sm.sessions { + if session.IsDone() && session.StartTime < cutoff.Unix() { + delete(sm.sessions, id) + } + } +} + +func (sm *SessionManager) Add(session *ProcessSession) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.sessions[session.ID] = session +} + +func (sm *SessionManager) Get(sessionID string) (*ProcessSession, error) { + sm.mu.RLock() + defer sm.mu.RUnlock() + + session, ok := sm.sessions[sessionID] + if !ok { + return nil, ErrSessionNotFound + } + + return session, nil +} + +func (sm *SessionManager) Remove(sessionID string) { + sm.mu.Lock() + defer sm.mu.Unlock() + delete(sm.sessions, sessionID) +} + +func (sm *SessionManager) List() []SessionInfo { + sm.mu.RLock() + defer sm.mu.RUnlock() + + result := make([]SessionInfo, 0, len(sm.sessions)) + for _, session := range sm.sessions { + result = append(result, session.ToSessionInfo()) + } + + return result +} + +func generateSessionID() string { + return uuid.New().String()[:8] +} diff --git a/pkg/tools/session_process_unix.go b/pkg/tools/session_process_unix.go new file mode 100644 index 000000000..2fe30166e --- /dev/null +++ b/pkg/tools/session_process_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package tools + +import ( + "syscall" +) + +func killProcessGroup(pid int) error { + if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + return nil +} diff --git a/pkg/tools/session_process_windows.go b/pkg/tools/session_process_windows.go new file mode 100644 index 000000000..7cf558954 --- /dev/null +++ b/pkg/tools/session_process_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package tools + +import ( + "os/exec" + "strconv" +) + +func killProcessGroup(pid int) error { + _ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run() + return nil +} diff --git a/pkg/tools/session_test.go b/pkg/tools/session_test.go new file mode 100644 index 000000000..6cfe72a10 --- /dev/null +++ b/pkg/tools/session_test.go @@ -0,0 +1,99 @@ +package tools + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSessionManager_AddGet(t *testing.T) { + sm := NewSessionManager() + session := &ProcessSession{ + ID: "test-1", + Command: "echo hello", + Status: "running", + StartTime: 1000, + } + + sm.Add(session) + + got, err := sm.Get("test-1") + require.NoError(t, err) + require.Equal(t, "test-1", got.ID) +} + +func TestSessionManager_Remove(t *testing.T) { + sm := NewSessionManager() + session := &ProcessSession{ + ID: "test-1", + Command: "echo hello", + Status: "running", + StartTime: 1000, + } + sm.Add(session) + sm.Remove("test-1") + + _, err := sm.Get("test-1") + require.ErrorIs(t, err, ErrSessionNotFound) +} + +func TestSessionManager_List(t *testing.T) { + sm := NewSessionManager() + sm.Add(&ProcessSession{ + ID: "test-1", + Command: "echo hello", + Status: "running", + StartTime: 1000, + }) + sm.Add(&ProcessSession{ + ID: "test-2", + Command: "echo world", + Status: "running", + StartTime: 1001, + }) + sm.Add(&ProcessSession{ + ID: "test-3", + Command: "echo done", + Status: "done", + StartTime: 1002, + }) + + sessions := sm.List() + require.Len(t, sessions, 3) + + ids := make(map[string]bool) + for _, s := range sessions { + ids[s.ID] = true + } + require.True(t, ids["test-1"]) + require.True(t, ids["test-2"]) + require.True(t, ids["test-3"]) +} + +func TestProcessSession_IsDone(t *testing.T) { + session := &ProcessSession{Status: "running"} + require.False(t, session.IsDone()) + + session.Status = "done" + require.True(t, session.IsDone()) + + session.Status = "exited" + require.True(t, session.IsDone()) +} + +func TestProcessSession_ToSessionInfo(t *testing.T) { + session := &ProcessSession{ + ID: "test-1", + PID: 12345, + Command: "echo hello", + Status: "running", + StartTime: 1000, + } + + info := session.ToSessionInfo() + require.Equal(t, "test-1", info.ID) + require.Equal(t, "echo hello", info.Command) + require.Equal(t, "running", info.Status) + require.Equal(t, 12345, info.PID) + require.Equal(t, int64(1000), info.StartedAt) +} diff --git a/pkg/tools/base.go b/pkg/tools/shared/base.go similarity index 51% rename from pkg/tools/base.go rename to pkg/tools/shared/base.go index ec743e164..298e1b478 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/shared/base.go @@ -1,6 +1,10 @@ -package tools +package toolshared -import "context" +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/session" +) // Tool is the interface that all tools must implement. type Tool interface { @@ -10,6 +14,24 @@ type Tool interface { Execute(ctx context.Context, args map[string]any) *ToolResult } +const ( + ToolPromptLayerCapability = "capability" + ToolPromptSlotTooling = "tooling" + ToolPromptSlotMCP = "mcp" + ToolPromptSourceRegistry = "tool_registry:native" + ToolPromptSourceDiscovery = "tool_registry:discovery" +) + +type PromptMetadata struct { + Layer string + Slot string + Source string +} + +type PromptMetadataProvider interface { + PromptMetadata() PromptMetadata +} + // --- Request-scoped tool context (channel / chatID) --- // // Carried via context.Value so that concurrent tool calls each receive @@ -21,8 +43,13 @@ type Tool interface { type toolCtxKey struct{ name string } var ( - ctxKeyChannel = &toolCtxKey{"channel"} - ctxKeyChatID = &toolCtxKey{"chatID"} + ctxKeyChannel = &toolCtxKey{"channel"} + ctxKeyChatID = &toolCtxKey{"chatID"} + ctxKeyMessageID = &toolCtxKey{"messageID"} + ctxKeyReplyToMessageID = &toolCtxKey{"replyToMessageID"} + ctxKeyAgentID = &toolCtxKey{"agentID"} + ctxKeySessionKey = &toolCtxKey{"sessionKey"} + ctxKeySessionScope = &toolCtxKey{"sessionScope"} ) // WithToolContext returns a child context carrying channel and chatID. @@ -32,6 +59,35 @@ func WithToolContext(ctx context.Context, channel, chatID string) context.Contex return ctx } +// WithToolMessageContext returns a child context carrying inbound message IDs. +func WithToolMessageContext(ctx context.Context, messageID, replyToMessageID string) context.Context { + ctx = context.WithValue(ctx, ctxKeyMessageID, messageID) + ctx = context.WithValue(ctx, ctxKeyReplyToMessageID, replyToMessageID) + return ctx +} + +// WithToolInboundContext returns a child context carrying channel/chat and inbound IDs. +func WithToolInboundContext( + ctx context.Context, + channel, chatID, messageID, replyToMessageID string, +) context.Context { + ctx = WithToolContext(ctx, channel, chatID) + ctx = WithToolMessageContext(ctx, messageID, replyToMessageID) + return ctx +} + +// WithToolSessionContext returns a child context carrying turn-scoped session metadata. +func WithToolSessionContext( + ctx context.Context, + agentID, sessionKey string, + scope *session.SessionScope, +) context.Context { + ctx = context.WithValue(ctx, ctxKeyAgentID, agentID) + ctx = context.WithValue(ctx, ctxKeySessionKey, sessionKey) + ctx = context.WithValue(ctx, ctxKeySessionScope, session.CloneScope(scope)) + return ctx +} + // ToolChannel extracts the channel from ctx, or "" if unset. func ToolChannel(ctx context.Context) string { v, _ := ctx.Value(ctxKeyChannel).(string) @@ -44,6 +100,36 @@ func ToolChatID(ctx context.Context) string { return v } +// ToolMessageID extracts the current inbound message ID from ctx, or "" if unset. +func ToolMessageID(ctx context.Context) string { + v, _ := ctx.Value(ctxKeyMessageID).(string) + return v +} + +// ToolReplyToMessageID extracts the current inbound reply target from ctx, or "" if unset. +func ToolReplyToMessageID(ctx context.Context) string { + v, _ := ctx.Value(ctxKeyReplyToMessageID).(string) + return v +} + +// ToolAgentID extracts the active turn's agent ID from ctx, or "" if unset. +func ToolAgentID(ctx context.Context) string { + v, _ := ctx.Value(ctxKeyAgentID).(string) + return v +} + +// ToolSessionKey extracts the active turn's session key from ctx, or "" if unset. +func ToolSessionKey(ctx context.Context) string { + v, _ := ctx.Value(ctxKeySessionKey).(string) + return v +} + +// ToolSessionScope extracts the active turn's structured session scope from ctx. +func ToolSessionScope(ctx context.Context) *session.SessionScope { + scope, _ := ctx.Value(ctxKeySessionScope).(*session.SessionScope) + return session.CloneScope(scope) +} + // AsyncCallback is a function type that async tools use to notify completion. // When an async tool finishes its work, it calls this callback with the result. // diff --git a/pkg/tools/result.go b/pkg/tools/shared/result.go similarity index 65% rename from pkg/tools/result.go rename to pkg/tools/shared/result.go index cab833284..e4b16f7b3 100644 --- a/pkg/tools/result.go +++ b/pkg/tools/shared/result.go @@ -1,6 +1,16 @@ -package tools +package toolshared -import "encoding/json" +import ( + "encoding/json" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +const ( + HandledToolLLMNote = "The requested output has already been delivered to the user in the current chat. Do not call send_file or any other delivery tool again. If you reply, provide only a brief confirmation." + ArtifactPathsLLMNote = "Use `send_file` with one of these paths to send it to the user, or use file/exec tools to save it inside the workspace if requested." +) // ToolResult represents the structured return value from tool execution. // It provides clear semantics for different types of results and supports @@ -34,6 +44,53 @@ type ToolResult struct { // Media contains media store refs produced by this tool. // When non-empty, the agent will publish these as OutboundMediaMessage. Media []string `json:"media,omitempty"` + + // Messages holds the ephemeral session history after execution. + // Only populated by SubTurn executions; used by evaluator_optimizer + // to carry stateful worker context across evaluation iterations. + Messages []providers.Message `json:"-"` + + // ArtifactTags exposes local artifact paths back to the LLM in a structured + // form, e.g. "[file:/tmp/example.png]". This is used when a tool produced a + // reusable local artifact but did not deliver it to the user yet. + ArtifactTags []string `json:"artifact_tags,omitempty"` + + // ResponseHandled indicates that this tool execution already satisfied the + // user's request at the channel/output level, so the agent loop can stop + // without a follow-up assistant response. + ResponseHandled bool `json:"response_handled,omitempty"` +} + +// ContentForLLM returns the normalized textual content to append to the +// conversation after a tool call. Errors fall back to Err when ForLLM is empty. +func (tr *ToolResult) ContentForLLM() string { + if tr == nil { + return "" + } + content := tr.ForLLM + if content == "" && tr.Err != nil { + content = tr.Err.Error() + } + if tr.ResponseHandled { + if content == "" { + return HandledToolLLMNote + } + if !strings.Contains(content, HandledToolLLMNote) { + content += "\n" + HandledToolLLMNote + } + } + if len(tr.ArtifactTags) > 0 { + artifactNote := "Local artifact paths: " + strings.Join(tr.ArtifactTags, " ") + "\n" + ArtifactPathsLLMNote + if content == "" { + content = artifactNote + } else if !strings.Contains(content, artifactNote) { + content += "\n" + artifactNote + } + } + if content != "" { + return content + } + return "" } // NewToolResult creates a basic ToolResult with content for the LLM. @@ -158,3 +215,9 @@ func (tr *ToolResult) WithError(err error) *ToolResult { tr.Err = err return tr } + +// WithResponseHandled marks the tool result as already delivered to the user. +func (tr *ToolResult) WithResponseHandled() *ToolResult { + tr.ResponseHandled = true + return tr +} diff --git a/pkg/tools/types.go b/pkg/tools/shared/types.go similarity index 58% rename from pkg/tools/types.go rename to pkg/tools/shared/types.go index a6015cde3..8a74d30f3 100644 --- a/pkg/tools/types.go +++ b/pkg/tools/shared/types.go @@ -1,4 +1,4 @@ -package tools +package toolshared import "context" @@ -56,3 +56,32 @@ type ToolFunctionDefinition struct { Description string `json:"description"` Parameters map[string]any `json:"parameters"` } + +type ExecRequest struct { + Action string `json:"action"` + Command string `json:"command,omitempty"` + PTY bool `json:"pty,omitempty"` + Background bool `json:"background,omitempty"` + Timeout int `json:"timeout,omitempty"` + Env map[string]string `json:"env,omitempty"` + Cwd string `json:"cwd,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Data string `json:"data,omitempty"` +} + +type ExecResponse struct { + SessionID string `json:"sessionId,omitempty"` + Status string `json:"status,omitempty"` + ExitCode int `json:"exitCode,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + Sessions []SessionInfo `json:"sessions,omitempty"` +} + +type SessionInfo struct { + ID string `json:"id"` + Command string `json:"command"` + Status string `json:"status"` + PID int `json:"pid"` + StartedAt int64 `json:"startedAt"` +} diff --git a/pkg/tools/shared_facade.go b/pkg/tools/shared_facade.go new file mode 100644 index 000000000..8409ea060 --- /dev/null +++ b/pkg/tools/shared_facade.go @@ -0,0 +1,118 @@ +package tools + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/session" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +type ( + Message = toolshared.Message + ToolCall = toolshared.ToolCall + FunctionCall = toolshared.FunctionCall + LLMResponse = toolshared.LLMResponse + UsageInfo = toolshared.UsageInfo + LLMProvider = toolshared.LLMProvider + ToolDefinition = toolshared.ToolDefinition + ToolFunctionDefinition = toolshared.ToolFunctionDefinition + ExecRequest = toolshared.ExecRequest + ExecResponse = toolshared.ExecResponse + SessionInfo = toolshared.SessionInfo + Tool = toolshared.Tool + AsyncCallback = toolshared.AsyncCallback + AsyncExecutor = toolshared.AsyncExecutor + PromptMetadata = toolshared.PromptMetadata + PromptMetadataProvider = toolshared.PromptMetadataProvider + ToolResult = toolshared.ToolResult +) + +const ( + handledToolLLMNote = toolshared.HandledToolLLMNote + artifactPathsLLMNote = toolshared.ArtifactPathsLLMNote + + ToolPromptLayerCapability = toolshared.ToolPromptLayerCapability + ToolPromptSlotTooling = toolshared.ToolPromptSlotTooling + ToolPromptSlotMCP = toolshared.ToolPromptSlotMCP + ToolPromptSourceRegistry = toolshared.ToolPromptSourceRegistry + ToolPromptSourceDiscovery = toolshared.ToolPromptSourceDiscovery +) + +func WithToolContext(ctx context.Context, channel, chatID string) context.Context { + return toolshared.WithToolContext(ctx, channel, chatID) +} + +func WithToolMessageContext(ctx context.Context, messageID, replyToMessageID string) context.Context { + return toolshared.WithToolMessageContext(ctx, messageID, replyToMessageID) +} + +func WithToolInboundContext( + ctx context.Context, + channel, chatID, messageID, replyToMessageID string, +) context.Context { + return toolshared.WithToolInboundContext(ctx, channel, chatID, messageID, replyToMessageID) +} + +func WithToolSessionContext( + ctx context.Context, + agentID, sessionKey string, + scope *session.SessionScope, +) context.Context { + return toolshared.WithToolSessionContext(ctx, agentID, sessionKey, scope) +} + +func ToolChannel(ctx context.Context) string { + return toolshared.ToolChannel(ctx) +} + +func ToolChatID(ctx context.Context) string { + return toolshared.ToolChatID(ctx) +} + +func ToolMessageID(ctx context.Context) string { + return toolshared.ToolMessageID(ctx) +} + +func ToolReplyToMessageID(ctx context.Context) string { + return toolshared.ToolReplyToMessageID(ctx) +} + +func ToolAgentID(ctx context.Context) string { + return toolshared.ToolAgentID(ctx) +} + +func ToolSessionKey(ctx context.Context) string { + return toolshared.ToolSessionKey(ctx) +} + +func ToolSessionScope(ctx context.Context) *session.SessionScope { + return toolshared.ToolSessionScope(ctx) +} + +func ToolToSchema(tool Tool) map[string]any { + return toolshared.ToolToSchema(tool) +} + +func NewToolResult(forLLM string) *ToolResult { + return toolshared.NewToolResult(forLLM) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} + +func AsyncResult(forLLM string) *ToolResult { + return toolshared.AsyncResult(forLLM) +} + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func UserResult(content string) *ToolResult { + return toolshared.UserResult(content) +} + +func MediaResult(forLLM string, mediaRefs []string) *ToolResult { + return toolshared.MediaResult(forLLM, mediaRefs) +} diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 0931121df..7d4e24e84 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -3,27 +3,48 @@ package tools import ( "bytes" "context" + "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" "regexp" "runtime" "strings" + "sync" "time" + "github.com/creack/pty" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/isolation" ) +var ( + globalSessionManager = NewSessionManager() + sessionManagerMu sync.RWMutex +) + +func getSessionManager() *SessionManager { + sessionManagerMu.RLock() + defer sessionManagerMu.RUnlock() + return globalSessionManager +} + type ExecTool struct { workingDir string timeout time.Duration denyPatterns []*regexp.Regexp allowPatterns []*regexp.Regexp customAllowPatterns []*regexp.Regexp + allowedPathPatterns []*regexp.Regexp restrictToWorkspace bool filterEnv bool + allowRemote bool + sessionManager *SessionManager } var ( @@ -33,7 +54,7 @@ var ( regexp.MustCompile(`\brmdir\s+/s\b`), // Match disk wiping commands (must be followed by space/args) regexp.MustCompile( - `\b(format|mkfs|diskpart)\b\s`, + `(^|[^-\w])\b(format|mkfs|diskpart)\b\s`, ), regexp.MustCompile(`\bdd\s+if=`), // Block writes to block devices (all common naming schemes). @@ -94,17 +115,28 @@ var ( } ) -func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) { - return NewExecToolWithConfig(workingDir, restrict, nil) +func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regexp) (*ExecTool, error) { + return NewExecToolWithConfig(workingDir, restrict, nil, allowPaths...) } -func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) { +func NewExecToolWithConfig( + workingDir string, + restrict bool, + cfg *config.Config, + allowPaths ...[]*regexp.Regexp, +) (*ExecTool, error) { denyPatterns := make([]*regexp.Regexp, 0) customAllowPatterns := make([]*regexp.Regexp, 0) + var allowedPathPatterns []*regexp.Regexp + allowRemote := true + if len(allowPaths) > 0 { + allowedPathPatterns = allowPaths[0] + } - if config != nil { - execConfig := config.Tools.Exec + if cfg != nil { + execConfig := cfg.Tools.Exec enableDenyPatterns := execConfig.EnableDenyPatterns + allowRemote = execConfig.AllowRemote if enableDenyPatterns { denyPatterns = append(denyPatterns, defaultDenyPatterns...) if len(execConfig.CustomDenyPatterns) > 0 { @@ -132,14 +164,14 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf denyPatterns = append(denyPatterns, defaultDenyPatterns...) } - timeout := 60 * time.Second - if config != nil && config.Tools.Exec.TimeoutSeconds > 0 { - timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second + var timeout time.Duration + if cfg != nil && cfg.Tools.Exec.TimeoutSeconds > 0 { + timeout = time.Duration(cfg.Tools.Exec.TimeoutSeconds) * time.Second } filterEnv := false - if config != nil { - filterEnv = config.Tools.Exec.FilterEnv + if cfg != nil { + filterEnv = cfg.Tools.Exec.FilterEnv } return &ExecTool{ @@ -148,8 +180,11 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf denyPatterns: denyPatterns, allowPatterns: nil, customAllowPatterns: customAllowPatterns, + allowedPathPatterns: allowedPathPatterns, restrictToWorkspace: restrict, filterEnv: filterEnv, + allowRemote: allowRemote, + sessionManager: getSessionManager(), }, nil } @@ -158,36 +193,122 @@ func (t *ExecTool) Name() string { } func (t *ExecTool) Description() string { - return "Execute a shell command and return its output. Use with caution." + return `Execute shell commands. Use background=true for long-running commands (returns sessionId). Use pty=true for interactive commands (can combine with background=true). Use poll/read/write/send-keys/kill with sessionId to manage background sessions. Sessions auto-cleanup 30 minutes after process exits; use kill to terminate early. Output buffer limit: 1MB.` } func (t *ExecTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"run", "list", "poll", "read", "write", "kill", "send-keys"}, + "description": "Action: run (execute command), list (show sessions), poll (check status), read (get output), write (send input), kill (terminate), send-keys (send keys to PTY)", + }, "command": map[string]any{ "type": "string", - "description": "The shell command to execute", + "description": "Shell command to execute (required for run)", }, - "working_dir": map[string]any{ + "sessionId": map[string]any{ "type": "string", - "description": "Optional working directory for the command", + "description": "Session ID (required for poll/read/write/kill/send-keys)", + }, + "keys": map[string]any{ + "type": "string", + "description": "Key names for send-keys: up, down, left, right, enter, tab, escape, backspace, ctrl-c, ctrl-d, home, end, pageup, pagedown, f1-f12", + }, + "data": map[string]any{ + "type": "string", + "description": "Data to write to stdin (required for write)", + }, + "background": map[string]any{ + "type": "string", + "description": "Run in background immediately", + }, + "pty": map[string]any{ + "type": "string", + "description": "Run in a pseudo-terminal (PTY) when available", + }, + "cwd": map[string]any{ + "type": "string", + "description": "Working directory for the command", + }, + "timeout": map[string]any{ + "type": "integer", + "description": "Timeout in seconds (0 = no timeout)", }, }, - "required": []string{"command"}, + "required": []string{"action"}, } } func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, _ := args["action"].(string) + if action == "" { + return ErrorResult("action is required") + } + + switch action { + case "run": + return t.executeRun(ctx, args) + case "list": + return t.executeList() + case "poll": + return t.executePoll(args) + case "read": + return t.executeRead(args) + case "write": + return t.executeWrite(args) + case "kill": + return t.executeKill(args) + case "send-keys": + return t.executeSendKeys(args) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s", action)) + } +} + +func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolResult { command, ok := args["command"].(string) if !ok { return ErrorResult("command is required") } + // GHSA-pv8c-p6jf-3fpp: block exec from remote channels (e.g. Telegram webhooks) + // unless explicitly opted-in via config. Fail-closed: empty channel = blocked. + if !t.allowRemote { + channel := ToolChannel(ctx) + if channel == "" { + channel, _ = args["__channel"].(string) + } + channel = strings.TrimSpace(channel) + if channel == "" || !constants.IsInternalChannel(channel) { + return ErrorResult("exec is restricted to internal channels") + } + } + + getBoolArg := func(key string) bool { + switch v := args[key].(type) { + case bool: + return v + case string: + return v == "true" + } + return false + } + isPty := getBoolArg("pty") + isBackground := getBoolArg("background") + + if isPty { + if runtime.GOOS == "windows" { + return ErrorResult("PTY is not supported on Windows. Use background=true without pty.") + } + } + cwd := t.workingDir - if wd, ok := args["working_dir"].(string); ok && wd != "" { + if wd, ok := args["cwd"].(string); ok && wd != "" { if t.restrictToWorkspace && t.workingDir != "" { - resolvedWD, err := validatePath(wd, t.workingDir, true) + resolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns) if err != nil { return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") } @@ -208,6 +329,37 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult return ErrorResult(guardError) } + // Re-resolve symlinks immediately before execution to shrink the TOCTOU window + // between validation and cmd.Dir assignment. + if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir { + resolved, err := filepath.EvalSymlinks(cwd) + if err != nil { + return ErrorResult(fmt.Sprintf("Command blocked by safety guard (path resolution failed: %v)", err)) + } + if isAllowedPath(resolved, t.allowedPathPatterns) { + cwd = resolved + } else { + absWorkspace, _ := filepath.Abs(t.workingDir) + wsResolved, _ := filepath.EvalSymlinks(absWorkspace) + if wsResolved == "" { + wsResolved = absWorkspace + } + rel, err := filepath.Rel(wsResolved, resolved) + if err != nil || !filepath.IsLocal(rel) { + return ErrorResult("Command blocked by safety guard (working directory escaped workspace)") + } + cwd = resolved + } + } + + if isBackground { + return t.runBackground(ctx, command, cwd, isPty) + } + + return t.runSync(ctx, command, cwd) +} + +func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult { // timeout == 0 means no timeout var cmdCtx context.Context var cancel context.CancelFunc @@ -238,7 +390,9 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult cmd.Stdout = &stdout cmd.Stderr = &stderr - if err := cmd.Start(); err != nil { + // Route shell execution through the shared isolation entry point so exec tool + // subprocesses receive the same isolation policy as other integrations. + if err := isolation.Start(cmd); err != nil { return ErrorResult(fmt.Sprintf("failed to start command: %v", err)) } @@ -270,13 +424,30 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult if err != nil { if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) { msg := fmt.Sprintf("Command timed out after %v", t.timeout) + if output != "" { + msg += "\n\nPartial output before timeout:\n" + output + } return &ToolResult{ ForLLM: msg, ForUser: msg, IsError: true, + Err: fmt.Errorf("command timeout: %w", err), } } - output += fmt.Sprintf("\nExit code: %v", err) + + // Extract detailed exit information + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode := exitErr.ExitCode() + output += fmt.Sprintf("\n\n[Command exited with code %d]", exitCode) + + // Add signal information if killed by signal (Unix) + if exitCode == -1 { + output += " (killed by signal)" + } + } else { + output += fmt.Sprintf("\n\n[Command failed: %v]", err) + } } if output == "" { @@ -303,6 +474,562 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult } } +func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEnabled bool) *ToolResult { + sessionID := generateSessionID() + session := &ProcessSession{ + ID: sessionID, + Command: command, + PTY: ptyEnabled, + Background: true, + StartTime: time.Now().Unix(), + Status: "running", + ptyKeyMode: PtyKeyModeCSI, + } + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", command) + } else { + cmd = exec.Command("sh", "-c", command) + } + if cwd != "" { + cmd.Dir = cwd + } + + prepareCommandForTermination(cmd) + + var stdoutReader io.ReadCloser + var stderrReader io.ReadCloser + var stdinWriter io.WriteCloser + + if ptyEnabled { + ptmx, tty, err := pty.Open() + if err != nil { + return ErrorResult(fmt.Sprintf("failed to create PTY: %v", err)) + } + + cmd.Stdin = tty + cmd.Stdout = tty + cmd.Stderr = tty + + // For PTY, we need Setsid to create a new session. + // Note: Setsid and Setpgid conflict, so we must replace SysProcAttr entirely. + setSysProcAttrForPty(cmd) + + session.ptyMaster = ptmx + } else { + var err error + stdoutReader, err = cmd.StdoutPipe() + if err != nil { + return ErrorResult(fmt.Sprintf("failed to create stdout pipe: %v", err)) + } + stderrReader, err = cmd.StderrPipe() + if err != nil { + return ErrorResult(fmt.Sprintf("failed to create stderr pipe: %v", err)) + } + stdinWriter, err = cmd.StdinPipe() + if err != nil { + return ErrorResult(fmt.Sprintf("failed to create stdin pipe: %v", err)) + } + session.stdoutPipe = io.MultiReader(stdoutReader, stderrReader) + session.stdinWriter = stdinWriter + } + + // Background sessions use the same startup path so isolation stays consistent + // with synchronous exec runs. + if err := isolation.Start(cmd); err != nil { + if session.ptyMaster != nil { + session.ptyMaster.Close() + } + return ErrorResult(fmt.Sprintf("failed to start command: %v", err)) + } + + session.PID = cmd.Process.Pid + t.sessionManager.Add(session) + + session.outputBuffer = &bytes.Buffer{} + + // PTY mode: read from ptyMaster and wait for process + // Note: On Linux, closing ptyMaster doesn't interrupt blocking Read() calls, + // so we need cmd.Wait() in a separate goroutine to detect process exit. + if session.PTY && session.ptyMaster != nil { + go func() { + cmd.Wait() // Wait for process to exit + session.mu.Lock() + if cmd.ProcessState != nil { + session.ExitCode = cmd.ProcessState.ExitCode() + } + session.Status = "done" + session.mu.Unlock() + }() + + go func() { + buf := make([]byte, 4096) + for { + n, err := session.ptyMaster.Read(buf) + if n > 0 { + raw := string(buf[:n]) + if mode := detectPtyKeyMode(raw); mode != PtyKeyModeNotFound && mode != session.GetPtyKeyMode() { + session.SetPtyKeyMode(mode) + } + + session.mu.Lock() + if session.outputBuffer.Len() >= maxOutputBufferSize { + if !session.outputTruncated { + session.outputBuffer.WriteString(outputTruncateMarker) + session.outputTruncated = true + } + } else { + session.outputBuffer.Write(buf[:n]) + } + session.mu.Unlock() + } + if err != nil { + break + } + } + }() + } else { + // Non-PTY mode: single goroutine reads pipes. + // When Read() returns EOF (pipe closed), we break. + // When process exits, OS closes pipe write end → Read() returns EOF → we exit. + go func() { + buf := make([]byte, 4096) + + // Read stdout + for { + n, err := stdoutReader.Read(buf) + if n > 0 { + session.mu.Lock() + if session.outputBuffer.Len() >= maxOutputBufferSize { + if !session.outputTruncated { + session.outputBuffer.WriteString(outputTruncateMarker) + session.outputTruncated = true + } + } else { + session.outputBuffer.Write(buf[:n]) + } + session.mu.Unlock() + } + if err != nil { + break + } + } + + // Read stderr + for { + n, err := stderrReader.Read(buf) + if n > 0 { + session.mu.Lock() + if session.outputBuffer.Len() >= maxOutputBufferSize { + if !session.outputTruncated { + session.outputBuffer.WriteString(outputTruncateMarker) + session.outputTruncated = true + } + } else { + session.outputBuffer.Write(buf[:n]) + } + session.mu.Unlock() + } + if err != nil { + break + } + } + + // All pipes closed, get exit status + if stdinWriter != nil { + stdinWriter.Close() + } + cmd.Wait() + + session.mu.Lock() + if cmd.ProcessState != nil { + session.ExitCode = cmd.ProcessState.ExitCode() + } + session.Status = "done" + session.mu.Unlock() + }() + } + + resp := ExecResponse{ + SessionID: sessionID, + Status: "running", + } + data, _ := json.Marshal(resp) + return &ToolResult{ + ForLLM: string(data), + ForUser: fmt.Sprintf("Session %s started", sessionID), + IsError: false, + } +} + +func (t *ExecTool) executeList() *ToolResult { + sessions := t.sessionManager.List() + resp := ExecResponse{ + Sessions: sessions, + } + data, _ := json.Marshal(resp) + return &ToolResult{ + ForLLM: string(data), + ForUser: fmt.Sprintf("%d active sessions", len(sessions)), + IsError: false, + } +} + +func (t *ExecTool) executePoll(args map[string]any) *ToolResult { + sessionID, ok := args["sessionId"].(string) + if !ok { + return ErrorResult("sessionId is required") + } + + session, err := t.sessionManager.Get(sessionID) + if err != nil { + if errors.Is(err, ErrSessionNotFound) { + return ErrorResult(fmt.Sprintf("session not found: %s", sessionID)) + } + return ErrorResult(err.Error()) + } + + resp := ExecResponse{ + SessionID: sessionID, + Status: session.GetStatus(), + ExitCode: session.GetExitCode(), + } + data, _ := json.Marshal(resp) + return &ToolResult{ + ForLLM: string(data), + IsError: false, + } +} + +func (t *ExecTool) executeRead(args map[string]any) *ToolResult { + sessionID, ok := args["sessionId"].(string) + if !ok { + return ErrorResult("sessionId is required") + } + + session, err := t.sessionManager.Get(sessionID) + if err != nil { + if errors.Is(err, ErrSessionNotFound) { + return ErrorResult(fmt.Sprintf("session not found: %s", sessionID)) + } + return ErrorResult(err.Error()) + } + + output := session.Read() + + resp := ExecResponse{ + SessionID: sessionID, + Output: output, + Status: session.GetStatus(), + } + data, _ := json.Marshal(resp) + return &ToolResult{ + ForLLM: string(data), + IsError: false, + } +} + +func (t *ExecTool) executeWrite(args map[string]any) *ToolResult { + sessionID, ok := args["sessionId"].(string) + if !ok { + return ErrorResult("sessionId is required") + } + + data, ok := args["data"].(string) + if !ok { + return ErrorResult("data is required") + } + + session, err := t.sessionManager.Get(sessionID) + if err != nil { + if errors.Is(err, ErrSessionNotFound) { + return ErrorResult(fmt.Sprintf("session not found: %s", sessionID)) + } + return ErrorResult(err.Error()) + } + + if session.IsDone() { + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + } + + if err := session.Write(data); err != nil { + if errors.Is(err, ErrSessionDone) { + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + } + return ErrorResult(fmt.Sprintf("failed to write to session: %v", err)) + } + + resp := ExecResponse{ + SessionID: sessionID, + Status: session.GetStatus(), + } + respData, _ := json.Marshal(resp) + return &ToolResult{ + ForLLM: string(respData), + IsError: false, + } +} + +func (t *ExecTool) executeKill(args map[string]any) *ToolResult { + sessionID, ok := args["sessionId"].(string) + if !ok { + return ErrorResult("sessionId is required") + } + + session, err := t.sessionManager.Get(sessionID) + if err != nil { + if errors.Is(err, ErrSessionNotFound) { + return ErrorResult(fmt.Sprintf("session not found: %s", sessionID)) + } + return ErrorResult(err.Error()) + } + + if session.IsDone() { + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + } + + if err := session.Kill(); err != nil { + return ErrorResult(fmt.Sprintf("failed to kill session: %v", err)) + } + + t.sessionManager.Remove(sessionID) + + resp := ExecResponse{ + SessionID: sessionID, + Status: "done", + } + data, _ := json.Marshal(resp) + return &ToolResult{ + ForLLM: string(data), + ForUser: fmt.Sprintf("Session %s killed", sessionID), + IsError: false, + } +} + +// keyMap maps key names to their escape sequences. +var keyMap = map[string]string{ + "enter": "\r", + "return": "\r", + "tab": "\t", + "escape": "\x1b", + "esc": "\x1b", + "space": " ", + "backspace": "\x7f", + "bspace": "\x7f", + "up": "\x1b[A", + "down": "\x1b[B", + "right": "\x1b[C", + "left": "\x1b[D", + "home": "\x1b[1~", + "end": "\x1b[4~", + "pageup": "\x1b[5~", + "pagedown": "\x1b[6~", + "pgup": "\x1b[5~", + "pgdn": "\x1b[6~", + "insert": "\x1b[2~", + "ic": "\x1b[2~", + "delete": "\x1b[3~", + "del": "\x1b[3~", + "dc": "\x1b[3~", + "btab": "\x1b[Z", + "f1": "\x1bOP", + "f2": "\x1bOQ", + "f3": "\x1bOR", + "f4": "\x1bOS", + "f5": "\x1b[15~", + "f6": "\x1b[17~", + "f7": "\x1b[18~", + "f8": "\x1b[19~", + "f9": "\x1b[20~", + "f10": "\x1b[21~", + "f11": "\x1b[23~", + "f12": "\x1b[24~", +} + +// ss3KeysMap maps key names to SS3 escape sequences +var ss3KeysMap = map[string]string{ + "up": "\x1bOA", + "down": "\x1bOB", + "right": "\x1bOC", + "left": "\x1bOD", + "home": "\x1bOH", + "end": "\x1bOF", +} + +func detectPtyKeyMode(raw string) PtyKeyMode { + const SMKX = "\x1b[?1h" + const RMKX = "\x1b[?1l" + + lastSmkx := strings.LastIndex(raw, SMKX) + lastRmkx := strings.LastIndex(raw, RMKX) + + if lastSmkx == -1 && lastRmkx == -1 { + return PtyKeyModeNotFound + } + + if lastSmkx > lastRmkx { + return PtyKeyModeSS3 + } + return PtyKeyModeCSI +} + +// encodeKeyToken encodes a single key token into its escape sequence. +// Supports: +// - Named keys: "enter", "tab", "up", "ctrl-c", "alt-x", etc. +// - Ctrl modifier: "ctrl-c" or "c-c" (sends Ctrl+char) +// - Alt modifier: "alt-x" or "m-x" (sends ESC+char) +func encodeKeyToken(token string, ptyKeyMode PtyKeyMode) (string, error) { + token = strings.ToLower(strings.TrimSpace(token)) + if token == "" { + return "", nil + } + + // Handle ctrl-X format (c-x) + if strings.HasPrefix(token, "c-") { + char := token[2] + if char >= 'a' && char <= 'z' { + return string(rune(char) & 0x1f), nil // ctrl-a through ctrl-z + } + return "", fmt.Errorf("invalid ctrl key: %s", token) + } + + // Handle ctrl-X format (ctrl-x) + if strings.HasPrefix(token, "ctrl-") { + char := token[5] + if char >= 'a' && char <= 'z' { + return string(rune(char) & 0x1f), nil + } + return "", fmt.Errorf("invalid ctrl key: %s", token) + } + + // Handle alt-X format (m-x or alt-x) + if strings.HasPrefix(token, "m-") || strings.HasPrefix(token, "alt-") { + var char string + if strings.HasPrefix(token, "m-") { + char = token[2:] + } else { + char = token[4:] + } + if len(char) == 1 { + return "\x1b" + char, nil + } + return "", fmt.Errorf("invalid alt key: %s", token) + } + + // Handle shift modifier for special keys (shift-up, shift-down, etc.) + if strings.HasPrefix(token, "s-") || strings.HasPrefix(token, "shift-") { + var key string + if strings.HasPrefix(token, "s-") { + key = token[2:] + } else { + key = token[6:] + } + // Apply shift modifier: for single-char keys, return uppercase + if seq, ok := keyMap[key]; ok { + // For escape sequences, we can't easily add shift + // For single-char keys (letters), return uppercase + if len(seq) == 1 { + return strings.ToUpper(seq), nil + } + return seq, nil + } + return "", fmt.Errorf("unknown key with shift: %s", key) + } + + if ptyKeyMode == PtyKeyModeSS3 { + if seq, ok := ss3KeysMap[token]; ok { + return seq, nil + } + } + + if seq, ok := keyMap[token]; ok { + return seq, nil + } + + return "", fmt.Errorf("unknown key: %s (use write action for text input)", token) +} + +// encodeKeySequence encodes a slice of key tokens into a single string. +func encodeKeySequence(tokens []string, ptyKeyMode PtyKeyMode) (string, error) { + var result string + for _, token := range tokens { + seq, err := encodeKeyToken(token, ptyKeyMode) + if err != nil { + return "", err + } + result += seq + } + return result, nil +} + +func (t *ExecTool) executeSendKeys(args map[string]any) *ToolResult { + sessionID, ok := args["sessionId"].(string) + if !ok { + return ErrorResult("sessionId is required") + } + + keysStr, ok := args["keys"].(string) + if !ok { + return ErrorResult("keys must be a string") + } + + if keysStr == "" { + return ErrorResult("keys cannot be empty") + } + + // Parse comma-separated key names + keyNames := strings.Split(keysStr, ",") + var keys []string + for _, k := range keyNames { + k = strings.TrimSpace(k) + if k != "" { + keys = append(keys, k) + } + } + + if len(keys) == 0 { + return ErrorResult("keys cannot be empty") + } + + session, err := t.sessionManager.Get(sessionID) + if err != nil { + if errors.Is(err, ErrSessionNotFound) { + return ErrorResult(fmt.Sprintf("session not found: %s", sessionID)) + } + return ErrorResult(err.Error()) + } + + ptyKeyMode := session.GetPtyKeyMode() + + data, err := encodeKeySequence(keys, ptyKeyMode) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid key: %v", err)) + } + + if session.IsDone() { + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + } + + if err := session.Write(data); err != nil { + if errors.Is(err, ErrSessionDone) { + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + } + return ErrorResult(fmt.Sprintf("failed to send keys: %v", err)) + } + + resp := ExecResponse{ + SessionID: sessionID, + Status: "running", + Output: fmt.Sprintf("Sent keys: %v", keys), + } + respData, _ := json.Marshal(resp) + return &ToolResult{ + ForLLM: string(respData), + IsError: false, + } +} + func (t *ExecTool) guardCommand(command, cwd string) string { cmd := strings.TrimSpace(command) lower := strings.ToLower(cmd) @@ -347,9 +1074,37 @@ func (t *ExecTool) guardCommand(command, cwd string) string { return "" } - matches := absolutePathPattern.FindAllString(cmd, -1) + // Web URL schemes whose path components (starting with //) should be exempt + // from workspace sandbox checks. file: is intentionally excluded so that + // file:// URIs are still validated against the workspace boundary. + webSchemes := []string{"http:", "https:", "ftp:", "ftps:", "sftp:", "ssh:", "git:"} + + matchIndices := absolutePathPattern.FindAllStringIndex(cmd, -1) + + for _, loc := range matchIndices { + raw := cmd[loc[0]:loc[1]] + + // Skip URL path components that look like they're from web URLs. + // When a URL like "https://github.com" is parsed, the regex captures + // "//github.com" as a match (the path portion after "https:"). + // Use the exact match position (loc[0]) so that duplicate //path substrings + // in the same command are each evaluated at their own position. + if strings.HasPrefix(raw, "//") && loc[0] > 0 { + before := cmd[:loc[0]] + isWebURL := false + + for _, scheme := range webSchemes { + if strings.HasSuffix(before, scheme) { + isWebURL = true + break + } + } + + if isWebURL { + continue + } + } - for _, raw := range matches { p, err := filepath.Abs(raw) if err != nil { continue @@ -358,6 +1113,9 @@ func (t *ExecTool) guardCommand(command, cwd string) string { if safePaths[p] { continue } + if isAllowedPath(p, t.allowedPathPatterns) { + continue + } rel, err := filepath.Rel(cwdPath, p) if err != nil { diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index ff9ea4a15..a8de2f4c9 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -2,12 +2,16 @@ package tools import ( "context" + "encoding/json" "os" "path/filepath" + "runtime" "strings" "testing" "time" + "github.com/stretchr/testify/require" + "github.com/sipeed/picoclaw/pkg/config" ) @@ -20,6 +24,7 @@ func TestShellTool_Success(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "echo 'hello world'", } @@ -50,6 +55,7 @@ func TestShellTool_Failure(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "ls /nonexistent_directory_12345", } @@ -82,6 +88,7 @@ func TestShellTool_Timeout(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "sleep 10", } @@ -112,8 +119,9 @@ func TestShellTool_WorkingDir(t *testing.T) { ctx := context.Background() args := map[string]any{ - "command": "cat test.txt", - "working_dir": tmpDir, + "action": "run", + "command": "cat test.txt", + "cwd": tmpDir, } result := tool.Execute(ctx, args) @@ -136,6 +144,7 @@ func TestShellTool_DangerousCommand(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "rm -rf /", } @@ -159,6 +168,7 @@ func TestShellTool_DangerousCommand_KillBlocked(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "kill 12345", } @@ -198,6 +208,7 @@ func TestShellTool_StderrCapture(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "sh -c 'echo stdout; echo stderr >&2'", } @@ -222,6 +233,7 @@ func TestShellTool_OutputTruncation(t *testing.T) { ctx := context.Background() // Generate long output (>10000 chars) args := map[string]any{ + "action": "run", "command": "python3 -c \"print('x' * 20000)\" || echo " + strings.Repeat("x", 20000), } @@ -251,8 +263,9 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) { } result := tool.Execute(context.Background(), map[string]any{ - "command": "pwd", - "working_dir": outsideDir, + "action": "run", + "command": "pwd", + "cwd": outsideDir, }) if !result.IsError { @@ -289,8 +302,9 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) { } result := tool.Execute(context.Background(), map[string]any{ - "command": "cat secret.txt", - "working_dir": link, + "action": "run", + "command": "cat secret.txt", + "cwd": link, }) if !result.IsError { @@ -301,6 +315,85 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) { } } +// TestShellTool_RemoteChannelBlockedByDefault verifies exec is blocked for remote channels +func TestShellTool_RemoteChannelBlockedByDefault(t *testing.T) { + cfg := &config.Config{} + cfg.Tools.Exec.EnableDenyPatterns = true + cfg.Tools.Exec.AllowRemote = false + + tool, err := NewExecToolWithConfig("", false, cfg) + if err != nil { + t.Fatalf("NewExecToolWithConfig() error: %v", err) + } + ctx := WithToolContext(context.Background(), "telegram", "chat-1") + result := tool.Execute(ctx, map[string]any{"action": "run", "command": "echo hi"}) + + if !result.IsError { + t.Fatal("expected remote-channel exec to be blocked") + } + if !strings.Contains(result.ForLLM, "restricted to internal channels") { + t.Errorf("expected 'restricted to internal channels' message, got: %s", result.ForLLM) + } +} + +// TestShellTool_InternalChannelAllowed verifies exec is allowed for internal channels +func TestShellTool_InternalChannelAllowed(t *testing.T) { + cfg := &config.Config{} + cfg.Tools.Exec.EnableDenyPatterns = true + cfg.Tools.Exec.AllowRemote = false + + tool, err := NewExecToolWithConfig("", false, cfg) + if err != nil { + t.Fatalf("NewExecToolWithConfig() error: %v", err) + } + ctx := WithToolContext(context.Background(), "cli", "direct") + result := tool.Execute(ctx, map[string]any{"action": "run", "command": "echo hi"}) + + if result.IsError { + t.Fatalf("expected internal channel exec to succeed, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "hi") { + t.Errorf("expected output to contain 'hi', got: %s", result.ForLLM) + } +} + +// TestShellTool_EmptyChannelBlockedWhenNotAllowRemote verifies fail-closed when no channel context +func TestShellTool_EmptyChannelBlockedWhenNotAllowRemote(t *testing.T) { + cfg := &config.Config{} + cfg.Tools.Exec.EnableDenyPatterns = true + cfg.Tools.Exec.AllowRemote = false + + tool, err := NewExecToolWithConfig("", false, cfg) + if err != nil { + t.Fatalf("NewExecToolWithConfig() error: %v", err) + } + result := tool.Execute(context.Background(), map[string]any{ + "command": "echo hi", + }) + + if !result.IsError { + t.Fatal("expected exec with empty channel to be blocked when allowRemote=false") + } +} + +// TestShellTool_AllowRemoteBypassesChannelCheck verifies allowRemote=true permits any channel +func TestShellTool_AllowRemoteBypassesChannelCheck(t *testing.T) { + cfg := &config.Config{} + cfg.Tools.Exec.EnableDenyPatterns = true + cfg.Tools.Exec.AllowRemote = true + + tool, err := NewExecToolWithConfig("", false, cfg) + if err != nil { + t.Fatalf("NewExecToolWithConfig() error: %v", err) + } + ctx := WithToolContext(context.Background(), "telegram", "chat-1") + result := tool.Execute(ctx, map[string]any{"action": "run", "command": "echo hi"}) + + if result.IsError { + t.Fatalf("expected allowRemote=true to permit remote channel, got: %s", result.ForLLM) + } +} + // TestShellTool_RestrictToWorkspace verifies workspace restriction func TestShellTool_RestrictToWorkspace(t *testing.T) { tmpDir := t.TempDir() @@ -313,6 +406,7 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) { ctx := context.Background() args := map[string]any{ + "action": "run", "command": "cat ../../etc/passwd", } @@ -350,7 +444,7 @@ func TestShellTool_DevNullAllowed(t *testing.T) { } for _, cmd := range commands { - result := tool.Execute(context.Background(), map[string]any{"command": cmd}) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if result.IsError && strings.Contains(result.ForLLM, "blocked") { t.Errorf("command should not be blocked: %s\n error: %s", cmd, result.ForLLM) } @@ -379,7 +473,7 @@ func TestShellTool_BlockDevices(t *testing.T) { } for _, cmd := range blocked { - result := tool.Execute(context.Background(), map[string]any{"command": cmd}) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if !result.IsError { t.Errorf("expected block device write to be blocked: %s", cmd) } @@ -403,13 +497,78 @@ func TestShellTool_SafePathsInWorkspaceRestriction(t *testing.T) { } for _, cmd := range commands { - result := tool.Execute(context.Background(), map[string]any{"command": cmd}) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("safe path should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM) } } } +// TestShellTool_ExitCodeDetails verifies that exit codes are captured with details +func TestShellTool_ExitCodeDetails(t *testing.T) { + tool, err := NewExecTool("", false) + if err != nil { + t.Fatalf("unable to configure exec tool: %s", err) + } + + ctx := context.Background() + args := map[string]any{ + "action": "run", + "command": "sh -c 'exit 42'", + } + + result := tool.Execute(ctx, args) + + if !result.IsError { + t.Error("expected error for non-zero exit code") + } + + // Should contain the exit code in the message (new format: "exited with code 42") + if !strings.Contains(result.ForLLM, "42") { + t.Errorf("expected exit code 42 in error message, got: %s", result.ForLLM) + } + + // Verify the new detailed message format + if !strings.Contains(result.ForLLM, "exited with code") { + t.Errorf("expected 'exited with code' in message, got: %s", result.ForLLM) + } + + // Err field is set by the exec system (may or may not be set depending on implementation) + // The important thing is that IsError=true + t.Logf("Exit code result: %s", result.ForLLM) +} + +// TestShellTool_TimeoutWithPartialOutput verifies timeout includes partial output +func TestShellTool_TimeoutWithPartialOutput(t *testing.T) { + tool, err := NewExecTool("", false) + if err != nil { + t.Fatalf("unable to configure exec tool: %s", err) + } + + tool.SetTimeout(1 * time.Second) // Give more time for echo to complete + + ctx := context.Background() + // Use a command that outputs immediately then sleeps + args := map[string]any{ + "action": "run", + "command": "echo 'partial output before timeout' && sleep 30", + } + + result := tool.Execute(ctx, args) + + if !result.IsError { + t.Error("expected error for timeout") + } + + // Should mention timeout + if !strings.Contains(result.ForLLM, "timed out") { + t.Errorf("expected 'timed out' in message, got: %s", result.ForLLM) + } + + // Log the result for debugging (partial output depends on shell behavior) + t.Logf("Timeout result: %s", result.ForLLM) +} + // TestShellTool_CustomAllowPatterns verifies that custom allow patterns exempt // commands from deny pattern checks. func TestShellTool_CustomAllowPatterns(t *testing.T) { @@ -443,3 +602,1014 @@ func TestShellTool_CustomAllowPatterns(t *testing.T) { t.Errorf("'git push upstream main' should still be blocked by deny pattern") } } + +// TestShellTool_URLsNotBlocked verifies that commands containing URLs are not +// incorrectly blocked by the workspace restriction safety guard (issue #1203). +func TestShellTool_URLsNotBlocked(t *testing.T) { + tmpDir := t.TempDir() + tool, err := NewExecTool(tmpDir, true) + if err != nil { + t.Fatalf("unable to configure exec tool: %s", err) + } + + // These commands contain URLs and should NOT be blocked by workspace restriction. + // The URL path components (e.g., "//github.com") should be recognized as URLs, + // not as file system paths. + commands := []string{ + "agent-browser open https://github.com", + "curl https://api.example.com/data", + "wget http://example.com/file", + "browser open https://github.com/user/repo", + "fetch ftp://ftp.example.com/file.txt", + "git clone https://github.com/sipeed/picoclaw.git", + } + + for _, cmd := range commands { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd}) + cancel() + if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { + t.Errorf("command with URL should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM) + } + } +} + +// TestShellTool_FileURISandboxing verifies that file:// URIs that escape the +// workspace are still blocked, even though other URLs are allowed (issue #1254). +func TestShellTool_FileURISandboxing(t *testing.T) { + tmpDir := t.TempDir() + tool, err := NewExecTool(tmpDir, true) + if err != nil { + t.Fatalf("unable to configure exec tool: %s", err) + } + + // These file:// URIs should be blocked if they reference paths outside the workspace. + // Unlike web URLs (http://, https://, ftp://), file:// URIs can be used to escape the sandbox. + blockedCommands := []string{ + "cat file:///etc/passwd", + "cat file:///etc/hosts", + "cat file:///root/.ssh/id_rsa", + } + + for _, cmd := range blockedCommands { + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) + if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") { + t.Errorf("file:// URI outside workspace should be blocked: %s", cmd) + } + } + + // These file:// URIs should be allowed if they reference paths inside the workspace. + // Create a test file inside the temp directory + testFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil { + t.Fatalf("failed to create test file: %s", err) + } + + allowedCommands := []string{ + "cat file://" + testFile, + } + + for _, cmd := range allowedCommands { + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) + if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { + t.Errorf("file:// URI inside workspace should be allowed: %s\n error: %s", cmd, result.ForLLM) + } + } +} + +// TestShellTool_URLBypassPrevented verifies that a command cannot bypass the workspace +// sandbox by smuggling a real path after a URL that contains the same //path substring. +// e.g. "echo https://etc/passwd && cat //etc/passwd" must still be blocked. +func TestShellTool_URLBypassPrevented(t *testing.T) { + tmpDir := t.TempDir() + tool, err := NewExecTool(tmpDir, true) + if err != nil { + t.Fatalf("unable to configure exec tool: %s", err) + } + + // The path //etc/passwd appears twice: once as the host part of an https URL + // and once as a real (escaped) absolute path. The guard must block the command + // because the second occurrence is a genuine out-of-workspace path. + blockedCommands := []string{ + "echo https://etc/passwd && cat //etc/passwd", + "curl https://host/file && ls //etc", + } + + for _, cmd := range blockedCommands { + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) + if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") { + t.Errorf("bypass attempt should be blocked: %q\n got: %s", cmd, result.ForLLM) + } + } +} + +func TestShellTool_Background_ReturnsImmediately(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + ctx := context.Background() + args := map[string]any{ + "action": "run", + "command": "sleep 5", + "background": "true", + } + + start := time.Now() + result := tool.Execute(ctx, args) + elapsed := time.Since(start) + + require.False(t, result.IsError, "background run should not error: %s", result.ForLLM) + require.Less(t, elapsed, time.Second, "background run should return immediately") + require.Contains(t, result.ForLLM, "sessionId") +} + +func TestShellTool_List_Empty(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := context.Background() + args := map[string]any{"action": "list"} + + result := tool.Execute(ctx, args) + require.False(t, result.IsError) + require.Contains(t, result.ForUser, "0 active sessions") +} + +func TestShellTool_RunBackground_List(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + runResult := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sleep 10", + "background": "true", + }) + require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(runResult.ForLLM), &resp) + require.NoError(t, err) + require.NotEmpty(t, resp.SessionID) + + time.Sleep(100 * time.Millisecond) + + listResult := tool.Execute(ctx, map[string]any{"action": "list"}) + require.False(t, listResult.IsError) + + var listResp ExecResponse + err = json.Unmarshal([]byte(listResult.ForLLM), &listResp) + require.NoError(t, err) + require.Len(t, listResp.Sessions, 1) + require.Equal(t, resp.SessionID, listResp.Sessions[0].ID) + + killResult := tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) + require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM) +} + +func TestShellTool_Read_Output(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + runResult := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "echo hello", + "background": "true", + }) + require.False(t, runResult.IsError) + + var resp ExecResponse + err = json.Unmarshal([]byte(runResult.ForLLM), &resp) + require.NoError(t, err) + + time.Sleep(200 * time.Millisecond) + + readResult := tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": resp.SessionID, + }) + + if !readResult.IsError { + var readResp ExecResponse + err = json.Unmarshal([]byte(readResult.ForLLM), &readResp) + require.NoError(t, err) + } +} + +func TestShellTool_Kill(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + runResult := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sleep 100", + "background": "true", + }) + require.False(t, runResult.IsError) + + var resp ExecResponse + err = json.Unmarshal([]byte(runResult.ForLLM), &resp) + require.NoError(t, err) + + killResult := tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) + require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM) + + time.Sleep(100 * time.Millisecond) + + listResult := tool.Execute(ctx, map[string]any{"action": "list"}) + var listResp ExecResponse + err = json.Unmarshal([]byte(listResult.ForLLM), &listResp) + require.NoError(t, err) + require.Len(t, listResp.Sessions, 0) +} + +func TestShellTool_PTY_AllowedCommands(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Test that PTY is allowed for non-interpreter commands + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "cat", + "pty": "true", + "background": "true", + }) + require.False(t, result.IsError, "PTY with cat should succeed: %s", result.ForLLM) + require.Contains(t, result.ForLLM, "sessionId") + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + require.NotEmpty(t, resp.SessionID) + + // Clean up + tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) +} + +func TestShellTool_PTY_WriteRead(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a PTY session with a command that waits for input + // Using 'cat' which will wait for stdin + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "cat", + "pty": "true", + "background": "true", + }) + require.False(t, result.IsError, "PTY run should succeed: %s", result.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + + // Write some input to cat + writeResult := tool.Execute(ctx, map[string]any{ + "action": "write", + "sessionId": resp.SessionID, + "data": "hello\n", + }) + require.False(t, writeResult.IsError, "write should succeed: %s", writeResult.ForLLM) + + // Give cat time to process and output + time.Sleep(200 * time.Millisecond) + + // Read the output + readResult := tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": resp.SessionID, + }) + + require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM) + + var readResp ExecResponse + err = json.Unmarshal([]byte(readResult.ForLLM), &readResp) + require.NoError(t, err) + // PTY output should contain "hello" + require.Contains(t, readResp.Output, "hello") + + // Clean up + tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) +} + +func TestShellTool_PTY_Poll(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a PTY session with a long-running command + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sleep 2", + "pty": "true", + "background": "true", + }) + require.False(t, result.IsError, "PTY run should succeed: %s", result.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + + // Poll should show running + pollResult := tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": resp.SessionID, + }) + require.False(t, pollResult.IsError, "poll should succeed: %s", pollResult.ForLLM) + + var pollResp ExecResponse + err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp) + require.NoError(t, err) + require.Equal(t, "running", pollResp.Status) + + // Wait for sleep to complete + time.Sleep(2500 * time.Millisecond) + + // Poll should show done + pollResult = tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": resp.SessionID, + }) + require.False(t, pollResult.IsError) + + err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp) + require.NoError(t, err) + require.Equal(t, "done", pollResp.Status) +} + +func TestShellTool_PTY_Kill(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a PTY session with a long-running command + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sleep 10", + "pty": "true", + "background": "true", + }) + require.False(t, result.IsError, "PTY run should succeed: %s", result.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + + // Kill the session + killResult := tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) + require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM) + + // Verify kill response shows done status + var killResp ExecResponse + err = json.Unmarshal([]byte(killResult.ForLLM), &killResp) + require.NoError(t, err) + require.Equal(t, "done", killResp.Status) + + // Poll should return error since session is removed after kill + pollResult := tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": resp.SessionID, + }) + // Session is removed after kill, so poll returns error with "session not found" + require.True(t, pollResult.IsError, "poll should error after kill (session removed)") + require.Contains(t, pollResult.ForLLM, "session not found") +} + +func TestShellTool_Write_Read_NonPTY(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a background process that reads from stdin and outputs it + // Using 'cat' which echoes stdin to stdout + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "cat", + "pty": false, + "background": "true", + }) + require.False(t, result.IsError, "run should succeed: %s", result.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + + // Write some input to cat + writeResult := tool.Execute(ctx, map[string]any{ + "action": "write", + "sessionId": resp.SessionID, + "data": "hello world\n", + }) + require.False(t, writeResult.IsError, "write should succeed: %s", writeResult.ForLLM) + + // Give cat time to process and output + time.Sleep(200 * time.Millisecond) + + // Read the output + readResult := tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": resp.SessionID, + }) + require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM) + + var readResp ExecResponse + err = json.Unmarshal([]byte(readResult.ForLLM), &readResp) + require.NoError(t, err) + require.Contains(t, readResp.Output, "hello world") + + // Clean up + tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) +} + +func TestShellTool_Read_NonPTY_Running(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a long-running process that produces output over time + // Using sh -c with sleep at the end so process doesn't exit immediately + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sh -c 'echo line1; sleep 0.5; echo line2; sleep 0.5; echo line3; sleep 10'", + "pty": false, + "background": "true", + }) + require.False(t, result.IsError, "run should succeed: %s", result.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + + // Give time for first outputs to be produced + time.Sleep(300 * time.Millisecond) + + // Read output while process is running + readResult := tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": resp.SessionID, + }) + require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM) + + var readResp ExecResponse + err = json.Unmarshal([]byte(readResult.ForLLM), &readResp) + require.NoError(t, err) + // Should have at least line1 + require.Contains(t, readResp.Output, "line1") + + // Wait for line3 to be produced (line1=0s, line2=0.5s, line3=1s, then sleep 10) + time.Sleep(1200 * time.Millisecond) + + // Read again - should have line3 as well + readResult = tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": resp.SessionID, + }) + require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM) + + err = json.Unmarshal([]byte(readResult.ForLLM), &readResp) + require.NoError(t, err) + require.Contains(t, readResp.Output, "line3") + + // Clean up + tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) +} + +func TestShellTool_ProcessGroupKill(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Process group kill not supported on Windows") + } + + // Note: Testing process group kill with PTY is tricky because the command + // must be run through an interpreter (sh, bash) which is blocked for PTY. + // Instead, we test with non-PTY mode which also uses Setsid for background processes. + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a shell that spawns child processes (non-PTY mode) + // The sh -c command creates child sleep processes + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sh -c 'sleep 30 & sleep 30 & wait'", + "pty": false, + "background": "true", + }) + require.False(t, result.IsError, "run should succeed: %s", result.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + + // Give time for child processes to spawn + time.Sleep(500 * time.Millisecond) + + // Kill the session - should kill the entire process group + killResult := tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) + require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM) + + // Verify kill response shows done status + var killResp ExecResponse + err = json.Unmarshal([]byte(killResult.ForLLM), &killResp) + require.NoError(t, err) + require.Equal(t, "done", killResp.Status) + + // Poll should return error since session is removed after kill + pollResult := tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": resp.SessionID, + }) + require.True(t, pollResult.IsError, "poll should error after kill (session removed)") + require.Contains(t, pollResult.ForLLM, "session not found") +} + +func TestShellTool_PTY_ProcessGroupKill(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY process group kill not supported on Windows") + } + + // This test binary creates 4 child sleep processes and waits for signals. + // It's not an interpreter, so it's allowed with PTY mode. + // The binary is created in /tmp/test_pgroup.c and compiled as part of test setup. + testBinary := "/tmp/test_pgroup" + if _, err := os.Stat(testBinary); os.IsNotExist(err) { + t.Skip("Test binary /tmp/test_pgroup not found - run: gcc -o /tmp/test_pgroup /tmp/test_pgroup.c") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start the test binary with PTY mode + // It forks 4 child sleep processes and waits for signals + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": testBinary, + "pty": "true", + "background": "true", + }) + require.False(t, result.IsError, "run should succeed: %s", result.ForLLM) + + var resp ExecResponse + err = json.Unmarshal([]byte(result.ForLLM), &resp) + require.NoError(t, err) + + // Give time for child processes to spawn + time.Sleep(500 * time.Millisecond) + + // Kill the session - should kill the entire process group + killResult := tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": resp.SessionID, + }) + require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM) + + // Verify kill response shows done status + var killResp ExecResponse + err = json.Unmarshal([]byte(killResult.ForLLM), &killResp) + require.NoError(t, err) + require.Equal(t, "done", killResp.Status) + + // Poll should return error since session is removed after kill + pollResult := tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": resp.SessionID, + }) + require.True(t, pollResult.IsError, "poll should error after kill (session removed)") + require.Contains(t, pollResult.ForLLM, "session not found") +} + +func TestShellTool_PTY_Background_Read(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a fast command with PTY + background mode + runResult := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "echo hello", + "pty": "true", + "background": "true", + }) + require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForLLM) + + var runResp ExecResponse + err = json.Unmarshal([]byte(runResult.ForLLM), &runResp) + require.NoError(t, err) + require.NotEmpty(t, runResp.SessionID) + require.Equal(t, "running", runResp.Status) + + // Wait for command to complete + time.Sleep(500 * time.Millisecond) + + // Read output - this is the key test: PTY + background mode should preserve output + readResult := tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": runResp.SessionID, + }) + require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM) + require.Contains(t, readResult.ForLLM, "hello", "output should contain 'hello'") +} + +func TestShellTool_PTY_Background_ReadNoBlock(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PTY not supported on Windows") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + // Start a long-running command with PTY + background mode + // This command produces no output, just sleeps + runResult := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sleep 10", + "pty": "true", + "background": "true", + }) + require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForLLM) + + var runResp ExecResponse + err = json.Unmarshal([]byte(runResult.ForLLM), &runResp) + require.NoError(t, err) + require.NotEmpty(t, runResp.SessionID) + + // Read immediately - should NOT block even though process is running and has no output + // This tests that Read() returns quickly (within 1 second) instead of blocking for 10 seconds + start := time.Now() + readResult := tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": runResp.SessionID, + }) + elapsed := time.Since(start) + + require.False(t, readResult.IsError, "read should succeed: %s", readResult.ForLLM) + require.Less(t, elapsed.Seconds(), 1.0, "read should not block, should return within 1 second") + + // Kill the session to clean up + killResult := tool.Execute(ctx, map[string]any{ + "action": "kill", + "sessionId": runResp.SessionID, + }) + require.False(t, killResult.IsError, "kill should succeed: %s", killResult.ForLLM) +} + +func TestShellTool_Poll_Status(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + sm := NewSessionManager() + tool.sessionManager = sm + + ctx := WithToolContext(context.Background(), "cli", "test") + + runResult := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "sleep 1", + "background": "true", + }) + require.False(t, runResult.IsError) + + var resp ExecResponse + err = json.Unmarshal([]byte(runResult.ForLLM), &resp) + require.NoError(t, err) + + pollResult := tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": resp.SessionID, + }) + require.False(t, pollResult.IsError) + + var pollResp ExecResponse + err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp) + require.NoError(t, err) + require.Equal(t, "running", pollResp.Status) + + time.Sleep(1200 * time.Millisecond) + + pollResult = tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": resp.SessionID, + }) + require.False(t, pollResult.IsError) + + err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp) + require.NoError(t, err) + require.Equal(t, "done", pollResp.Status) +} + +func TestShellTool_Action_Run_Sync(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + ctx := context.Background() + + result := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "echo hello", + }) + + require.False(t, result.IsError) + require.Contains(t, result.ForLLM, "hello") +} + +// TestShellTool_Background_ReadAfterExit verifies that we can read +// buffered output even after the background process has exited. +func TestShellTool_Background_ReadAfterExit(t *testing.T) { + tool, err := NewExecTool("", false) + require.NoError(t, err) + + ctx := context.Background() + + // Start a background command that produces output and exits quickly + runResult := tool.Execute(ctx, map[string]any{ + "action": "run", + "command": "echo hello && sleep 1 && echo world", + "background": "true", + }) + require.False(t, runResult.IsError, "run should succeed: %s", runResult.ForUser) + + // Parse session ID from response + var resp ExecResponse + err = json.Unmarshal([]byte(runResult.ForLLM), &resp) + require.NoError(t, err) + require.NotEmpty(t, resp.SessionID) + sessionID := resp.SessionID + + // Wait for process to exit (sleep 1 + some buffer) + time.Sleep(1500 * time.Millisecond) + + // Poll to verify process is done + pollResult := tool.Execute(ctx, map[string]any{ + "action": "poll", + "sessionId": sessionID, + }) + require.False(t, pollResult.IsError, "poll should succeed: %s", pollResult.ForLLM) + var pollResp ExecResponse + err = json.Unmarshal([]byte(pollResult.ForLLM), &pollResp) + require.NoError(t, err) + require.Equal(t, "done", pollResp.Status, "process should be done") + + // Try to read output AFTER process has exited + readResult := tool.Execute(ctx, map[string]any{ + "action": "read", + "sessionId": sessionID, + }) + require.False(t, readResult.IsError, "read should succeed after exit: %s", readResult.ForLLM) + + var readResp ExecResponse + err = json.Unmarshal([]byte(readResult.ForLLM), &readResp) + require.NoError(t, err) + + // Output should contain both "hello" and "world" + require.Contains(t, readResp.Output, "hello", "should contain hello") + require.Contains(t, readResp.Output, "world", "should contain world after sleep") +} + +func TestSendKeys_CtrlC(t *testing.T) { + // Note: Ctrl-C as a signal requires sending SIGINT to the process group, + // which requires elevated privileges. Writing "\x03" to PTY passes the byte + // to the process but doesn't generate SIGINT for processes that don't read stdin. + // For interrupting processes, use the kill action instead. + t.Skip("Ctrl-C as signal not supported - use kill action for interruption") +} + +func TestEncodeKeyToken(t *testing.T) { + tests := []struct { + token string + expected string + hasError bool + }{ + // Named keys + {"enter", "\r", false}, + {"return", "\r", false}, + {"tab", "\t", false}, + {"escape", "\x1b", false}, + {"esc", "\x1b", false}, + {"backspace", "\x7f", false}, + {"up", "\x1b[A", false}, + {"down", "\x1b[B", false}, + {"left", "\x1b[D", false}, + {"right", "\x1b[C", false}, + {"home", "\x1b[1~", false}, + {"end", "\x1b[4~", false}, + {"pageup", "\x1b[5~", false}, + {"pagedown", "\x1b[6~", false}, + {"delete", "\x1b[3~", false}, + {"f1", "\x1bOP", false}, + {"f12", "\x1b[24~", false}, + + // Ctrl keys + {"ctrl-c", "\x03", false}, + {"ctrl-d", "\x04", false}, + {"ctrl-a", "\x01", false}, + {"ctrl-z", "\x1a", false}, + {"c-c", "\x03", false}, + {"c-d", "\x04", false}, + + // Alt keys + {"alt-x", "\x1bx", false}, + {"m-x", "\x1bx", false}, + + // Case insensitive tests + {"ENTER", "\r", false}, + {"TAB", "\t", false}, + {"CTRL-C", "\x03", false}, + {"Ctrl-D", "\x04", false}, + {"ALT-X", "\x1bx", false}, + {"M-X", "\x1bx", false}, + {"UP", "\x1b[A", false}, + {"DOWN", "\x1b[B", false}, + + // Unknown keys should return error (use write action for text input) + {"unknown-key", "", true}, + } + + for _, tt := range tests { + t.Run(tt.token, func(t *testing.T) { + result, err := encodeKeyToken(tt.token, PtyKeyModeCSI) + if tt.hasError { + require.Error(t, err, "expected error for %s", tt.token) + } else { + require.NoError(t, err, "unexpected error for %s", tt.token) + require.Equal(t, tt.expected, result, "wrong encoding for %s", tt.token) + } + }) + } +} + +// TestDetectPtyKeyMode tests smkx/rmkx detection in PTY output +func TestDetectPtyKeyMode(t *testing.T) { + tests := []struct { + name string + raw string + expected PtyKeyMode + }{ + {"no toggle", "hello world", PtyKeyModeNotFound}, + {"smkx only", "\x1b[?1h\x1b=", PtyKeyModeSS3}, + {"rmkx only", "\x1b[?1l\x1b>", PtyKeyModeCSI}, + {"both smkx first", "\x1b[?1h\x1b=...\x1b[?1l\x1b>", PtyKeyModeCSI}, + {"both rmkx first", "\x1b[?1l\x1b>...\x1b[?1h\x1b=", PtyKeyModeSS3}, + {"multiple toggles smkx last", "\x1b[?1h\x1b=...\x1b[?1l\x1b>...\x1b[?1h\x1b=", PtyKeyModeSS3}, + {"multiple toggles rmkx last", "\x1b[?1l\x1b>...\x1b[?1h\x1b=...\x1b[?1l\x1b>", PtyKeyModeCSI}, + {"partial smkx", "\x1b[?1h", PtyKeyModeSS3}, + {"partial rmkx", "\x1b[?1l", PtyKeyModeCSI}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := detectPtyKeyMode(tt.raw) + require.Equal(t, tt.expected, result, "wrong mode for %s", tt.name) + }) + } +} + +func TestEncodeKeyTokenWithPtyKeyMode(t *testing.T) { + tests := []struct { + name string + token string + mode PtyKeyMode + expected string + hasError bool + }{ + // CSI mode + {"up csi", "up", PtyKeyModeCSI, "\x1b[A", false}, + {"down csi", "down", PtyKeyModeCSI, "\x1b[B", false}, + {"left csi", "left", PtyKeyModeCSI, "\x1b[D", false}, + {"right csi", "right", PtyKeyModeCSI, "\x1b[C", false}, + + // SS3 mode + {"up ss3", "up", PtyKeyModeSS3, "\x1bOA", false}, + {"down ss3", "down", PtyKeyModeSS3, "\x1bOB", false}, + {"left ss3", "left", PtyKeyModeSS3, "\x1bOD", false}, + {"right ss3", "right", PtyKeyModeSS3, "\x1bOC", false}, + {"home ss3", "home", PtyKeyModeSS3, "\x1bOH", false}, + {"end ss3", "end", PtyKeyModeSS3, "\x1bOF", false}, + + // Other keys unaffected by mode + {"enter ss3", "enter", PtyKeyModeSS3, "\r", false}, + {"tab ss3", "tab", PtyKeyModeSS3, "\t", false}, + {"ctrl-c ss3", "ctrl-c", PtyKeyModeSS3, "\x03", false}, + + // NotFound behaves like CSI + {"up notfound", "up", PtyKeyModeNotFound, "\x1b[A", false}, + {"down notfound", "down", PtyKeyModeNotFound, "\x1b[B", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := encodeKeyToken(tt.token, tt.mode) + if tt.hasError { + require.Error(t, err, "expected error for %s", tt.name) + } else { + require.NoError(t, err, "unexpected error for %s", tt.name) + require.Equal(t, tt.expected, result, "wrong encoding for %s", tt.name) + } + }) + } +} diff --git a/pkg/tools/shell_timeout_unix_test.go b/pkg/tools/shell_timeout_unix_test.go index 357e1276e..dfd28454c 100644 --- a/pkg/tools/shell_timeout_unix_test.go +++ b/pkg/tools/shell_timeout_unix_test.go @@ -30,6 +30,7 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) { tool.SetTimeout(500 * time.Millisecond) args := map[string]any{ + "action": "run", // Spawn a child process that would outlive the shell unless process-group kill is used. "command": "sleep 60 & echo $! > child.pid; wait", } diff --git a/pkg/tools/skills_install_test.go b/pkg/tools/skills_install_test.go deleted file mode 100644 index 676fcecc0..000000000 --- a/pkg/tools/skills_install_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package tools - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/sipeed/picoclaw/pkg/skills" -) - -func TestInstallSkillToolName(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) - assert.Equal(t, "install_skill", tool.Name()) -} - -func TestInstallSkillToolMissingSlug(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) - result := tool.Execute(context.Background(), map[string]any{}) - assert.True(t, result.IsError) - assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string") -} - -func TestInstallSkillToolEmptySlug(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) - result := tool.Execute(context.Background(), map[string]any{ - "slug": " ", - }) - assert.True(t, result.IsError) - assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string") -} - -func TestInstallSkillToolUnsafeSlug(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) - - cases := []string{ - "../etc/passwd", - "path/traversal", - "path\\traversal", - } - - for _, slug := range cases { - result := tool.Execute(context.Background(), map[string]any{ - "slug": slug, - }) - assert.True(t, result.IsError, "slug %q should be rejected", slug) - assert.Contains(t, result.ForLLM, "invalid slug") - } -} - -func TestInstallSkillToolAlreadyExists(t *testing.T) { - workspace := t.TempDir() - skillDir := filepath.Join(workspace, "skills", "existing-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - - tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace) - result := tool.Execute(context.Background(), map[string]any{ - "slug": "existing-skill", - "registry": "clawhub", - }) - assert.True(t, result.IsError) - assert.Contains(t, result.ForLLM, "already installed") -} - -func TestInstallSkillToolRegistryNotFound(t *testing.T) { - workspace := t.TempDir() - tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace) - result := tool.Execute(context.Background(), map[string]any{ - "slug": "some-skill", - "registry": "nonexistent", - }) - assert.True(t, result.IsError) - assert.Contains(t, result.ForLLM, "registry") - assert.Contains(t, result.ForLLM, "not found") -} - -func TestInstallSkillToolParameters(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) - params := tool.Parameters() - - props, ok := params["properties"].(map[string]any) - assert.True(t, ok) - assert.Contains(t, props, "slug") - assert.Contains(t, props, "version") - assert.Contains(t, props, "registry") - assert.Contains(t, props, "force") - - required, ok := params["required"].([]string) - assert.True(t, ok) - assert.Contains(t, required, "slug") - assert.Contains(t, required, "registry") -} - -func TestInstallSkillToolMissingRegistry(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) - result := tool.Execute(context.Background(), map[string]any{ - "slug": "some-skill", - }) - assert.True(t, result.IsError) - assert.Contains(t, result.ForLLM, "invalid registry") -} diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go index be40ffda2..d019d511a 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -7,7 +7,10 @@ import ( ) type SpawnTool struct { - manager *SubagentManager + spawner SubTurnSpawner + defaultModel string + maxTokens int + temperature float64 allowlistCheck func(targetAgentID string) bool } @@ -15,9 +18,19 @@ type SpawnTool struct { var _ AsyncExecutor = (*SpawnTool)(nil) func NewSpawnTool(manager *SubagentManager) *SpawnTool { - return &SpawnTool{ - manager: manager, + if manager == nil { + return &SpawnTool{} } + return &SpawnTool{ + defaultModel: manager.defaultModel, + maxTokens: manager.maxTokens, + temperature: manager.temperature, + } +} + +// SetSpawner sets the SubTurnSpawner for direct sub-turn execution. +func (t *SpawnTool) SetSpawner(spawner SubTurnSpawner) { + t.spawner = spawner } func (t *SpawnTool) Name() string { @@ -59,11 +72,19 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul // ExecuteAsync implements AsyncExecutor. The callback is passed through to the // subagent manager as a call parameter — never stored on the SpawnTool instance. -func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult { +func (t *SpawnTool) ExecuteAsync( + ctx context.Context, + args map[string]any, + cb AsyncCallback, +) *ToolResult { return t.execute(ctx, args, cb) } -func (t *SpawnTool) execute(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult { +func (t *SpawnTool) execute( + ctx context.Context, + args map[string]any, + cb AsyncCallback, +) *ToolResult { task, ok := args["task"].(string) if !ok || strings.TrimSpace(task) == "" { return ErrorResult("task is required and must be a non-empty string") @@ -79,28 +100,53 @@ func (t *SpawnTool) execute(ctx context.Context, args map[string]any, cb AsyncCa } } - if t.manager == nil { - return ErrorResult("Subagent manager not configured") + // Build system prompt for spawned subagent + systemPrompt := fmt.Sprintf( + `You are a spawned subagent running in the background. Complete the given task independently and report back when done. + +Task: %s`, + task, + ) + + if label != "" { + systemPrompt = fmt.Sprintf( + `You are a spawned subagent labeled "%s" running in the background. Complete the given task independently and report back when done. + +Task: %s`, + label, + task, + ) } - // Read channel/chatID from context (injected by registry). - // Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests) - // to preserve the same defaults as the original NewSpawnTool constructor. - channel := ToolChannel(ctx) - if channel == "" { - channel = "cli" - } - chatID := ToolChatID(ctx) - if chatID == "" { - chatID = "direct" + // Use spawner if available (direct SpawnSubTurn call) + if t.spawner != nil { + // Launch async sub-turn in goroutine + go func() { + result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{ + Model: t.defaultModel, + Tools: nil, // Will inherit from parent via context + SystemPrompt: systemPrompt, + MaxTokens: t.maxTokens, + Temperature: t.temperature, + Async: true, // Async execution + }) + if err != nil { + result = ErrorResult(fmt.Sprintf("Spawn failed: %v", err)).WithError(err) + } + + // Call callback if provided + if cb != nil { + cb(ctx, result) + } + }() + + // Return immediate acknowledgment + if label != "" { + return AsyncResult(fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task)) + } + return AsyncResult(fmt.Sprintf("Spawned subagent for task: %s", task)) } - // Pass callback to manager for async completion notification - result, err := t.manager.Spawn(ctx, task, label, agentID, channel, chatID, cb) - if err != nil { - return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err)) - } - - // Return AsyncResult since the task runs in background - return AsyncResult(result) + // Fallback: spawner not configured + return ErrorResult("Subagent manager not configured") } diff --git a/pkg/tools/spawn_status.go b/pkg/tools/spawn_status.go new file mode 100644 index 000000000..416fd2226 --- /dev/null +++ b/pkg/tools/spawn_status.go @@ -0,0 +1,178 @@ +package tools + +import ( + "context" + "fmt" + "sort" + "strings" + "time" +) + +// SpawnStatusTool reports the status of subagents that were spawned via the +// spawn tool. It can query a specific task by ID, or list every known task with +// a summary count broken-down by status. +type SpawnStatusTool struct { + manager *SubagentManager +} + +// NewSpawnStatusTool creates a SpawnStatusTool backed by the given manager. +func NewSpawnStatusTool(manager *SubagentManager) *SpawnStatusTool { + return &SpawnStatusTool{manager: manager} +} + +func (t *SpawnStatusTool) Name() string { + return "spawn_status" +} + +func (t *SpawnStatusTool) Description() string { + return "Get the status of spawned subagents. " + + "Returns a list of all subagents and their current state " + + "(running, completed, failed, or canceled), or retrieves details " + + "for a specific subagent task when task_id is provided. " + + "Results are scoped to the current conversation's channel and chat ID; " + + "all tasks are listed only when no channel/chat context is injected " + + "(e.g. direct programmatic calls via Execute)." +} + +func (t *SpawnStatusTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "task_id": map[string]any{ + "type": "string", + "description": "Optional task ID (e.g. \"subagent-1\") to inspect a specific " + + "subagent. When omitted, all visible subagents are listed.", + }, + }, + "required": []string{}, + } +} + +func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + if t.manager == nil { + return ErrorResult("Subagent manager not configured") + } + + // Derive the calling conversation's identity so we can scope results to the + // current chat only — preventing cross-conversation task leakage in + // multi-user deployments. + callerChannel := ToolChannel(ctx) + callerChatID := ToolChatID(ctx) + + var taskID string + if rawTaskID, ok := args["task_id"]; ok && rawTaskID != nil { + taskIDStr, ok := rawTaskID.(string) + if !ok { + return ErrorResult("task_id must be a string") + } + taskID = strings.TrimSpace(taskIDStr) + } + + if taskID != "" { + // GetTaskCopy returns a consistent snapshot under the manager lock, + // eliminating any data race with the concurrent subagent goroutine. + taskCopy, ok := t.manager.GetTaskCopy(taskID) + if !ok { + return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) + } + + // Restrict lookup to tasks that belong to this conversation. + if callerChannel != "" && taskCopy.OriginChannel != "" && taskCopy.OriginChannel != callerChannel { + return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) + } + if callerChatID != "" && taskCopy.OriginChatID != "" && taskCopy.OriginChatID != callerChatID { + return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) + } + + return NewToolResult(spawnStatusFormatTask(&taskCopy)) + } + + // ListTaskCopies returns consistent snapshots under the manager lock. + origTasks := t.manager.ListTaskCopies() + if len(origTasks) == 0 { + return NewToolResult("No subagents have been spawned yet.") + } + + tasks := make([]*SubagentTask, 0, len(origTasks)) + for i := range origTasks { + cpy := &origTasks[i] + + // Filter to tasks that originate from the current conversation only. + if callerChannel != "" && cpy.OriginChannel != "" && cpy.OriginChannel != callerChannel { + continue + } + if callerChatID != "" && cpy.OriginChatID != "" && cpy.OriginChatID != callerChatID { + continue + } + + tasks = append(tasks, cpy) + } + + if len(tasks) == 0 { + return NewToolResult("No subagents found for this conversation.") + } + + // Order by creation time (ascending) so spawning order is preserved. + // Fall back to ID string for tasks created in the same millisecond. + sort.Slice(tasks, func(i, j int) bool { + if tasks[i].Created != tasks[j].Created { + return tasks[i].Created < tasks[j].Created + } + return tasks[i].ID < tasks[j].ID + }) + + counts := map[string]int{} + for _, task := range tasks { + counts[task.Status]++ + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Subagent status report (%d total):\n", len(tasks))) + for _, status := range []string{"running", "completed", "failed", "canceled"} { + if n := counts[status]; n > 0 { + label := strings.ToUpper(status[:1]) + status[1:] + ":" + sb.WriteString(fmt.Sprintf(" %-10s %d\n", label, n)) + } + } + sb.WriteString("\n") + + for _, task := range tasks { + sb.WriteString(spawnStatusFormatTask(task)) + sb.WriteString("\n\n") + } + + return NewToolResult(strings.TrimRight(sb.String(), "\n")) +} + +// spawnStatusFormatTask renders a single SubagentTask as a human-readable block. +func spawnStatusFormatTask(task *SubagentTask) string { + var sb strings.Builder + + header := fmt.Sprintf("[%s] status=%s", task.ID, task.Status) + if task.Label != "" { + header += fmt.Sprintf(" label=%q", task.Label) + } + if task.AgentID != "" { + header += fmt.Sprintf(" agent=%s", task.AgentID) + } + if task.Created > 0 { + created := time.UnixMilli(task.Created).UTC().Format("2006-01-02 15:04:05 UTC") + header += fmt.Sprintf(" created=%s", created) + } + sb.WriteString(header) + + if task.Task != "" { + sb.WriteString(fmt.Sprintf("\n task: %s", task.Task)) + } + if task.Result != "" { + result := task.Result + const maxResultLen = 300 + runes := []rune(result) + if len(runes) > maxResultLen { + result = string(runes[:maxResultLen]) + "…" + } + sb.WriteString(fmt.Sprintf("\n result: %s", result)) + } + + return sb.String() +} diff --git a/pkg/tools/spawn_status_test.go b/pkg/tools/spawn_status_test.go new file mode 100644 index 000000000..9c772d61a --- /dev/null +++ b/pkg/tools/spawn_status_test.go @@ -0,0 +1,406 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "testing" + "time" +) + +func TestSpawnStatusTool_Name(t *testing.T) { + provider := &MockLLMProvider{} + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace) + tool := NewSpawnStatusTool(manager) + + if tool.Name() != "spawn_status" { + t.Errorf("Expected name 'spawn_status', got '%s'", tool.Name()) + } +} + +func TestSpawnStatusTool_Description(t *testing.T) { + provider := &MockLLMProvider{} + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace) + tool := NewSpawnStatusTool(manager) + + desc := tool.Description() + if desc == "" { + t.Error("Description should not be empty") + } + if !strings.Contains(strings.ToLower(desc), "subagent") { + t.Errorf("Description should mention 'subagent', got: %s", desc) + } +} + +func TestSpawnStatusTool_Parameters(t *testing.T) { + provider := &MockLLMProvider{} + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace) + tool := NewSpawnStatusTool(manager) + + params := tool.Parameters() + if params["type"] != "object" { + t.Errorf("Expected type 'object', got: %v", params["type"]) + } + props, ok := params["properties"].(map[string]any) + if !ok { + t.Fatal("Expected 'properties' to be a map") + } + if _, hasTaskID := props["task_id"]; !hasTaskID { + t.Error("Expected 'task_id' parameter in properties") + } +} + +func TestSpawnStatusTool_NilManager(t *testing.T) { + tool := &SpawnStatusTool{manager: nil} + result := tool.Execute(context.Background(), map[string]any{}) + if !result.IsError { + t.Error("Expected error result when manager is nil") + } +} + +func TestSpawnStatusTool_Empty(t *testing.T) { + provider := &MockLLMProvider{} + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace) + tool := NewSpawnStatusTool(manager) + + result := tool.Execute(context.Background(), map[string]any{}) + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "No subagents") { + t.Errorf("Expected 'No subagents' message, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ListAll(t *testing.T) { + provider := &MockLLMProvider{} + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace) + + now := time.Now().UnixMilli() + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", + Task: "Do task A", + Label: "task-a", + Status: "running", + Created: now, + } + manager.tasks["subagent-2"] = &SubagentTask{ + ID: "subagent-2", + Task: "Do task B", + Label: "task-b", + Status: "completed", + Result: "Done successfully", + Created: now, + } + manager.tasks["subagent-3"] = &SubagentTask{ + ID: "subagent-3", + Task: "Do task C", + Status: "failed", + Result: "Error: something went wrong", + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{}) + + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + + // Summary header + if !strings.Contains(result.ForLLM, "3 total") { + t.Errorf("Expected total count in header, got: %s", result.ForLLM) + } + + // Individual task IDs + for _, id := range []string{"subagent-1", "subagent-2", "subagent-3"} { + if !strings.Contains(result.ForLLM, id) { + t.Errorf("Expected task %s in output, got:\n%s", id, result.ForLLM) + } + } + + // Status values + for _, status := range []string{"running", "completed", "failed"} { + if !strings.Contains(result.ForLLM, status) { + t.Errorf("Expected status '%s' in output, got:\n%s", status, result.ForLLM) + } + } + + // Result content + if !strings.Contains(result.ForLLM, "Done successfully") { + t.Errorf("Expected result text in output, got:\n%s", result.ForLLM) + } +} + +func TestSpawnStatusTool_GetByID(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + manager.mu.Lock() + manager.tasks["subagent-42"] = &SubagentTask{ + ID: "subagent-42", + Task: "Specific task", + Label: "my-task", + Status: "failed", + Result: "Something went wrong", + Created: time.Now().UnixMilli(), + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-42"}) + + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "subagent-42") { + t.Errorf("Expected task ID in output, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "failed") { + t.Errorf("Expected status 'failed' in output, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Something went wrong") { + t.Errorf("Expected result text in output, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "my-task") { + t.Errorf("Expected label in output, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_GetByID_NotFound(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnStatusTool(manager) + + result := tool.Execute(context.Background(), map[string]any{"task_id": "nonexistent-999"}) + if !result.IsError { + t.Errorf("Expected error for nonexistent task, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "nonexistent-999") { + t.Errorf("Expected task ID in error message, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_TaskID_NonString(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnStatusTool(manager) + + for _, badVal := range []any{42, 3.14, true, map[string]any{"x": 1}, []string{"a"}} { + result := tool.Execute(context.Background(), map[string]any{"task_id": badVal}) + if !result.IsError { + t.Errorf("Expected error for task_id=%T(%v), got success: %s", badVal, badVal, result.ForLLM) + } + if !strings.Contains(result.ForLLM, "task_id must be a string") { + t.Errorf("Expected type-error message, got: %s", result.ForLLM) + } + } +} + +func TestSpawnStatusTool_ResultTruncation(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + longResult := strings.Repeat("X", 500) + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", + Task: "Long task", + Status: "completed", + Result: longResult, + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-1"}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + // Output should be shorter than the raw result due to truncation + if len(result.ForLLM) >= len(longResult) { + t.Errorf("Expected result to be truncated, but ForLLM is %d chars", len(result.ForLLM)) + } + if !strings.Contains(result.ForLLM, "…") { + t.Errorf("Expected truncation indicator '…' in output, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ResultTruncation_Unicode(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + // Each CJK rune is 3 bytes; 400 runes = 1200 bytes — well over the 300-rune limit. + cjkChar := string(rune(0x5b57)) + longResult := strings.Repeat(cjkChar, 400) + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", + Task: "Unicode task", + Status: "completed", + Result: longResult, + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-1"}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "…") { + t.Errorf("Expected truncation indicator in output") + } + // The truncated result must be valid UTF-8 (no split rune boundaries). + if !strings.Contains(result.ForLLM, cjkChar) { + t.Errorf("Expected CJK runes to appear intact in output") + } +} + +func TestSpawnStatusTool_StatusCounts(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + manager.mu.Lock() + for i, status := range []string{"running", "running", "completed", "failed", "canceled"} { + id := fmt.Sprintf("subagent-%d", i+1) + manager.tasks[id] = &SubagentTask{ID: id, Task: "t", Status: status} + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + // The summary line should mention all statuses that have counts + for _, want := range []string{"Running:", "Completed:", "Failed:", "Canceled:"} { + if !strings.Contains(result.ForLLM, want) { + t.Errorf("Expected %q in summary, got:\n%s", want, result.ForLLM) + } + } +} + +func TestSpawnStatusTool_SortByCreatedTimestamp(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + now := time.Now().UnixMilli() + manager.mu.Lock() + // Intentionally insert with out-of-order IDs and timestamps that reflect + // true spawn order: subagent-2 was spawned first, subagent-10 second. + manager.tasks["subagent-10"] = &SubagentTask{ + ID: "subagent-10", Task: "second", Status: "running", + Created: now + 1, + } + manager.tasks["subagent-2"] = &SubagentTask{ + ID: "subagent-2", Task: "first", Status: "running", + Created: now, + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + + pos2 := strings.Index(result.ForLLM, "subagent-2") + pos10 := strings.Index(result.ForLLM, "subagent-10") + if pos2 < 0 || pos10 < 0 { + t.Fatalf("Both task IDs should appear in output:\n%s", result.ForLLM) + } + if pos2 > pos10 { + t.Errorf("Expected subagent-2 (created first) to appear before subagent-10, but got:\n%s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ChannelFiltering_ListAll(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", Task: "mine", Status: "running", + OriginChannel: "telegram", OriginChatID: "chat-A", + } + manager.tasks["subagent-2"] = &SubagentTask{ + ID: "subagent-2", Task: "other user", Status: "running", + OriginChannel: "telegram", OriginChatID: "chat-B", + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + + // Caller is chat-A — should only see subagent-1. + ctx := WithToolContext(context.Background(), "telegram", "chat-A") + result := tool.Execute(ctx, map[string]any{}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "subagent-1") { + t.Errorf("Expected own task in output, got:\n%s", result.ForLLM) + } + if strings.Contains(result.ForLLM, "subagent-2") { + t.Errorf("Should NOT see other chat's task, got:\n%s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ChannelFiltering_GetByID(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + manager.mu.Lock() + manager.tasks["subagent-99"] = &SubagentTask{ + ID: "subagent-99", Task: "secret", Status: "completed", Result: "private data", + OriginChannel: "slack", OriginChatID: "room-Z", + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + + // Different chat trying to look up subagent-99 by ID. + ctx := WithToolContext(context.Background(), "slack", "room-OTHER") + result := tool.Execute(ctx, map[string]any{"task_id": "subagent-99"}) + + if !result.IsError { + t.Errorf("Expected error (cross-chat lookup blocked), got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ChannelFiltering_NoContext(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", Task: "t", Status: "completed", + OriginChannel: "telegram", OriginChatID: "chat-A", + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + + // No ToolContext injected (e.g. a direct programmatic call that bypasses + // WithToolContext entirely) — callerChannel and callerChatID are both "". + // Note: the normal CLI path uses ProcessDirectWithChannel("cli", "direct"), + // which *does* inject a non-empty context; this test covers the case where + // no context injection happens at all. + // The filter conditions require a non-empty caller value, so all tasks pass through. + result := tool.Execute(context.Background(), map[string]any{}) + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "subagent-1") { + t.Errorf("Expected task visible from no-context caller, got:\n%s", result.ForLLM) + } +} diff --git a/pkg/tools/spawn_test.go b/pkg/tools/spawn_test.go index 43223b8db..fda6bbd89 100644 --- a/pkg/tools/spawn_test.go +++ b/pkg/tools/spawn_test.go @@ -6,6 +6,24 @@ import ( "testing" ) +// mockSpawner implements SubTurnSpawner for testing +type mockSpawner struct{} + +func (m *mockSpawner) SpawnSubTurn(ctx context.Context, cfg SubTurnConfig) (*ToolResult, error) { + // Extract task from system prompt for response + task := cfg.SystemPrompt + if strings.Contains(task, "Task: ") { + parts := strings.Split(task, "Task: ") + if len(parts) > 1 { + task = parts[1] + } + } + return &ToolResult{ + ForLLM: "Task completed: " + task, + ForUser: "Task completed", + }, nil +} + func TestSpawnTool_Execute_EmptyTask(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") @@ -44,6 +62,7 @@ func TestSpawnTool_Execute_ValidTask(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSpawnTool(manager) + tool.SetSpawner(&mockSpawner{}) ctx := context.Background() args := map[string]any{ diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index e51cbaafa..feeabe536 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -4,11 +4,35 @@ import ( "context" "fmt" "sync" + "sync/atomic" "time" "github.com/sipeed/picoclaw/pkg/providers" ) +// SubTurnSpawner is an interface for spawning sub-turns. +// This avoids circular dependency between tools and agent packages. +type SubTurnSpawner interface { + SpawnSubTurn(ctx context.Context, cfg SubTurnConfig) (*ToolResult, error) +} + +// SubTurnConfig holds configuration for spawning a sub-turn. +type SubTurnConfig struct { + Model string + Tools []Tool + SystemPrompt string + MaxTokens int + Temperature float64 + Async bool // true for async (spawn), false for sync (subagent) + Critical bool // continue running after parent finishes gracefully + Timeout time.Duration // 0 = use default (5 minutes) + MaxContextRunes int // 0 = auto, -1 = no limit, >0 = explicit limit + ActualSystemPrompt string + InitialMessages []providers.Message + InitialTokenBudget *atomic.Int64 // Shared token budget for team members; nil if no budget + TargetAgentID string // If set, run as this agent (its workspace, model, tools) +} + type SubagentTask struct { ID string Task string @@ -21,6 +45,15 @@ type SubagentTask struct { Created int64 } +type SpawnSubTurnFunc func( + ctx context.Context, + task, label, agentID string, + tools *ToolRegistry, + maxTokens int, + temperature float64, + hasMaxTokens, hasTemperature bool, +) (*ToolResult, error) + type SubagentManager struct { tasks map[string]*SubagentTask mu sync.RWMutex @@ -34,6 +67,13 @@ type SubagentManager struct { hasMaxTokens bool hasTemperature bool nextID int + spawner SpawnSubTurnFunc + + // mediaResolver resolves media:// refs in tool-loop messages before + // each LLM call in the legacy RunToolLoop fallback path. + // This lets subagents reuse the same media handling behavior as the + // main agent loop without importing pkg/agent and creating a cycle. + mediaResolver func([]providers.Message) []providers.Message } func NewSubagentManager( @@ -51,6 +91,23 @@ func NewSubagentManager( } } +func (sm *SubagentManager) SetSpawner(spawner SpawnSubTurnFunc) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.spawner = spawner +} + +// SetMediaResolver injects a message preprocessor that resolves media:// refs +// into LLM-ready content before each tool-loop iteration. +// This is only used by the legacy RunToolLoop fallback path. +func (sm *SubagentManager) SetMediaResolver( + resolver func([]providers.Message) []providers.Message, +) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.mediaResolver = resolver +} + // SetLLMOptions sets max tokens and temperature for subagent LLM calls. func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) { sm.mu.Lock() @@ -108,25 +165,16 @@ func (sm *SubagentManager) Spawn( return fmt.Sprintf("Spawned subagent for task: %s", task), nil } -func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) { +func (sm *SubagentManager) runTask( + ctx context.Context, + task *SubagentTask, + callback AsyncCallback, +) { task.Status = "running" task.Created = time.Now().UnixMilli() - - // Build system prompt for subagent - systemPrompt := `You are a subagent. Complete the given task independently and report the result. -You have access to tools - use them as needed to complete your task. -After completing the task, provide a clear summary of what was done.` - - messages := []providers.Message{ - { - Role: "system", - Content: systemPrompt, - }, - { - Role: "user", - Content: task.Task, - }, - } + // TODO(eventbus): once subagents are modeled as child turns inside + // pkg/agent, emit SubTurnEnd and SubTurnResultDelivered from the parent + // AgentLoop instead of this legacy manager. // Check if context is already canceled before starting select { @@ -139,37 +187,81 @@ After completing the task, provide a clear summary of what was done.` default: } - // Run tool loop with access to tools sm.mu.RLock() + spawner := sm.spawner tools := sm.tools maxIter := sm.maxIterations maxTokens := sm.maxTokens temperature := sm.temperature hasMaxTokens := sm.hasMaxTokens hasTemperature := sm.hasTemperature + mediaResolver := sm.mediaResolver sm.mu.RUnlock() - var llmOptions map[string]any - if hasMaxTokens || hasTemperature { - llmOptions = map[string]any{} - if hasMaxTokens { - llmOptions["max_tokens"] = maxTokens + var result *ToolResult + var err error + + if spawner != nil { + result, err = spawner( + ctx, + task.Task, + task.Label, + task.AgentID, + tools, + maxTokens, + temperature, + hasMaxTokens, + hasTemperature, + ) + } else { + // Fallback to legacy RunToolLoop + systemPrompt := `You are a subagent. Complete the given task independently and report the result. +You have access to tools - use them as needed to complete your task. +After completing the task, provide a clear summary of what was done.` + + messages := []providers.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: task.Task}, } - if hasTemperature { - llmOptions["temperature"] = temperature + + var llmOptions map[string]any + if hasMaxTokens || hasTemperature { + llmOptions = map[string]any{} + if hasMaxTokens { + llmOptions["max_tokens"] = maxTokens + } + if hasTemperature { + llmOptions["temperature"] = temperature + } + } + + var loopResult *ToolLoopResult + loopResult, err = RunToolLoop(ctx, ToolLoopConfig{ + Provider: sm.provider, + Model: sm.defaultModel, + Tools: tools, + MaxIterations: maxIter, + LLMOptions: llmOptions, + MediaResolver: mediaResolver, + }, messages, task.OriginChannel, task.OriginChatID) + + if err == nil { + result = &ToolResult{ + ForLLM: fmt.Sprintf( + "Subagent '%s' completed (iterations: %d): %s", + task.Label, + loopResult.Iterations, + loopResult.Content, + ), + ForUser: loopResult.Content, + Silent: false, + IsError: false, + Async: false, + } } } - loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ - Provider: sm.provider, - Model: sm.defaultModel, - Tools: tools, - MaxIterations: maxIter, - LLMOptions: llmOptions, - }, messages, task.OriginChannel, task.OriginChatID) - sm.mu.Lock() - var result *ToolResult defer func() { sm.mu.Unlock() // Call callback if provided and result is set @@ -196,19 +288,7 @@ After completing the task, provide a clear summary of what was done.` } } else { task.Status = "completed" - task.Result = loopResult.Content - result = &ToolResult{ - ForLLM: fmt.Sprintf( - "Subagent '%s' completed (iterations: %d): %s", - task.Label, - loopResult.Iterations, - loopResult.Content, - ), - ForUser: loopResult.Content, - Silent: false, - IsError: false, - Async: false, - } + task.Result = result.ForLLM } } @@ -219,6 +299,18 @@ func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) { return task, ok } +// GetTaskCopy returns a copy of the task with the given ID, taken under the +// read lock, so the caller receives a consistent snapshot with no data race. +func (sm *SubagentManager) GetTaskCopy(taskID string) (SubagentTask, bool) { + sm.mu.RLock() + defer sm.mu.RUnlock() + task, ok := sm.tasks[taskID] + if !ok { + return SubagentTask{}, false + } + return *task, true +} + func (sm *SubagentManager) ListTasks() []*SubagentTask { sm.mu.RLock() defer sm.mu.RUnlock() @@ -230,17 +322,42 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask { return tasks } +// ListTaskCopies returns value copies of all tasks, taken under the read lock, +// so callers receive consistent snapshots with no data race. +func (sm *SubagentManager) ListTaskCopies() []SubagentTask { + sm.mu.RLock() + defer sm.mu.RUnlock() + + copies := make([]SubagentTask, 0, len(sm.tasks)) + for _, task := range sm.tasks { + copies = append(copies, *task) + } + return copies +} + // SubagentTool executes a subagent task synchronously and returns the result. -// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion -// and returns the result directly in the ToolResult. +// It directly calls SubTurnSpawner with Async=false for synchronous execution. type SubagentTool struct { - manager *SubagentManager + spawner SubTurnSpawner + defaultModel string + maxTokens int + temperature float64 } func NewSubagentTool(manager *SubagentManager) *SubagentTool { - return &SubagentTool{ - manager: manager, + if manager == nil { + return &SubagentTool{} } + return &SubagentTool{ + defaultModel: manager.defaultModel, + maxTokens: manager.maxTokens, + temperature: manager.temperature, + } +} + +// SetSpawner sets the SubTurnSpawner for direct sub-turn execution. +func (t *SubagentTool) SetSpawner(spawner SubTurnSpawner) { + t.spawner = spawner } func (t *SubagentTool) Name() string { @@ -276,86 +393,64 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe label, _ := args["label"].(string) - if t.manager == nil { - return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil")) + // Build system prompt for subagent + systemPrompt := fmt.Sprintf( + `You are a subagent. Complete the given task independently and provide a clear, concise result. + +Task: %s`, + task, + ) + + if label != "" { + systemPrompt = fmt.Sprintf( + `You are a subagent labeled "%s". Complete the given task independently and provide a clear, concise result. + +Task: %s`, + label, + task, + ) } - // Build messages for subagent - messages := []providers.Message{ - { - Role: "system", - Content: "You are a subagent. Complete the given task independently and provide a clear, concise result.", - }, - { - Role: "user", - Content: task, - }, - } - - // Use RunToolLoop to execute with tools (same as async SpawnTool) - sm := t.manager - sm.mu.RLock() - tools := sm.tools - maxIter := sm.maxIterations - maxTokens := sm.maxTokens - temperature := sm.temperature - hasMaxTokens := sm.hasMaxTokens - hasTemperature := sm.hasTemperature - sm.mu.RUnlock() - - var llmOptions map[string]any - if hasMaxTokens || hasTemperature { - llmOptions = map[string]any{} - if hasMaxTokens { - llmOptions["max_tokens"] = maxTokens + // Use spawner if available (direct SpawnSubTurn call) + if t.spawner != nil { + result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{ + Model: t.defaultModel, + Tools: nil, // Will inherit from parent via context + SystemPrompt: systemPrompt, + MaxTokens: t.maxTokens, + Temperature: t.temperature, + Async: false, // Synchronous execution + }) + if err != nil { + return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err) } - if hasTemperature { - llmOptions["temperature"] = temperature + + // Format result for display + userContent := result.ForLLM + if result.ForUser != "" { + userContent = result.ForUser + } + maxUserLen := 500 + if len(userContent) > maxUserLen { + userContent = userContent[:maxUserLen] + "..." + } + + labelStr := label + if labelStr == "" { + labelStr = "(unnamed)" + } + llmContent := fmt.Sprintf("Subagent task completed:\nLabel: %s\nResult: %s", + labelStr, result.ForLLM) + + return &ToolResult{ + ForLLM: llmContent, + ForUser: userContent, + Silent: false, + IsError: result.IsError, + Async: false, } } - // Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests) - // to preserve the same defaults as the original NewSubagentTool constructor. - channel := ToolChannel(ctx) - if channel == "" { - channel = "cli" - } - chatID := ToolChatID(ctx) - if chatID == "" { - chatID = "direct" - } - - loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ - Provider: sm.provider, - Model: sm.defaultModel, - Tools: tools, - MaxIterations: maxIter, - LLMOptions: llmOptions, - }, messages, channel, chatID) - if err != nil { - return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err) - } - - // ForUser: Brief summary for user (truncated if too long) - userContent := loopResult.Content - maxUserLen := 500 - if len(userContent) > maxUserLen { - userContent = userContent[:maxUserLen] + "..." - } - - // ForLLM: Full execution details - labelStr := label - if labelStr == "" { - labelStr = "(unnamed)" - } - llmContent := fmt.Sprintf("Subagent task completed:\nLabel: %s\nIterations: %d\nResult: %s", - labelStr, loopResult.Iterations, loopResult.Content) - - return &ToolResult{ - ForLLM: llmContent, - ForUser: userContent, - Silent: false, - IsError: false, - Async: false, - } + // Fallback: spawner not configured + return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("spawner not set")) } diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go index 4b6f130a5..89ac7d4b5 100644 --- a/pkg/tools/subagent_tool_test.go +++ b/pkg/tools/subagent_tool_test.go @@ -48,24 +48,19 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager.SetLLMOptions(2048, 0.6) - tool := NewSubagentTool(manager) - ctx := WithToolContext(context.Background(), "cli", "direct") - args := map[string]any{"task": "Do something"} - result := tool.Execute(ctx, args) - - if result == nil || result.IsError { - t.Fatalf("Expected successful result, got: %+v", result) + // Verify options are set on manager + if manager.maxTokens != 2048 { + t.Errorf("manager.maxTokens = %d, want 2048", manager.maxTokens) } - - if provider.lastOptions == nil { - t.Fatal("Expected LLM options to be passed, got nil") + if manager.temperature != 0.6 { + t.Errorf("manager.temperature = %f, want 0.6", manager.temperature) } - if provider.lastOptions["max_tokens"] != 2048 { - t.Fatalf("max_tokens = %v, want %d", provider.lastOptions["max_tokens"], 2048) + if !manager.hasMaxTokens { + t.Error("manager.hasMaxTokens should be true") } - if provider.lastOptions["temperature"] != 0.6 { - t.Fatalf("temperature = %v, want %v", provider.lastOptions["temperature"], 0.6) + if !manager.hasTemperature { + t.Error("manager.hasTemperature should be true") } } @@ -150,6 +145,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSubagentTool(manager) + tool.SetSpawner(&mockSpawner{}) ctx := WithToolContext(context.Background(), "telegram", "chat-123") args := map[string]any{ @@ -204,6 +200,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSubagentTool(manager) + tool.SetSpawner(&mockSpawner{}) ctx := context.Background() args := map[string]any{ @@ -277,6 +274,7 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSubagentTool(manager) + tool.SetSpawner(&mockSpawner{}) channel := "test-channel" chatID := "test-chat" @@ -302,6 +300,7 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") tool := NewSubagentTool(manager) + tool.SetSpawner(&mockSpawner{}) ctx := context.Background() diff --git a/pkg/tools/sysproc_unix.go b/pkg/tools/sysproc_unix.go new file mode 100644 index 000000000..0fb03d43a --- /dev/null +++ b/pkg/tools/sysproc_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package tools + +import ( + "os/exec" + "syscall" +) + +func setSysProcAttrForPty(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} +} diff --git a/pkg/tools/sysproc_windows.go b/pkg/tools/sysproc_windows.go new file mode 100644 index 000000000..150f166fb --- /dev/null +++ b/pkg/tools/sysproc_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package tools + +import "os/exec" + +func setSysProcAttrForPty(cmd *exec.Cmd) { + // Windows doesn't support Setsid, and PTY is not available on Windows anyway. + // This function is a no-op for Windows builds. +} diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 244f0d4a2..ac568f598 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -24,6 +24,11 @@ type ToolLoopConfig struct { Tools *ToolRegistry MaxIterations int LLMOptions map[string]any + + // MediaResolver resolves media:// refs in messages before each LLM call. + // This is optional and is mainly used by subagent legacy fallback execution + // so subagents can reuse the same multimodal media handling as the main loop. + MediaResolver func(messages []providers.Message) []providers.Message } // ToolLoopResult contains the result of running the tool loop. @@ -63,8 +68,27 @@ func RunToolLoop( if llmOpts == nil { llmOpts = map[string]any{} } - // 3. Call LLM - response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts) + + // 3. Resolve media:// refs and Call LLM. + // Tools like load_image produce media:// refs in their result messages. + // Without this step, the LLM would receive raw "media://uuid" strings + // instead of base64-encoded image data URLs. + // + // We build a separate callMessages slice so that: + // (a) the resolver output is used for the LLM call only, + // (b) the original `messages` slice keeps the unresolved refs for + // subsequent iterations — the resolver is idempotent but working + // on the original avoids double-encoding issues. + // + // On iteration 1 the initial user messages typically have no media:// + // refs (they come from plain text), so this is effectively a no-op; + // it becomes relevant from iteration 2 onward when tool results may + // contain media refs. + callMessages := messages + if config.MediaResolver != nil && iteration > 1 { + callMessages = config.MediaResolver(messages) + } + response, err := config.Provider.Chat(ctx, callMessages, providerToolDefs, config.Model, llmOpts) if err != nil { logger.ErrorCF("toolloop", "LLM call failed", map[string]any{ @@ -159,16 +183,17 @@ func RunToolLoop( // Append results in original order for _, r := range results { - contentForLLM := r.result.ForLLM - if contentForLLM == "" && r.result.Err != nil { - contentForLLM = r.result.Err.Error() - } + contentForLLM := r.result.ContentForLLM() - messages = append(messages, providers.Message{ + toolMsg := providers.Message{ Role: "tool", Content: contentForLLM, ToolCallID: r.tc.ID, - }) + } + if len(r.result.Media) > 0 && !r.result.ResponseHandled { + toolMsg.Media = append(toolMsg.Media, r.result.Media...) + } + messages = append(messages, toolMsg) } } diff --git a/pkg/tools/validate.go b/pkg/tools/validate.go new file mode 100644 index 000000000..940344708 --- /dev/null +++ b/pkg/tools/validate.go @@ -0,0 +1,209 @@ +package tools + +import ( + "fmt" + "math" +) + +// validateToolArgs validates args against a JSON Schema-like map. +// schema is expected to have optional keys: "properties", "required", "additionalProperties". +func validateToolArgs(schema map[string]any, args map[string]any) error { + if len(schema) == 0 { + return nil + } + + if args == nil { + args = map[string]any{} + } + + if err := checkRequired(schema, args); err != nil { + return err + } + + propsRaw, ok := schema["properties"] + if !ok { + return nil // no properties defined — accept any args + } + + props, ok := propsRaw.(map[string]any) + if !ok { + return nil + } + + additional := allowsAdditional(schema) + + for key, val := range args { + propSchemaRaw, known := props[key] + if !known { + if !additional { + return fmt.Errorf("unexpected property %q", key) + } + continue + } + propSchema, ok := propSchemaRaw.(map[string]any) + if !ok { + continue // can't validate without a proper schema map + } + if err := checkType(key, val, propSchema); err != nil { + return err + } + } + + return nil +} + +// checkRequired verifies that every field listed in schema["required"] is present in args. +func checkRequired(schema map[string]any, args map[string]any) error { + reqRaw, ok := schema["required"] + if !ok { + return nil + } + + var required []string + + switch r := reqRaw.(type) { + case []string: + required = r + case []any: + for _, v := range r { + s, ok := v.(string) + if ok { + required = append(required, s) + } + } + default: + return nil + } + + for _, field := range required { + if _, present := args[field]; !present { + return fmt.Errorf("missing required property %q", field) + } + } + return nil +} + +// allowsAdditional returns true when the schema explicitly sets +// "additionalProperties" to true, or when the key is absent (default: reject extras). +func allowsAdditional(schema map[string]any) bool { + v, ok := schema["additionalProperties"] + if !ok { + return false + } + b, ok := v.(bool) + return ok && b +} + +// checkType validates that val matches the JSON Schema type declared in propSchema. +func checkType(key string, val any, propSchema map[string]any) error { + typeRaw, ok := propSchema["type"] + if !ok { + return nil // no type constraint + } + typeName, ok := typeRaw.(string) + if !ok { + return nil + } + + switch typeName { + case "string": + if _, ok := val.(string); !ok { + return fmt.Errorf("property %q: expected string, got %T", key, val) + } + case "integer": + switch v := val.(type) { + case float64: + if v != math.Trunc(v) { + return fmt.Errorf("property %q: expected integer, got float64 with fractional part", key) + } + case int: + // ok + case int64: + // ok + default: + return fmt.Errorf("property %q: expected integer, got %T", key, val) + } + case "number": + switch val.(type) { + case float64, int, int64: + // ok + default: + return fmt.Errorf("property %q: expected number, got %T", key, val) + } + case "boolean": + if _, ok := val.(bool); !ok { + return fmt.Errorf("property %q: expected boolean, got %T", key, val) + } + case "array": + arr, ok := val.([]any) + if !ok { + return fmt.Errorf("property %q: expected array, got %T", key, val) + } + if err := checkArrayItems(key, arr, propSchema); err != nil { + return err + } + case "object": + obj, ok := val.(map[string]any) + if !ok { + return fmt.Errorf("property %q: expected object, got %T", key, val) + } + if err := validateToolArgs(propSchema, obj); err != nil { + return fmt.Errorf("property %q: %w", key, err) + } + } + + if err := checkEnum(key, val, propSchema); err != nil { + return err + } + + return nil +} + +// checkArrayItems validates each element of arr against the "items" sub-schema. +func checkArrayItems(key string, arr []any, propSchema map[string]any) error { + itemsRaw, ok := propSchema["items"] + if !ok { + return nil + } + itemSchema, ok := itemsRaw.(map[string]any) + if !ok { + return nil + } + for i, elem := range arr { + elemKey := fmt.Sprintf("%s[%d]", key, i) + if err := checkType(elemKey, elem, itemSchema); err != nil { + return err + } + } + return nil +} + +// checkEnum validates that val is one of the allowed enum values in propSchema. +func checkEnum(key string, val any, propSchema map[string]any) error { + enumRaw, ok := propSchema["enum"] + if !ok { + return nil + } + + switch ev := enumRaw.(type) { + case []any: + for _, allowed := range ev { + if val == allowed { + return nil + } + } + case []string: + s, ok := val.(string) + if ok { + for _, allowed := range ev { + if s == allowed { + return nil + } + } + } + default: + return nil // unknown enum format, skip + } + + return fmt.Errorf("property %q: value %v is not in enum", key, val) +} diff --git a/pkg/tools/validate_test.go b/pkg/tools/validate_test.go new file mode 100644 index 000000000..e7f4f619a --- /dev/null +++ b/pkg/tools/validate_test.go @@ -0,0 +1,465 @@ +package tools + +import ( + "context" + "strings" + "testing" +) + +// Ensure imports are used. +var ( + _ = context.Background + _ = strings.Contains +) + +func TestValidateToolArgs(t *testing.T) { + baseSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "age": map[string]any{"type": "integer"}, + }, + "required": []string{"name"}, + } + + tests := []struct { + name string + schema map[string]any + args map[string]any + wantErr string // empty means no error expected + }{ + { + name: "valid args all required present", + schema: baseSchema, + args: map[string]any{"name": "alice", "age": float64(30)}, + }, + { + name: "missing required field", + schema: baseSchema, + args: map[string]any{"age": float64(30)}, + wantErr: "missing required property \"name\"", + }, + { + name: "wrong type string field gets number", + schema: baseSchema, + args: map[string]any{"name": float64(42)}, + wantErr: "expected string", + }, + { + name: "nil args with required fields", + schema: baseSchema, + args: nil, + wantErr: "missing required property \"name\"", + }, + { + name: "nil args no required fields", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + args: nil, + }, + { + name: "empty args no required fields", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + args: map[string]any{}, + }, + { + name: "optional field correct type", + schema: baseSchema, + args: map[string]any{"name": "bob", "age": float64(25)}, + }, + { + name: "optional field wrong type", + schema: baseSchema, + args: map[string]any{"name": "bob", "age": "twenty"}, + wantErr: "expected integer", + }, + { + name: "integer as float64 no fractional part", + schema: baseSchema, + args: map[string]any{"name": "carol", "age": float64(42)}, + }, + { + name: "actual float for integer field", + schema: baseSchema, + args: map[string]any{"name": "dave", "age": float64(42.5)}, + wantErr: "expected integer, got float64 with fractional part", + }, + { + name: "number type accepts float", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "score": map[string]any{"type": "number"}, + }, + }, + args: map[string]any{"score": float64(3.14)}, + }, + { + name: "number type accepts integer", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "score": map[string]any{"type": "number"}, + }, + }, + args: map[string]any{"score": float64(10)}, + }, + { + name: "boolean type valid", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "flag": map[string]any{"type": "boolean"}, + }, + }, + args: map[string]any{"flag": true}, + }, + { + name: "boolean type wrong", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "flag": map[string]any{"type": "boolean"}, + }, + }, + args: map[string]any{"flag": "true"}, + wantErr: "expected boolean", + }, + { + name: "required as []any from MCP deserialization", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "cmd": map[string]any{"type": "string"}, + }, + "required": []any{"cmd"}, + }, + args: map[string]any{}, + wantErr: "missing required property \"cmd\"", + }, + { + name: "enum valid value []any", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string", "enum": []any{"red", "green", "blue"}}, + }, + }, + args: map[string]any{"color": "red"}, + }, + { + name: "enum invalid value []any", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string", "enum": []any{"red", "green", "blue"}}, + }, + }, + args: map[string]any{"color": "yellow"}, + wantErr: "not in enum", + }, + { + name: "enum valid value []string", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string", "enum": []string{"red", "green", "blue"}}, + }, + }, + args: map[string]any{"color": "green"}, + }, + { + name: "enum invalid value []string", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string", "enum": []string{"red", "green", "blue"}}, + }, + }, + args: map[string]any{"color": "yellow"}, + wantErr: "not in enum", + }, + { + name: "extra unexpected property rejected", + schema: baseSchema, + args: map[string]any{"name": "eve", "hobby": "chess"}, + wantErr: "unexpected property \"hobby\"", + }, + { + name: "extra property allowed with additionalProperties true", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + "additionalProperties": true, + }, + args: map[string]any{"name": "eve", "hobby": "chess"}, + }, + { + name: "nested object valid", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "address": map[string]any{ + "type": "object", + "properties": map[string]any{ + "city": map[string]any{"type": "string"}, + }, + "required": []string{"city"}, + }, + }, + }, + args: map[string]any{ + "address": map[string]any{"city": "Berlin"}, + }, + }, + { + name: "nested object wrong type", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "address": map[string]any{ + "type": "object", + "properties": map[string]any{ + "city": map[string]any{"type": "string"}, + }, + }, + }, + }, + args: map[string]any{"address": "not an object"}, + wantErr: "expected object", + }, + { + name: "array with valid element types", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "tags": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + }, + }, + }, + args: map[string]any{"tags": []any{"a", "b", "c"}}, + }, + { + name: "array with wrong element types", + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "tags": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + }, + }, + }, + args: map[string]any{"tags": []any{"a", float64(2)}}, + wantErr: "expected string", + }, + { + name: "schema with no properties key accepts any args", + schema: map[string]any{ + "type": "object", + }, + args: map[string]any{"anything": "goes"}, + }, + { + name: "empty schema accepts anything", + schema: map[string]any{}, + args: map[string]any{"foo": "bar"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateToolArgs(tc.schema, tc.args) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tc.wantErr, err) + } + }) + } +} + +func TestValidateToolArgs_RegistryIntegration(t *testing.T) { + r := NewToolRegistry() + r.Register(&mockRegistryTool{ + name: "read_file", + desc: "reads a file", + params: map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + }, + "required": []string{"path"}, + }, + result: SilentResult("file contents"), + }) + + // Valid args — should succeed + result := r.Execute(context.Background(), "read_file", map[string]any{"path": "/tmp/x"}) + if result.IsError { + t.Errorf("expected success, got error: %s", result.ForLLM) + } + + // Missing required field — should fail with validation error + result = r.Execute(context.Background(), "read_file", map[string]any{}) + if !result.IsError { + t.Error("expected validation error for missing required field") + } + if !strings.Contains(result.ForLLM, "missing required p") { + t.Errorf("expected 'missing required p...' in error, got %q", result.ForLLM) + } + if result.Err == nil { + t.Error("expected Err to be set via WithError") + } + + // Wrong type — should fail with validation error + result = r.Execute(context.Background(), "read_file", map[string]any{"path": 123.0}) + if !result.IsError { + t.Error("expected validation error for wrong type") + } + if !strings.Contains(result.ForLLM, "expected string") { + t.Errorf("expected 'expected string' in error, got %q", result.ForLLM) + } + + // Extra property — should fail with validation error + result = r.Execute(context.Background(), "read_file", map[string]any{"path": "/x", "__inject": true}) + if !result.IsError { + t.Error("expected validation error for extra property") + } + if !strings.Contains(result.ForLLM, "unexpected prop") { + t.Errorf("expected 'unexpected prop...' in error, got %q", result.ForLLM) + } +} + +func TestValidateToolArgs_RealSchemas(t *testing.T) { + execSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{"type": "string"}, + "working_dir": map[string]any{"type": "string"}, + }, + "required": []string{"command"}, + } + + cronSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []any{"add", "list", "remove", "enable", "disable"}, + }, + }, + "required": []string{"action"}, + } + + webSearchSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + "count": map[string]any{"type": "integer"}, + }, + "required": []string{"query"}, + } + + tests := []struct { + name string + schema map[string]any + args map[string]any + wantErr string + }{ + // ExecTool + { + name: "exec valid args", + schema: execSchema, + args: map[string]any{"command": "ls -la", "working_dir": "/tmp"}, + }, + { + name: "exec missing required command", + schema: execSchema, + args: map[string]any{"working_dir": "/tmp"}, + wantErr: "missing required property \"command\"", + }, + { + name: "exec wrong type for command", + schema: execSchema, + args: map[string]any{"command": float64(123)}, + wantErr: "expected string", + }, + { + name: "exec extra injected arg", + schema: execSchema, + args: map[string]any{"command": "ls", "malicious": "payload"}, + wantErr: "unexpected property \"malicious\"", + }, + + // CronTool + { + name: "cron valid enum value", + schema: cronSchema, + args: map[string]any{"action": "add"}, + }, + { + name: "cron invalid enum value", + schema: cronSchema, + args: map[string]any{"action": "destroy"}, + wantErr: "not in enum", + }, + + // WebSearchTool + { + name: "websearch valid args", + schema: webSearchSchema, + args: map[string]any{"query": "golang testing", "count": float64(10)}, + }, + { + name: "websearch missing required query", + schema: webSearchSchema, + args: map[string]any{"count": float64(5)}, + wantErr: "missing required property \"query\"", + }, + { + name: "websearch wrong type for count", + schema: webSearchSchema, + args: map[string]any{"query": "test", "count": "ten"}, + wantErr: "expected integer", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateToolArgs(tc.schema, tc.args) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tc.wantErr, err) + } + }) + } +} diff --git a/pkg/tools/web.go b/pkg/tools/web.go deleted file mode 100644 index a803c5d0e..000000000 --- a/pkg/tools/web.go +++ /dev/null @@ -1,888 +0,0 @@ -package tools - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "regexp" - "strings" - "time" -) - -const ( - userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - - // HTTP client timeouts for web tool providers. - searchTimeout = 10 * time.Second // Brave, Tavily, DuckDuckGo - perplexityTimeout = 30 * time.Second // Perplexity (LLM-based, slower) - fetchTimeout = 60 * time.Second // WebFetchTool - - defaultMaxChars = 50000 - maxRedirects = 5 - searchMaxResponseSize int64 = 2 << 20 // 2 MB — limit for search provider responses -) - -// Pre-compiled regexes for HTML text extraction -var ( - reScript = regexp.MustCompile(`<script[\s\S]*?</script>`) - reStyle = regexp.MustCompile(`<style[\s\S]*?</style>`) - reTags = regexp.MustCompile(`<[^>]+>`) - reWhitespace = regexp.MustCompile(`[^\S\n]+`) - reBlankLines = regexp.MustCompile(`\n{3,}`) - - // DuckDuckGo result extraction - reDDGLink = regexp.MustCompile(`<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>`) - reDDGSnippet = regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`) -) - -// createHTTPClient creates an HTTP client with optional proxy support -func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) { - client := &http.Client{ - Timeout: timeout, - Transport: &http.Transport{ - MaxIdleConns: 10, - IdleConnTimeout: 30 * time.Second, - DisableCompression: false, - TLSHandshakeTimeout: 15 * time.Second, - }, - } - - if proxyURL != "" { - proxy, err := url.Parse(proxyURL) - if err != nil { - return nil, fmt.Errorf("invalid proxy URL: %w", err) - } - scheme := strings.ToLower(proxy.Scheme) - switch scheme { - case "http", "https", "socks5", "socks5h": - default: - return nil, fmt.Errorf( - "unsupported proxy scheme %q (supported: http, https, socks5, socks5h)", - proxy.Scheme, - ) - } - if proxy.Host == "" { - return nil, fmt.Errorf("invalid proxy URL: missing host") - } - client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy) - } else { - client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment - } - - return client, nil -} - -type SearchProvider interface { - Search(ctx context.Context, query string, count int) (string, error) -} - -type BraveSearchProvider struct { - apiKey string - proxy string - client *http.Client -} - -func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { - searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", - url.QueryEscape(query), count) - - req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Accept", "application/json") - req.Header.Set("X-Subscription-Token", p.apiKey) - - resp, err := p.client.Do(req) - if err != nil { - return "", fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, searchMaxResponseSize)) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("brave api error (status %d): %s", resp.StatusCode, string(body)) - } - - var searchResp struct { - Web struct { - Results []struct { - Title string `json:"title"` - URL string `json:"url"` - Description string `json:"description"` - } `json:"results"` - } `json:"web"` - } - - if err := json.Unmarshal(body, &searchResp); err != nil { - // Log error body for debugging - fmt.Printf("Brave API Error Body: %s\n", string(body)) - return "", fmt.Errorf("failed to parse response: %w", err) - } - - results := searchResp.Web.Results - if len(results) == 0 { - return fmt.Sprintf("No results for: %s", query), nil - } - - var lines []string - lines = append(lines, fmt.Sprintf("Results for: %s", query)) - for i, item := range results { - if i >= count { - break - } - lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) - if item.Description != "" { - lines = append(lines, fmt.Sprintf(" %s", item.Description)) - } - } - - return strings.Join(lines, "\n"), nil -} - -type TavilySearchProvider struct { - apiKey string - baseURL string - proxy string - client *http.Client -} - -func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { - searchURL := p.baseURL - if searchURL == "" { - searchURL = "https://api.tavily.com/search" - } - - payload := map[string]any{ - "api_key": p.apiKey, - "query": query, - "search_depth": "advanced", - "include_answer": false, - "include_images": false, - "include_raw_content": false, - "max_results": count, - } - - bodyBytes, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf("failed to marshal payload: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewBuffer(bodyBytes)) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", userAgent) - - resp, err := p.client.Do(req) - if err != nil { - return "", fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, searchMaxResponseSize)) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body)) - } - - var searchResp struct { - Results []struct { - Title string `json:"title"` - URL string `json:"url"` - Content string `json:"content"` - } `json:"results"` - } - - if err := json.Unmarshal(body, &searchResp); err != nil { - return "", fmt.Errorf("failed to parse response: %w", err) - } - - results := searchResp.Results - if len(results) == 0 { - return fmt.Sprintf("No results for: %s", query), nil - } - - var lines []string - lines = append(lines, fmt.Sprintf("Results for: %s (via Tavily)", query)) - for i, item := range results { - if i >= count { - break - } - lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) - if item.Content != "" { - lines = append(lines, fmt.Sprintf(" %s", item.Content)) - } - } - - return strings.Join(lines, "\n"), nil -} - -type DuckDuckGoSearchProvider struct { - proxy string - client *http.Client -} - -func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { - searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query)) - - req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("User-Agent", userAgent) - - resp, err := p.client.Do(req) - if err != nil { - return "", fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, searchMaxResponseSize)) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - - return p.extractResults(string(body), count, query) -} - -func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) { - // Simple regex based extraction for DDG HTML - // Strategy: Find all result containers or key anchors directly - - // Try finding the result links directly first, as they are the most critical - // Pattern: <a class="result__a" href="...">Title</a> - // The previous regex was a bit strict. Let's make it more flexible for attributes order/content - matches := reDDGLink.FindAllStringSubmatch(html, count+5) - - if len(matches) == 0 { - return fmt.Sprintf("No results found or extraction failed. Query: %s", query), nil - } - - var lines []string - lines = append(lines, fmt.Sprintf("Results for: %s (via DuckDuckGo)", query)) - - // Pre-compile snippet regex to run inside the loop - // We'll search for snippets relative to the link position or just globally if needed - // But simple global search for snippets might mismatch order. - // Since we only have the raw HTML string, let's just extract snippets globally and assume order matches (risky but simple for regex) - // Or better: Let's assume the snippet follows the link in the HTML - - // A better regex approach: iterate through text and find matches in order - // But for now, let's grab all snippets too - snippetMatches := reDDGSnippet.FindAllStringSubmatch(html, count+5) - - maxItems := min(len(matches), count) - - for i := range maxItems { - urlStr := matches[i][1] - title := stripTags(matches[i][2]) - title = strings.TrimSpace(title) - - // URL decoding if needed - if strings.Contains(urlStr, "uddg=") { - if u, err := url.QueryUnescape(urlStr); err == nil { - _, after, ok := strings.Cut(u, "uddg=") - if ok { - urlStr = after - } - } - } - - lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, title, urlStr)) - - // Attempt to attach snippet if available and index aligns - if i < len(snippetMatches) { - snippet := stripTags(snippetMatches[i][1]) - snippet = strings.TrimSpace(snippet) - if snippet != "" { - lines = append(lines, fmt.Sprintf(" %s", snippet)) - } - } - } - - return strings.Join(lines, "\n"), nil -} - -func stripTags(content string) string { - return reTags.ReplaceAllString(content, "") -} - -type PerplexitySearchProvider struct { - apiKey string - proxy string - client *http.Client -} - -func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { - searchURL := "https://api.perplexity.ai/chat/completions" - - payload := map[string]any{ - "model": "sonar", - "messages": []map[string]string{ - { - "role": "system", - "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.", - }, - { - "role": "user", - "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count), - }, - }, - "max_tokens": 1000, - } - - payloadBytes, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf("failed to marshal request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, "POST", searchURL, strings.NewReader(string(payloadBytes))) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+p.apiKey) - req.Header.Set("User-Agent", userAgent) - - resp, err := p.client.Do(req) - if err != nil { - return "", fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, searchMaxResponseSize)) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("Perplexity API error: %s", string(body)) - } - - var searchResp struct { - Choices []struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } `json:"choices"` - } - - if err := json.Unmarshal(body, &searchResp); err != nil { - return "", fmt.Errorf("failed to parse response: %w", err) - } - - if len(searchResp.Choices) == 0 { - return fmt.Sprintf("No results for: %s", query), nil - } - - return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil -} - -type SearXNGSearchProvider struct { - baseURL string -} - -func (p *SearXNGSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { - searchURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=general", - strings.TrimSuffix(p.baseURL, "/"), - url.QueryEscape(query)) - - req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("SearXNG returned status %d", resp.StatusCode) - } - - var result struct { - Results []struct { - Title string `json:"title"` - URL string `json:"url"` - Content string `json:"content"` - Engine string `json:"engine"` - Score float64 `json:"score"` - } `json:"results"` - } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("failed to parse response: %w", err) - } - - if len(result.Results) == 0 { - return fmt.Sprintf("No results for: %s", query), nil - } - - // Limit results to requested count - if len(result.Results) > count { - result.Results = result.Results[:count] - } - - // Format results in standard PicoClaw format - var b strings.Builder - b.WriteString(fmt.Sprintf("Results for: %s (via SearXNG)\n", query)) - for i, r := range result.Results { - b.WriteString(fmt.Sprintf("%d. %s\n", i+1, r.Title)) - b.WriteString(fmt.Sprintf(" %s\n", r.URL)) - if r.Content != "" { - b.WriteString(fmt.Sprintf(" %s\n", r.Content)) - } - } - - return b.String(), nil -} - -type GLMSearchProvider struct { - apiKey string - baseURL string - searchEngine string - proxy string - client *http.Client -} - -func (p *GLMSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { - searchURL := p.baseURL - if searchURL == "" { - searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search" - } - - payload := map[string]any{ - "search_query": query, - "search_engine": p.searchEngine, - "search_intent": false, - "count": count, - "content_size": "medium", - } - - bodyBytes, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf("failed to marshal payload: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewReader(bodyBytes)) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+p.apiKey) - - resp, err := p.client.Do(req) - if err != nil { - return "", fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("GLM Search API error (status %d): %s", resp.StatusCode, string(body)) - } - - var searchResp struct { - SearchResult []struct { - Title string `json:"title"` - Content string `json:"content"` - Link string `json:"link"` - } `json:"search_result"` - } - - if err := json.Unmarshal(body, &searchResp); err != nil { - return "", fmt.Errorf("failed to parse response: %w", err) - } - - results := searchResp.SearchResult - if len(results) == 0 { - return fmt.Sprintf("No results for: %s", query), nil - } - - var lines []string - lines = append(lines, fmt.Sprintf("Results for: %s (via GLM Search)", query)) - for i, item := range results { - if i >= count { - break - } - lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.Link)) - if item.Content != "" { - lines = append(lines, fmt.Sprintf(" %s", item.Content)) - } - } - - return strings.Join(lines, "\n"), nil -} - -type WebSearchTool struct { - provider SearchProvider - maxResults int -} - -type WebSearchToolOptions struct { - BraveAPIKey string - BraveMaxResults int - BraveEnabled bool - TavilyAPIKey string - TavilyBaseURL string - TavilyMaxResults int - TavilyEnabled bool - DuckDuckGoMaxResults int - DuckDuckGoEnabled bool - PerplexityAPIKey string - PerplexityMaxResults int - PerplexityEnabled bool - SearXNGBaseURL string - SearXNGMaxResults int - SearXNGEnabled bool - GLMSearchAPIKey string - GLMSearchBaseURL string - GLMSearchEngine string - GLMSearchMaxResults int - GLMSearchEnabled bool - Proxy string -} - -func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { - var provider SearchProvider - maxResults := 5 - - // Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search - if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" { - client, err := createHTTPClient(opts.Proxy, perplexityTimeout) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err) - } - provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy, client: client} - if opts.PerplexityMaxResults > 0 { - maxResults = opts.PerplexityMaxResults - } - } else if opts.BraveEnabled && opts.BraveAPIKey != "" { - client, err := createHTTPClient(opts.Proxy, searchTimeout) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err) - } - provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey, proxy: opts.Proxy, client: client} - if opts.BraveMaxResults > 0 { - maxResults = opts.BraveMaxResults - } - } else if opts.SearXNGEnabled && opts.SearXNGBaseURL != "" { - provider = &SearXNGSearchProvider{baseURL: opts.SearXNGBaseURL} - if opts.SearXNGMaxResults > 0 { - maxResults = opts.SearXNGMaxResults - } - } else if opts.TavilyEnabled && opts.TavilyAPIKey != "" { - client, err := createHTTPClient(opts.Proxy, searchTimeout) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err) - } - provider = &TavilySearchProvider{ - apiKey: opts.TavilyAPIKey, - baseURL: opts.TavilyBaseURL, - proxy: opts.Proxy, - client: client, - } - if opts.TavilyMaxResults > 0 { - maxResults = opts.TavilyMaxResults - } - } else if opts.DuckDuckGoEnabled { - client, err := createHTTPClient(opts.Proxy, searchTimeout) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err) - } - provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy, client: client} - if opts.DuckDuckGoMaxResults > 0 { - maxResults = opts.DuckDuckGoMaxResults - } - } else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" { - client, err := createHTTPClient(opts.Proxy, searchTimeout) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err) - } - searchEngine := opts.GLMSearchEngine - if searchEngine == "" { - searchEngine = "search_std" - } - provider = &GLMSearchProvider{ - apiKey: opts.GLMSearchAPIKey, - baseURL: opts.GLMSearchBaseURL, - searchEngine: searchEngine, - proxy: opts.Proxy, - client: client, - } - if opts.GLMSearchMaxResults > 0 { - maxResults = opts.GLMSearchMaxResults - } - } else { - return nil, nil - } - - return &WebSearchTool{ - provider: provider, - maxResults: maxResults, - }, nil -} - -func (t *WebSearchTool) Name() string { - return "web_search" -} - -func (t *WebSearchTool) Description() string { - return "Search the web for current information. Returns titles, URLs, and snippets from search results." -} - -func (t *WebSearchTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "query": map[string]any{ - "type": "string", - "description": "Search query", - }, - "count": map[string]any{ - "type": "integer", - "description": "Number of results (1-10)", - "minimum": 1.0, - "maximum": 10.0, - }, - }, - "required": []string{"query"}, - } -} - -func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { - query, ok := args["query"].(string) - if !ok { - return ErrorResult("query is required") - } - - count := t.maxResults - if c, ok := args["count"].(float64); ok { - if int(c) > 0 && int(c) <= 10 { - count = int(c) - } - } - - result, err := t.provider.Search(ctx, query, count) - if err != nil { - return ErrorResult(fmt.Sprintf("search failed: %v", err)) - } - - return &ToolResult{ - ForLLM: result, - ForUser: result, - } -} - -type WebFetchTool struct { - maxChars int - proxy string - client *http.Client - fetchLimitBytes int64 -} - -func NewWebFetchTool(maxChars int, fetchLimitBytes int64) (*WebFetchTool, error) { - // createHTTPClient cannot fail with an empty proxy string. - return NewWebFetchToolWithProxy(maxChars, "", fetchLimitBytes) -} - -func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64) (*WebFetchTool, error) { - if maxChars <= 0 { - maxChars = defaultMaxChars - } - client, err := createHTTPClient(proxy, fetchTimeout) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err) - } - client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - if len(via) >= maxRedirects { - return fmt.Errorf("stopped after %d redirects", maxRedirects) - } - return nil - } - if fetchLimitBytes <= 0 { - fetchLimitBytes = 10 * 1024 * 1024 // Security Fallback - } - return &WebFetchTool{ - maxChars: maxChars, - proxy: proxy, - client: client, - fetchLimitBytes: fetchLimitBytes, - }, nil -} - -func (t *WebFetchTool) Name() string { - return "web_fetch" -} - -func (t *WebFetchTool) Description() string { - return "Fetch a URL and extract readable content (HTML to text). Use this to get weather info, news, articles, or any web content." -} - -func (t *WebFetchTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "url": map[string]any{ - "type": "string", - "description": "URL to fetch", - }, - "maxChars": map[string]any{ - "type": "integer", - "description": "Maximum characters to extract", - "minimum": 100.0, - }, - }, - "required": []string{"url"}, - } -} - -func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { - urlStr, ok := args["url"].(string) - if !ok { - return ErrorResult("url is required") - } - - parsedURL, err := url.Parse(urlStr) - if err != nil { - return ErrorResult(fmt.Sprintf("invalid URL: %v", err)) - } - - if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { - return ErrorResult("only http/https URLs are allowed") - } - - if parsedURL.Host == "" { - return ErrorResult("missing domain in URL") - } - - maxChars := t.maxChars - if mc, ok := args["maxChars"].(float64); ok { - if int(mc) > 100 { - maxChars = int(mc) - } - } - - req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) - if err != nil { - return ErrorResult(fmt.Sprintf("failed to create request: %v", err)) - } - - req.Header.Set("User-Agent", userAgent) - - resp, err := t.client.Do(req) - if err != nil { - return ErrorResult(fmt.Sprintf("request failed: %v", err)) - } - - resp.Body = http.MaxBytesReader(nil, resp.Body, t.fetchLimitBytes) - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - var maxBytesErr *http.MaxBytesError - if errors.As(err, &maxBytesErr) { - return ErrorResult(fmt.Sprintf("failed to read response: size exceeded %d bytes limit", t.fetchLimitBytes)) - } - return ErrorResult(fmt.Sprintf("failed to read response: %v", err)) - } - - contentType := resp.Header.Get("Content-Type") - - var text, extractor string - - if strings.Contains(contentType, "application/json") { - var jsonData any - if err := json.Unmarshal(body, &jsonData); err == nil { - formatted, _ := json.MarshalIndent(jsonData, "", " ") - text = string(formatted) - extractor = "json" - } else { - text = string(body) - extractor = "raw" - } - } else if strings.Contains(contentType, "text/html") || len(body) > 0 && - (strings.HasPrefix(string(body), "<!DOCTYPE") || strings.HasPrefix(strings.ToLower(string(body)), "<html")) { - text = t.extractText(string(body)) - extractor = "text" - } else { - text = string(body) - extractor = "raw" - } - - truncated := len(text) > maxChars - if truncated { - text = text[:maxChars] - } - - result := map[string]any{ - "url": urlStr, - "status": resp.StatusCode, - "extractor": extractor, - "truncated": truncated, - "length": len(text), - "text": text, - } - - resultJSON, _ := json.MarshalIndent(result, "", " ") - - return &ToolResult{ - ForLLM: string(resultJSON), - ForUser: fmt.Sprintf( - "Fetched %d bytes from %s (extractor: %s, truncated: %v)", - len(text), - urlStr, - extractor, - truncated, - ), - } -} - -func (t *WebFetchTool) extractText(htmlContent string) string { - result := reScript.ReplaceAllLiteralString(htmlContent, "") - result = reStyle.ReplaceAllLiteralString(result, "") - result = reTags.ReplaceAllLiteralString(result, "") - - result = strings.TrimSpace(result) - - result = reWhitespace.ReplaceAllString(result, " ") - result = reBlankLines.ReplaceAllString(result, "\n\n") - - lines := strings.Split(result, "\n") - var cleanLines []string - for _, line := range lines { - line = strings.TrimSpace(line) - if line != "" { - cleanLines = append(cleanLines, line) - } - } - - return strings.Join(cleanLines, "\n") -} diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go deleted file mode 100644 index bdd30d385..000000000 --- a/pkg/tools/web_test.go +++ /dev/null @@ -1,815 +0,0 @@ -package tools - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/sipeed/picoclaw/pkg/logger" -) - -const testFetchLimit = int64(10 * 1024 * 1024) - -// TestWebTool_WebFetch_Success verifies successful URL fetching -func TestWebTool_WebFetch_Success(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - w.WriteHeader(http.StatusOK) - w.Write([]byte("<html><body><h1>Test Page</h1><p>Content here</p></body></html>")) - })) - defer server.Close() - - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - t.Fatalf("Failed to create web fetch tool: %v", err) - } - - ctx := context.Background() - args := map[string]any{ - "url": server.URL, - } - - result := tool.Execute(ctx, args) - - // Success should not be an error - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) - } - - // ForLLM should contain the fetched content (full JSON result) - if !strings.Contains(result.ForLLM, "Test Page") { - t.Errorf("Expected ForLLM to contain 'Test Page', got: %s", result.ForLLM) - } - - // ForUser should contain summary - if !strings.Contains(result.ForUser, "bytes") && !strings.Contains(result.ForUser, "extractor") { - t.Errorf("Expected ForUser to contain summary, got: %s", result.ForUser) - } -} - -// TestWebTool_WebFetch_JSON verifies JSON content handling -func TestWebTool_WebFetch_JSON(t *testing.T) { - testData := map[string]string{"key": "value", "number": "123"} - expectedJSON, _ := json.MarshalIndent(testData, "", " ") - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write(expectedJSON) - })) - defer server.Close() - - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - ctx := context.Background() - args := map[string]any{ - "url": server.URL, - } - - result := tool.Execute(ctx, args) - - // Success should not be an error - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) - } - - // ForLLM should contain formatted JSON - if !strings.Contains(result.ForLLM, "key") && !strings.Contains(result.ForLLM, "value") { - t.Errorf("Expected ForLLM to contain JSON data, got: %s", result.ForLLM) - } -} - -// TestWebTool_WebFetch_InvalidURL verifies error handling for invalid URL -func TestWebTool_WebFetch_InvalidURL(t *testing.T) { - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - ctx := context.Background() - args := map[string]any{ - "url": "not-a-valid-url", - } - - result := tool.Execute(ctx, args) - - // Should return error result - if !result.IsError { - t.Errorf("Expected error for invalid URL") - } - - // Should contain error message (either "invalid URL" or scheme error) - if !strings.Contains(result.ForLLM, "URL") && !strings.Contains(result.ForUser, "URL") { - t.Errorf("Expected error message for invalid URL, got ForLLM: %s", result.ForLLM) - } -} - -// TestWebTool_WebFetch_UnsupportedScheme verifies error handling for non-http URLs -func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - ctx := context.Background() - args := map[string]any{ - "url": "ftp://example.com/file.txt", - } - - result := tool.Execute(ctx, args) - - // Should return error result - if !result.IsError { - t.Errorf("Expected error for unsupported URL scheme") - } - - // Should mention only http/https allowed - if !strings.Contains(result.ForLLM, "http/https") && !strings.Contains(result.ForUser, "http/https") { - t.Errorf("Expected scheme error message, got ForLLM: %s", result.ForLLM) - } -} - -// TestWebTool_WebFetch_MissingURL verifies error handling for missing URL -func TestWebTool_WebFetch_MissingURL(t *testing.T) { - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - ctx := context.Background() - args := map[string]any{} - - result := tool.Execute(ctx, args) - - // Should return error result - if !result.IsError { - t.Errorf("Expected error when URL is missing") - } - - // Should mention URL is required - if !strings.Contains(result.ForLLM, "url is required") && !strings.Contains(result.ForUser, "url is required") { - t.Errorf("Expected 'url is required' message, got ForLLM: %s", result.ForLLM) - } -} - -// TestWebTool_WebFetch_Truncation verifies content truncation -func TestWebTool_WebFetch_Truncation(t *testing.T) { - longContent := strings.Repeat("x", 20000) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - w.Write([]byte(longContent)) - })) - defer server.Close() - - tool, err := NewWebFetchTool(1000, testFetchLimit) // Limit to 1000 chars - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - ctx := context.Background() - args := map[string]any{ - "url": server.URL, - } - - result := tool.Execute(ctx, args) - - // Success should not be an error - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) - } - - // ForLLM should contain truncated content (not the full 20000 chars) - resultMap := make(map[string]any) - json.Unmarshal([]byte(result.ForLLM), &resultMap) - if text, ok := resultMap["text"].(string); ok { - if len(text) > 1100 { // Allow some margin - t.Errorf("Expected content to be truncated to ~1000 chars, got: %d", len(text)) - } - } - - // Should be marked as truncated - if truncated, ok := resultMap["truncated"].(bool); !ok || !truncated { - t.Errorf("Expected 'truncated' to be true in result") - } -} - -func TestWebFetchTool_PayloadTooLarge(t *testing.T) { - // Create a mock HTTP server - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - w.WriteHeader(http.StatusOK) - - // Generate a payload intentionally larger than our limit. - // Limit: 10 * 1024 * 1024 (10MB). We generate 10MB + 100 bytes of the letter 'A'. - largeData := bytes.Repeat([]byte("A"), int(testFetchLimit)+100) - - w.Write(largeData) - })) - // Ensure the server is shut down at the end of the test - defer ts.Close() - - // Initialize the tool - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - // Prepare the arguments pointing to the URL of our local mock server - args := map[string]any{ - "url": ts.URL, - } - - // Execute the tool - ctx := context.Background() - result := tool.Execute(ctx, args) - - // Assuming ErrorResult sets the ForLLM field with the error text. - if result == nil { - t.Fatal("expected a ToolResult, got nil") - } - - // Search for the exact error string we set earlier in the Execute method - expectedErrorMsg := fmt.Sprintf("size exceeded %d bytes limit", testFetchLimit) - - if !strings.Contains(result.ForLLM, expectedErrorMsg) && !strings.Contains(result.ForUser, expectedErrorMsg) { - t.Errorf("test failed: expected error %q, but got: %+v", expectedErrorMsg, result) - } -} - -// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing -func TestWebTool_WebSearch_NoApiKey(t *testing.T) { - tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""}) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if tool != nil { - t.Errorf("Expected nil tool when Brave API key is empty") - } - - // Also nil when nothing is enabled - tool, err = NewWebSearchTool(WebSearchToolOptions{}) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if tool != nil { - t.Errorf("Expected nil tool when no provider is enabled") - } -} - -// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query -func TestWebTool_WebSearch_MissingQuery(t *testing.T) { - tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5}) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - ctx := context.Background() - args := map[string]any{} - - result := tool.Execute(ctx, args) - - // Should return error result - if !result.IsError { - t.Errorf("Expected error when query is missing") - } -} - -// TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction -func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - w.WriteHeader(http.StatusOK) - w.Write( - []byte( - `<html><body><script>alert('test');</script><style>body{color:red;}</style><h1>Title</h1><p>Content</p></body></html>`, - ), - ) - })) - defer server.Close() - - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - ctx := context.Background() - args := map[string]any{ - "url": server.URL, - } - - result := tool.Execute(ctx, args) - - // Success should not be an error - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) - } - - // ForLLM should contain extracted text (without script/style tags) - if !strings.Contains(result.ForLLM, "Title") && !strings.Contains(result.ForLLM, "Content") { - t.Errorf("Expected ForLLM to contain extracted text, got: %s", result.ForLLM) - } - - // Should NOT contain script or style tags in ForLLM - if strings.Contains(result.ForLLM, "<script>") || strings.Contains(result.ForLLM, "<style>") { - t.Errorf("Expected script/style tags to be removed, got: %s", result.ForLLM) - } -} - -// TestWebFetchTool_extractText verifies text extraction preserves newlines -func TestWebFetchTool_extractText(t *testing.T) { - tool := &WebFetchTool{} - - tests := []struct { - name string - input string - wantFunc func(t *testing.T, got string) - }{ - { - name: "preserves newlines between block elements", - input: "<html><body><h1>Title</h1>\n<p>Paragraph 1</p>\n<p>Paragraph 2</p></body></html>", - wantFunc: func(t *testing.T, got string) { - lines := strings.Split(got, "\n") - if len(lines) < 2 { - t.Errorf("Expected multiple lines, got %d: %q", len(lines), got) - } - if !strings.Contains(got, "Title") || !strings.Contains(got, "Paragraph 1") || - !strings.Contains(got, "Paragraph 2") { - t.Errorf("Missing expected text: %q", got) - } - }, - }, - { - name: "removes script and style tags", - input: "<script>alert('x');</script><style>body{}</style><p>Keep this</p>", - wantFunc: func(t *testing.T, got string) { - if strings.Contains(got, "alert") || strings.Contains(got, "body{}") { - t.Errorf("Expected script/style content removed, got: %q", got) - } - if !strings.Contains(got, "Keep this") { - t.Errorf("Expected 'Keep this' to remain, got: %q", got) - } - }, - }, - { - name: "collapses excessive blank lines", - input: "<p>A</p>\n\n\n\n\n<p>B</p>", - wantFunc: func(t *testing.T, got string) { - if strings.Contains(got, "\n\n\n") { - t.Errorf("Expected excessive blank lines collapsed, got: %q", got) - } - }, - }, - { - name: "collapses horizontal whitespace", - input: "<p>hello world</p>", - wantFunc: func(t *testing.T, got string) { - if strings.Contains(got, " ") { - t.Errorf("Expected spaces collapsed, got: %q", got) - } - if !strings.Contains(got, "hello world") { - t.Errorf("Expected 'hello world', got: %q", got) - } - }, - }, - { - name: "empty input", - input: "", - wantFunc: func(t *testing.T, got string) { - if got != "" { - t.Errorf("Expected empty string, got: %q", got) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tool.extractText(tt.input) - tt.wantFunc(t, got) - }) - } -} - -// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain -func TestWebTool_WebFetch_MissingDomain(t *testing.T) { - tool, err := NewWebFetchTool(50000, testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - ctx := context.Background() - args := map[string]any{ - "url": "https://", - } - - result := tool.Execute(ctx, args) - - // Should return error result - if !result.IsError { - t.Errorf("Expected error for URL without domain") - } - - // Should mention missing domain - if !strings.Contains(result.ForLLM, "domain") && !strings.Contains(result.ForUser, "domain") { - t.Errorf("Expected domain error message, got ForLLM: %s", result.ForLLM) - } -} - -func TestCreateHTTPClient_ProxyConfigured(t *testing.T) { - client, err := createHTTPClient("http://127.0.0.1:7890", 12*time.Second) - if err != nil { - t.Fatalf("createHTTPClient() error: %v", err) - } - if client.Timeout != 12*time.Second { - t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second) - } - - tr, ok := client.Transport.(*http.Transport) - if !ok { - t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) - } - if tr.Proxy == nil { - t.Fatal("transport.Proxy is nil, want non-nil") - } - - req, err := http.NewRequest("GET", "https://example.com", nil) - if err != nil { - t.Fatalf("http.NewRequest() error: %v", err) - } - proxyURL, err := tr.Proxy(req) - if err != nil { - t.Fatalf("transport.Proxy(req) error: %v", err) - } - if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" { - t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890") - } -} - -func TestCreateHTTPClient_InvalidProxy(t *testing.T) { - _, err := createHTTPClient("://bad-proxy", 10*time.Second) - if err == nil { - t.Fatal("createHTTPClient() expected error for invalid proxy URL, got nil") - } -} - -func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) { - client, err := createHTTPClient("socks5://127.0.0.1:1080", 8*time.Second) - if err != nil { - t.Fatalf("createHTTPClient() error: %v", err) - } - - tr, ok := client.Transport.(*http.Transport) - if !ok { - t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) - } - req, err := http.NewRequest("GET", "https://example.com", nil) - if err != nil { - t.Fatalf("http.NewRequest() error: %v", err) - } - proxyURL, err := tr.Proxy(req) - if err != nil { - t.Fatalf("transport.Proxy(req) error: %v", err) - } - if proxyURL == nil || proxyURL.String() != "socks5://127.0.0.1:1080" { - t.Fatalf("proxy URL = %v, want %q", proxyURL, "socks5://127.0.0.1:1080") - } -} - -func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) { - _, err := createHTTPClient("ftp://127.0.0.1:21", 10*time.Second) - if err == nil { - t.Fatal("createHTTPClient() expected error for unsupported scheme, got nil") - } - if !strings.Contains(err.Error(), "unsupported proxy scheme") { - t.Fatalf("error = %q, want to contain %q", err.Error(), "unsupported proxy scheme") - } -} - -func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) { - t.Setenv("HTTP_PROXY", "http://127.0.0.1:8888") - t.Setenv("http_proxy", "http://127.0.0.1:8888") - t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888") - t.Setenv("https_proxy", "http://127.0.0.1:8888") - t.Setenv("ALL_PROXY", "") - t.Setenv("all_proxy", "") - t.Setenv("NO_PROXY", "") - t.Setenv("no_proxy", "") - - client, err := createHTTPClient("", 10*time.Second) - if err != nil { - t.Fatalf("createHTTPClient() error: %v", err) - } - - tr, ok := client.Transport.(*http.Transport) - if !ok { - t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) - } - if tr.Proxy == nil { - t.Fatal("transport.Proxy is nil, want proxy function from environment") - } - - req, err := http.NewRequest("GET", "https://example.com", nil) - if err != nil { - t.Fatalf("http.NewRequest() error: %v", err) - } - if _, err := tr.Proxy(req); err != nil { - t.Fatalf("transport.Proxy(req) error: %v", err) - } -} - -func TestNewWebFetchToolWithProxy(t *testing.T) { - tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } else if tool.maxChars != 1024 { - t.Fatalf("maxChars = %d, want %d", tool.maxChars, 1024) - } - - if tool.proxy != "http://127.0.0.1:7890" { - t.Fatalf("proxy = %q, want %q", tool.proxy, "http://127.0.0.1:7890") - } - - tool, err = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890", testFetchLimit) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } - - if tool.maxChars != 50000 { - t.Fatalf("default maxChars = %d, want %d", tool.maxChars, 50000) - } -} - -func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { - t.Run("perplexity", func(t *testing.T) { - tool, err := NewWebSearchTool(WebSearchToolOptions{ - PerplexityEnabled: true, - PerplexityAPIKey: "k", - PerplexityMaxResults: 3, - Proxy: "http://127.0.0.1:7890", - }) - if err != nil { - t.Fatalf("NewWebSearchTool() error: %v", err) - } - p, ok := tool.provider.(*PerplexitySearchProvider) - if !ok { - t.Fatalf("provider type = %T, want *PerplexitySearchProvider", tool.provider) - } - if p.proxy != "http://127.0.0.1:7890" { - t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") - } - }) - - t.Run("brave", func(t *testing.T) { - tool, err := NewWebSearchTool(WebSearchToolOptions{ - BraveEnabled: true, - BraveAPIKey: "k", - BraveMaxResults: 3, - Proxy: "http://127.0.0.1:7890", - }) - if err != nil { - t.Fatalf("NewWebSearchTool() error: %v", err) - } - p, ok := tool.provider.(*BraveSearchProvider) - if !ok { - t.Fatalf("provider type = %T, want *BraveSearchProvider", tool.provider) - } - if p.proxy != "http://127.0.0.1:7890" { - t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") - } - }) - - t.Run("duckduckgo", func(t *testing.T) { - tool, err := NewWebSearchTool(WebSearchToolOptions{ - DuckDuckGoEnabled: true, - DuckDuckGoMaxResults: 3, - Proxy: "http://127.0.0.1:7890", - }) - if err != nil { - t.Fatalf("NewWebSearchTool() error: %v", err) - } - p, ok := tool.provider.(*DuckDuckGoSearchProvider) - if !ok { - t.Fatalf("provider type = %T, want *DuckDuckGoSearchProvider", tool.provider) - } - if p.proxy != "http://127.0.0.1:7890" { - t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") - } - }) -} - -// TestWebTool_TavilySearch_Success verifies successful Tavily search -func TestWebTool_TavilySearch_Success(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Errorf("Expected POST request, got %s", r.Method) - } - if r.Header.Get("Content-Type") != "application/json" { - t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) - } - - // Verify payload - var payload map[string]any - json.NewDecoder(r.Body).Decode(&payload) - if payload["api_key"] != "test-key" { - t.Errorf("Expected api_key test-key, got %v", payload["api_key"]) - } - if payload["query"] != "test query" { - t.Errorf("Expected query 'test query', got %v", payload["query"]) - } - - // Return mock response - response := map[string]any{ - "results": []map[string]any{ - { - "title": "Test Result 1", - "url": "https://example.com/1", - "content": "Content for result 1", - }, - { - "title": "Test Result 2", - "url": "https://example.com/2", - "content": "Content for result 2", - }, - }, - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - tool, err := NewWebSearchTool(WebSearchToolOptions{ - TavilyEnabled: true, - TavilyAPIKey: "test-key", - TavilyBaseURL: server.URL, - TavilyMaxResults: 5, - }) - if err != nil { - t.Fatalf("NewWebSearchTool() error: %v", err) - } - - ctx := context.Background() - args := map[string]any{ - "query": "test query", - } - - result := tool.Execute(ctx, args) - - // Success should not be an error - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) - } - - // ForUser should contain result titles and URLs - if !strings.Contains(result.ForUser, "Test Result 1") || - !strings.Contains(result.ForUser, "https://example.com/1") { - t.Errorf("Expected results in output, got: %s", result.ForUser) - } - - // Should mention via Tavily - if !strings.Contains(result.ForUser, "via Tavily") { - t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser) - } -} - -func TestWebTool_GLMSearch_Success(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Errorf("Expected POST request, got %s", r.Method) - } - if r.Header.Get("Content-Type") != "application/json" { - t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) - } - if r.Header.Get("Authorization") != "Bearer test-glm-key" { - t.Errorf("Expected Authorization Bearer test-glm-key, got %s", r.Header.Get("Authorization")) - } - - var payload map[string]any - json.NewDecoder(r.Body).Decode(&payload) - if payload["search_query"] != "test query" { - t.Errorf("Expected search_query 'test query', got %v", payload["search_query"]) - } - if payload["search_engine"] != "search_std" { - t.Errorf("Expected search_engine 'search_std', got %v", payload["search_engine"]) - } - - response := map[string]any{ - "id": "web-search-test", - "created": 1709568000, - "search_result": []map[string]any{ - { - "title": "Test GLM Result", - "content": "GLM search snippet", - "link": "https://example.com/glm", - "media": "Example", - "publish_date": "2026-03-04", - }, - }, - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - tool, err := NewWebSearchTool(WebSearchToolOptions{ - GLMSearchEnabled: true, - GLMSearchAPIKey: "test-glm-key", - GLMSearchBaseURL: server.URL, - GLMSearchEngine: "search_std", - }) - if err != nil { - t.Fatalf("NewWebSearchTool() error: %v", err) - } - - result := tool.Execute(context.Background(), map[string]any{ - "query": "test query", - }) - - if result.IsError { - t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) - } - if !strings.Contains(result.ForUser, "Test GLM Result") { - t.Errorf("Expected 'Test GLM Result' in output, got: %s", result.ForUser) - } - if !strings.Contains(result.ForUser, "https://example.com/glm") { - t.Errorf("Expected URL in output, got: %s", result.ForUser) - } - if !strings.Contains(result.ForUser, "via GLM Search") { - t.Errorf("Expected 'via GLM Search' in output, got: %s", result.ForUser) - } -} - -func TestWebTool_GLMSearch_APIError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"error":"invalid api key"}`)) - })) - defer server.Close() - - tool, err := NewWebSearchTool(WebSearchToolOptions{ - GLMSearchEnabled: true, - GLMSearchAPIKey: "bad-key", - GLMSearchBaseURL: server.URL, - GLMSearchEngine: "search_std", - }) - if err != nil { - t.Fatalf("NewWebSearchTool() error: %v", err) - } - - result := tool.Execute(context.Background(), map[string]any{ - "query": "test query", - }) - - if !result.IsError { - t.Errorf("Expected IsError=true for 401 response") - } - if !strings.Contains(result.ForLLM, "status 401") { - t.Errorf("Expected status 401 in error, got: %s", result.ForLLM) - } -} - -func TestWebTool_GLMSearch_Priority(t *testing.T) { - // GLM Search should only be selected when all other providers are disabled - tool, err := NewWebSearchTool(WebSearchToolOptions{ - DuckDuckGoEnabled: true, - DuckDuckGoMaxResults: 5, - GLMSearchEnabled: true, - GLMSearchAPIKey: "test-key", - GLMSearchBaseURL: "https://example.com", - GLMSearchEngine: "search_std", - }) - if err != nil { - t.Fatalf("NewWebSearchTool() error: %v", err) - } - - // DuckDuckGo should win over GLM Search - if _, ok := tool.provider.(*DuckDuckGoSearchProvider); !ok { - t.Errorf("Expected DuckDuckGoSearchProvider when both enabled, got %T", tool.provider) - } - - // With DuckDuckGo disabled, GLM Search should be selected - tool2, err := NewWebSearchTool(WebSearchToolOptions{ - DuckDuckGoEnabled: false, - GLMSearchEnabled: true, - GLMSearchAPIKey: "test-key", - GLMSearchBaseURL: "https://example.com", - GLMSearchEngine: "search_std", - }) - if err != nil { - t.Fatalf("NewWebSearchTool() error: %v", err) - } - if _, ok := tool2.provider.(*GLMSearchProvider); !ok { - t.Errorf("Expected GLMSearchProvider when only GLM enabled, got %T", tool2.provider) - } -} diff --git a/pkg/updater/updater.go b/pkg/updater/updater.go new file mode 100644 index 000000000..2d4cc950e --- /dev/null +++ b/pkg/updater/updater.go @@ -0,0 +1,717 @@ +package updater + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "github.com/minio/selfupdate" + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// httpClient is a shared HTTP client used for release checks and downloads. +// The Timeout value applies to the entire HTTP request: dialing, TLS +// handshake, redirects, and reading the response body. It is NOT only +// a connection (dial) timeout. To control lower-level timeouts (dial, +// TLS handshake, response header wait), supply a custom Transport with +// an appropriately configured net.Dialer. +var httpClient = &http.Client{Timeout: 2 * time.Minute} + +func getWithRetry(rawURL string) (*http.Response, error) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + return utils.DoRequestWithRetry(httpClient, req) +} + +// DownloadAndExtractRelease downloads a release archive (or uses a direct +// asset URL) and extracts it to a temporary directory. It returns the +// extraction directory on success. If releaseURL is empty, the latest +// release of the current project is used. platform/arch can be used to +// select the correct asset (e.g. "linux", "amd64"). +func DownloadAndExtractRelease(releaseURL, platform, arch string) (string, error) { + assetURL, checksum, err := findAssetInfo(releaseURL, platform, arch) + if err != nil { + return "", err + } + + // Download asset to temp file. Use the asset URL extension so + // extractArchive can detect the archive format (zip/tar.gz/tar). + tmpPattern := "picoclaw-release-*" + if u, perr := url.Parse(assetURL); perr == nil { + base := filepath.Base(u.Path) + lbase := strings.ToLower(base) + switch { + case strings.HasSuffix(lbase, ".zip"): + tmpPattern += ".zip" + case strings.HasSuffix(lbase, ".tar.gz") || strings.HasSuffix(lbase, ".tgz"): + tmpPattern += ".tar.gz" + case strings.HasSuffix(lbase, ".tar"): + tmpPattern += ".tar" + default: + tmpPattern += ".archive" + } + } else { + tmpPattern += ".archive" + } + + tmpFile, err := os.CreateTemp("", tmpPattern) + if err != nil { + return "", err + } + tmpPath := tmpFile.Name() + defer tmpFile.Close() + + resp, err := getWithRetry(assetURL) + if err != nil { + os.Remove(tmpPath) + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + os.Remove(tmpPath) + return "", fmt.Errorf("failed to download asset: status %d", resp.StatusCode) + } + + // Stream download while computing SHA256 to avoid a second download. + // Also show a simple progress line to stderr so users see activity. + h := sha256.New() + pw := &progressWriter{total: resp.ContentLength} + mw := io.MultiWriter(tmpFile, h, pw) + if _, err = io.Copy(mw, resp.Body); err != nil { + _ = os.Remove(tmpPath) + return "", err + } + // ensure final progress line ends with newline + pw.Finish() + + // verify checksum if available + if checksum != "" { + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, checksum) { + _ = os.Remove(tmpPath) + return "", fmt.Errorf("checksum mismatch: got %s expected %s", got, checksum) + } + } + + // Extract + destDir, err := os.MkdirTemp("", "picoclaw-extract-*") + if err != nil { + os.Remove(tmpPath) + return "", err + } + + if err := extractArchive(tmpPath, destDir); err != nil { + os.Remove(tmpPath) + os.RemoveAll(destDir) + return "", err + } + + // cleanup archive file; keep extracted contents + _ = os.Remove(tmpPath) + return destDir, nil +} + +// UpdateSelfFromRelease downloads the release matching the given parameters, +// extracts it and applies the binary named programName to update the +// currently running executable using minio/selfupdate. +// If releaseURL is empty, the latest release is used. If platform or arch +// is empty, runtime values are used. +func UpdateSelfFromRelease(releaseURL, platform, arch, programName string) error { + if platform == "" { + platform = runtime.GOOS + } + if arch == "" { + arch = runtime.GOARCH + } + + dir, err := DownloadAndExtractRelease(releaseURL, platform, arch) + if err != nil { + return err + } + defer os.RemoveAll(dir) + + binPath, err := findBinaryInDir(dir, programName) + if err != nil { + return err + } + + // ensure executable bit on non-windows + if runtime.GOOS != "windows" { + _ = os.Chmod(binPath, 0o755) + } + + f, err := os.Open(binPath) + if err != nil { + return err + } + defer f.Close() + + // Backup current executable so we can roll back if needed. + var opts selfupdate.Options + if exePath, err := os.Executable(); err == nil { + opts.OldSavePath = exePath + ".old" + } + + if err := selfupdate.Apply(f, opts); err != nil { + return fmt.Errorf("apply update: %w", err) + } + + return nil +} + +// UpdateSelf updates the running executable by fetching the latest release +// and applying the binary matching programName. +func UpdateSelf(programName string) error { + // By default, select the latest stable release when no explicit + // release URL is provided. Use --nightly or a custom URL to override. + return UpdateSelfFromRelease("", runtime.GOOS, runtime.GOARCH, programName) +} + +// GetReleaseAPIURL returns the GitHub Releases API URL for the given repo owner. +// Example: owner="sky5454" -> https://api.github.com/repos/sky5454/picoclaw/releases/latest +func GetReleaseAPIURL(owner string) string { + return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/latest", owner) +} + +// GetProdReleaseAPIURL returns the production release API URL (upstream). +func GetProdReleaseAPIURL() string { + return GetReleaseAPIURL("sipeed") +} + +// GetReleaseTagAPIURL returns the GitHub Releases API URL for a specific tag. +// Example: owner="sipeed", tag="nightly" -> https://api.github.com/repos/sipeed/picoclaw/releases/tags/nightly +func GetReleaseTagAPIURL(owner, tag string) string { + return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/tags/%s", owner, tag) +} + +// GetNightlyReleaseAPIURL returns the nightly release API URL for the production repo. +func GetNightlyReleaseAPIURL() string { + return GetReleaseTagAPIURL("sipeed", "nightly") +} + +// findAssetURL resolves the appropriate asset URL for the given release +// selector. It accepts direct archive URLs as well as GitHub release URLs +// or empty (latest release for the project). +func findAssetInfo(releaseURL, platform, arch string) (string, string, error) { + // returns (assetURL, sha256ChecksumHex, error) + if looksLikeDirectAssetURL(releaseURL) { + return "", "", fmt.Errorf("no checksum found for asset %s", releaseURL) + } + + apiURL := buildReleaseAPIURL(releaseURL) + if apiURL == "" { + // If caller provided an empty releaseURL, default to the + // production latest release API URL (stable release). + apiURL = GetProdReleaseAPIURL() + } + + resp, err := getWithRetry(apiURL) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("failed to query releases: status %d", resp.StatusCode) + } + + var data struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Digest string `json:"digest"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", "", err + } + + // Selection order: platform -> arch -> extension. + platformLower := strings.ToLower(platform) + archLower := strings.ToLower(arch) + + isZip := func(name string) bool { + return strings.HasSuffix(name, ".zip") + } + isTarGz := func(name string) bool { + return strings.HasSuffix(name, ".tar.gz") || strings.HasSuffix(name, ".tgz") + } + isTar := func(name string) bool { return strings.HasSuffix(name, ".tar") } + + // collect indices of assets that contain platform (if provided) + var platformIdx []int + for i, a := range data.Assets { + n := strings.ToLower(a.Name) + if platform == "" || strings.Contains(n, platformLower) { + platformIdx = append(platformIdx, i) + } + } + + pickBest := func(idxs []int) (string, int, bool) { + if len(idxs) == 0 { + return "", -1, false + } + // prefer arch matches within idxs; if arch was specified but + // no arch match exists among idxs, treat as no candidate. + var archIdx []int + if arch != "" { + aliases := archAliases(archLower) + for _, i := range idxs { + n := strings.ToLower(data.Assets[i].Name) + for _, ali := range aliases { + if strings.Contains(n, ali) { + archIdx = append(archIdx, i) + break + } + } + } + if len(archIdx) == 0 { + return "", -1, false + } + } + candidates := archIdx + if len(candidates) == 0 { + candidates = idxs + } + + // extension preference + if platformLower == "windows" { + // prefer .zip only + for _, i := range candidates { + if isZip(strings.ToLower(data.Assets[i].Name)) { + return data.Assets[i].BrowserDownloadURL, i, true + } + } + // if no zip found, fallthrough to first candidate + return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true + } + + // non-windows: prefer tar.gz/tgz, then tar, then zip + for _, i := range candidates { + if isTarGz(strings.ToLower(data.Assets[i].Name)) { + return data.Assets[i].BrowserDownloadURL, i, true + } + } + for _, i := range candidates { + if isTar(strings.ToLower(data.Assets[i].Name)) { + return data.Assets[i].BrowserDownloadURL, i, true + } + } + for _, i := range candidates { + if isZip(strings.ToLower(data.Assets[i].Name)) { + return data.Assets[i].BrowserDownloadURL, i, true + } + } + // fallback to first candidate + return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true + } + + // Try platform matches first + if url, idx, ok := pickBest(platformIdx); ok { + // attempt to find checksum: prefer asset digest from API if present + if d := strings.TrimSpace(data.Assets[idx].Digest); d != "" { + dLower := strings.ToLower(d) + if strings.HasPrefix(dLower, "sha256:") { + hexpart := strings.TrimPrefix(dLower, "sha256:") + return url, hexpart, nil + } + // If digest already looks like a 64-hex, return it + if ok, _ := regexp.MatchString("(?i)^[a-f0-9]{64}$", dLower); ok { + return url, dLower, nil + } + } + // Look for checksum assets and verify by computing the asset's sha256. + for j, a := range data.Assets { + n := strings.ToLower(a.Name) + if strings.Contains(n, "sha256") || + strings.Contains(n, "sha256sum") || + strings.Contains(n, "checksums") || + strings.HasSuffix(n, ".sha256") || + strings.HasSuffix(n, ".sha256sum") { + resp2, err := getWithRetry(data.Assets[j].BrowserDownloadURL) + if err != nil { + continue + } + bs, err := io.ReadAll(resp2.Body) + resp2.Body.Close() + if err != nil { + continue + } + if h, ok := findHashInChecksumContent(bs, url); ok { + return url, h, nil + } + } + } + // No checksum found for the selected platform asset -> error + return "", "", fmt.Errorf("no checksum found for asset %s", url) + } + + // No platform match — require explicit platform+arch; fail fast. + return "", "", fmt.Errorf("no release asset matching platform %q and arch %q", platform, arch) +} + +func looksLikeDirectAssetURL(u string) bool { + if u == "" { + return false + } + lower := strings.ToLower(u) + if strings.HasSuffix(lower, ".zip") || + strings.HasSuffix(lower, ".tar.gz") || + strings.HasSuffix(lower, ".tgz") || + strings.HasSuffix(lower, ".tar") { + return true + } + if strings.Contains(lower, "/releases/download/") { + return true + } + return false +} + +func buildReleaseAPIURL(releaseURL string) string { + if releaseURL == "" { + return "" + } + if strings.Contains(releaseURL, "api.github.com") { + return releaseURL + } + u, err := url.Parse(releaseURL) + if err != nil { + return "" + } + if u.Host != "github.com" { + return "" + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) < 2 { + return "" + } + owner := parts[0] + repo := parts[1] + // if tag specified + if len(parts) >= 5 && parts[2] == "releases" && parts[3] == "tag" { + tag := parts[4] + return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, tag) + } + // default to latest + return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) +} + +// NOTE: helper functions to compute SHA256 from URL/path were removed +// after refactoring to stream the download and verify the checksum +// during the single download to avoid double-transfer. + +// findHashInChecksumContent attempts to locate a 64-hex SHA256 in the +// checksum file content that corresponds to assetURL. It returns the +// found hash (lowercase) and true, or "", false if not found. +func findHashInChecksumContent(bs []byte, assetURL string) (string, bool) { + s := strings.ToLower(string(bs)) + var assetBase string + if u, err := url.Parse(assetURL); err == nil { + assetBase = strings.ToLower(filepath.Base(u.Path)) + } else { + assetBase = strings.ToLower(filepath.Base(assetURL)) + } + re := regexp.MustCompile(`(?i)\b([a-f0-9]{64})\b`) + // prefer a line containing the asset filename + for _, line := range strings.Split(s, "\n") { + if strings.Contains(line, assetBase) { + if m := re.FindString(line); m != "" { + return m, true + } + } + } + // fallback: if there's exactly one unique 64-hex value, return it + matches := re.FindAllString(s, -1) + uniq := map[string]struct{}{} + for _, m := range matches { + uniq[m] = struct{}{} + } + if len(uniq) == 1 { + for k := range uniq { + return k, true + } + } + return "", false +} + +// progressWriter implements io.Writer and prints a simple progress +// line to stderr while bytes are written. It is intended to be used +// as one writer in an io.MultiWriter so we can stream-to-disk, compute +// the sha256, and update the progress display in a single pass. +type progressWriter struct { + total int64 + written int64 + last time.Time +} + +func (pw *progressWriter) Write(p []byte) (int, error) { + n := len(p) + pw.written += int64(n) + now := time.Now() + if pw.last.IsZero() || now.Sub(pw.last) >= 200*time.Millisecond || (pw.total > 0 && pw.written == pw.total) { + pw.print() + pw.last = now + } + return n, nil +} + +func (pw *progressWriter) print() { + if pw.total > 0 { + pct := float64(pw.written) * 100.0 / float64(pw.total) + fmt.Fprintf(os.Stderr, "\rDownloading: %s / %s (%.1f%%)", humanBytes(pw.written), humanBytes(pw.total), pct) + } else { + fmt.Fprintf(os.Stderr, "\rDownloading: %s", humanBytes(pw.written)) + } +} + +func (pw *progressWriter) Finish() { + pw.print() + fmt.Fprintln(os.Stderr, "") +} + +func humanBytes(n int64) string { + f := float64(n) + const ( + KB = 1024.0 + MB = KB * 1024.0 + GB = MB * 1024.0 + ) + switch { + case f >= GB: + return fmt.Sprintf("%.2f GB", f/GB) + case f >= MB: + return fmt.Sprintf("%.2f MB", f/MB) + case f >= KB: + return fmt.Sprintf("%.2f KB", f/KB) + default: + return fmt.Sprintf("%d B", n) + } +} + +// archAliases returns common name variants for an architecture string +// so we can match release asset names like "x86_64" vs Go's "amd64". +// archAliases returns name variants for an architecture string. +// If `arch` is empty or matches the local runtime.GOARCH, prefer the +// compile-time architecture aliases provided by archAliasesForLocal +// (implemented per-architecture via build tags). For other `arch` +// values we use a small synonyms map. +func archAliases(arch string) []string { + a := strings.ToLower(arch) + if syns, ok := archSynonyms[a]; ok { + return syns + } + return []string{a} +} + +var archSynonyms = map[string][]string{ + "amd64": {"amd64", "x86_64", "x64"}, + "x86_64": {"amd64", "x86_64", "x64"}, + "x64": {"amd64", "x86_64", "x64"}, + "386": {"386", "x86"}, + "x86": {"386", "x86"}, + "arm64": {"arm64", "aarch64"}, + "aarch64": {"arm64", "aarch64"}, + "arm": {"arm"}, +} + +func extractArchive(archivePath, destDir string) error { + lower := strings.ToLower(archivePath) + if strings.HasSuffix(lower, ".zip") { + return extractZip(archivePath, destDir) + } + // treat .tar.gz and .tgz as gzip+tar + if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") { + return extractTarGz(archivePath, destDir) + } + if strings.HasSuffix(lower, ".tar") { + return extractTar(archivePath, destDir) + } + // fallback: try tar.gz + return extractTarGz(archivePath, destDir) +} + +func extractZip(archivePath, destDir string) error { + r, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer r.Close() + destClean := filepath.Clean(destDir) + for _, f := range r.File { + target := filepath.Clean(filepath.Join(destClean, f.Name)) + if !strings.HasPrefix(target, destClean+string(os.PathSeparator)) && target != destClean { + return fmt.Errorf("path traversal detected: %s", f.Name) + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, f.FileInfo().Mode()); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, f.FileInfo().Mode()) + if err != nil { + rc.Close() + return err + } + if _, err := io.Copy(out, rc); err != nil { + rc.Close() + out.Close() + return err + } + rc.Close() + out.Close() + } + return nil +} + +func extractTarGz(archivePath, destDir string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + gzr, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gzr.Close() + tr := tar.NewReader(gzr) + return extractTarFromReader(tr, destDir) +} + +func extractTar(archivePath, destDir string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + tr := tar.NewReader(f) + return extractTarFromReader(tr, destDir) +} + +// extractTarFromReader contains logic common to extracting entries from a +// tar.Reader and is used by both extractTarGz and extractTar to avoid +// duplicated code (golangci-lint: dupl). +func extractTarFromReader(tr *tar.Reader, destDir string) error { + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + target := filepath.Clean(filepath.Join(filepath.Clean(destDir), hdr.Name)) + if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) && + target != filepath.Clean(destDir) { + return fmt.Errorf("path traversal detected: %s", hdr.Name) + } + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + out.Close() + } + } + return nil +} + +func findBinaryInDir(dir, programName string) (string, error) { + wanted := []string{programName} + if runtime.GOOS == "windows" { + wanted = append([]string{programName + ".exe"}, wanted...) + } else { + // also accept programs with .exe in archives targeting windows + wanted = append(wanted, programName+".exe") + } + + var found string + if err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { + if err != nil || found != "" { + return err + } + if d.IsDir() { + return nil + } + base := filepath.Base(p) + for _, w := range wanted { + if base == w { + found = p + return io.EOF // use EOF to stop walking early + } + } + return nil + }); err != nil && err != io.EOF { + return "", err + } + if found == "" { + return "", fmt.Errorf("binary %q not found in archive", programName) + } + return found, nil +} + +// NewUpdateCommand returns a cobra command that triggers UpdateSelfFromRelease. +func NewUpdateCommand(binaryName string) *cobra.Command { + var urlStr, platform, arch string + cmd := &cobra.Command{ + Use: "update", + Short: "Check and apply updates from GitHub releases", + RunE: func(cmd *cobra.Command, args []string) error { + if platform == "" { + platform = runtime.GOOS + } + if arch == "" { + arch = runtime.GOARCH + } + fmt.Printf("Current version: %s\n", config.FormatVersion()) + if err := UpdateSelfFromRelease(urlStr, platform, arch, binaryName); err != nil { + return err + } + fmt.Println("Update applied; restart to use the new version.") + return nil + }, + } + cmd.Flags().StringVarP(&urlStr, "url", "u", "", "Direct URL to download release asset or release page") + cmd.Flags().StringVar(&platform, "platform", "", "Target platform (default: runtime.GOOS)") + cmd.Flags().StringVar(&arch, "arch", "", "Target arch (default: runtime.GOARCH)") + return cmd +} diff --git a/pkg/updater/updater_test.go b/pkg/updater/updater_test.go new file mode 100644 index 000000000..75159af12 --- /dev/null +++ b/pkg/updater/updater_test.go @@ -0,0 +1,415 @@ +package updater + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// matchesMagic checks whether the file at path looks like a platform binary +// by inspecting magic bytes (ELF for linux, MZ for windows). +func matchesMagic(path, platform string) (bool, error) { + f, err := os.Open(path) + if err != nil { + return false, err + } + defer f.Close() + buf := make([]byte, 4) + n, err := f.Read(buf) + if err != nil && err != io.EOF { + return false, err + } + if n >= 4 && buf[0] == 0x7f && buf[1] == 'E' && buf[2] == 'L' && buf[3] == 'F' { + return strings.Contains(platform, "linux"), nil + } + if n >= 2 && buf[0] == 'M' && buf[1] == 'Z' { + return strings.Contains(platform, "windows"), nil + } + return false, nil +} + +type testReleaseAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Digest string `json:"digest,omitempty"` +} + +type testReleasePayload struct { + TagName string `json:"tag_name"` + Assets []testReleaseAsset `json:"assets"` +} + +const testReleaseAPIPath = "/api.github.com/repos/sipeed/picoclaw/releases/latest" + +// TestDownloadAndExtractRelease_IntegrationLatestRelease downloads the latest +// public release for a single platform as an opt-in smoke test. +func TestDownloadAndExtractRelease_IntegrationLatestRelease(t *testing.T) { + if os.Getenv("PICOCLAW_INTEGRATION_TESTS") == "" { + t.Skip("skipping integration test (set PICOCLAW_INTEGRATION_TESTS=1 to enable)") + } + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + const platform = "linux" + const arch = "amd64" + apiURL := GetProdReleaseAPIURL() + assetURL, checksum, err := findAssetInfo(apiURL, platform, arch) + if err != nil { + t.Fatalf("findAssetInfo failed for %s/%s: %v", platform, arch, err) + } + t.Logf("asset URL: %s checksum: %s", assetURL, checksum) + + dir, err := DownloadAndExtractRelease(apiURL, platform, arch) + if err != nil { + t.Fatalf("DownloadAndExtractRelease failed for %s/%s: %v", platform, arch, err) + } + defer os.RemoveAll(dir) + + var found bool + _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + info, err := d.Info() + if err != nil { + return err + } + if info.Size() < 64 { + return nil + } + ok, err := matchesMagic(path, platform) + if err != nil { + return err + } + if ok { + found = true + t.Logf("found artifact: %s (size=%d)", path, info.Size()) + } + return nil + }) + if !found { + t.Fatalf("no binary-like artifact found for %s/%s", platform, arch) + } +} + +func TestFindAssetInfo_SelectsPreferredAsset(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case testReleaseAPIPath: + writeReleasePayload(w, testReleasePayload{ + TagName: "v0.2.6", + Assets: []testReleaseAsset{ + { + Name: "picoclaw_Linux_x86_64.zip", + BrowserDownloadURL: server.URL + "/assets/picoclaw_Linux_x86_64.zip", + Digest: "sha256:" + strings.Repeat("1", 64), + }, + { + Name: "picoclaw_Linux_x86_64.tar.gz", + BrowserDownloadURL: server.URL + "/assets/picoclaw_Linux_x86_64.tar.gz", + Digest: "sha256:" + strings.Repeat("2", 64), + }, + { + Name: "picoclaw_Windows_x86_64.zip", + BrowserDownloadURL: server.URL + "/assets/picoclaw_Windows_x86_64.zip", + Digest: "sha256:" + strings.Repeat("3", 64), + }, + { + Name: "picoclaw_Windows_arm64.zip", + BrowserDownloadURL: server.URL + "/assets/picoclaw_Windows_arm64.zip", + Digest: "sha256:" + strings.Repeat("4", 64), + }, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + withTestHTTPClient(t, server.Client()) + + tests := []struct { + name string + platform string + arch string + wantURL string + wantChecksum string + }{ + { + name: "linux prefers tar.gz over zip", + platform: "linux", + arch: "amd64", + wantURL: server.URL + "/assets/picoclaw_Linux_x86_64.tar.gz", + wantChecksum: strings.Repeat("2", 64), + }, + { + name: "windows amd64 matches x86_64 zip", + platform: "windows", + arch: "amd64", + wantURL: server.URL + "/assets/picoclaw_Windows_x86_64.zip", + wantChecksum: strings.Repeat("3", 64), + }, + { + name: "windows arm64 matches arm64 zip", + platform: "windows", + arch: "arm64", + wantURL: server.URL + "/assets/picoclaw_Windows_arm64.zip", + wantChecksum: strings.Repeat("4", 64), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotURL, gotChecksum, err := findAssetInfo(server.URL+testReleaseAPIPath, tc.platform, tc.arch) + if err != nil { + t.Fatalf( + "findAssetInfo(%q, %q, %q) error: %v", + server.URL+testReleaseAPIPath, + tc.platform, + tc.arch, + err, + ) + } + if gotURL != tc.wantURL { + t.Fatalf("assetURL = %q, want %q", gotURL, tc.wantURL) + } + if gotChecksum != tc.wantChecksum { + t.Fatalf("checksum = %q, want %q", gotChecksum, tc.wantChecksum) + } + }) + } +} + +func TestFindAssetInfo_UsesChecksumAssetWhenDigestMissing(t *testing.T) { + const checksum = "77b564f36da6d1e02169d0ecc837728eecb9ef983c317d9186ac9651798b924c" + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case testReleaseAPIPath: + writeReleasePayload(w, testReleasePayload{ + TagName: "v0.2.6", + Assets: []testReleaseAsset{ + { + Name: "picoclaw_Windows_x86_64.zip", + BrowserDownloadURL: server.URL + "/assets/picoclaw_Windows_x86_64.zip", + }, + { + Name: "checksums.txt", + BrowserDownloadURL: server.URL + "/assets/checksums.txt", + }, + }, + }) + case "/assets/checksums.txt": + _, _ = io.WriteString(w, checksum+" picoclaw_Windows_x86_64.zip\n") + case "/assets/picoclaw_Windows_x86_64.zip": + w.WriteHeader(http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + withTestHTTPClient(t, server.Client()) + + gotURL, gotChecksum, err := findAssetInfo(server.URL+testReleaseAPIPath, "windows", "amd64") + if err != nil { + t.Fatalf("findAssetInfo returned error: %v", err) + } + if gotURL != server.URL+"/assets/picoclaw_Windows_x86_64.zip" { + t.Fatalf("assetURL = %q, want %q", gotURL, server.URL+"/assets/picoclaw_Windows_x86_64.zip") + } + if gotChecksum != checksum { + t.Fatalf("checksum = %q, want %q", gotChecksum, checksum) + } +} + +func TestDownloadAndExtractRelease_ExtractsTarGz(t *testing.T) { + tarGzContent := buildTestTarGz(t, map[string]string{ + "picoclaw_Linux_x86_64/picoclaw": "test linux binary payload", + }) + sum := sha256.Sum256(tarGzContent) + checksum := hex.EncodeToString(sum[:]) + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case testReleaseAPIPath: + writeReleasePayload(w, testReleasePayload{ + TagName: "v0.2.6", + Assets: []testReleaseAsset{ + { + Name: "picoclaw_Linux_x86_64.tar.gz", + BrowserDownloadURL: server.URL + "/assets/picoclaw_Linux_x86_64.tar.gz", + Digest: "sha256:" + checksum, + }, + }, + }) + case "/assets/picoclaw_Linux_x86_64.tar.gz": + w.Header().Set("Content-Type", "application/gzip") + _, _ = w.Write(tarGzContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + withTestHTTPClient(t, server.Client()) + + dir, err := DownloadAndExtractRelease(server.URL+testReleaseAPIPath, "linux", "amd64") + if err != nil { + t.Fatalf("DownloadAndExtractRelease returned error: %v", err) + } + defer os.RemoveAll(dir) + + binPath, err := findBinaryInDir(dir, "picoclaw") + if err != nil { + t.Fatalf("findBinaryInDir returned error: %v", err) + } + + bs, err := os.ReadFile(binPath) + if err != nil { + t.Fatalf("ReadFile extracted asset: %v", err) + } + if got := string(bs); got != "test linux binary payload" { + t.Fatalf("extracted content = %q, want %q", got, "test linux binary payload") + } +} + +func TestDownloadAndExtractRelease_RetriesTransientAssetFailure(t *testing.T) { + zipContent := buildTestZip(t, map[string]string{ + "picoclaw.exe": "test windows binary payload", + }) + sum := sha256.Sum256(zipContent) + checksum := hex.EncodeToString(sum[:]) + + var assetAttempts int + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api.github.com/repos/sipeed/picoclaw/releases/latest": + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf( + w, + `{"tag_name":"v0.2.6","assets":[{"name":"picoclaw_Windows_x86_64.zip","browser_download_url":%q,"digest":"sha256:%s"}]}`, + server.URL+"/assets/picoclaw_Windows_x86_64.zip", + checksum, + ) + case "/assets/picoclaw_Windows_x86_64.zip": + assetAttempts++ + if assetAttempts == 1 { + w.WriteHeader(http.StatusGatewayTimeout) + return + } + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + withTestHTTPClient(t, server.Client()) + + dir, err := DownloadAndExtractRelease( + server.URL+"/api.github.com/repos/sipeed/picoclaw/releases/latest", + "windows", + "amd64", + ) + if err != nil { + t.Fatalf("DownloadAndExtractRelease returned error: %v", err) + } + defer os.RemoveAll(dir) + + if assetAttempts != 2 { + t.Fatalf("asset attempts = %d, want 2", assetAttempts) + } + + bs, err := os.ReadFile(filepath.Join(dir, "picoclaw.exe")) + if err != nil { + t.Fatalf("ReadFile extracted asset: %v", err) + } + if got := string(bs); got != "test windows binary payload" { + t.Fatalf("extracted content = %q, want %q", got, "test windows binary payload") + } +} + +func buildTestZip(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, content := range files { + w, err := zw.Create(name) + if err != nil { + t.Fatalf("Create zip entry %q: %v", name, err) + } + if _, err := io.WriteString(w, content); err != nil { + t.Fatalf("Write zip entry %q: %v", name, err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("Close zip writer: %v", err) + } + return buf.Bytes() +} + +func buildTestTarGz(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + + for name, content := range files { + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Mode: 0o755, + Size: int64(len(content)), + }); err != nil { + t.Fatalf("Write tar header %q: %v", name, err) + } + if _, err := io.WriteString(tw, content); err != nil { + t.Fatalf("Write tar entry %q: %v", name, err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("Close tar writer: %v", err) + } + if err := gzw.Close(); err != nil { + t.Fatalf("Close gzip writer: %v", err) + } + return buf.Bytes() +} + +func writeReleasePayload(w http.ResponseWriter, payload testReleasePayload) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(payload) +} + +func withTestHTTPClient(t *testing.T, client *http.Client) { + t.Helper() + + origClient := httpClient + httpClient = client + httpClient.Timeout = 5 * time.Second + t.Cleanup(func() { + httpClient = origClient + }) +} diff --git a/pkg/utils/bm25.go b/pkg/utils/bm25.go new file mode 100644 index 000000000..f8b9f6882 --- /dev/null +++ b/pkg/utils/bm25.go @@ -0,0 +1,289 @@ +// Package utils provides shared, reusable algorithms. +// This file implements a generic BM25 search engine. +// +// Usage: +// +// type MyDoc struct { ID string; Body string } +// +// corpus := []MyDoc{...} +// engine := bm25.New(corpus, func(d MyDoc) string { +// return d.ID + " " + d.Body +// }) +// results := engine.Search("my query", 5) +package utils + +import ( + "math" + "sort" + "strings" +) + +// ── Tuning defaults ─────────────────────────────────────────────────────────── + +const ( + // DefaultBM25K1 is the term-frequency saturation factor (typical range 1.2–2.0). + // Higher values give more weight to repeated terms. + DefaultBM25K1 = 1.2 + + // DefaultBM25B is the document-length normalization factor (0 = none, 1 = full). + DefaultBM25B = 0.75 +) + +// BM25Engine is a BM25 search engine over a generic corpus. +// T is the document type; the caller supplies a TextFunc that extracts the +// searchable text from each document. +// +// The engine precomputes its index once at construction time and reuses it for +// subsequent searches. If the corpus content changes, construct a new engine. +type BM25Engine[T any] struct { + corpus []T + textFunc func(T) string + k1 float64 + b float64 + index *bm25Index +} + +// BM25Option is a functional option to configure a BM25Engine. +type BM25Option func(*bm25Config) + +type bm25Config struct { + k1 float64 + b float64 +} + +type bm25Index struct { + entries []bm25DocEntry + idf map[string]float32 + docLenNorm []float32 + posting map[string][]int32 +} + +type bm25DocEntry struct { + tf map[string]uint32 +} + +// WithK1 overrides the term-frequency saturation constant (default 1.2). +func WithK1(k1 float64) BM25Option { + return func(c *bm25Config) { c.k1 = k1 } +} + +// WithB overrides the document-length normalization factor (default 0.75). +func WithB(b float64) BM25Option { + return func(c *bm25Config) { c.b = b } +} + +// NewBM25Engine creates a BM25Engine for the given corpus. +// +// - corpus : slice of documents of any type T. +// - textFunc : function that returns the searchable text for a document. +// - opts : optional tuning (WithK1, WithB). +// +// The corpus slice is referenced, not copied. Callers must not mutate it +// concurrently with Search(). +func NewBM25Engine[T any](corpus []T, textFunc func(T) string, opts ...BM25Option) *BM25Engine[T] { + cfg := bm25Config{k1: DefaultBM25K1, b: DefaultBM25B} + for _, o := range opts { + o(&cfg) + } + engine := &BM25Engine[T]{ + corpus: corpus, + textFunc: textFunc, + k1: cfg.k1, + b: cfg.b, + } + engine.index = buildBM25Index(corpus, textFunc, cfg.k1, cfg.b) + return engine +} + +// BM25Result is a single ranked result from a Search call. +type BM25Result[T any] struct { + Document T + Score float32 +} + +// Search ranks the corpus against query and returns the top-k results. +// Returns an empty slice (not nil) when there are no matches. +// +// Complexity: O(|Q|×avgPostingLen + candidates × log k) per search after the +// one-time indexing work performed by NewBM25Engine. +func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] { + if topK <= 0 { + return []BM25Result[T]{} + } + + queryTerms := bm25Tokenize(query) + if len(queryTerms) == 0 { + return []BM25Result[T]{} + } + + if len(e.corpus) == 0 || e.index == nil { + return []BM25Result[T]{} + } + + // Step 4: score via posting lists + // Deduplicate query terms to avoid double-weighting the same term. + unique := bm25Dedupe(queryTerms) + + scores := make(map[int32]float32) + for _, term := range unique { + termIDF, ok := e.index.idf[term] + if !ok { + continue // term not in vocabulary → zero contribution + } + for _, docID := range e.index.posting[term] { + freq := float32(e.index.entries[docID].tf[term]) + // TF_norm = freq * (k1+1) / (freq + docLenNorm) + tfNorm := freq * float32(e.k1+1) / (freq + e.index.docLenNorm[docID]) + scores[docID] += termIDF * tfNorm + } + } + + if len(scores) == 0 { + return []BM25Result[T]{} + } + + // Step 5: top-K via fixed-size min-heap + heap := make([]bm25ScoredDoc, 0, topK) + + for docID, sc := range scores { + switch { + case len(heap) < topK: + heap = append(heap, bm25ScoredDoc{docID: docID, score: sc}) + if len(heap) == topK { + bm25MinHeapify(heap) + } + case sc > heap[0].score: + heap[0] = bm25ScoredDoc{docID: docID, score: sc} + bm25SiftDown(heap, 0) + } + } + + sort.Slice(heap, func(i, j int) bool { return heap[i].score > heap[j].score }) + + out := make([]BM25Result[T], len(heap)) + for i, h := range heap { + out[i] = BM25Result[T]{ + Document: e.corpus[h.docID], + Score: h.score, + } + } + return out +} + +func buildBM25Index[T any](corpus []T, textFunc func(T) string, k1, b float64) *bm25Index { + N := len(corpus) + if N == 0 { + return nil + } + + entries := make([]bm25DocEntry, N) + rawLens := make([]int, N) + df := make(map[string]int, 64) + totalLen := 0 + + for i, doc := range corpus { + tokens := bm25Tokenize(textFunc(doc)) + totalLen += len(tokens) + rawLens[i] = len(tokens) + + tf := make(map[string]uint32, len(tokens)) + for _, t := range tokens { + tf[t]++ + } + for term := range tf { + df[term]++ + } + + entries[i] = bm25DocEntry{tf: tf} + } + + avgDocLen := float64(totalLen) / float64(N) + if avgDocLen == 0 { + avgDocLen = 1 + } + + idf := make(map[string]float32, len(df)) + for term, freq := range df { + idf[term] = float32(math.Log( + (float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1, + )) + } + + docLenNorm := make([]float32, N) + for i, rawLen := range rawLens { + docLenNorm[i] = float32(k1 * (1 - b + b*float64(rawLen)/avgDocLen)) + } + + posting := make(map[string][]int32, len(df)) + for i, entry := range entries { + for term := range entry.tf { + posting[term] = append(posting[term], int32(i)) + } + } + + return &bm25Index{ + entries: entries, + idf: idf, + docLenNorm: docLenNorm, + posting: posting, + } +} + +// bm25Tokenize splits s into lowercase tokens, stripping edge punctuation. +func bm25Tokenize(s string) []string { + raw := strings.Fields(strings.ToLower(s)) + out := raw[:0] // reuse backing array to avoid extra allocation + for _, t := range raw { + t = strings.Trim(t, ".,;:!?\"'()/\\-_") + if t != "" { + out = append(out, t) + } + } + return out +} + +// bm25Dedupe returns a new slice with duplicate tokens removed, +// preserving first-occurrence order. +func bm25Dedupe(tokens []string) []string { + seen := make(map[string]struct{}, len(tokens)) + out := make([]string, 0, len(tokens)) + for _, t := range tokens { + if _, ok := seen[t]; !ok { + seen[t] = struct{}{} + out = append(out, t) + } + } + return out +} + +type bm25ScoredDoc struct { + docID int32 + score float32 +} + +// bm25MinHeapify builds a min-heap in-place using Floyd's algorithm: O(k). +func bm25MinHeapify(h []bm25ScoredDoc) { + for i := len(h)/2 - 1; i >= 0; i-- { + bm25SiftDown(h, i) + } +} + +// bm25SiftDown restores the min-heap property starting at node i: O(log k). +func bm25SiftDown(h []bm25ScoredDoc, i int) { + n := len(h) + for { + smallest := i + l, r := 2*i+1, 2*i+2 + if l < n && h[l].score < h[smallest].score { + smallest = l + } + if r < n && h[r].score < h[smallest].score { + smallest = r + } + if smallest == i { + break + } + h[i], h[smallest] = h[smallest], h[i] + i = smallest + } +} diff --git a/pkg/utils/bm25_test.go b/pkg/utils/bm25_test.go new file mode 100644 index 000000000..216fe733d --- /dev/null +++ b/pkg/utils/bm25_test.go @@ -0,0 +1,235 @@ +package utils + +import ( + "fmt" + "reflect" + "strings" + "testing" +) + +// testDoc is a generic structure for use in tests. +type testDoc struct { + ID int + Text string +} + +func extractText(d testDoc) string { + return d.Text +} + +func TestBM25Search_EdgeCases(t *testing.T) { + corpus := []testDoc{ + {1, "hello world"}, + {2, "foo bar"}, + } + engine := NewBM25Engine(corpus, extractText) + + tests := []struct { + name string + query string + topK int + }{ + {"Zero topK", "hello", 0}, + {"Negative topK", "hello", -1}, + {"Empty query", "", 5}, + {"Query with only punctuation", "...,,,!!!", 5}, + {"No matches found", "golang", 5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := engine.Search(tt.query, tt.topK) + if len(results) != 0 { + t.Errorf("expected 0 results, got %d", len(results)) + } + // Check that it never returns nil, but an empty slice + if results == nil { + t.Errorf("expected empty slice, got nil") + } + }) + } +} + +func TestBM25Search_EmptyCorpus(t *testing.T) { + engine := NewBM25Engine([]testDoc{}, extractText) + results := engine.Search("hello", 5) + if len(results) != 0 || results == nil { + t.Errorf("expected empty slice from empty corpus, got %v", results) + } +} + +func TestBM25Search_RankingLogic(t *testing.T) { + corpus := []testDoc{ + {1, "the quick brown fox jumps over the lazy dog"}, + {2, "quick fox"}, + {3, "quick quick quick fox"}, // High Term Frequency (TF) + {4, "completely irrelevant document here"}, + } + engine := NewBM25Engine(corpus, extractText) + + t.Run("Term Frequency (TF) boosts score", func(t *testing.T) { + results := engine.Search("quick", 5) + if len(results) < 3 { + t.Fatalf("expected at least 3 results, got %d", len(results)) + } + // Doc 3 has the word "quick" repeated 3 times, it should beat Doc 2 + if results[0].Document.ID != 3 { + t.Errorf("expected doc 3 to rank first due to high TF, got doc %d", results[0].Document.ID) + } + }) + + t.Run("Document Length penalty", func(t *testing.T) { + results := engine.Search("fox", 5) + if len(results) < 3 { + t.Fatalf("expected at least 3 results, got %d", len(results)) + } + // Doc 2 ("quick fox") is much shorter than Doc 1 ("the quick brown fox..."), + // so, with equal Term Frequency for the word "fox" (1 time), Doc 2 wins. + if results[0].Document.ID != 2 { + t.Errorf("expected doc 2 to rank first due to shorter length, got doc %d", results[0].Document.ID) + } + }) + + t.Run("TopK limits results", func(t *testing.T) { + results := engine.Search("quick", 2) + if len(results) != 2 { + t.Errorf("expected exactly 2 results, got %d", len(results)) + } + }) +} + +func TestBM25Tokenize(t *testing.T) { + tests := []struct { + input string + expected []string + }{ + {"Hello World", []string{"hello", "world"}}, + {" spaces everywhere ", []string{"spaces", "everywhere"}}, + {"punctuation... test!!!", []string{"punctuation", "test"}}, + {"(parentheses) and-hyphens", []string{"parentheses", "and-hyphens"}}, // hyphens trimmed from edges + {"internal-hyphen is kept", []string{"internal-hyphen", "is", "kept"}}, + {".,;?!", []string{}}, // Becomes empty after trim + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := bm25Tokenize(tt.input) + if len(got) == 0 && len(tt.expected) == 0 { + return // Both empty + } + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("bm25Tokenize(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} + +func TestBM25Dedupe(t *testing.T) { + input := []string{"apple", "banana", "apple", "orange", "banana"} + expected := []string{"apple", "banana", "orange"} + + got := bm25Dedupe(input) + if !reflect.DeepEqual(got, expected) { + t.Errorf("bm25Dedupe() = %v, want %v", got, expected) + } +} + +func TestBM25Options(t *testing.T) { + corpus := []testDoc{{1, "test"}} + + engine := NewBM25Engine( + corpus, + extractText, + WithK1(2.5), + WithB(0.9), + ) + + if engine.k1 != 2.5 { + t.Errorf("expected k1 to be 2.5, got %v", engine.k1) + } + if engine.b != 0.9 { + t.Errorf("expected b to be 0.9, got %v", engine.b) + } +} + +func TestBM25Search_SortingStability(t *testing.T) { + // Ensure that sorting by heap returns in correct descending order + corpus := []testDoc{ + {1, "golang is good"}, + {2, "golang golang"}, + {3, "golang golang golang"}, + {4, "golang golang golang golang"}, + } + engine := NewBM25Engine(corpus, extractText) + results := engine.Search("golang", 10) + + if len(results) != 4 { + t.Fatalf("expected 4 results, got %d", len(results)) + } + + // Score should be strictly decreasing + for i := 1; i < len(results); i++ { + if results[i].Score > results[i-1].Score { + t.Errorf("results not sorted correctly: result %d score (%v) > result %d score (%v)", + i, results[i].Score, i-1, results[i-1].Score) + } + } +} + +func BenchmarkBM25Search_ReusedIndex(b *testing.B) { + corpus := benchmarkBM25Corpus(2000) + engine := NewBM25Engine(corpus, extractText) + query := "hardware gpio i2c sensor controller latency" + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + results := engine.Search(query, 10) + if len(results) == 0 { + b.Fatal("expected non-empty results") + } + } +} + +func BenchmarkBM25Search_RebuildEachTime(b *testing.B) { + corpus := benchmarkBM25Corpus(2000) + query := "hardware gpio i2c sensor controller latency" + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + engine := NewBM25Engine(corpus, extractText) + results := engine.Search(query, 10) + if len(results) == 0 { + b.Fatal("expected non-empty results") + } + } +} + +func benchmarkBM25Corpus(size int) []testDoc { + corpus := make([]testDoc, size) + topics := []string{ + "hardware gpio pwm adc sensor controller latency throughput", + "telegram markdown parser message escape formatting bot command", + "jsonl memory session history storage append compact recovery", + "openai provider routing agent tool search registry hidden tools", + "i2c spi uart serial device bus address transfer clock", + } + + for i := range corpus { + topic := topics[i%len(topics)] + corpus[i] = testDoc{ + ID: i, + Text: fmt.Sprintf( + "doc %d %s repeated repeated %s variant-%d %s", + i, + topic, + topic, + i%17, + strings.Repeat("token ", (i%7)+1), + ), + } + } + + return corpus +} diff --git a/pkg/utils/context.go b/pkg/utils/context.go new file mode 100644 index 000000000..2007de9a3 --- /dev/null +++ b/pkg/utils/context.go @@ -0,0 +1,173 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package utils + +import ( + "encoding/json" + "fmt" + "unicode/utf8" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +// CalculateDefaultMaxContextRunes computes a default context limit based on the model's context window. +// Strategy: Use 75% of the context window and convert to rune estimate. +// +// Token-to-rune conversion ratios (conservative estimates): +// - English: ~4 chars per token +// - Chinese: ~1.5-2 chars per token +// - Mixed: ~3 chars per token (used here for safety) +func CalculateDefaultMaxContextRunes(contextWindow int) int { + if contextWindow <= 0 { + // Conservative fallback when context window is unknown + return 8000 // ~2000 tokens + } + + // Use 75% of context window to leave headroom + targetTokens := int(float64(contextWindow) * 0.75) + + // Convert tokens to runes using conservative ratio + const avgCharsPerToken = 3 + return targetTokens * avgCharsPerToken +} + +// ResolveMaxContextRunes determines the final MaxContextRunes value to use. +// Priority: explicit config > auto-calculate > conservative default +func ResolveMaxContextRunes(configValue, contextWindow int) int { + switch { + case configValue > 0: + // Explicitly configured, use as-is + return configValue + case configValue == -1: + // Explicitly disabled + return -1 + default: + // 0 or unset: auto-calculate + return CalculateDefaultMaxContextRunes(contextWindow) + } +} + +// MeasureContextRunes calculates the total rune count of a message list. +// Includes content, reasoning content, and estimates for tool calls. +func MeasureContextRunes(messages []providers.Message) int { + totalRunes := 0 + for _, msg := range messages { + totalRunes += utf8.RuneCountInString(msg.Content) + totalRunes += utf8.RuneCountInString(msg.ReasoningContent) + + // Tool calls: serialize to JSON and count + if len(msg.ToolCalls) > 0 { + for _, tc := range msg.ToolCalls { + totalRunes += utf8.RuneCountInString(tc.Name) + // Arguments: serialize and count + if argsJSON, err := json.Marshal(tc.Arguments); err == nil { + totalRunes += utf8.RuneCount(argsJSON) + } else { + // Fallback estimate if serialization fails + totalRunes += 100 + } + } + } + + // ToolCallID + totalRunes += utf8.RuneCountInString(msg.ToolCallID) + } + return totalRunes +} + +// TruncateContextSmart intelligently truncates message history to fit within maxRunes. +// +// Strategy: +// 1. Always preserve system messages (they define the agent's behavior) +// 2. Keep the most recent messages (they contain current context) +// 3. Drop older middle messages when necessary +// 4. Insert a truncation notice to inform the LLM +// +// Returns the truncated message list. +func TruncateContextSmart(messages []providers.Message, maxRunes int) []providers.Message { + if len(messages) == 0 { + return messages + } + + // Separate system messages from others + var systemMsgs []providers.Message + var otherMsgs []providers.Message + + for _, msg := range messages { + if msg.Role == "system" { + systemMsgs = append(systemMsgs, msg) + } else { + otherMsgs = append(otherMsgs, msg) + } + } + + // Calculate system message size + systemRunes := 0 + for _, msg := range systemMsgs { + systemRunes += utf8.RuneCountInString(msg.Content) + systemRunes += utf8.RuneCountInString(msg.ReasoningContent) + } + + // Reserve space for truncation notice (estimate ~80 runes) + const truncationNoticeEstimate = 80 + + // Allocate remaining space for other messages + remainingRunes := maxRunes - systemRunes - truncationNoticeEstimate + if remainingRunes <= 0 { + // System messages already exceed limit - return only system messages + return systemMsgs + } + + // Collect recent messages in reverse order until we hit the limit + var keptMsgs []providers.Message + currentRunes := 0 + + for i := len(otherMsgs) - 1; i >= 0; i-- { + msg := otherMsgs[i] + msgRunes := utf8.RuneCountInString(msg.Content) + + utf8.RuneCountInString(msg.ReasoningContent) + + // Estimate tool call size + if len(msg.ToolCalls) > 0 { + for _, tc := range msg.ToolCalls { + msgRunes += utf8.RuneCountInString(tc.Name) + if argsJSON, err := json.Marshal(tc.Arguments); err == nil { + msgRunes += utf8.RuneCount(argsJSON) + } else { + msgRunes += 100 + } + } + } + msgRunes += utf8.RuneCountInString(msg.ToolCallID) + + if currentRunes+msgRunes > remainingRunes { + // Would exceed limit, stop collecting + break + } + + // Prepend to maintain chronological order + keptMsgs = append([]providers.Message{msg}, keptMsgs...) + currentRunes += msgRunes + } + + // If we dropped messages, add a truncation notice + result := systemMsgs + if len(keptMsgs) < len(otherMsgs) { + droppedCount := len(otherMsgs) - len(keptMsgs) + truncationNotice := providers.Message{ + Role: "system", + Content: fmt.Sprintf( + "[Context truncated: %d earlier messages omitted to stay within context limits]", + droppedCount, + ), + } + result = append(result, truncationNotice) + } + + result = append(result, keptMsgs...) + return result +} diff --git a/pkg/utils/context_test.go b/pkg/utils/context_test.go new file mode 100644 index 000000000..450a29249 --- /dev/null +++ b/pkg/utils/context_test.go @@ -0,0 +1,450 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package utils + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestCalculateDefaultMaxContextRunes(t *testing.T) { + tests := []struct { + name string + contextWindow int + want int + }{ + { + name: "zero context window uses fallback", + contextWindow: 0, + want: 8000, + }, + { + name: "negative context window uses fallback", + contextWindow: -1, + want: 8000, + }, + { + name: "small context window (4k tokens)", + contextWindow: 4000, + want: 9000, // 4000 * 0.75 * 3 = 9000 + }, + { + name: "medium context window (128k tokens)", + contextWindow: 128000, + want: 288000, // 128000 * 0.75 * 3 = 288000 + }, + { + name: "large context window (1M tokens)", + contextWindow: 1000000, + want: 2250000, // 1000000 * 0.75 * 3 = 2250000 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CalculateDefaultMaxContextRunes(tt.contextWindow) + if got != tt.want { + t.Errorf("CalculateDefaultMaxContextRunes(%d) = %d, want %d", + tt.contextWindow, got, tt.want) + } + }) + } +} + +func TestResolveMaxContextRunes(t *testing.T) { + tests := []struct { + name string + configValue int + contextWindow int + want int + }{ + { + name: "explicit positive value", + configValue: 12000, + contextWindow: 4000, + want: 12000, + }, + { + name: "explicit disable (-1)", + configValue: -1, + contextWindow: 4000, + want: -1, + }, + { + name: "zero uses auto-calculate", + configValue: 0, + contextWindow: 4000, + want: 9000, // 4000 * 0.75 * 3 + }, + { + name: "unset (0) with unknown context window", + configValue: 0, + contextWindow: 0, + want: 8000, // fallback + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveMaxContextRunes(tt.configValue, tt.contextWindow) + if got != tt.want { + t.Errorf("ResolveMaxContextRunes(%d, %d) = %d, want %d", + tt.configValue, tt.contextWindow, got, tt.want) + } + }) + } +} + +func TestMeasureContextRunes(t *testing.T) { + tests := []struct { + name string + messages []providers.Message + want int + }{ + { + name: "empty messages", + messages: []providers.Message{}, + want: 0, + }, + { + name: "single simple message", + messages: []providers.Message{ + {Role: "user", Content: "Hello"}, + }, + want: 5, // "Hello" = 5 runes + }, + { + name: "message with reasoning", + messages: []providers.Message{ + { + Role: "assistant", + Content: "Answer", + ReasoningContent: "Thinking", + }, + }, + want: 14, // "Answer" (6) + "Thinking" (8) = 14 + }, + { + name: "message with tool call", + messages: []providers.Message{ + { + Role: "assistant", + Content: "Using tool", + ToolCalls: []providers.ToolCall{ + { + Name: "test_tool", + Arguments: map[string]any{"key": "value"}, + }, + }, + }, + }, + want: 10 + 9 + 15, // "Using tool" + "test_tool" + {"key":"value"} + }, + { + name: "multiple messages", + messages: []providers.Message{ + {Role: "system", Content: "You are helpful"}, + {Role: "user", Content: "Hi"}, + {Role: "assistant", Content: "Hello!"}, + }, + want: 15 + 2 + 6, // 15 + 2 + 6 = 23 + }, + { + name: "unicode characters", + messages: []providers.Message{ + {Role: "user", Content: "\u4f60\u597d\u4e16\u754c"}, // 4 Chinese characters + }, + want: 4, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MeasureContextRunes(tt.messages) + if got != tt.want { + t.Errorf("MeasureContextRunes() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestTruncateContextSmart(t *testing.T) { + tests := []struct { + name string + messages []providers.Message + maxRunes int + wantLen int + wantHas []string // Content strings that should be present + wantNot []string // Content strings that should be absent + }{ + { + name: "empty messages", + messages: []providers.Message{}, + maxRunes: 100, + wantLen: 0, + }, + { + name: "no truncation needed", + messages: []providers.Message{ + {Role: "system", Content: "System"}, + {Role: "user", Content: "Hello"}, + }, + maxRunes: 100, + wantLen: 2, + wantHas: []string{"System", "Hello"}, + }, + { + name: "truncate when limit is tight", + messages: []providers.Message{ + {Role: "system", Content: "System"}, + {Role: "user", Content: "Message 1 with some content here"}, + {Role: "assistant", Content: "Response 1 with some content here"}, + {Role: "user", Content: "Message 2 with some content here"}, + {Role: "assistant", Content: "Response 2 with some content here"}, + {Role: "user", Content: "Latest"}, + }, + maxRunes: 120, // Tight limit to force truncation + wantLen: -1, // Don't check exact length, just verify truncation occurred + wantHas: []string{"System", "Latest"}, + wantNot: []string{"Message 1", "Response 1"}, + }, + { + name: "system messages exceed limit", + messages: []providers.Message{ + {Role: "system", Content: "Very long system message"}, + {Role: "user", Content: "User message"}, + }, + maxRunes: 10, // Less than system message + wantLen: 1, // Only system message + wantHas: []string{"Very long system message"}, + wantNot: []string{"User message"}, + }, + { + name: "preserve multiple system messages", + messages: []providers.Message{ + {Role: "system", Content: "Sys1"}, + {Role: "system", Content: "Sys2"}, + {Role: "user", Content: "Old"}, + {Role: "user", Content: "New"}, + }, + maxRunes: 200, // Generous limit + wantLen: 4, // Both system + truncation notice + new + wantHas: []string{"Sys1", "Sys2", "New"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TruncateContextSmart(tt.messages, tt.maxRunes) + + if tt.wantLen >= 0 && len(got) != tt.wantLen { + t.Errorf("TruncateContextSmart() returned %d messages, want %d", + len(got), tt.wantLen) + } + + // Check for expected content + allContent := "" + for _, msg := range got { + allContent += msg.Content + " " + } + + for _, want := range tt.wantHas { + found := false + for _, msg := range got { + if msg.Content == want || containsSubstring(msg.Content, want) { + found = true + break + } + } + if !found { + t.Errorf("Expected content %q not found in truncated messages", want) + } + } + + for _, notWant := range tt.wantNot { + for _, msg := range got { + if containsSubstring(msg.Content, notWant) { + t.Errorf("Unexpected content %q found in truncated messages", notWant) + } + } + } + }) + } +} + +func containsSubstring(s, substr string) bool { + return len(s) >= len(substr) && findSubstring(s, substr) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// TestSubTurnConfigMaxContextRunes verifies that MaxContextRunes configuration +// is properly integrated into the SubTurn execution flow. +func TestSubTurnConfigMaxContextRunes(t *testing.T) { + tests := []struct { + name string + maxContextRunes int + contextWindow int + wantResolved int + }{ + { + name: "default (0) auto-calculates from context window", + maxContextRunes: 0, + contextWindow: 4000, + wantResolved: 9000, // 4000 * 0.75 * 3 + }, + { + name: "explicit value is used", + maxContextRunes: 12000, + contextWindow: 4000, + wantResolved: 12000, + }, + { + name: "disabled (-1) returns -1", + maxContextRunes: -1, + contextWindow: 4000, + wantResolved: -1, + }, + { + name: "fallback when context window unknown", + maxContextRunes: 0, + contextWindow: 0, + wantResolved: 8000, // conservative fallback + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveMaxContextRunes(tt.maxContextRunes, tt.contextWindow) + if got != tt.wantResolved { + t.Errorf("utils.ResolveMaxContextRunes(%d, %d) = %d, want %d", + tt.maxContextRunes, tt.contextWindow, got, tt.wantResolved) + } + }) + } +} + +// TestContextTruncationFlow verifies the complete context truncation flow: +// 1. Messages accumulate beyond soft limit +// 2. Truncation is triggered +// 3. System messages are preserved +// 4. Recent messages are kept +func TestContextTruncationFlow(t *testing.T) { + // Build a message history that exceeds the limit + messages := []providers.Message{ + {Role: "system", Content: "You are a helpful assistant"}, // ~27 runes + {Role: "user", Content: "First question"}, // ~14 runes + {Role: "assistant", Content: "First answer"}, // ~12 runes + {Role: "user", Content: "Second question"}, // ~15 runes + {Role: "assistant", Content: "Second answer"}, // ~13 runes + {Role: "user", Content: "Third question"}, // ~14 runes + {Role: "assistant", Content: "Third answer"}, // ~12 runes + {Role: "user", Content: "Latest question"}, // ~15 runes + } + + // Total: ~122 runes + totalRunes := MeasureContextRunes(messages) + if totalRunes < 100 { + t.Errorf("Expected total runes > 100, got %d", totalRunes) + } + + // Set limit to 150 runes - should force truncation of old messages + // but preserve system + truncation notice + recent messages + maxRunes := 150 + truncated := TruncateContextSmart(messages, maxRunes) + + // Verify truncation occurred + if len(truncated) >= len(messages) { + t.Errorf("Expected truncation, but got %d messages (original: %d)", + len(truncated), len(messages)) + } + + // Verify system message is preserved + foundSystem := false + for _, msg := range truncated { + if msg.Role == "system" && msg.Content == "You are a helpful assistant" { + foundSystem = true + break + } + } + if !foundSystem { + t.Error("System message was not preserved after truncation") + } + + // Verify latest message is preserved + foundLatest := false + for _, msg := range truncated { + if msg.Content == "Latest question" { + foundLatest = true + break + } + } + if !foundLatest { + t.Error("Latest message was not preserved after truncation") + } + + // Verify truncation notice is present + foundNotice := false + for _, msg := range truncated { + if msg.Role == "system" && containsSubstring(msg.Content, "truncated") { + foundNotice = true + break + } + } + if !foundNotice { + t.Error("Truncation notice was not added") + } + + // Verify result is within limit (with some tolerance for estimation) + resultRunes := MeasureContextRunes(truncated) + if resultRunes > maxRunes+20 { // Allow 20 rune tolerance + t.Errorf("Truncated context (%d runes) significantly exceeds limit (%d runes)", + resultRunes, maxRunes) + } +} + +// TestContextTruncationPreservesToolCalls verifies that tool calls are +// properly handled during context truncation. +func TestContextTruncationPreservesToolCalls(t *testing.T) { + messages := []providers.Message{ + {Role: "system", Content: "System"}, + {Role: "user", Content: "Old message that should be dropped"}, + { + Role: "assistant", + Content: "Recent tool use", + ToolCalls: []providers.ToolCall{ + { + Name: "important_tool", + Arguments: map[string]any{"key": "value"}, + }, + }, + }, + } + + // Set a generous limit that should keep the tool call message + maxRunes := 200 + truncated := TruncateContextSmart(messages, maxRunes) + + // Verify tool call message is preserved + foundToolCall := false + for _, msg := range truncated { + if len(msg.ToolCalls) > 0 && msg.ToolCalls[0].Name == "important_tool" { + foundToolCall = true + break + } + } + if !foundToolCall { + t.Error("Tool call message was not preserved during truncation") + } +} diff --git a/pkg/utils/http_client.go b/pkg/utils/http_client.go new file mode 100644 index 000000000..bda7c5c83 --- /dev/null +++ b/pkg/utils/http_client.go @@ -0,0 +1,48 @@ +package utils + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// CreateHTTPClient creates an HTTP client with optional proxy support. +// If proxyURL is empty, it uses the system environment proxy settings. +// Supported proxy schemes: http, https, socks5, socks5h. +func CreateHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) { + client := &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + DisableCompression: false, + TLSHandshakeTimeout: 15 * time.Second, + }, + } + + if proxyURL != "" { + proxy, err := url.Parse(proxyURL) + if err != nil { + return nil, fmt.Errorf("invalid proxy URL: %w", err) + } + scheme := strings.ToLower(proxy.Scheme) + switch scheme { + case "http", "https", "socks5", "socks5h": + default: + return nil, fmt.Errorf( + "unsupported proxy scheme %q (supported: http, https, socks5, socks5h)", + proxy.Scheme, + ) + } + if proxy.Host == "" { + return nil, fmt.Errorf("invalid proxy URL: missing host") + } + client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy) + } else { + client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment + } + + return client, nil +} diff --git a/pkg/utils/http_client_test.go b/pkg/utils/http_client_test.go new file mode 100644 index 000000000..ff3d0429b --- /dev/null +++ b/pkg/utils/http_client_test.go @@ -0,0 +1,110 @@ +package utils + +import ( + "net/http" + "strings" + "testing" + "time" +) + +func TestCreateHTTPClient_ProxyConfigured(t *testing.T) { + client, err := CreateHTTPClient("http://127.0.0.1:7890", 12*time.Second) + if err != nil { + t.Fatalf("createHTTPClient() error: %v", err) + } + if client.Timeout != 12*time.Second { + t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second) + } + + tr, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) + } + if tr.Proxy == nil { + t.Fatal("transport.Proxy is nil, want non-nil") + } + + req, err := http.NewRequest("GET", "https://example.com", nil) + if err != nil { + t.Fatalf("http.NewRequest() error: %v", err) + } + proxyURL, err := tr.Proxy(req) + if err != nil { + t.Fatalf("transport.Proxy(req) error: %v", err) + } + if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" { + t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890") + } +} + +func TestCreateHTTPClient_InvalidProxy(t *testing.T) { + _, err := CreateHTTPClient("://bad-proxy", 10*time.Second) + if err == nil { + t.Fatal("createHTTPClient() expected error for invalid proxy URL, got nil") + } +} + +func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) { + client, err := CreateHTTPClient("socks5://127.0.0.1:1080", 8*time.Second) + if err != nil { + t.Fatalf("createHTTPClient() error: %v", err) + } + + tr, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) + } + req, err := http.NewRequest("GET", "https://example.com", nil) + if err != nil { + t.Fatalf("http.NewRequest() error: %v", err) + } + proxyURL, err := tr.Proxy(req) + if err != nil { + t.Fatalf("transport.Proxy(req) error: %v", err) + } + if proxyURL == nil || proxyURL.String() != "socks5://127.0.0.1:1080" { + t.Fatalf("proxy URL = %v, want %q", proxyURL, "socks5://127.0.0.1:1080") + } +} + +func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) { + _, err := CreateHTTPClient("ftp://127.0.0.1:21", 10*time.Second) + if err == nil { + t.Fatal("createHTTPClient() expected error for unsupported scheme, got nil") + } + if !strings.Contains(err.Error(), "unsupported proxy scheme") { + t.Fatalf("error = %q, want to contain %q", err.Error(), "unsupported proxy scheme") + } +} + +func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) { + t.Setenv("HTTP_PROXY", "http://127.0.0.1:8888") + t.Setenv("http_proxy", "http://127.0.0.1:8888") + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888") + t.Setenv("https_proxy", "http://127.0.0.1:8888") + t.Setenv("ALL_PROXY", "") + t.Setenv("all_proxy", "") + t.Setenv("NO_PROXY", "") + t.Setenv("no_proxy", "") + + client, err := CreateHTTPClient("", 10*time.Second) + if err != nil { + t.Fatalf("createHTTPClient() error: %v", err) + } + + tr, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) + } + if tr.Proxy == nil { + t.Fatal("transport.Proxy is nil, want proxy function from environment") + } + + req, err := http.NewRequest("GET", "https://example.com", nil) + if err != nil { + t.Fatalf("http.NewRequest() error: %v", err) + } + if _, err := tr.Proxy(req); err != nil { + t.Fatalf("transport.Proxy(req) error: %v", err) + } +} diff --git a/pkg/utils/http_retry.go b/pkg/utils/http_retry.go index 135ea0ef5..514f9781b 100644 --- a/pkg/utils/http_retry.go +++ b/pkg/utils/http_retry.go @@ -4,12 +4,16 @@ import ( "context" "fmt" "net/http" + "strconv" "time" ) const maxRetries = 3 -var retryDelayUnit = time.Second +var ( + retryDelayUnit = time.Second + maxRetrySleepDuration = 1 * time.Minute +) func shouldRetry(statusCode int) bool { return statusCode == http.StatusTooManyRequests || @@ -36,7 +40,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response, } if i < maxRetries-1 { - if err = sleepWithCtx(req.Context(), retryDelayUnit*time.Duration(i+1)); err != nil { + if err = sleepWithCtx(req.Context(), retryDelayForAttempt(resp, i)); err != nil { if resp != nil { resp.Body.Close() } @@ -47,6 +51,57 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response, return resp, err } +func retryDelayForAttempt(resp *http.Response, attempt int) time.Duration { + fallback := retryDelayUnit * time.Duration(attempt+1) + if resp == nil || resp.StatusCode != http.StatusTooManyRequests { + return clampRetryDelay(fallback) + } + + retryAfter := resp.Header.Get("Retry-After") + if retryAfter == "" { + return clampRetryDelay(fallback) + } + + if delay, ok := numericRetryAfterDelay(retryAfter); ok { + return delay + } + + if when, err := http.ParseTime(retryAfter); err == nil { + delay := time.Until(when) + if serverDate, err := http.ParseTime(resp.Header.Get("Date")); err == nil { + delay = when.Sub(serverDate) + } + if delay < 0 { + return 0 + } + return clampRetryDelay(delay) + } + + return clampRetryDelay(fallback) +} + +func numericRetryAfterDelay(retryAfter string) (time.Duration, bool) { + seconds, err := strconv.ParseInt(retryAfter, 10, 64) + if err != nil || seconds < 0 { + return 0, false + } + maxSeconds := int64(maxRetrySleepDuration / time.Second) + if seconds > maxSeconds { + return maxRetrySleepDuration, true + } + return clampRetryDelay(time.Duration(seconds) * time.Second), true +} + +func clampRetryDelay(delay time.Duration) time.Duration { + if delay <= 0 { + return 0 + } + if delay > maxRetrySleepDuration { + return maxRetrySleepDuration + } + return delay +} + func sleepWithCtx(ctx context.Context, d time.Duration) error { timer := time.NewTimer(d) defer timer.Stop() diff --git a/pkg/utils/http_retry_test.go b/pkg/utils/http_retry_test.go index d64cd5eda..4d6021ff7 100644 --- a/pkg/utils/http_retry_test.go +++ b/pkg/utils/http_retry_test.go @@ -80,6 +80,81 @@ func TestDoRequestWithRetry(t *testing.T) { } } +func TestDoRequestWithRetry_RetryAfter429Honored(t *testing.T) { + retryDelayUnit = 10 * time.Millisecond + t.Cleanup(func() { retryDelayUnit = time.Second }) + + attempts := 0 + var firstAttemptAt time.Time + var secondAttemptAt time.Time + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts == 1 { + firstAttemptAt = time.Now() + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + if attempts == 2 { + secondAttemptAt = time.Now() + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := &http.Client{Timeout: 5 * time.Second} + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + require.NoError(t, err) + + resp, err := DoRequestWithRetry(client, req) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp.Body.Close() + require.Equal(t, 2, attempts) + + assert.GreaterOrEqual(t, secondAttemptAt.Sub(firstAttemptAt), 900*time.Millisecond) +} + +func TestDoRequestWithRetry_RetryAfter429InvalidFallsBack(t *testing.T) { + retryDelayUnit = 50 * time.Millisecond + t.Cleanup(func() { retryDelayUnit = time.Second }) + + attempts := 0 + var firstAttemptAt time.Time + var secondAttemptAt time.Time + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts == 1 { + firstAttemptAt = time.Now() + w.Header().Set("Retry-After", "invalid") + w.WriteHeader(http.StatusTooManyRequests) + return + } + if attempts == 2 { + secondAttemptAt = time.Now() + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := &http.Client{Timeout: 5 * time.Second} + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + require.NoError(t, err) + + resp, err := DoRequestWithRetry(client, req) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp.Body.Close() + require.Equal(t, 2, attempts) + + assert.GreaterOrEqual(t, secondAttemptAt.Sub(firstAttemptAt), 45*time.Millisecond) + assert.Less(t, secondAttemptAt.Sub(firstAttemptAt), 500*time.Millisecond) +} + func TestDoRequestWithRetry_ContextCancel(t *testing.T) { // Use a long retry delay so cancellation always hits during sleepWithCtx. retryDelayUnit = 10 * time.Second @@ -204,3 +279,87 @@ func TestDoRequestWithRetry_Delay(t *testing.T) { assert.GreaterOrEqual(t, delays[2], time.Millisecond) } + +func TestRetryDelayForAttempt_DateRetryAfterUsesResponseDateHeader(t *testing.T) { + maxRetrySleepDuration = time.Minute + t.Cleanup(func() { maxRetrySleepDuration = time.Minute }) + + serverDate := time.Date(2000, 1, 2, 15, 4, 5, 0, time.UTC) + retryAfterAt := serverDate.Add(10 * time.Second) + resp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{ + "Retry-After": []string{retryAfterAt.Format(http.TimeFormat)}, + "Date": []string{serverDate.Format(http.TimeFormat)}, + }, + } + + assert.Equal(t, 10*time.Second, retryDelayForAttempt(resp, 0)) +} + +func TestRetryDelayForAttempt_DateRetryAfterInvalidOrMissingDateFallsBackSafely(t *testing.T) { + maxRetrySleepDuration = 30 * time.Second + t.Cleanup(func() { maxRetrySleepDuration = time.Minute }) + + retryAfterAt := time.Now().UTC().Add(3 * time.Second).Format(http.TimeFormat) + testcases := []struct { + name string + header http.Header + }{ + { + name: "invalid-date-header", + header: http.Header{ + "Retry-After": []string{retryAfterAt}, + "Date": []string{"invalid-date"}, + }, + }, + { + name: "missing-date-header", + header: http.Header{ + "Retry-After": []string{retryAfterAt}, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: tc.header, + } + + delay := retryDelayForAttempt(resp, 0) + assert.Greater(t, delay, time.Duration(0)) + assert.GreaterOrEqual(t, delay, 1500*time.Millisecond) + assert.LessOrEqual(t, delay, 5*time.Second) + }) + } +} + +func TestRetryDelayForAttempt_RetryAfterIsCapped(t *testing.T) { + maxRetrySleepDuration = 2 * time.Second + t.Cleanup(func() { maxRetrySleepDuration = time.Minute }) + + resp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{ + "Retry-After": []string{"999999"}, + }, + } + + assert.Equal(t, 2*time.Second, retryDelayForAttempt(resp, 0)) +} + +func TestRetryDelayForAttempt_RetryAfterNumericOverflowStillCaps(t *testing.T) { + maxRetrySleepDuration = 2 * time.Second + t.Cleanup(func() { maxRetrySleepDuration = time.Minute }) + + resp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{ + "Retry-After": []string{"9223372036854775807"}, + }, + } + + assert.Equal(t, 2*time.Second, retryDelayForAttempt(resp, 0)) +} diff --git a/pkg/utils/markdown.go b/pkg/utils/markdown.go new file mode 100644 index 000000000..c7873252a --- /dev/null +++ b/pkg/utils/markdown.go @@ -0,0 +1,411 @@ +package utils + +import ( + "bytes" + "net/url" + "regexp" + "strconv" + "strings" + + "golang.org/x/net/html" +) + +var ( + reSpaces = regexp.MustCompile(`[ \t]+`) + reNewlines = regexp.MustCompile(`\n{3,}`) + reEmptyListItem = regexp.MustCompile(`(?m)^[-*]\s*$`) + reImageOnlyLink = regexp.MustCompile(`\[!\[\]\(<[^>]*>\)\]\(<[^>]*>\)`) + reEmptyHeader = regexp.MustCompile(`(?m)^#{1,6}\s*$`) + reLeadingLineSpace = regexp.MustCompile(`(?m)^([ \t])([^ \t\n])`) +) + +var skipTags = map[string]bool{ + "script": true, "style": true, "head": true, + "noscript": true, "template": true, + "nav": true, "footer": true, "aside": true, "header": true, "form": true, "dialog": true, +} + +func isSafeHref(href string) bool { + lower := strings.ToLower(strings.TrimSpace(href)) + if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "vbscript:") || + strings.HasPrefix(lower, "data:") { + return false + } + u, err := url.Parse(strings.TrimSpace(href)) + if err != nil { + return false + } + scheme := strings.ToLower(u.Scheme) + return scheme == "" || scheme == "http" || scheme == "https" || scheme == "mailto" +} + +func isSafeImageSrc(src string) bool { + lower := strings.ToLower(strings.TrimSpace(src)) + if strings.HasPrefix(lower, "data:image/") { + return true + } + return isSafeHref(src) +} + +func escapeMdAlt(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `[`, `\[`) + s = strings.ReplaceAll(s, `]`, `\]`) + return s +} + +func getAttr(n *html.Node, key string) string { + for _, a := range n.Attr { + if a.Key == key { + return a.Val + } + } + return "" +} + +func normalizeAttr(val string) string { + val = strings.ReplaceAll(val, "\n", "") + val = strings.ReplaceAll(val, "\r", "") + val = strings.ReplaceAll(val, "\t", "") + return strings.TrimSpace(val) +} + +func isUnlikelyNode(n *html.Node) bool { + if n.Type != html.ElementNode { + return false + } + classId := strings.ToLower(getAttr(n, "class") + " " + getAttr(n, "id")) + if classId == " " { + return false + } + if strings.Contains(classId, "article") || strings.Contains(classId, "main") || + strings.Contains(classId, "content") { + return false + } + unlikelyKeywords := []string{ + "menu", + "nav", + "footer", + "sidebar", + "cookie", + "banner", + "sponsor", + "advert", + "popup", + "modal", + "newsletter", + "share", + "social", + } + for _, keyword := range unlikelyKeywords { + if strings.Contains(classId, keyword) { + return true + } + } + return false +} + +type converter struct { + stack []*bytes.Buffer + linkHrefs []string + linkStates []bool + emphStack []string // Tracks "**", "*", "~~" for buffered emphasis + olCounters []int + inPre bool + listDepth int +} + +func newConverter() *converter { + return &converter{ + stack: []*bytes.Buffer{{}}, + } +} + +func (c *converter) write(s string) { + c.stack[len(c.stack)-1].WriteString(s) +} + +func (c *converter) pushBuf() { + c.stack = append(c.stack, &bytes.Buffer{}) +} + +func (c *converter) popBuf() string { + top := c.stack[len(c.stack)-1] + c.stack = c.stack[:len(c.stack)-1] + return top.String() +} + +func (c *converter) walk(n *html.Node) { + if n.Type == html.ElementNode { + if skipTags[n.Data] { + return + } + if isUnlikelyNode(n) { + return + } + } + + if n.Type == html.TextNode { + text := n.Data + if !c.inPre { + text = strings.ReplaceAll(text, "\n", " ") + text = reSpaces.ReplaceAllString(text, " ") + } + if text != "" { + c.write(text) + } + return + } + + if n.Type != html.ElementNode { + for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { + c.walk(ch) + } + return + } + + // Opening Tags + switch n.Data { + // Buffer emphasis content so we can TrimSpace the inner text, + // avoiding the regex-across-boundaries bug. + case "b", "strong": + c.emphStack = append(c.emphStack, "**") + c.pushBuf() + case "i", "em": + c.emphStack = append(c.emphStack, "*") + c.pushBuf() + case "del", "s": + c.emphStack = append(c.emphStack, "~~") + c.pushBuf() + + case "a": + href := normalizeAttr(getAttr(n, "href")) + if href != "" && !isSafeHref(href) { + href = "#" + } + hasHref := href != "" + c.linkStates = append(c.linkStates, hasHref) + if hasHref { + c.linkHrefs = append(c.linkHrefs, href) + c.pushBuf() + } + + case "h1": + c.write("\n\n# ") + case "h2": + c.write("\n\n## ") + case "h3": + c.write("\n\n### ") + case "h4": + c.write("\n\n#### ") + case "h5": + c.write("\n\n##### ") + case "h6": + c.write("\n\n###### ") + + case "p": + c.write("\n\n") + case "br": + c.write("\n") + case "hr": + c.write("\n\n---\n\n") + + case "ol": + c.olCounters = append(c.olCounters, 1) + // Only write leading newline for top-level list. + if c.listDepth == 0 { + c.write("\n") + } + c.listDepth++ + case "ul": + if c.listDepth == 0 { + c.write("\n") + } + c.listDepth++ + case "li": + c.write("\n") + if c.listDepth > 1 { + c.write(strings.Repeat(" ", c.listDepth-1)) + } + if n.Parent != nil && n.Parent.Data == "ol" && len(c.olCounters) > 0 { + idx := c.olCounters[len(c.olCounters)-1] + c.write(strconv.Itoa(idx) + ". ") + c.olCounters[len(c.olCounters)-1]++ + } else { + c.write("- ") + } + + case "pre": + c.inPre = true + c.write("\n\n```\n") + case "code": + if !c.inPre { + c.write("`") + } + + case "blockquote": + c.pushBuf() + for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { + c.walk(ch) + } + inner := strings.TrimSpace(c.popBuf()) + lines := strings.Split(inner, "\n") + var quoted []string + for _, l := range lines { + if strings.TrimSpace(l) == "" { + quoted = append(quoted, ">") + } else { + quoted = append(quoted, "> "+l) + } + } + var deduped []string + for i, line := range quoted { + if line == ">" && i > 0 && deduped[len(deduped)-1] == ">" { + continue + } + deduped = append(deduped, line) + } + c.write("\n\n" + strings.Join(deduped, "\n") + "\n\n") + return + + case "img": + src := normalizeAttr(getAttr(n, "src")) + if src == "" { + src = normalizeAttr(getAttr(n, "data-src")) + } + if src == "" { + return + } + alt := escapeMdAlt(normalizeAttr(getAttr(n, "alt"))) + if isSafeImageSrc(src) { + c.write("![" + alt + "](" + src + ")") + } + return + } + + // Traverse Children + for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { + c.walk(ch) + } + + // Closing Tags + switch n.Data { + // Pop buffer, trim, wrap with the correct marker. + case "b", "strong", "i", "em", "del", "s": + if len(c.emphStack) == 0 { + break + } + marker := c.emphStack[len(c.emphStack)-1] + c.emphStack = c.emphStack[:len(c.emphStack)-1] + inner := strings.TrimSpace(c.popBuf()) + if inner != "" { + c.write(marker + inner + marker) + } + + case "a": + if len(c.linkStates) == 0 { + break + } + hasHref := c.linkStates[len(c.linkStates)-1] + c.linkStates = c.linkStates[:len(c.linkStates)-1] + if !hasHref { + break + } + href := c.linkHrefs[len(c.linkHrefs)-1] + c.linkHrefs = c.linkHrefs[:len(c.linkHrefs)-1] + inner := strings.TrimSpace(c.popBuf()) + if strings.Contains(inner, "\n") { + lines := strings.Split(inner, "\n") + linked := false + for i, l := range lines { + cleanLine := strings.TrimSpace(l) + if cleanLine != "" && !strings.HasPrefix(cleanLine, "![") && !linked { + lines[i] = "[" + cleanLine + "](" + href + ")" + linked = true + } + } + c.write(strings.Join(lines, "\n")) + } else { + c.write("[" + inner + "](" + href + ")") + } + + case "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "p", + "div", + "section", + "article", + "header", + "footer", + "aside", + "nav", + "figure": + c.write("\n") + + case "ol": + c.listDepth-- + if len(c.olCounters) > 0 { + c.olCounters = c.olCounters[:len(c.olCounters)-1] + } + if c.listDepth == 0 { + c.write("\n") + } + case "ul": + c.listDepth-- + if c.listDepth == 0 { + c.write("\n") + } + + case "pre": + c.inPre = false + c.write("\n```\n\n") + case "code": + if !c.inPre { + c.write("`") + } + } +} + +func HtmlToMarkdown(htmlStr string) (string, error) { + doc, err := html.Parse(strings.NewReader(htmlStr)) + if err != nil { + return "", err + } + + c := newConverter() + c.walk(doc) + + res := c.stack[0].String() + + // Post-processing + res = reImageOnlyLink.ReplaceAllString(res, "") + res = reEmptyListItem.ReplaceAllString(res, "") + res = reEmptyHeader.ReplaceAllString(res, "") + + lines := strings.Split(res, "\n") + var cleanLines []string + for _, line := range lines { + line = strings.TrimRight(line, " \t") + cleanTest := strings.TrimSpace(line) + if cleanTest == "[](</>)" || cleanTest == "[](#)" || cleanTest == "-" { + cleanLines = append(cleanLines, "") + continue + } + cleanLines = append(cleanLines, line) + } + res = strings.Join(cleanLines, "\n") + + res = strings.TrimSpace(res) + res = reNewlines.ReplaceAllString(res, "\n\n") + + // Strip a single leading space from lines that are NOT list indentation. + // "(?m)^([ \t])([^ \t\n])" matches exactly one space/tab at line start followed + // by a non-whitespace char, so " - nested" (4 spaces) is left untouched. + res = reLeadingLineSpace.ReplaceAllString(res, "$2") + + return res, nil +} diff --git a/pkg/utils/markdown_test.go b/pkg/utils/markdown_test.go new file mode 100644 index 000000000..72277fb91 --- /dev/null +++ b/pkg/utils/markdown_test.go @@ -0,0 +1,245 @@ +package utils + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +func TestHtmlToMarkdown(t *testing.T) { + // Define our test cases + tests := []struct { + name string + input string + expected string + }{ + { + name: "Removes scripts and styles", + input: `<script>alert("hello");</script><style>body { color: red; }</style><p>Clean text</p>`, + expected: "Clean text", + }, + { + name: "Extracts links correctly", + input: `Visit my <a href="https://example.com">website</a> for info.`, + expected: "Visit my [website](https://example.com) for info.", + }, + { + name: "Converts headers (H1, H2, H3)", + input: `<h1>Main Title</h1><h2>Subtitle</h2><h3>Section</h3>`, + expected: "# Main Title\n\n## Subtitle\n\n### Section", + }, + { + name: "Handles bold and italics", + input: `Text <b>bold</b> and <strong>strong</strong>, then <i>italic</i> and <em>em</em>.`, + expected: "Text **bold** and **strong**, then *italic* and *em*.", + }, + { + name: "Converts lists", + input: `<ul><li>First element</li><li>Second element</li></ul>`, + expected: "- First element\n- Second element", + }, + { + name: "Handles paragraphs and line breaks (<br>)", + input: `<p>First paragraph</p><p>Second paragraph with<br>a line break.</p>`, + expected: "First paragraph\n\nSecond paragraph with\na line break.", + }, + { + name: "Decodes HTML entities", + input: `Math: 5 > 3 & 2 < 4. A "quote".`, + expected: "Math: 5 > 3 & 2 < 4. A \"quote\".", + }, + { + name: "Cleans up residual HTML tags", + input: `<div><span>Text inside div and span</span></div>`, + expected: "Text inside div and span", + }, + { + name: "Removes multiple spaces and excessive empty lines", + input: `This text has too many spaces. <br><br><br><br> And too many newlines.`, + expected: "This text has too many spaces.\n\nAnd too many newlines.", + }, + { + name: "Nested lists with indentation", + input: "<ul><li>One<ul><li>Two</li></ul></li></ul>", + // Expect the sub-element to have 4 spaces of indentation + expected: "- One\n - Two", + }, + { + name: "Image support", + input: `<img src="image.jpg" alt="alternative text">`, + // Correct Markdown syntax for images + expected: "![alternative text](image.jpg)", + }, + { + name: "Image support without alt-text", + input: `<img src="image.jpg">`, + // If alt is missing, square brackets remain empty + expected: "![](image.jpg)", + }, + { + name: "XSS Bypass on Links (Obfuscated HTML entities)", + // The Go HTML parser resolves entities, so this becomes "javascript:alert(1)" + input: `<a href="jav ascript:alert(1)">Click here</a>`, + // Our isSafeHref (if updated with net/url) should neutralize it to "#" + expected: "[Click here](#)", + }, + { + name: "Empty link or used as anchor", + input: `<a name="top"></a>`, + // With no text or href, it shouldn't print anything (not even empty brackets) + expected: "", + }, + { + name: "Link without href but with text (Textual anchor)", + input: `<a id="top">Back to top</a>`, + // Should extract only plain text, without generating a broken Markdown link like [Back to top](#) or [Back to top]() + expected: "Back to top", + }, + { + name: "Badly spaced bold and italics (Edge Case)", + input: `<b> Text </b>`, + // In Markdown `** Text **` is often not formatted correctly. The ideal is `**Text**` + expected: "**Text**", + }, + { + name: "Complex Test - Real Article", + input: ` + <h1>Article Title</h1> + <p>This is an <strong>introductory text</strong> with a <a href="http://link.com">link</a>.</p> + <h2>Subtitle</h2> + <ul> + <li>Point one</li> + <li>Point two</li> + </ul> + <script>console.log("do not show me")</script> + `, + // Note: The indentation of the real HTML test will generate spaces that + // regex will clean up. + expected: "# Article Title\n\nThis is an **introductory text** with a [link](http://link.com).\n\n## Subtitle\n\n- Point one\n- Point two", + }, + { + name: "Ordered list (OL)", + input: `<ol><li>First</li><li>Second</li><li>Third</li></ol>`, + expected: "1. First\n2. Second\n3. Third", + }, + { + name: "Ordered list nested in unordered list", + input: `<ul><li>Fruits<ol><li>Apples</li><li>Pears</li></ol></li><li>Vegetables</li></ul>`, + expected: "- Fruits\n 1. Apples\n 2. Pears\n- Vegetables", + }, + { + name: "Code block (pre/code)", + input: "<pre><code>func main() {\n fmt.Println(\"hello\")\n}</code></pre>", + expected: "```\nfunc main() {\n fmt.Println(\"hello\")\n}\n```", + }, + { + name: "Inline code", + input: `<p>Use the command <code>go test ./...</code> to run the tests.</p>`, + expected: "Use the command `go test ./...` to run the tests.", + }, + { + name: "Simple blockquote", + input: `<blockquote><p>An important quote.</p></blockquote>`, + expected: "> An important quote.", + }, + { + name: "Multiline blockquote", + input: `<blockquote><p>First line of the quote.</p><p>Second line of the quote.</p></blockquote>`, + expected: "> First line of the quote.\n>\n> Second line of the quote.", + }, + { + name: "Strikethrough text (del/s)", + input: `This text is <del>deleted</del> and this is <s>crossed out</s>.`, + expected: "This text is ~~deleted~~ and this is ~~crossed out~~.", + }, + { + name: "Horizontal separator (HR)", + input: `<p>Above the line</p><hr><p>Below the line</p>`, + expected: "Above the line\n\n---\n\nBelow the line", + }, + { + name: "Bold nested in link", + input: `<a href="https://example.com"><strong>Linked bold text</strong></a>`, + expected: "[**Linked bold text**](https://example.com)", + }, + { + name: "data-src Image (lazy loading)", + input: `<img data-src="lazy.jpg" alt="Lazy image">`, + expected: "![Lazy image](lazy.jpg)", + }, + { + name: "Image with javascript: src blocked", + input: `<img src="javascript:alert(1)" alt="XSS">`, + // src is not safe, so the image is not emitted + expected: "", + }, + { + name: "Link with data: href blocked", + input: `<a href="data:text/html,<script>alert(1)</script>">Click</a>`, + expected: "[Click](#)", + }, + { + name: "Deeply nested divs", + input: `<div><div><div><div><p>Deeply nested text</p></div></div></div></div>`, + expected: "Deeply nested text", + }, + { + name: "Non-consecutive headers (H1, H3, H5)", + input: `<h1>Title</h1><h3>Subsection</h3><h5>Sub-subsection</h5>`, + expected: "# Title\n\n### Subsection\n\n##### Sub-subsection", + }, + { + name: "Paragraph with mixed multiple emphasis", + input: `<p><strong>Important:</strong> read the <strong><em>critical instructions</em></strong> <em>carefully</em>.</p>`, + expected: "**Important:** read the ***critical instructions*** *carefully*.", + }, + { + name: "Article with nav and aside sections (noise to filter)", + input: ` + <nav><a href="/home">Home</a><a href="/about-us">About us</a></nav> + <article> + <h2>Article title</h2> + <p>This is the body of the article.</p> + </article> + <aside><p>Advertisement</p></aside> + `, + expected: "## Article title\n\nThis is the body of the article.", + }, + { + name: "Text with mixed special HTML entities", + input: `Copyright © 2024 — All rights reserved ®`, + expected: "Copyright © 2024 — All rights reserved ®", + }, + { + name: "Mailto link", + input: `Write to us at <a href="mailto:info@example.com">info@example.com</a>`, + expected: "Write to us at [info@example.com](mailto:info@example.com)", + }, + { + name: "Image inside a link (clickable figure)", + input: `<a href="https://example.com"><img src="photo.jpg" alt="Photo"></a>`, + // The image-link without text must not generate broken markup + expected: "[![Photo](photo.jpg)](https://example.com)", + }, + { + name: "Empty content or only whitespace", + input: ` <p> </p> <div> </div> `, + expected: "", + }, + } + + // Iterate over all test cases + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := HtmlToMarkdown(tt.input) + if err != nil { + logger.ErrorCF("tool", "Failed to parse html to markdown: %s", map[string]any{"error": err.Error()}) + } + + if got != tt.expected { + t.Errorf("\nTest case failed: %s\nInput: %q\nGot: %q\nExpected: %q", + tt.name, tt.input, got, tt.expected) + } + }) + } +} diff --git a/pkg/utils/media.go b/pkg/utils/media.go index 3e1c5d88e..823ca155e 100644 --- a/pkg/utils/media.go +++ b/pkg/utils/media.go @@ -1,6 +1,7 @@ package utils import ( + "fmt" "io" "net/http" "net/url" @@ -12,11 +13,24 @@ import ( "github.com/google/uuid" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" ) +var audioExtensions = []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"} + +func AudioFormat(path string) (string, error) { + ext := strings.ToLower(filepath.Ext(path)) + for _, supportedExt := range audioExtensions { + if ext == supportedExt { + return strings.TrimPrefix(ext, "."), nil + } + } + + return "", fmt.Errorf("unsupported audio format for %q", path) +} + // IsAudioFile checks if a file is an audio file based on its filename extension and content type. func IsAudioFile(filename, contentType string) bool { - audioExtensions := []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"} audioTypes := []string{"audio/", "application/ogg", "application/x-ogg"} for _, ext := range audioExtensions { @@ -67,7 +81,7 @@ func DownloadFile(urlStr, filename string, opts DownloadOptions) string { opts.LoggerPrefix = "utils" } - mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") + mediaDir := media.TempDir() if err := os.MkdirAll(mediaDir, 0o700); err != nil { logger.ErrorCF(opts.LoggerPrefix, "Failed to create media directory", map[string]any{ "error": err.Error(), diff --git a/pkg/utils/string.go b/pkg/utils/string.go index 02f346db4..dbaafdb7f 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -2,9 +2,18 @@ package utils import ( "strings" + "sync/atomic" "unicode" ) +// Global variable to disable truncation +var disableTruncation atomic.Bool + +// SetDisableTruncation globally enables or disables string truncation +func SetDisableTruncation(enabled bool) { + disableTruncation.Store(enabled) +} + // SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides, // zero-width characters), and other non-graphic characters that could confuse an LLM // or cause display issues in the agent UI. @@ -30,6 +39,10 @@ func SanitizeMessageContent(input string) string { // Handles multi-byte Unicode characters properly. // If the string is truncated, "..." is appended to indicate truncation. func Truncate(s string, maxLen int) string { + // If the no-truncate flag is active, it returns the full string + if disableTruncation.Load() { + return s + } if maxLen <= 0 { return "" } diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go new file mode 100644 index 000000000..1834d7f78 --- /dev/null +++ b/pkg/utils/tool_feedback.go @@ -0,0 +1,90 @@ +package utils + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +const ToolFeedbackContinuationHint = "Continuing the current task." + +func FormatArgsJSON(args map[string]any, prettyPrint, disableEscapeHTML bool) string { + // Normalize nil to empty map for consistent output + if args == nil { + args = map[string]any{} + } + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + if prettyPrint { + enc.SetIndent("", " ") + } + if disableEscapeHTML { + enc.SetEscapeHTML(false) + } + if err := enc.Encode(args); err != nil { + // Fallback to fmt.Sprintf to preserve visibility of problematic args + return fmt.Sprintf("%v", args) + } + return strings.TrimSpace(buf.String()) +} + +// FormatToolFeedbackMessage renders a tool feedback message for chat channels. +// It keeps the tool name on the first line for animation and can include both +// a human explanation and the serialized tool arguments in the body. +func FormatToolFeedbackMessage(toolName, explanation, argsPreview string) string { + toolName = strings.TrimSpace(toolName) + explanation = strings.TrimSpace(explanation) + argsPreview = strings.TrimSpace(argsPreview) + + bodyLines := make([]string, 0, 2) + if explanation != "" { + bodyLines = append(bodyLines, explanation) + } + if argsPreview != "" { + bodyLines = append(bodyLines, "```json\n"+argsPreview+"\n```") + } + body := strings.Join(bodyLines, "\n") + + if toolName == "" { + return body + } + if body == "" { + return fmt.Sprintf("\U0001f527 `%s`", toolName) + } + + return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, body) +} + +// FitToolFeedbackMessage keeps tool feedback within a single outbound message. +// It preserves the first line when possible and truncates the explanation body +// instead of letting the message be split into multiple chunks. +func FitToolFeedbackMessage(content string, maxLen int) string { + content = strings.TrimSpace(content) + if content == "" || maxLen <= 0 { + return "" + } + if len([]rune(content)) <= maxLen { + return content + } + + firstLine, rest, hasRest := strings.Cut(content, "\n") + firstLine = strings.TrimSpace(firstLine) + rest = strings.TrimSpace(rest) + + if !hasRest || rest == "" { + return Truncate(firstLine, maxLen) + } + + if len([]rune(firstLine)) >= maxLen { + return Truncate(firstLine, maxLen) + } + + remaining := maxLen - len([]rune(firstLine)) - 1 + if remaining <= 0 { + return Truncate(firstLine, maxLen) + } + + return firstLine + "\n" + Truncate(rest, remaining) +} diff --git a/pkg/utils/tool_feedback_dedupe.go b/pkg/utils/tool_feedback_dedupe.go new file mode 100644 index 000000000..b1adb60eb --- /dev/null +++ b/pkg/utils/tool_feedback_dedupe.go @@ -0,0 +1,39 @@ +package utils + +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func normalizeToolFeedbackComparisonText(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + text = strings.TrimSpace(text) + if text == "" { + return "" + } + return strings.Join(strings.Fields(text), " ") +} + +func ToolCallExplanationDuplicatesContent(content string, toolCalls []providers.ToolCall) bool { + normalizedContent := normalizeToolFeedbackComparisonText(content) + if normalizedContent == "" || len(toolCalls) == 0 { + return false + } + + for _, tc := range toolCalls { + if tc.ExtraContent == nil { + continue + } + explanation := normalizeToolFeedbackComparisonText(tc.ExtraContent.ToolFeedbackExplanation) + if explanation == "" { + continue + } + if explanation == normalizedContent { + return true + } + } + + return false +} diff --git a/pkg/utils/tool_feedback_dedupe_test.go b/pkg/utils/tool_feedback_dedupe_test.go new file mode 100644 index 000000000..cc587080f --- /dev/null +++ b/pkg/utils/tool_feedback_dedupe_test.go @@ -0,0 +1,55 @@ +package utils + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestToolCallExplanationDuplicatesContent(t *testing.T) { + t.Run("exact duplicate", func(t *testing.T) { + toolCalls := []providers.ToolCall{{ + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }} + + if !ToolCallExplanationDuplicatesContent("Read the file before replying.", toolCalls) { + t.Fatal("expected duplicated content to be detected") + } + }) + + t.Run("whitespace normalized duplicate", func(t *testing.T) { + toolCalls := []providers.ToolCall{{ + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file\nbefore replying.", + }, + }} + + if !ToolCallExplanationDuplicatesContent(" Read the file before replying. ", toolCalls) { + t.Fatal("expected whitespace-only differences to be ignored") + } + }) + + t.Run("distinct content", func(t *testing.T) { + toolCalls := []providers.ToolCall{{ + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }} + + if ToolCallExplanationDuplicatesContent( + "I will summarize the findings after reading the file.", + toolCalls, + ) { + t.Fatal("expected distinct content to remain visible") + } + }) + + t.Run("missing explanation", func(t *testing.T) { + toolCalls := []providers.ToolCall{{}} + if ToolCallExplanationDuplicatesContent("Read the file before replying.", toolCalls) { + t.Fatal("expected empty tool explanations to skip dedupe") + } + }) +} diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go new file mode 100644 index 000000000..da4accce4 --- /dev/null +++ b/pkg/utils/tool_feedback_test.go @@ -0,0 +1,156 @@ +package utils + +import ( + "encoding/json" + "testing" +) + +func TestFormatToolFeedbackMessage(t *testing.T) { + got := FormatToolFeedbackMessage( + "read_file", + "I will read README.md first to confirm the current project structure.", + "{\n \"path\": \"README.md\"\n}", + ) + want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure.\n```json\n{\n \"path\": \"README.md\"\n}\n```" + if got != want { + t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFormatToolFeedbackMessage_EmptyExplanationShowsArgs(t *testing.T) { + got := FormatToolFeedbackMessage("read_file", "", "{\n \"path\": \"README.md\"\n}") + want := "\U0001f527 `read_file`\n```json\n{\n \"path\": \"README.md\"\n}\n```" + if got != want { + t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) { + got := FormatToolFeedbackMessage("", "Continue drafting the final response.", "") + want := "Continue drafting the final response." + if got != want { + t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFormatToolFeedbackMessage_EmptyExplanationAndArgsKeepsOnlyToolLine(t *testing.T) { + got := FormatToolFeedbackMessage("read_file", "", "") + want := "\U0001f527 `read_file`" + if got != want { + t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) { + got := FitToolFeedbackMessage( + "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.", + 40, + ) + want := "\U0001f527 `read_file`\nRead README.md first to..." + if got != want { + t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFitToolFeedbackMessage_TruncatesSingleLineMessage(t *testing.T) { + got := FitToolFeedbackMessage("\U0001f527 `read_file`", 10) + want := "\U0001f527 `read..." + if got != want { + t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFormatArgsJSON_Defaults(t *testing.T) { + args := map[string]any{"path": "README.md", "line": 42} + got := FormatArgsJSON(args, false, false) + var gotVal, wantVal any + if err := json.Unmarshal([]byte(got), &gotVal); err != nil { + t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err) + } + want := `{"path":"README.md","line":42}` + if err := json.Unmarshal([]byte(want), &wantVal); err != nil { + t.Fatalf("invalid test want JSON: %v", err) + } + if !jsonValEq(gotVal, wantVal) { + t.Fatalf("FormatArgsJSON() = %q, want %q", got, want) + } +} + +func TestFormatArgsJSON_PrettyPrint(t *testing.T) { + args := map[string]any{"path": "README.md", "line": 42} + got := FormatArgsJSON(args, true, false) + var gotVal any + if err := json.Unmarshal([]byte(got), &gotVal); err != nil { + t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err) + } + want := `{"path":"README.md","line":42}` + var wantVal any + if err := json.Unmarshal([]byte(want), &wantVal); err != nil { + t.Fatalf("invalid test want JSON: %v", err) + } + if !jsonValEq(gotVal, wantVal) { + t.Fatalf("FormatArgsJSON() prettyPrint = %q, want structure %q", got, want) + } +} + +func TestFormatArgsJSON_DisableEscapeHTML(t *testing.T) { + args := map[string]any{"msg": "a < b && c > d"} + got := FormatArgsJSON(args, false, true) + var gotVal, wantVal any + want := `{"msg":"a < b && c > d"}` + if err := json.Unmarshal([]byte(got), &gotVal); err != nil { + t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err) + } + if err := json.Unmarshal([]byte(want), &wantVal); err != nil { + t.Fatalf("invalid test want JSON: %v", err) + } + if !jsonValEq(gotVal, wantVal) { + t.Fatalf("FormatArgsJSON() disableEscapeHTML = %q, want %q", got, want) + } +} + +func TestFormatArgsJSON_PrettyPrintAndDisableEscapeHTML(t *testing.T) { + args := map[string]any{"msg": "a < b && c > d"} + got := FormatArgsJSON(args, true, true) + var gotVal, wantVal any + want := `{"msg":"a < b && c > d"}` + if err := json.Unmarshal([]byte(got), &gotVal); err != nil { + t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err) + } + if err := json.Unmarshal([]byte(want), &wantVal); err != nil { + t.Fatalf("invalid test want JSON: %v", err) + } + if !jsonValEq(gotVal, wantVal) { + t.Fatalf("FormatArgsJSON() combined = %q, want %q", got, want) + } +} + +func TestFormatArgsJSON_EscapeHTMLByDefault(t *testing.T) { + args := map[string]any{"msg": "a < b && c > d"} + got := FormatArgsJSON(args, false, false) + var gotVal, wantVal any + want := `{"msg":"a \u003c b \u0026\u0026 c \u003e d"}` + if err := json.Unmarshal([]byte(got), &gotVal); err != nil { + t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err) + } + if err := json.Unmarshal([]byte(want), &wantVal); err != nil { + t.Fatalf("invalid test want JSON: %v", err) + } + if !jsonValEq(gotVal, wantVal) { + t.Fatalf("FormatArgsJSON() default escape = %q, want %q", got, want) + } +} + +func TestFormatArgsJSON_NilArgs(t *testing.T) { + got := FormatArgsJSON(nil, false, false) + want := `{}` + if got != want { + t.Fatalf("FormatArgsJSON() nil = %q, want %q", got, want) + } +} + +func jsonValEq(a, b any) bool { + aJSON, _ := json.Marshal(a) + bJSON, _ := json.Marshal(b) + return string(aJSON) == string(bJSON) +} diff --git a/pkg/utils/visible_tool_calls.go b/pkg/utils/visible_tool_calls.go new file mode 100644 index 000000000..8c4d89a51 --- /dev/null +++ b/pkg/utils/visible_tool_calls.go @@ -0,0 +1,106 @@ +package utils + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +type VisibleToolCall struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function *VisibleToolCallFunction `json:"function,omitempty"` + ExtraContent *VisibleToolCallExtraContent `json:"extra_content,omitempty"` +} + +type VisibleToolCallFunction struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +type VisibleToolCallExtraContent struct { + ToolFeedbackExplanation string `json:"tool_feedback_explanation,omitempty"` +} + +func BuildVisibleToolCalls( + toolCalls []providers.ToolCall, + maxArgsLen int, +) []VisibleToolCall { + if len(toolCalls) == 0 { + return nil + } + + visible := make([]VisibleToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + name, _ := VisibleToolCallNameAndArguments(tc) + argsPreview := VisibleToolCallArgumentsPreview(tc, maxArgsLen) + explanation := "" + if tc.ExtraContent != nil { + explanation = strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation) + } + if name == "" && explanation == "" && argsPreview == "" { + continue + } + + visibleCall := VisibleToolCall{ + ID: strings.TrimSpace(tc.ID), + Type: strings.TrimSpace(tc.Type), + } + if visibleCall.Type == "" { + visibleCall.Type = "function" + } + if name != "" || argsPreview != "" { + visibleCall.Function = &VisibleToolCallFunction{ + Name: name, + Arguments: argsPreview, + } + } + if explanation != "" { + visibleCall.ExtraContent = &VisibleToolCallExtraContent{ + ToolFeedbackExplanation: explanation, + } + } + + visible = append(visible, visibleCall) + } + + if len(visible) == 0 { + return nil + } + return visible +} + +func VisibleToolCallNameAndArguments(tc providers.ToolCall) (string, string) { + name := strings.TrimSpace(tc.Name) + argsJSON := "" + if tc.Function != nil { + if name == "" { + name = strings.TrimSpace(tc.Function.Name) + } + argsJSON = strings.TrimSpace(tc.Function.Arguments) + } + if argsJSON == "" && len(tc.Arguments) > 0 { + if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { + argsJSON = string(encodedArgs) + } + } + return name, strings.TrimSpace(argsJSON) +} + +func VisibleToolCallArgumentsPreview(tc providers.ToolCall, maxLen int) string { + _, argsJSON := VisibleToolCallNameAndArguments(tc) + if argsJSON == "" { + return "" + } + + var pretty bytes.Buffer + if err := json.Indent(&pretty, []byte(argsJSON), "", " "); err == nil { + argsJSON = pretty.String() + } + if maxLen > 0 { + return Truncate(argsJSON, maxLen) + } + return argsJSON +} diff --git a/pkg/utils/visible_tool_calls_test.go b/pkg/utils/visible_tool_calls_test.go new file mode 100644 index 000000000..fe9467c57 --- /dev/null +++ b/pkg/utils/visible_tool_calls_test.go @@ -0,0 +1,33 @@ +package utils + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestBuildVisibleToolCalls_DoesNotTruncateExplanation(t *testing.T) { + explanation := "Read README.md first to confirm the current project structure before editing the config example." + toolCalls := []providers.ToolCall{{ + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: explanation, + }, + }} + + visible := BuildVisibleToolCalls(toolCalls, 20) + if len(visible) != 1 { + t.Fatalf("len(visible) = %d, want 1", len(visible)) + } + if visible[0].ExtraContent == nil || visible[0].ExtraContent.ToolFeedbackExplanation != explanation { + t.Fatalf("visible explanation = %#v, want %q", visible[0].ExtraContent, explanation) + } + if visible[0].Function == nil || visible[0].Function.Arguments == "" { + t.Fatalf("visible function = %#v, want truncated args preview", visible[0].Function) + } +} diff --git a/pkg/voice/transcriber_test.go b/pkg/voice/transcriber_test.go deleted file mode 100644 index 9b6add333..000000000 --- a/pkg/voice/transcriber_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package voice - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "github.com/sipeed/picoclaw/pkg/config" -) - -// Ensure GroqTranscriber satisfies the Transcriber interface at compile time. -var _ Transcriber = (*GroqTranscriber)(nil) - -func TestGroqTranscriberName(t *testing.T) { - tr := NewGroqTranscriber("sk-test") - if got := tr.Name(); got != "groq" { - t.Errorf("Name() = %q, want %q", got, "groq") - } -} - -func TestDetectTranscriber(t *testing.T) { - tests := []struct { - name string - cfg *config.Config - wantNil bool - wantName string - }{ - { - name: "no config", - cfg: &config.Config{}, - wantNil: true, - }, - { - name: "groq provider key", - cfg: &config.Config{ - Providers: config.ProvidersConfig{ - Groq: config.ProviderConfig{APIKey: "sk-groq-direct"}, - }, - }, - wantName: "groq", - }, - { - name: "groq via model list", - cfg: &config.Config{ - ModelList: []config.ModelConfig{ - {Model: "openai/gpt-4o", APIKey: "sk-openai"}, - {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"}, - }, - }, - wantName: "groq", - }, - { - name: "groq model list entry without key is skipped", - cfg: &config.Config{ - ModelList: []config.ModelConfig{ - {Model: "groq/llama-3.3-70b", APIKey: ""}, - }, - }, - wantNil: true, - }, - { - name: "provider key takes priority over model list", - cfg: &config.Config{ - Providers: config.ProvidersConfig{ - Groq: config.ProviderConfig{APIKey: "sk-groq-direct"}, - }, - ModelList: []config.ModelConfig{ - {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"}, - }, - }, - wantName: "groq", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - tr := DetectTranscriber(tc.cfg) - if tc.wantNil { - if tr != nil { - t.Errorf("DetectTranscriber() = %v, want nil", tr) - } - return - } - if tr == nil { - t.Fatal("DetectTranscriber() = nil, want non-nil") - } - if got := tr.Name(); got != tc.wantName { - t.Errorf("Name() = %q, want %q", got, tc.wantName) - } - }) - } -} - -func TestTranscribe(t *testing.T) { - // Write a minimal fake audio file so the transcriber can open and send it. - tmpDir := t.TempDir() - audioPath := filepath.Join(tmpDir, "clip.ogg") - if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil { - t.Fatalf("failed to write fake audio file: %v", err) - } - - t.Run("success", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/audio/transcriptions" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - if r.Header.Get("Authorization") != "Bearer sk-test" { - t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization")) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(TranscriptionResponse{ - Text: "hello world", - Language: "en", - Duration: 1.5, - }) - })) - defer srv.Close() - - tr := NewGroqTranscriber("sk-test") - tr.apiBase = srv.URL - - resp, err := tr.Transcribe(context.Background(), audioPath) - if err != nil { - t.Fatalf("Transcribe() error: %v", err) - } - if resp.Text != "hello world" { - t.Errorf("Text = %q, want %q", resp.Text, "hello world") - } - if resp.Language != "en" { - t.Errorf("Language = %q, want %q", resp.Language, "en") - } - }) - - t.Run("api error", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized) - })) - defer srv.Close() - - tr := NewGroqTranscriber("sk-bad") - tr.apiBase = srv.URL - - _, err := tr.Transcribe(context.Background(), audioPath) - if err == nil { - t.Fatal("expected error for non-200 response, got nil") - } - }) - - t.Run("missing file", func(t *testing.T) { - tr := NewGroqTranscriber("sk-test") - _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg")) - if err == nil { - t.Fatal("expected error for missing file, got nil") - } - }) -} diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh new file mode 100755 index 000000000..df2100aec --- /dev/null +++ b/scripts/build-macos-app.sh @@ -0,0 +1,108 @@ +#!/bin/bash +# Build macOS .app bundle for PicoClaw Launcher + +set -e + +EXECUTABLE=$1 + +if [ -z "$EXECUTABLE" ]; then + echo "Usage: $0 <executable>" + exit 1 +fi + +LAUNCHER_EXECUTABLE="picoclaw-launcher-${EXECUTABLE}" +EXECUTABLE="picoclaw-${EXECUTABLE}" +echo "executable: $EXECUTABLE" + +APP_NAME="PicoClaw Launcher" +APP_PATH="./build/${APP_NAME}.app" +APP_CONTENTS="${APP_PATH}/Contents" +APP_MACOS="${APP_CONTENTS}/MacOS" +APP_RESOURCES="${APP_CONTENTS}/Resources" +APP_EXECUTABLE="picoclaw-launcher" +ICON_SOURCE="./scripts/icon.icns" + +# Clean up existing .app +if [ -d "$APP_PATH" ]; then + echo "Removing existing ${APP_PATH}" + rm -rf "$APP_PATH" +fi + +# Create directory structure +echo "Creating .app bundle structure..." +mkdir -p "$APP_MACOS" +mkdir -p "$APP_RESOURCES" + +# Copy executable +echo "Copying executable..." +if [ -f "./build/${LAUNCHER_EXECUTABLE}" ]; then + cp "./build/${LAUNCHER_EXECUTABLE}" "${APP_MACOS}/${APP_EXECUTABLE}" +else + echo "Error: ./build/${LAUNCHER_EXECUTABLE} not found. Please build the web backend first." + echo "Run: make build-launcher" + exit 1 +fi +if [ -f "./build/${EXECUTABLE}" ]; then + cp "./build/${EXECUTABLE}" "${APP_MACOS}/picoclaw" +else + echo "Error: ./build/${EXECUTABLE} not found. Please build the main file first." + echo "Run: make build" + exit 1 +fi +chmod +x "${APP_MACOS}/"* + +# Create Info.plist +echo "Creating Info.plist..." +cat > "${APP_CONTENTS}/Info.plist" << 'EOF' +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleExecutable</key> + <string>picoclaw-launcher</string> + <key>CFBundleIdentifier</key> + <string>com.picoclaw.launcher</string> + <key>CFBundleName</key> + <string>PicoClaw Launcher</string> + <key>CFBundleDisplayName</key> + <string>PicoClaw Launcher</string> + <key>CFBundleIconFile</key> + <string>icon.icns</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleShortVersionString</key> + <string>1.0</string> + <key>CFBundleVersion</key> + <string>1</string> + <key>NSHighResolutionCapable</key> + <true/> + <key>NSSupportsAutomaticGraphicsSwitching</key> + <true/> + <key>LSUIElement</key> + <true/> + <key>LSMinimumSystemVersion</key> + <string>10.11</string> +</dict> +</plist> +EOF + +#sips -z 128 128 "$ICON_SOURCE" --out "${ICONSET_PATH}/icon_128x128.png" > /dev/null 2>&1 +# +## Create icns file +#iconutil -c icns "$ICONSET_PATH" -o "$ICON_OUTPUT" 2>/dev/null || { +# echo "Warning: iconutil failed" +#} + +cp $ICON_SOURCE "${APP_RESOURCES}/icon.icns" + +echo "" +echo "==========================================" +echo "Successfully created: ${APP_PATH}" +echo "==========================================" +echo "" +echo "To launch PicoClaw:" +echo " 1. Double-click ${APP_NAME}.app in Finder" +echo " 2. Or use: open ${APP_PATH}" +echo "" +echo "Note: The app will run in the menu bar (systray) without a terminal window." +echo "" diff --git a/scripts/copydir.go b/scripts/copydir.go new file mode 100644 index 000000000..6e2777612 --- /dev/null +++ b/scripts/copydir.go @@ -0,0 +1,186 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" +) + +func main() { + if len(os.Args) != 3 { + fmt.Fprintf(os.Stderr, "usage: go run scripts/copydir.go <src> <dst>\n") + os.Exit(2) + } + + repoRoot, err := findRepoRoot() + if err != nil { + fmt.Fprintf(os.Stderr, "locate repo root: %v\n", err) + os.Exit(1) + } + + src, err := normalizePathArg(os.Args[1], repoRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "resolve src path: %v\n", err) + os.Exit(1) + } + + dst, err := normalizePathArg(os.Args[2], repoRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "resolve dst path: %v\n", err) + os.Exit(1) + } + + if err := ensurePathWithinRepo(repoRoot, src); err != nil { + fmt.Fprintf(os.Stderr, "invalid src path: %v\n", err) + os.Exit(1) + } + if err := ensurePathWithinRepo(repoRoot, dst); err != nil { + fmt.Fprintf(os.Stderr, "invalid dst path: %v\n", err) + os.Exit(1) + } + if samePath(repoRoot, dst) { + fmt.Fprintln(os.Stderr, "invalid dst path: destination cannot be repo root") + os.Exit(1) + } + + if err := os.RemoveAll(dst); err != nil { + fmt.Fprintf(os.Stderr, "remove %s: %v\n", dst, err) + os.Exit(1) + } + + if err := copyTree(src, dst); err != nil { + fmt.Fprintf(os.Stderr, "copy %s -> %s: %v\n", src, dst, err) + os.Exit(1) + } +} + +func findRepoRoot() (string, error) { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "", fmt.Errorf("unable to locate copydir.go source path") + } + + scriptDir := filepath.Dir(file) + candidate := filepath.Clean(filepath.Join(scriptDir, "..")) + if err := validateRepoRoot(candidate); err == nil { + return candidate, nil + } + + wd, err := os.Getwd() + if err != nil { + return "", err + } + + cur, err := filepath.Abs(wd) + if err != nil { + return "", err + } + + for { + if err := validateRepoRoot(cur); err == nil { + return filepath.Clean(cur), nil + } + parent := filepath.Dir(cur) + if parent == cur { + return "", fmt.Errorf("could not find repository root from %s", wd) + } + cur = parent + } +} + +func validateRepoRoot(root string) error { + anchors := []string{ + filepath.Join(root, "go.sum"), + filepath.Join(root, "LICENSE"), + filepath.Join(root, ".github"), + } + for _, anchor := range anchors { + if _, err := os.Stat(anchor); err != nil { + return fmt.Errorf("missing repo anchor %s: %w", anchor, err) + } + } + return nil +} + +func normalizePathArg(arg, repoRoot string) (string, error) { + resolved := strings.ReplaceAll(arg, "${codespace}", repoRoot) + abs, err := filepath.Abs(resolved) + if err != nil { + return "", err + } + return filepath.Clean(abs), nil +} + +func ensurePathWithinRepo(repoRoot, path string) error { + rel, err := filepath.Rel(repoRoot, path) + if err != nil { + return err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("path %s is outside repository root %s", path, repoRoot) + } + return nil +} + +func samePath(a, b string) bool { + return filepath.Clean(a) == filepath.Clean(b) +} + +func copyTree(src, dst string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("source is not a directory: %s", src) + } + + return filepath.Walk(src, func(path string, entry os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + + target := dst + if rel != "." { + target = filepath.Join(dst, rel) + } + + if entry.IsDir() { + return os.MkdirAll(target, entry.Mode()) + } + + return copyFile(path, target, entry.Mode()) + }) +} + +func copyFile(src, dst string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + + return out.Close() +} diff --git a/scripts/icon.icns b/scripts/icon.icns new file mode 100644 index 000000000..bcf9adcd7 Binary files /dev/null and b/scripts/icon.icns differ diff --git a/scripts/lint-docs.sh b/scripts/lint-docs.sh new file mode 100755 index 000000000..7351298b6 --- /dev/null +++ b/scripts/lint-docs.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +failures=0 + +error() { + local path="$1" + local reason="$2" + local suggestion="${3:-}" + + echo "docs lint: $path" >&2 + echo " reason: $reason" >&2 + if [[ -n "$suggestion" ]]; then + echo " fix: $suggestion" >&2 + fi + failures=1 +} + +lowercase() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' +} + +suggest_noncanonical_translation_name() { + local path="$1" + local dir + local base + local stem + local locale + + dir="$(dirname "$path")" + base="$(basename "$path")" + + if [[ "$base" =~ ^(.+)_([A-Za-z]{2}(-[A-Za-z]{2})?)\.md$ ]]; then + stem="${BASH_REMATCH[1]}" + locale="$(lowercase "${BASH_REMATCH[2]}")" + printf '%s/%s.%s.md' "$dir" "$stem" "$locale" + return + fi + + if [[ "$base" =~ ^(.+)\.([A-Za-z]{2}(-[A-Za-z]{2})?)\.md$ ]]; then + stem="${BASH_REMATCH[1]}" + locale="$(lowercase "${BASH_REMATCH[2]}")" + printf '%s/%s.%s.md' "$dir" "$stem" "$locale" + return + fi + + printf 'rename it to use a lowercase .<locale>.md suffix beside the English source' +} + +suggest_docs_language_bucket_target() { + local path="$1" + local locale + local file + local name + local -a matches + + if [[ "$path" =~ ^docs/([A-Za-z]{2}(-[A-Za-z]{2})?)/.+\.md$ ]]; then + locale="$(lowercase "${BASH_REMATCH[1]}")" + file="$(basename "$path")" + name="${file%.md}" + mapfile -t matches < <(find docs/project docs/guides docs/reference docs/operations docs/security docs/architecture docs/channels docs/design docs/migration -type f -name "${name}.md" 2>/dev/null | sort) + if [[ "${#matches[@]}" -eq 1 ]]; then + printf '%s' "${matches[0]%.md}.${locale}.md" + return + fi + fi + + printf 'move it to a typed docs directory and rename it to <name>.<locale>.md beside the English source' +} + +suggest_nested_locale_bucket_target() { + local path="$1" + local prefix + local locale + local rest + + if [[ "$path" =~ ^(docs/(project|guides|reference|operations|security|architecture|design|migration))/([A-Za-z]{2}(-[A-Za-z]{2})?)/(.*)\.md$ ]]; then + prefix="${BASH_REMATCH[1]}" + locale="$(lowercase "${BASH_REMATCH[3]}")" + rest="${BASH_REMATCH[5]}" + printf '%s/%s.%s.md' "$prefix" "$rest" "$locale" + return + fi + + if [[ "$path" =~ ^(docs/channels/[^/]+)/([A-Za-z]{2}(-[A-Za-z]{2})?)/(.*)\.md$ ]]; then + prefix="${BASH_REMATCH[1]}" + locale="$(lowercase "${BASH_REMATCH[2]}")" + rest="${BASH_REMATCH[4]}" + printf '%s/%s.%s.md' "$prefix" "$rest" "$locale" + return + fi + + printf 'move the file beside its English source and rename it to <name>.<locale>.md' +} + +is_noncanonical_translation_name() { + local path="$1" + local base + + base="$(basename "$path")" + + [[ "$base" =~ ^.+_[A-Za-z]{2}(-[A-Za-z]{2})?\.md$ ]] && return 0 + [[ "$base" =~ ^.+\.[A-Z]{2}(-[A-Z]{2})?\.md$ ]] && return 0 + [[ "$base" =~ ^.+\.[a-z]{2}-[A-Z]{2}\.md$ ]] && return 0 + [[ "$base" =~ ^.+\.[A-Z]{2}-[a-z]{2}\.md$ ]] && return 0 + + return 1 +} + +is_noncanonical_locale_bucket() { + local path="$1" + + [[ "$path" =~ ^docs/(project|guides|reference|operations|security|architecture|design|migration)/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]] && return 0 + [[ "$path" =~ ^docs/channels/[^/]+/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]] && return 0 + return 1 +} + +is_root_docs_language_bucket() { + local path="$1" + [[ "$path" =~ ^docs/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]] +} + +is_translation_file() { + local path="$1" + [[ "$path" =~ ^(.+)\.([a-z]{2})(-[a-z]{2})?\.md$ ]] +} + +translation_base() { + local path="$1" + local locale="$2" + + if [[ "$path" == docs/project/* ]]; then + local rel="${path#docs/project/}" + echo "${rel%.$locale.md}.md" + return + fi + + echo "${path%.$locale.md}.md" +} + +while IFS= read -r path; do + [[ -f "$path" ]] || continue + + case "$path" in + README.*.md) + error \ + "$path" \ + "translated project entry docs must live under docs/project/" \ + "move it to docs/project/$(basename "$path")" + ;; + CONTRIBUTING.*.md) + error \ + "$path" \ + "translated project entry docs must live under docs/project/" \ + "move it to docs/project/$(basename "$path")" + ;; + esac + + if [[ "$path" =~ (^|/)README_[A-Za-z0-9-]+\.md$ ]]; then + error \ + "$path" \ + "legacy README translation names are not allowed" \ + "rename it to use README.<locale>.md, for example $(suggest_noncanonical_translation_name "$path")" + fi + + if is_noncanonical_translation_name "$path"; then + error \ + "$path" \ + "translation files must use lowercase .<locale>.md suffixes and no underscore variants" \ + "rename it to $(suggest_noncanonical_translation_name "$path")" + fi + + if is_root_docs_language_bucket "$path"; then + error \ + "$path" \ + "language bucket directories under docs/ are not allowed" \ + "move it to $(suggest_docs_language_bucket_target "$path")" + fi + + if is_noncanonical_locale_bucket "$path"; then + error \ + "$path" \ + "translations must live beside the English source, not under locale-named subdirectories" \ + "move it to $(suggest_nested_locale_bucket_target "$path")" + fi + + if [[ "$path" =~ ^docs/[^/]+\.md$ && "$path" != "docs/README.md" ]]; then + error \ + "$path" \ + "top-level docs Markdown files must move into a typed docs/ subdirectory" \ + "move it into one of docs/project/, docs/guides/, docs/reference/, docs/operations/, docs/security/, docs/architecture/, docs/channels/, docs/design/, or docs/migration/" + fi + + if is_translation_file "$path"; then + locale="${BASH_REMATCH[2]}${BASH_REMATCH[3]}" + + if [[ "$path" == docs/design/* ]]; then + continue + fi + + base="$(translation_base "$path" "$locale")" + if [[ ! -f "$base" ]]; then + error \ + "$path" \ + "missing English source document '$base'" \ + "add the English source document at '$base' or move this translation beside the correct English source" + fi + fi +done < <(git ls-files --cached --others --exclude-standard -- '*.md') + +if [[ "$failures" -ne 0 ]]; then + echo "docs lint: failed" >&2 + exit 1 +fi + +echo "docs lint: OK" diff --git a/scripts/setup.iss b/scripts/setup.iss new file mode 100644 index 000000000..c081d4dff --- /dev/null +++ b/scripts/setup.iss @@ -0,0 +1,65 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "PicoClaw Launcher" +#define MyAppVersion "1.0" +#define MyAppPublisher "PicoClaw" +#define MyAppURL "https://github.com/sipeed/picoclaw" +#define MyAppExeName "picoclaw-launcher.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{C8A1B4E7-D5F9-4C2A-8A6E-5F4D3C2A1B0E} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\PicoClaw +DefaultGroupName={#MyAppName} +; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run +; on anything but x64 and Windows 11 on Arm. +ArchitecturesAllowed=x64compatible +; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the +; install be done in "64-bit mode" on x64 or Windows 11 on Arm, +; meaning it should use the native 64-bit Program Files directory and +; the 64-bit view of the registry. +ArchitecturesInstallIn64BitMode=x64compatible +DisableProgramGroupPage=yes +; Remove the following line to run in administrative install mode (install for all users.) +PrivilegesRequired=lowest +OutputDir=build +OutputBaseFilename=PicoClawSetup +Compression=lzma +SolidCompression=yes +WizardStyle=modern +; SourceDir=windows +SetupIconFile=icon.ico + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Dirs] + +[Files] +Source: "..\web\build\picoclaw-launcher.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}"; Flags: ignoreversion +Source: "..\build\picoclaw.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\web\backend\icon.ico"; DestDir: "{app}"; Flags: ignoreversion +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[UninstallDelete] + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; IconFilename: "{app}\icon.ico" +Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Tasks: desktopicon; IconFilename: "{app}\icon.ico" + +[Run] +Filename:"{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + diff --git a/web/Makefile b/web/Makefile new file mode 100644 index 000000000..254c439e9 --- /dev/null +++ b/web/Makefile @@ -0,0 +1,209 @@ +.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean \ + build-android-arm64 build-android-bundle + +# Go variables +GO?=go +WEB_GO?=$(GO) +CGO_ENABLED?=0 +GO_BUILD_TAGS?=goolm,stdjson +GOFLAGS?=-v -tags $(GO_BUILD_TAGS) +GOCACHE?=$(abspath ../.cache/go-build) +GOMODCACHE?=$(abspath ../.cache/go-mod) +GOTOOLCHAIN?=local +export CGO_ENABLED +export GOCACHE +export GOMODCACHE +export GOTOOLCHAIN + +# Build variables +BUILD_DIR=build +EXT= +OUTPUT?=$(BUILD_DIR)/picoclaw-launcher$(EXT) +OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64$(EXT) +FRONTEND_DIR=frontend +FRONTEND_INSTALL_STAMP=$(FRONTEND_DIR)/node_modules/.picoclaw-install-stamp +BACKEND_DIR=backend +BACKEND_DIST=$(BACKEND_DIR)/dist +PICOCLAW_BINARY_NAME=picoclaw +PICOCLAW_BINARY?=$(abspath ../build/$(PICOCLAW_BINARY_NAME)) +LAUNCHER_GUI_LDFLAG= + +ifeq ($(OS),Windows_NT) + POWERSHELL=powershell -NoProfile -Command + WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL)) +endif + +# Version +ifeq ($(OS),Windows_NT) + VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL)) + GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL)) + BUILD_TIME_RAW:=$(strip $(shell powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'")) + GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>NUL)) +else + VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>/dev/null)) + GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>/dev/null)) + BUILD_TIME_RAW:=$(strip $(shell date +%FT%T%z)) + GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>/dev/null)) +endif +VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev) +GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev) +BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev) +GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown) +CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config +LDFLAGS=-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 + + +# OS detection +ifeq ($(OS),Windows_NT) + UNAME_S=Windows + ifeq ($(WINDOWS_GOARCH_RAW),amd64) + UNAME_M=x86_64 + else ifeq ($(WINDOWS_GOARCH_RAW),arm64) + UNAME_M=arm64 + else ifeq ($(WINDOWS_GOARCH_RAW),386) + UNAME_M=x86 + else + UNAME_M=$(if $(WINDOWS_GOARCH_RAW),$(WINDOWS_GOARCH_RAW),x86_64) + endif +else + UNAME_S:=$(shell uname -s) + UNAME_M:=$(shell uname -m) +endif + +# Platform-specific settings +ifeq ($(UNAME_S),Linux) + PLATFORM=linux + ifeq ($(UNAME_M),x86_64) + ARCH=amd64 + else ifeq ($(UNAME_M),aarch64) + ARCH=arm64 + else ifeq ($(UNAME_M),armv81) + ARCH=arm64 + else ifeq ($(UNAME_M),loongarch64) + ARCH=loong64 + else ifeq ($(UNAME_M),riscv64) + ARCH=riscv64 + else ifeq ($(UNAME_M),mipsel) + ARCH=mipsle + else + ARCH=$(UNAME_M) + endif +else ifeq ($(UNAME_S),Darwin) + PLATFORM=darwin + WEB_GO=CGO_ENABLED=1 go + ifeq ($(UNAME_M),x86_64) + ARCH=amd64 + else ifeq ($(UNAME_M),arm64) + ARCH=arm64 + else + ARCH=$(UNAME_M) + endif +else ifeq ($(UNAME_S),Windows) + PLATFORM=windows + ifeq ($(UNAME_M),x86_64) + ARCH=amd64 + else ifeq ($(UNAME_M),arm64) + ARCH=arm64 + else + ARCH=$(UNAME_M) + endif + EXT=.exe + PICOCLAW_BINARY_NAME=picoclaw.exe + LAUNCHER_GUI_LDFLAG=-H=windowsgui +else + PLATFORM=$(UNAME_S) + ARCH=$(UNAME_M) +endif + +LAUNCHER_LDFLAGS=$(strip $(LAUNCHER_GUI_LDFLAG) $(LDFLAGS)) + +# Run both frontend and backend dev servers +dev: build-dev-picoclaw + @if [ ! -f "$(BACKEND_DIST)/index.html" ]; then \ + echo "Embedded frontend not found, building..."; \ + $(MAKE) build-frontend; \ + fi + @echo "Starting backend and frontend dev servers..." + @$(MAKE) dev-backend BACKEND_ARGS='-no-browser' & $(MAKE) dev-frontend + +# Start frontend dev server (Vite, with proxy to backend) +dev-frontend: + cd $(FRONTEND_DIR) && pnpm dev + +# Start backend dev server +dev-backend: + cd $(BACKEND_DIR) && PICOCLAW_BINARY="$(PICOCLAW_BINARY)" ${WEB_GO} run -ldflags "$(LAUNCHER_LDFLAGS)" . $(BACKEND_ARGS) + +# Build frontend and embed into Go binary +build: build-frontend +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$(OUTPUT)') | Out-Null" +else + @mkdir -p "$$(dirname "$(OUTPUT)")" +endif + ${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/ + +# Build launcher for Android ARM64 (frontend must already be built) +build-android-arm64: build-frontend +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null" +else + @mkdir -p $(BUILD_DIR) +endif + GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/ + +# Build launcher for all Android architectures +build-android-bundle: build-frontend +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null" +else + @mkdir -p $(BUILD_DIR) +endif + GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/ + @echo "All Android launcher builds complete" + +build-frontend: +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "if ((-not (Test-Path -LiteralPath '$(FRONTEND_DIR)/node_modules')) -or (-not (Test-Path -LiteralPath '$(FRONTEND_DIR)/node_modules/.bin/tsc')) -or (-not (Test-Path -LiteralPath '$(FRONTEND_INSTALL_STAMP)')) -or ((Get-Content -LiteralPath '$(FRONTEND_INSTALL_STAMP)' -Raw).Trim() -ne (((Get-FileHash -LiteralPath '$(FRONTEND_DIR)/package.json' -Algorithm SHA256).Hash + ':' + (Get-FileHash -LiteralPath '$(FRONTEND_DIR)/pnpm-lock.yaml' -Algorithm SHA256).Hash)))) { Write-Host 'Installing frontend dependencies...'; Push-Location '$(FRONTEND_DIR)'; try { pnpm install --frozen-lockfile } finally { Pop-Location }; Set-Content -LiteralPath '$(FRONTEND_INSTALL_STAMP)' -Value (((Get-FileHash -LiteralPath '$(FRONTEND_DIR)/package.json' -Algorithm SHA256).Hash + ':' + (Get-FileHash -LiteralPath '$(FRONTEND_DIR)/pnpm-lock.yaml' -Algorithm SHA256).Hash)) -NoNewline }" +else + @expected_stamp="$$(cat $(FRONTEND_DIR)/package.json $(FRONTEND_DIR)/pnpm-lock.yaml | cksum | awk '{print $$1 ":" $$2}')"; \ + if [ ! -d $(FRONTEND_DIR)/node_modules ] || \ + [ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \ + [ ! -f $(FRONTEND_INSTALL_STAMP) ] || \ + [ "$$(cat $(FRONTEND_INSTALL_STAMP) 2>/dev/null)" != "$$expected_stamp" ]; then \ + echo "Installing frontend dependencies..."; \ + (cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \ + printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \ + fi +endif + @echo "Building frontend..." + @cd $(FRONTEND_DIR) && pnpm build:backend + +build-dev-picoclaw: + @echo "Building picoclaw for launcher development..." +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$(PICOCLAW_BINARY)') | Out-Null" +else + @mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")" +endif + @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw + +# Run all tests +test: + cd $(BACKEND_DIR) && ${WEB_GO} test ./... + cd $(FRONTEND_DIR) && pnpm lint + +# Lint and format +lint: + cd $(BACKEND_DIR) && ${WEB_GO} vet ./... + cd $(FRONTEND_DIR) && pnpm check + +# Clean build artifacts +clean: +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "$$paths=@('$(FRONTEND_DIR)/dist','$(BACKEND_DIST)','$(BUILD_DIR)'); foreach($$p in $$paths){ if (Test-Path -LiteralPath $$p) { Remove-Item -LiteralPath $$p -Recurse -Force } }" + @node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs +else + rm -rf $(FRONTEND_DIR)/dist $(BACKEND_DIST) $(BUILD_DIR) + node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs +endif diff --git a/web/README.md b/web/README.md new file mode 100644 index 000000000..2a57524e0 --- /dev/null +++ b/web/README.md @@ -0,0 +1,367 @@ +# PicoClaw Web + +`web/` contains the standalone WebUI launcher for PicoClaw. +It is not just a frontend: it is a small launcher service that bundles a React dashboard, exposes a backend API, manages launcher authentication, and starts or attaches to the `picoclaw gateway` process. + +![PicoClaw Launcher](./picoclaw-launcher.png) + +## What This Directory Provides + +- A browser-based chat UI backed by the Pico channel WebSocket proxy. +- A dashboard for models, credentials, channels, agent tools, skills, logs, and runtime settings. +- A launcher process that can auto-open the browser, show a system tray menu, and persist launcher-specific settings. +- A controlled way to start, stop, restart, and inspect the `picoclaw gateway` subprocess. +- A single-binary deployment target where the frontend is embedded into the Go backend. + +## Architecture + +This directory is a small monorepo: + +- `backend/` + - Go HTTP server and launcher runtime. + - Serves REST APIs, authentication endpoints, channel helper flows, and the Pico WebSocket reverse proxy. + - Embeds compiled frontend assets from `backend/dist`. +- `frontend/` + - Vite + React 19 + TanStack Router SPA. + - Provides the launcher dashboard and chat UI. + +At runtime the launcher and the main PicoClaw engine are separate processes: + +1. The launcher starts the web backend on port `18800` by default. +2. The launcher serves the dashboard and handles dashboard authentication. +3. When allowed, it starts or attaches to `picoclaw gateway -E`. +4. The frontend talks only to the launcher backend. +5. The launcher proxies chat traffic to the gateway through `/pico/ws`. + +## Dashboard Capabilities + +The current frontend exposes these major pages and flows: + +- `/` + - Chat UI with session history, default model selection, and Pico channel messaging. +- `/models` + - Add, edit, delete, and set the default model. + - Supports API-key models, OAuth-backed models, and local/CLI-backed models. +- `/credentials` + - Manage provider credentials. + - Current built-in flows: OpenAI, Anthropic, and Google Antigravity. +- `/channels/*` + - Configure supported channels from a shared catalog. + - Current catalog: `weixin`, `telegram`, `discord`, `slack`, `feishu`, `dingtalk`, `line`, `qq`, `onebot`, `wecom`, `whatsapp`, `whatsapp_native`, `pico`, `maixcam`, `matrix`, `irc`. + - Includes QR-based binding helpers for WeChat and WeCom. +- `/agent/skills` + - Browse built-in, global, and workspace skills. + - Import Markdown skills into the workspace and delete workspace-owned skills. +- `/agent/tools` + - View tool availability and enable or disable tool switches through config-backed APIs. +- `/config` + - Edit agent defaults, exec controls, cron controls, heartbeat, device monitoring, launcher networking, and launch-at-login settings. +- `/logs` + - View the in-memory gateway log buffer and clear it. + +The UI currently supports English and Simplified Chinese, plus light and dark themes. + +## Runtime Behavior + +### Config Resolution + +The launcher uses the same PicoClaw config file as the main binary. + +- Default app config path: `~/.picoclaw/config.json` +- Override with environment variable: `PICOCLAW_CONFIG` +- Override with a positional CLI argument: `picoclaw-launcher /path/to/config.json` + +Launcher-only settings are stored beside that app config: + +- File name: `launcher-config.json` +- Default location: `~/.picoclaw/launcher-config.json` + +That file currently stores: + +- `port` +- `public` +- `allowed_cidrs` + +If `-port` or `-public` are passed explicitly, the CLI flag wins for that run. +If they are omitted, stored launcher settings are used. + +### First-Run Onboarding + +If the target config file does not exist, the launcher tries to bootstrap it automatically by running: + +```bash +picoclaw onboard +``` + +The launcher looks for the main PicoClaw binary in this order: + +1. `PICOCLAW_BINARY` +2. A `picoclaw` binary in the same directory as the launcher +3. `picoclaw` from `PATH` + +If onboarding or gateway startup cannot find the main binary, set `PICOCLAW_BINARY` explicitly. + +### Gateway Management + +The launcher manages `picoclaw gateway -E`. + +On startup it tries to auto-start or attach to the gateway, but only when startup preconditions pass. In the current code, the main checks are: + +- a default model is configured +- the default model entry is valid +- the default model has usable credentials +- local/runtime-probed models are reachable + +When a gateway process is started by the launcher, the launcher: + +- captures stdout and stderr into an in-memory ring buffer +- tracks transient states such as `starting`, `restarting`, and `stopping` +- marks restart-required when the default model or enabled tool set changed since boot +- ensures the Pico channel is configured before startup + +### Launcher Authentication + +The dashboard is protected by password login. + +- First run uses `/launcher-setup` to create the dashboard password. +- Manual login uses `/launcher-login`. +- Successful login sets an HttpOnly session cookie. +- Existing sessions are invalidated when the launcher process restarts; otherwise the browser cookie expires after 31 days. +- When the launcher auto-opens a local browser after startup, it uses a one-shot loopback-only bootstrap endpoint to set the session cookie automatically. +- On supported platforms, the password is stored as a bcrypt hash in `launcher-auth.db`. +- On platforms where the SQLite password store is unavailable, the launcher stores the bcrypt hash in `launcher-config.json`. +- Legacy `launcher_token` values are migrated once into password login and are removed from saved launcher config. +- `PICOCLAW_LAUNCHER_TOKEN` is deprecated and ignored; after upgrading from env-token auth, open `/launcher-setup` to create a password. +- URL token login and `Authorization: Bearer` dashboard auth are not supported. + +### Network Exposure + +By default the launcher listens on: + +```text +127.0.0.1:18800 +``` + +With `-public` or `public: true`, it listens on all interfaces: + +```text +0.0.0.0:18800 +``` + +When public access is enabled: + +- the launcher still protects the dashboard with password login +- optional `allowed_cidrs` can restrict which client IP ranges may connect +- the gateway host is overridden so remote clients can still use the launcher-managed proxy paths + +## Build And Run + +### Prerequisites + +- Go `1.25+` +- Node.js 20.19+ or 22.13+ +- `pnpm` + +On macOS, the `web` Makefile enables `CGO_ENABLED=1` so tray-enabled launcher builds work as expected. +On Darwin or FreeBSD without cgo, the launcher falls back to headless mode without a tray. + +If you want to prepare the frontend workspace manually, you can still install dependencies yourself: + +```bash +cd frontend +pnpm install +``` + +### Recommended Development Workflow + +From the `web/` directory: + +```bash +make dev +``` + +This does three things: + +1. Builds `../build/picoclaw` for launcher development. +2. Starts the Go backend with `PICOCLAW_BINARY` pointing at that binary. +3. Starts the Vite frontend dev server. + +Use this when you want the full launcher flow during development. + +### Run Frontend And Backend Separately + +```bash +make dev-frontend +make dev-backend +``` + +Notes: + +- `dev-frontend` runs the Vite server. +- `dev-backend` runs the Go backend only. +- The Vite dev server proxies `/api` to `http://localhost:18800`. +- Chat WebSocket URLs are generated by the backend, so the frontend does not hardcode gateway addresses. +- Running `dev-backend` alone is mainly useful for backend work or when `backend/dist` already contains a built frontend. + +### Build The Standalone Launcher Binary + +From `web/`: + +```bash +make build +``` + +This: + +1. Installs frontend dependencies when needed. +2. Builds the frontend into `backend/dist`. +3. Embeds those assets into the Go backend. +4. Produces `build/picoclaw-launcher`. + +Override the output path if needed: + +```bash +make build OUTPUT=/tmp/picoclaw-launcher +``` + +From the repository root you can also use: + +```bash +make build-launcher +``` + +That writes the platform-specific launcher to: + +```text +build/picoclaw-launcher-<platform>-<arch> +``` + +and refreshes the `build/picoclaw-launcher` symlink. + +### Frontend-Only Builds + +For frontend work there are two useful package scripts: + +```bash +cd frontend +pnpm build +pnpm build:backend +``` + +- `pnpm build` writes a normal Vite build to `frontend/dist` +- `pnpm build:backend` writes the embeddable build to `../backend/dist` + +### Run The Built Launcher + +Examples: + +```bash +./build/picoclaw-launcher +./build/picoclaw-launcher -console +./build/picoclaw-launcher -public +./build/picoclaw-launcher -port 19999 /path/to/config.json +``` + +Current launcher flags: + +- `-port` +- `-public` +- `-no-browser` +- `-lang` +- `-console` + +## Make Targets + +From `web/`: + +```bash +make dev +make dev-frontend +make dev-backend +make build +make build-frontend +make test +make lint +make clean +``` + +What they do today: + +- `make build-frontend` + - Runs `pnpm install --frozen-lockfile` when dependencies are missing or stale. + - Builds the embeddable frontend into `backend/dist`. +- `make test` + - Runs backend Go tests. + - Runs frontend `pnpm lint`. +- `make lint` + - Runs backend `go vet`. + - Runs frontend `pnpm check`. + - `pnpm check` currently formats files with Prettier and fixes lint issues with ESLint, so this target can modify your working tree. +- `make clean` + - Removes `frontend/dist`, `backend/dist`, and `build/`, then recreates `backend/dist/.gitkeep`. + +## Directory Layout + +```text +web/ +├── backend/ +│ ├── api/ # REST API handlers and launcher runtime endpoints +│ ├── launcherconfig/ # launcher-config.json load/save/validation +│ ├── middleware/ # auth, content type, logging, CIDR allowlist +│ ├── model/ # Go data structures and logic wrappers +│ ├── utils/ # runtime helpers, onboarding, browser launch +│ ├── winres/ # Windows application resources +│ └── dist/ # embedded frontend build output +├── frontend/ +│ ├── src/api/ # browser API clients +│ ├── src/components/ # UI pages and shared components +│ ├── src/features/ # feature-specific state, controllers, and protocol helpers +│ ├── src/hooks/ # shared React hooks +│ ├── src/i18n/ # internationalization language packs +│ ├── src/lib/ # generic library utilities +│ ├── src/routes/ # TanStack file routes +│ ├── src/store/ # global state management +│ └── vite.config.ts # dev server and build config +├── Makefile +└── README.md +``` + +## Troubleshooting + +### You have to sign in again after the launcher restarts + +Existing dashboard sessions do not survive launcher restarts. +That is expected: each launcher process generates a new session value, so old cookies become invalid. +Sign in again with the dashboard password on `/launcher-login`. + +### "Start Gateway" stays disabled + +The launcher only allows gateway startup when the configured default model is usable. +Check these in the dashboard: + +- a default model is selected +- the model has credentials or OAuth state +- local models such as Ollama or vLLM are reachable + +### The launcher cannot find `picoclaw` + +Set the main binary explicitly: + +```bash +export PICOCLAW_BINARY=/absolute/path/to/picoclaw +``` + +This affects onboarding and gateway subprocess startup. + +### The backend starts but the UI is blank in development + +Use `make dev` for the normal workflow. +If you run only `make dev-backend`, either run `make dev-frontend` alongside it or build the embedded frontend first with `make build-frontend`. + +## Related Docs + +- Main project overview: [`../README.md`](../README.md) +- Configuration guide: [`../docs/guides/configuration.md`](../docs/guides/configuration.md) +- Providers: [`../docs/guides/providers.md`](../docs/guides/providers.md) +- Troubleshooting: [`../docs/operations/troubleshooting.md`](../docs/operations/troubleshooting.md) +- Official docs site: [docs.picoclaw.io](https://docs.picoclaw.io) diff --git a/web/backend/.gitignore b/web/backend/.gitignore new file mode 100644 index 000000000..509042171 --- /dev/null +++ b/web/backend/.gitignore @@ -0,0 +1,19 @@ +# Go build output +*.exe +*.dll +*.so +*.dylib +*.test +*.out +picoclaw-web + +# Frontend build artifacts (embedded by Go) +dist/* +!dist/.gitkeep + +# OS +.DS_Store + +# Editors +.vscode/ +.idea/ \ No newline at end of file diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go new file mode 100644 index 000000000..da07b76c0 --- /dev/null +++ b/web/backend/api/auth.go @@ -0,0 +1,270 @@ +package api + +import ( + "context" + "crypto/subtle" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/sipeed/picoclaw/web/backend/middleware" +) + +// PasswordStore is the interface for dashboard password persistence. +// Implemented by dashboardauth.Store and launcherconfig.PasswordStore. +type PasswordStore interface { + IsInitialized(ctx context.Context) (bool, error) + SetPassword(ctx context.Context, plain string) error + VerifyPassword(ctx context.Context, plain string) (bool, error) +} + +// LauncherAuthRouteOpts configures dashboard auth handlers. +type LauncherAuthRouteOpts struct { + SessionCookie string + SecureCookie func(*http.Request) bool + // PasswordStore enables password login. It must be non-nil for auth to work. + PasswordStore PasswordStore + // StoreError holds the error returned when opening the password store. When + // non-nil and PasswordStore is nil, auth endpoints fail closed with a + // recovery message. + StoreError error +} + +type launcherAuthLoginBody struct { + Password string `json:"password"` +} + +type launcherAuthSetupBody struct { + Password string `json:"password"` + Confirm string `json:"confirm"` +} + +type launcherAuthStatusResponse struct { + Authenticated bool `json:"authenticated"` + Initialized bool `json:"initialized"` +} + +// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status|setup. +func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) { + secure := opts.SecureCookie + if secure == nil { + secure = middleware.DefaultLauncherDashboardSecureCookie + } + h := &launcherAuthHandlers{ + sessionCookie: opts.SessionCookie, + secureCookie: secure, + store: opts.PasswordStore, + storeErr: opts.StoreError, + loginLimit: newLoginRateLimiter(), + } + mux.HandleFunc("POST /api/auth/login", h.handleLogin) + mux.HandleFunc("POST /api/auth/logout", h.handleLogout) + mux.HandleFunc("GET /api/auth/status", h.handleStatus) + mux.HandleFunc("POST /api/auth/setup", h.handleSetup) +} + +type launcherAuthHandlers struct { + sessionCookie string + secureCookie func(*http.Request) bool + store PasswordStore + storeErr error // set when the store failed to open; drives recovery messages + loginLimit *loginRateLimiter +} + +// isStoreInitialized safely queries the store. +// Returns (false, err) on store errors — callers must treat this as a 5xx, not as +// "uninitialized", to keep auth fail-closed. +func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) { + if h.store == nil { + if h.storeErr != nil { + return false, fmt.Errorf( + "password store unavailable (%w); "+ + "to recover, stop the application, reset dashboard password storage, and restart", + h.storeErr) + } + return false, fmt.Errorf("password store not configured") + } + return h.store.IsInitialized(ctx) +} + +func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var body launcherAuthLoginBody + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid JSON"}`)) + return + } + ip := clientIPForLimiter(r) + if !h.loginLimit.allow(ip) { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"error":"too many login attempts"}`)) + return + } + in := strings.TrimSpace(body.Password) + + initialized, initErr := h.isStoreInitialized(r.Context()) + if initErr != nil { + w.WriteHeader(http.StatusServiceUnavailable) + writeErrorf(w, "%v", initErr) + return + } + if !initialized { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"password has not been set"}`)) + return + } + + ok, err := h.store.VerifyPassword(r.Context(), in) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeErrorf(w, "password verification failed: %v", err) + return + } + if !ok { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid password"}`)) + return + } + + middleware.SetLauncherDashboardSessionCookie(w, r, h.sessionCookie, h.secureCookie) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte(`{"error":"method not allowed"}`)) + return + } + ct := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))) + if !strings.HasPrefix(ct, "application/json") { + w.WriteHeader(http.StatusUnsupportedMediaType) + _, _ = w.Write([]byte(`{"error":"Content-Type must be application/json"}`)) + return + } + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, logoutBodyMaxBytes)) + if err := dec.Decode(&struct{}{}); err != nil && err != io.EOF { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid JSON body"}`)) + return + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid JSON body"}`)) + return + } + + middleware.ClearLauncherDashboardSessionCookie(w, r, h.secureCookie) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + authed := false + if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil { + authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1 + } + initialized, initErr := h.isStoreInitialized(r.Context()) + if initErr != nil { + w.WriteHeader(http.StatusServiceUnavailable) + writeErrorf(w, "%v", initErr) + return + } + resp := launcherAuthStatusResponse{ + Authenticated: authed, + Initialized: initialized, + } + enc, err := json.Marshal(resp) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeErrorf(w, "marshal response failed: %v", err) + return + } + _, _ = w.Write(enc) +} + +// handleSetup sets or changes the dashboard password. +// +// Rules: +// - If the store has no password yet, anyone who can reach the setup endpoint +// may initialize the password. +// - If a password is already set, the caller must hold a valid session cookie. +func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if h.store == nil { + w.WriteHeader(http.StatusServiceUnavailable) + if h.storeErr != nil { + writeErrorf(w, "password store unavailable: %v", h.storeErr) + } else { + _, _ = w.Write([]byte(`{"error":"password store not configured"}`)) + } + return + } + + initialized, initErr := h.isStoreInitialized(r.Context()) + if initErr != nil { + w.WriteHeader(http.StatusServiceUnavailable) + writeErrorf(w, "%v", initErr) + return + } + + // If already initialized, require an active session (change-password flow). + if initialized { + authed := false + if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil { + authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1 + } + if !authed { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"must be authenticated to change password"}`)) + return + } + } + + var body launcherAuthSetupBody + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid JSON"}`)) + return + } + + pw := strings.TrimSpace(body.Password) + if pw == "" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"password must not be empty"}`)) + return + } + if pw != strings.TrimSpace(body.Confirm) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"passwords do not match"}`)) + return + } + if len([]rune(pw)) < 8 { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"password must be at least 8 characters"}`)) + return + } + + if err := h.store.SetPassword(r.Context(), pw); err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeErrorf(w, "failed to save password: %v", err) + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +// writeErrorf writes a JSON error response with a formatted message. +// json.Marshal is used to safely escape the message string. +func writeErrorf(w http.ResponseWriter, format string, args ...any) { + msg, _ := json.Marshal(fmt.Sprintf(format, args...)) + _, _ = w.Write([]byte(`{"error":` + string(msg) + `}`)) +} diff --git a/web/backend/api/auth_login_limiter.go b/web/backend/api/auth_login_limiter.go new file mode 100644 index 000000000..d606f03cf --- /dev/null +++ b/web/backend/api/auth_login_limiter.go @@ -0,0 +1,59 @@ +package api + +import ( + "net" + "net/http" + "strings" + "sync" + "time" +) + +const ( + loginAttemptsPerIP = 10 + loginAttemptWindow = time.Minute + logoutBodyMaxBytes = 4096 +) + +// loginRateLimiter limits POST /api/auth/login attempts per IP per minute. +type loginRateLimiter struct { + mu sync.Mutex + now func() time.Time + byIP map[string][]time.Time +} + +func newLoginRateLimiter() *loginRateLimiter { + return &loginRateLimiter{ + now: time.Now, + byIP: make(map[string][]time.Time), + } +} + +// allow reserves a slot for this request; false means rate limit exceeded. +func (l *loginRateLimiter) allow(ip string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + cutoff := now.Add(-loginAttemptWindow) + times := l.byIP[ip] + var kept []time.Time + for _, ts := range times { + if ts.After(cutoff) { + kept = append(kept, ts) + } + } + if len(kept) >= loginAttemptsPerIP { + l.byIP[ip] = kept + return false + } + kept = append(kept, now) + l.byIP[ip] = kept + return true +} + +func clientIPForLimiter(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return strings.TrimSpace(r.RemoteAddr) + } + return host +} diff --git a/web/backend/api/auth_test.go b/web/backend/api/auth_test.go new file mode 100644 index 000000000..f7f6037a0 --- /dev/null +++ b/web/backend/api/auth_test.go @@ -0,0 +1,364 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/web/backend/middleware" +) + +type fakePasswordStore struct { + initialized bool + password string + err error +} + +func (s *fakePasswordStore) IsInitialized(context.Context) (bool, error) { + if s.err != nil { + return false, s.err + } + return s.initialized, nil +} + +func (s *fakePasswordStore) SetPassword(_ context.Context, plain string) error { + if s.err != nil { + return s.err + } + s.password = plain + s.initialized = true + return nil +} + +func (s *fakePasswordStore) VerifyPassword(_ context.Context, plain string) (bool, error) { + if s.err != nil { + return false, s.err + } + return s.initialized && plain == s.password, nil +} + +func TestLauncherAuthLoginAndStatus(t *testing.T) { + const password = "dashboard-test-password" + const sess = "session-cookie-value" + store := &fakePasswordStore{initialized: true, password: password} + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: sess, + PasswordStore: store, + }) + + t.Run("status_unauthenticated", func(t *testing.T) { + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status code = %d", rec.Code) + } + var body struct { + Authenticated bool `json:"authenticated"` + Initialized bool `json:"initialized"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Authenticated { + t.Fatalf("unexpected authenticated=true: %+v", body) + } + }) + + t.Run("login_ok", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+password+`"}`)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "127.0.0.1:12345" + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String()) + } + cookies := rec.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != middleware.LauncherDashboardCookieName { + t.Fatalf("cookies = %#v", cookies) + } + }) + + t.Run("status_authenticated", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil) + req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess}) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status code = %d", rec.Code) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"authenticated":true`)) { + t.Fatalf("body = %s", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "token_help") { + t.Fatalf("authenticated response should omit token_help: %s", rec.Body.String()) + } + }) +} + +func TestLauncherAuthUninitializedStoreRequiresSetup(t *testing.T) { + const sess = "session-cookie-value" + store := &fakePasswordStore{} + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: sess, + PasswordStore: store, + }) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status code = %d body=%s", rec.Code, rec.Body.String()) + } + + var body struct { + Authenticated bool `json:"authenticated"` + Initialized bool `json:"initialized"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Initialized { + t.Fatalf("initialized = true, want false before setup") + } + if body.Authenticated { + t.Fatalf("unexpected authenticated=true: %+v", body) + } + + rec = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"not-set-yet"}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("login before setup code = %d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest( + http.MethodPost, + "/api/auth/setup", + strings.NewReader(`{"password":"12345678","confirm":"12345678"}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"12345678"}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login after setup code = %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestLauncherAuthSetupRequiresSessionWhenInitialized(t *testing.T) { + const sess = "session-cookie-value" + store := &fakePasswordStore{initialized: true, password: "old-password"} + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: sess, + PasswordStore: store, + }) + + body := strings.NewReader(`{"password":"new-password","confirm":"new-password"}`) + req := httptest.NewRequest(http.MethodPost, "/api/auth/setup", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("setup without session code = %d body=%s", rec.Code, rec.Body.String()) + } + + body = strings.NewReader(`{"password":"new-password","confirm":"new-password"}`) + req = httptest.NewRequest(http.MethodPost, "/api/auth/setup", body) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess}) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("setup with session code = %d body=%s", rec.Code, rec.Body.String()) + } + if store.password != "new-password" { + t.Fatalf("password = %q, want new-password", store.password) + } +} + +func TestLauncherAuthInitialSetupAllowsDirectSetup(t *testing.T) { + store := &fakePasswordStore{} + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: "session-cookie-value", + PasswordStore: store, + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/auth/setup", + strings.NewReader(`{"password":"12345678","confirm":"12345678"}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("setup without grant code = %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestLauncherAuthStoreUnavailableFailsClosed(t *testing.T) { + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: "session-cookie-value", + StoreError: errors.New("open auth store"), + }) + + for _, tc := range []struct { + name string + method string + path string + body string + }{ + {name: "status", method: http.MethodGet, path: "/api/auth/status"}, + {name: "login", method: http.MethodPost, path: "/api/auth/login", body: `{"password":"password"}`}, + {name: "setup", method: http.MethodPost, path: "/api/auth/setup", body: `{"password":"12345678","confirm":"12345678"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + if tc.body != "" { + req.Header.Set("Content-Type", "application/json") + } + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("code = %d body=%s", rec.Code, rec.Body.String()) + } + }) + } +} + +func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) { + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: "session-cookie-value", + }) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/logout", nil)) + if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusNotFound { + t.Fatalf("GET logout: code = %d (expected 404 or 405)", rec.Code) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + mux.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusUnsupportedMediaType { + t.Fatalf("wrong content-type: code = %d body=%s", rec2.Code, rec2.Body.String()) + } + + rec3 := httptest.NewRecorder() + req3 := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}`)) + req3.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec3, req3) + if rec3.Code != http.StatusOK { + t.Fatalf("POST json logout: code = %d", rec3.Code) + } +} + +func TestLauncherAuthLoginRateLimit(t *testing.T) { + store := &fakePasswordStore{initialized: true, password: "correct-password"} + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: "session-cookie-value", + PasswordStore: store, + }) + + // 11 failing logins by wrong password; each consumes allow() slot after valid JSON. + wrongBody := `{"password":"wrong"}` + for i := 0; i < loginAttemptsPerIP; i++ { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "192.168.5.5:9999" + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("iter %d: want 401 got %d %s", i, rec.Code, rec.Body.String()) + } + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "192.168.5.5:9999" + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("11th attempt: want 429 got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestLoginRateLimiterWindow(t *testing.T) { + l := newLoginRateLimiter() + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + l.now = func() time.Time { return t0 } + for i := 0; i < loginAttemptsPerIP; i++ { + if !l.allow("ip") { + t.Fatalf("want allow at %d", i) + } + } + if l.allow("ip") { + t.Fatal("want deny on 11th") + } + l.now = func() time.Time { return t0.Add(loginAttemptWindow + time.Second) } + if !l.allow("ip") { + t.Fatal("want allow after window") + } +} + +func TestReferrerPolicyMiddleware(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + h := middleware.ReferrerPolicyNoReferrer(next) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" { + t.Fatalf("Referrer-Policy = %q", got) + } +} + +func TestLauncherAuthLogoutEmptyBody(t *testing.T) { + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: "session-cookie-value", + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) + req.Header.Set("Content-Type", "application/json") + req.Body = http.NoBody + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d", rec.Code) + } +} + +func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) { + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: "session-cookie-value", + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400 got %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go new file mode 100644 index 000000000..82cd54b72 --- /dev/null +++ b/web/backend/api/channels.go @@ -0,0 +1,203 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type channelCatalogItem struct { + Name string `json:"name"` + ConfigKey string `json:"config_key"` + Variant string `json:"variant,omitempty"` +} + +var channelCatalog = []channelCatalogItem{ + {Name: "weixin", ConfigKey: "weixin"}, + {Name: "telegram", ConfigKey: "telegram"}, + {Name: "discord", ConfigKey: "discord"}, + {Name: "slack", ConfigKey: "slack"}, + {Name: "feishu", ConfigKey: "feishu"}, + {Name: "dingtalk", ConfigKey: "dingtalk"}, + {Name: "line", ConfigKey: "line"}, + {Name: "qq", ConfigKey: "qq"}, + {Name: "onebot", ConfigKey: "onebot"}, + {Name: "wecom", ConfigKey: "wecom"}, + {Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"}, + {Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"}, + {Name: "pico", ConfigKey: "pico"}, + {Name: "maixcam", ConfigKey: "maixcam"}, + {Name: "matrix", ConfigKey: "matrix"}, + {Name: "irc", ConfigKey: "irc"}, +} + +type channelConfigResponse struct { + Config any `json:"config"` + ConfiguredSecrets []string `json:"configured_secrets"` + ConfigKey string `json:"config_key"` + Variant string `json:"variant,omitempty"` +} + +// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux. +func (h *Handler) registerChannelRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog) + mux.HandleFunc("GET /api/channels/{name}/config", h.handleGetChannelConfig) +} + +// handleListChannelCatalog returns the channels supported by backend. +// +// GET /api/channels/catalog +func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "channels": channelCatalog, + }) +} + +// handleGetChannelConfig returns safe channel config plus secret presence metadata. +// +// GET /api/channels/{name}/config +func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) { + channelName := r.PathValue("name") + item, ok := findChannelCatalogItem(channelName) + if !ok { + http.Error(w, "Channel not found", http.StatusNotFound) + return + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, "Failed to load config", http.StatusInternalServerError) + return + } + + resp := buildChannelConfigResponse(cfg, item) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +func findChannelCatalogItem(name string) (channelCatalogItem, bool) { + for _, item := range channelCatalog { + if item.Name == name { + return item, true + } + } + return channelCatalogItem{}, false +} + +var channelSecretFieldMap = map[string][]string{ + "weixin": {"token"}, + "telegram": {"token"}, + "discord": {"token"}, + "slack": {"bot_token", "app_token"}, + "feishu": {"app_secret", "encrypt_key", "verification_token"}, + "dingtalk": {"client_secret"}, + "line": {"channel_secret", "channel_access_token"}, + "qq": {"app_secret"}, + "onebot": {"access_token"}, + "wecom": {"secret"}, + "pico": {"token"}, + "matrix": {"access_token"}, + "irc": {"password", "nickserv_password", "sasl_password"}, + "whatsapp": {}, + "whatsapp_native": {}, + "maixcam": {}, +} + +func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) channelConfigResponse { + resp := channelConfigResponse{ + ConfiguredSecrets: []string{}, + ConfigKey: item.ConfigKey, + Variant: item.Variant, + } + + bc := cfg.Channels.Get(item.ConfigKey) + if bc == nil { + bc = defaultChannelConfig(item.ConfigKey) + if bc == nil { + resp.Config = map[string]any{} + return resp + } + } + + // Detect configured secrets by checking the raw Settings JSON + secrets := detectConfiguredSecrets(bc.Settings, item.Name) + resp.ConfiguredSecrets = secrets + + // Parse settings into a generic map for JSON response + settings := map[string]any{} + if len(bc.Settings) > 0 { + if err := json.Unmarshal(bc.Settings, &settings); err != nil { + resp.Config = map[string]any{} + return resp + } + } + + // Remove secure fields from response + for _, key := range secrets { + delete(settings, key) + } + addChannelCommonConfig(settings, bc) + resp.Config = settings + + return resp +} + +func defaultChannelConfig(configKey string) *config.Channel { + return config.DefaultConfig().Channels.Get(configKey) +} + +func addChannelCommonConfig(settings map[string]any, bc *config.Channel) { + settings["enabled"] = bc.Enabled + if len(bc.AllowFrom) > 0 { + settings["allow_from"] = []string(bc.AllowFrom) + } + if bc.ReasoningChannelID != "" { + settings["reasoning_channel_id"] = bc.ReasoningChannelID + } + if bc.GroupTrigger.MentionOnly || len(bc.GroupTrigger.Prefixes) > 0 { + settings["group_trigger"] = bc.GroupTrigger + } + if bc.Typing.Enabled { + settings["typing"] = bc.Typing + } + if bc.Placeholder.Enabled || len(bc.Placeholder.Text) > 0 { + settings["placeholder"] = bc.Placeholder + } +} + +func detectConfiguredSecrets(settings config.RawNode, channelName string) []string { + var m map[string]any + if err := json.Unmarshal(settings, &m); err != nil { + return nil + } + + fields, ok := channelSecretFieldMap[channelName] + if !ok { + return nil + } + + var found []string + for _, key := range fields { + if val, exists := m[key]; exists { + switch v := val.(type) { + case string: + if v != "" { + found = append(found, key) + } + case map[string]any: + if s, ok := v["s"].(string); ok && s != "" { + found = append(found, key) + } + } + } + } + if found == nil { + return []string{} + } + return found +} diff --git a/web/backend/api/channels_test.go b/web/backend/api/channels_test.go new file mode 100644 index 000000000..0208af8e7 --- /dev/null +++ b/web/backend/api/channels_test.go @@ -0,0 +1,195 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestHandleGetChannelConfig_ReturnsSecretPresenceWithoutLeakingSecrets(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + bc := cfg.Channels[config.ChannelFeishu] + bc.Enabled = true + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + bcfg := decoded.(*config.FeishuSettings) + bcfg.AppID = "cli_test_app" + bcfg.AppSecret = *config.NewSecureString("feishu-secret-from-security") + bc.AllowFrom = config.FlexibleStringSlice{"ou_test_user"} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/channels/feishu/config", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf( + "GET /api/channels/feishu/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) + } + if strings.Contains(rec.Body.String(), "feishu-secret-from-security") { + t.Fatalf("response leaked secret value: %s", rec.Body.String()) + } + + var resp struct { + Config map[string]any `json:"config"` + ConfiguredSecrets []string `json:"configured_secrets"` + ConfigKey string `json:"config_key"` + Variant string `json:"variant"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if got := resp.ConfigKey; got != "feishu" { + t.Fatalf("config_key = %q, want %q", got, "feishu") + } + if got := resp.Config["app_id"]; got != "cli_test_app" { + t.Fatalf("config.app_id = %#v, want %q", got, "cli_test_app") + } + if got := resp.Config["enabled"]; got != true { + t.Fatalf("config.enabled = %#v, want true", got) + } + allowFrom, ok := resp.Config["allow_from"].([]any) + if !ok || len(allowFrom) != 1 || allowFrom[0] != "ou_test_user" { + t.Fatalf("config.allow_from = %#v, want [\"ou_test_user\"]", resp.Config["allow_from"]) + } + if _, exists := resp.Config["app_secret"]; exists { + t.Fatalf("config should omit app_secret, got %#v", resp.Config["app_secret"]) + } + if len(resp.ConfiguredSecrets) != 1 || resp.ConfiguredSecrets[0] != "app_secret" { + t.Fatalf("configured_secrets = %#v, want [\"app_secret\"]", resp.ConfiguredSecrets) + } +} + +func TestHandleGetChannelConfig_ReturnsNotFoundForUnknownChannel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/channels/not-a-channel/config", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("GET /api/channels/not-a-channel/config status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestHandleGetChannelConfig_ReturnsCommonFieldsWhenSettingsEmpty(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + bc := cfg.Channels[config.ChannelFeishu] + bc.Enabled = true + bc.AllowFrom = config.FlexibleStringSlice{"ou_common_user"} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/channels/feishu/config", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf( + "GET /api/channels/feishu/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) + } + + var resp struct { + Config map[string]any `json:"config"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if got := resp.Config["enabled"]; got != true { + t.Fatalf("config.enabled = %#v, want true", got) + } + allowFrom, ok := resp.Config["allow_from"].([]any) + if !ok || len(allowFrom) != 1 || allowFrom[0] != "ou_common_user" { + t.Fatalf("config.allow_from = %#v, want [\"ou_common_user\"]", resp.Config["allow_from"]) + } +} + +func TestHandleGetChannelConfig_ReturnsDefaultShapeForMissingChannel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + delete(cfg.Channels, config.ChannelIRC) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/channels/irc/config", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf( + "GET /api/channels/irc/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) + } + + var resp struct { + Config map[string]any `json:"config"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if got := resp.Config["server"]; got != "" { + t.Fatalf("config.server = %#v, want empty string", got) + } + if got := resp.Config["nick"]; got != "picoclaw" { + t.Fatalf("config.nick = %#v, want %q", got, "picoclaw") + } + if got := resp.Config["enabled"]; got != false { + t.Fatalf("config.enabled = %#v, want false", got) + } +} diff --git a/web/backend/api/config.go b/web/backend/api/config.go new file mode 100644 index 000000000..afcd3f74e --- /dev/null +++ b/web/backend/api/config.go @@ -0,0 +1,758 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "regexp" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// registerConfigRoutes binds configuration management endpoints to the ServeMux. +func (h *Handler) registerConfigRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/config", h.handleGetConfig) + mux.HandleFunc("PUT /api/config", h.handleUpdateConfig) + mux.HandleFunc("PATCH /api/config", h.handlePatchConfig) + mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns) +} + +func (h *Handler) applyRuntimeLogLevel() { + if h.debug { + logger.SetLevel(logger.DEBUG) + return + } + logger.SetLevelFromString(config.ResolveGatewayLogLevel(h.configPath)) +} + +// handleGetConfig returns the complete system configuration. +// +// GET /api/config +func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(cfg); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +// handleUpdateConfig updates the complete system configuration. +// +// PUT /api/config +func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var raw map[string]any + if err = json.Unmarshal(body, &raw); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + if err = normalizeChannelArrayFields(raw); err != nil { + http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest) + return + } + normalizedBody, err := json.Marshal(raw) + if err != nil { + http.Error(w, "Failed to normalize config payload", http.StatusBadRequest) + return + } + var cfg config.Config + if err = json.Unmarshal(normalizedBody, &cfg); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + if execAllowRemoteOmitted(body) { + cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote + } + + // Load existing config and copy security credentials before validation, + // so that security-managed fields (e.g. pico token) are available. + err = cfg.SecurityCopyFrom(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError) + return + } + applyConfigSecretsFromMap(&cfg, raw) + + if errs := validateConfig(&cfg); len(errs) > 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "status": "validation_error", + "errors": errs, + }) + return + } + + if err := config.SaveConfig(h.configPath, &cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + h.applyRuntimeLogLevel() + logger.Infof("configuration updated successfully") + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func execAllowRemoteOmitted(body []byte) bool { + var raw struct { + Tools *struct { + Exec *struct { + AllowRemote *bool `json:"allow_remote"` + } `json:"exec"` + } `json:"tools"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return false + } + return raw.Tools == nil || raw.Tools.Exec == nil || raw.Tools.Exec.AllowRemote == nil +} + +// handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396). +// Only the fields present in the request body will be updated; all other fields remain unchanged. +// +// PATCH /api/config +func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { + patchBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // Validate the patch is valid JSON + var patch map[string]any + if err = json.Unmarshal(patchBody, &patch); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + // Load existing config and marshal to a map for merging + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + existing, err := json.Marshal(cfg) + if err != nil { + http.Error(w, "Failed to serialize current config", http.StatusInternalServerError) + return + } + + var base map[string]any + if err = json.Unmarshal(existing, &base); err != nil { + http.Error(w, "Failed to parse current config", http.StatusInternalServerError) + return + } + + // Recursively merge patch into base + mergeMap(base, patch) + if err = normalizeChannelArrayFields(base); err != nil { + http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest) + return + } + + // Convert merged map back to Config struct + merged, err := json.Marshal(base) + if err != nil { + http.Error(w, "Failed to serialize merged config", http.StatusInternalServerError) + return + } + + var newCfg config.Config + if err = json.Unmarshal(merged, &newCfg); err != nil { + http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest) + return + } + + // Restore security fields (tokens/keys) from the loaded config before validation, + // because private fields are lost during JSON round-trip. + if err = newCfg.SecurityCopyFrom(h.configPath); err != nil { + http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError) + return + } + applyConfigSecretsFromMap(&newCfg, base) + + if errs := validateConfig(&newCfg); len(errs) > 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "status": "validation_error", + "errors": errs, + }) + return + } + + if err := config.SaveConfig(h.configPath, &newCfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + h.applyRuntimeLogLevel() + logger.Infof("configuration updated successfully") + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// handleTestCommandPatterns tests a command against whitelist and blacklist patterns. +// +// POST /api/config/test-command-patterns +func (h *Handler) handleTestCommandPatterns(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var req struct { + AllowPatterns []string `json:"allow_patterns"` + DenyPatterns []string `json:"deny_patterns"` + Command string `json:"command"` + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + lower := strings.ToLower(strings.TrimSpace(req.Command)) + + type result struct { + Allowed bool `json:"allowed"` + Blocked bool `json:"blocked"` + MatchedWhitelist *string `json:"matched_whitelist,omitempty"` + MatchedBlacklist *string `json:"matched_blacklist,omitempty"` + } + + resp := result{Allowed: false, Blocked: false} + + // Check whitelist first + for _, pattern := range req.AllowPatterns { + re, err := regexp.Compile(pattern) + if err != nil { + continue // skip invalid patterns + } + if re.MatchString(lower) { + resp.Allowed = true + resp.MatchedWhitelist = &pattern + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + } + + // Check blacklist + for _, pattern := range req.DenyPatterns { + re, err := regexp.Compile(pattern) + if err != nil { + continue + } + if re.MatchString(lower) { + resp.Blocked = true + resp.MatchedBlacklist = &pattern + break + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// validateConfig checks the config for common errors before saving. +// Returns a list of human-readable error strings; empty means valid. +func validateConfig(cfg *config.Config) []string { + var errs []string + + // Validate model_list entries + if err := cfg.ValidateModelList(); err != nil { + errs = append(errs, err.Error()) + } + + // Gateway port range + if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) { + errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port)) + } + + // Pico channel: token required when enabled + { + bc := cfg.Channels.GetByType(config.ChannelPico) + if bc != nil && bc.Enabled { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + if c, ok := decoded.(*config.PicoSettings); ok && c.Token.String() == "" { + errs = append(errs, "channels.pico.token is required when pico channel is enabled") + } + } + } + } + + // Telegram: token required when enabled + { + bc := cfg.Channels.GetByType(config.ChannelTelegram) + if bc != nil && bc.Enabled { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + if c, ok := decoded.(*config.TelegramSettings); ok && c.Token.String() == "" { + errs = append(errs, "channels.telegram.token is required when telegram channel is enabled") + } + } + } + } + + // Discord: token required when enabled + { + bc := cfg.Channels.GetByType(config.ChannelDiscord) + if bc != nil && bc.Enabled { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + if c, ok := decoded.(*config.DiscordSettings); ok && c.Token.String() == "" { + errs = append(errs, "channels.discord.token is required when discord channel is enabled") + } + } + } + } + + { + bc := cfg.Channels.GetByType(config.ChannelWeCom) + if bc != nil && bc.Enabled { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + if c, ok := decoded.(*config.WeComSettings); ok { + if c.BotID == "" { + errs = append(errs, "channels.wecom.bot_id is required when wecom channel is enabled") + } + if c.Secret.String() == "" { + errs = append(errs, "channels.wecom.secret is required when wecom channel is enabled") + } + } + } + } + } + + if cfg.Tools.Exec.Enabled { + if cfg.Tools.Exec.EnableDenyPatterns { + errs = append( + errs, + validateRegexPatterns("tools.exec.custom_deny_patterns", cfg.Tools.Exec.CustomDenyPatterns)...) + } + errs = append( + errs, + validateRegexPatterns("tools.exec.custom_allow_patterns", cfg.Tools.Exec.CustomAllowPatterns)...) + } + + return errs +} + +func validateRegexPatterns(field string, patterns []string) []string { + var errs []string + for index, pattern := range patterns { + if _, err := regexp.Compile(pattern); err != nil { + errs = append(errs, fmt.Sprintf("%s[%d] is not a valid regular expression: %v", field, index, err)) + } + } + return errs +} + +// mergeMap recursively merges src into dst (JSON Merge Patch semantics). +// - If a key in src has a null value, it is deleted from dst. +// - If both dst and src have a nested object for the same key, merge recursively. +// - Otherwise the value from src overwrites dst. +func mergeMap(dst, src map[string]any) { + for key, srcVal := range src { + if srcVal == nil { + delete(dst, key) + continue + } + srcMap, srcIsMap := srcVal.(map[string]any) + dstMap, dstIsMap := dst[key].(map[string]any) + if srcIsMap && dstIsMap { + mergeMap(dstMap, srcMap) + } else { + dst[key] = srcVal + } + } +} + +func asMapField(value map[string]any, key string) (map[string]any, bool) { + raw, exists := value[key] + if !exists { + return nil, false + } + m, isMap := raw.(map[string]any) + return m, isMap +} + +var ( + allowFromHiddenCharsRe = regexp.MustCompile("[\u200B\u200C\u200D\u200E\u200F\u202A-\u202E\u2060-\u2069\uFEFF]") + allowFromSplitRe = regexp.MustCompile("[,\uFF0C、;;\r\n\t]+") + conservativeSplitRe = regexp.MustCompile("[,\uFF0C\r\n\t]+") +) + +type stringArrayParserOptions struct { + stripHiddenChars bool +} + +func normalizeChannelArrayFields(raw map[string]any) error { + channelsMap, hasChannels := asMapField(raw, "channel_list") + if !hasChannels { + return nil + } + + defaultCfg := config.DefaultConfig() + for channelName, rawChannel := range channelsMap { + chMap, ok := rawChannel.(map[string]any) + if !ok { + continue + } + + if rawAllowFrom, exists := chMap["allow_from"]; exists { + normalized, err := normalizeStringArrayValue(rawAllowFrom, stringArrayParserOptions{ + stripHiddenChars: true, + }) + if err != nil { + return fmt.Errorf("channel_list.%s.allow_from: %w", channelName, err) + } + chMap["allow_from"] = normalized + } + + if groupTrigger, ok := asMapField(chMap, "group_trigger"); ok { + if rawPrefixes, exists := groupTrigger["prefixes"]; exists { + normalized, err := normalizeStringArrayValue(rawPrefixes, stringArrayParserOptions{}) + if err != nil { + return fmt.Errorf("channel_list.%s.group_trigger.prefixes: %w", channelName, err) + } + groupTrigger["prefixes"] = normalized + } + } + + settingsMap, hasSettings := asMapField(chMap, "settings") + if !hasSettings { + continue + } + + settingsType := channelSettingsType(defaultCfg, channelName, chMap) + if settingsType == nil { + continue + } + + for i := range settingsType.NumField() { + field := settingsType.Field(i) + if !field.IsExported() || !isStringSliceType(field.Type) { + continue + } + jsonKey := strings.Split(field.Tag.Get("json"), ",")[0] + if jsonKey == "" || jsonKey == "-" { + continue + } + rawValue, exists := settingsMap[jsonKey] + if !exists { + continue + } + + options := stringArrayParserOptions{} + if jsonKey == "allow_from" { + options.stripHiddenChars = true + } + normalized, err := normalizeStringArrayValue(rawValue, options) + if err != nil { + return fmt.Errorf("channel_list.%s.settings.%s: %w", channelName, jsonKey, err) + } + settingsMap[jsonKey] = normalized + } + } + return nil +} + +func channelSettingsType( + defaultCfg *config.Config, + channelName string, + channelMap map[string]any, +) reflect.Type { + if channelType, _ := channelMap["type"].(string); channelType != "" { + if bc := defaultCfg.Channels.GetByType(channelType); bc != nil { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + return derefType(reflect.TypeOf(decoded)) + } + } + } + + if bc := defaultCfg.Channels.Get(channelName); bc != nil { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + return derefType(reflect.TypeOf(decoded)) + } + } + + return nil +} + +func derefType(typ reflect.Type) reflect.Type { + for typ != nil && typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + return typ +} + +func isStringSliceType(typ reflect.Type) bool { + typ = derefType(typ) + return typ != nil && typ.Kind() == reflect.Slice && typ.Elem().Kind() == reflect.String +} + +func normalizeStringArrayValue(value any, options stringArrayParserOptions) ([]string, error) { + switch typed := value.(type) { + case nil: + return nil, nil + case string: + return parseStringArrayValue(typed, options), nil + case float64: + return normalizeStringArrayItems([]string{fmt.Sprintf("%.0f", typed)}, options), nil + case []string: + return normalizeStringArrayItems(typed, options), nil + case []any: + items := make([]string, 0, len(typed)) + for _, item := range typed { + switch raw := item.(type) { + case string: + items = append(items, raw) + case float64: + items = append(items, fmt.Sprintf("%.0f", raw)) + default: + return nil, fmt.Errorf("unsupported list item type %T", item) + } + } + return normalizeStringArrayItems(items, options), nil + default: + return nil, fmt.Errorf("unsupported list field type %T", value) + } +} + +func parseStringArrayValue(raw string, options stringArrayParserOptions) []string { + if strings.TrimSpace(raw) == "" { + return []string{} + } + splitRe := conservativeSplitRe + if options.stripHiddenChars { + splitRe = allowFromSplitRe + } + return normalizeStringArrayItems(splitRe.Split(raw, -1), options) +} + +func normalizeStringArrayItems(items []string, options stringArrayParserOptions) []string { + result := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + normalized := item + if options.stripHiddenChars { + normalized = allowFromHiddenCharsRe.ReplaceAllString(normalized, "") + } + normalized = strings.TrimSpace(normalized) + if normalized == "" { + continue + } + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + if len(result) == 0 { + return []string{} + } + return result +} + +func getSecretString(m map[string]any, key string) (string, bool) { + if raw, exists := m[key]; exists { + s, isString := raw.(string) + if isString { + return s, true + } + } + if raw, exists := m["_"+key]; exists { + s, isString := raw.(string) + if isString { + return s, true + } + } + return "", false +} + +func applyConfigSecretsFromMap(cfg *config.Config, raw map[string]any) { + channelsMap, hasChannels := asMapField(raw, "channel_list") + if !hasChannels { + return + } + + for chName, chData := range channelsMap { + chMap, ok := chData.(map[string]any) + if !ok { + continue + } + bc := cfg.Channels.Get(chName) + if bc == nil { + continue + } + decoded, err := bc.GetDecoded() + if err != nil || decoded == nil { + continue + } + rv := reflect.ValueOf(decoded) + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + continue + } + // Channel-specific settings live under the "settings" key in the raw map + settingsMap := chMap + if sm, hasSettings := asMapField(chMap, "settings"); hasSettings { + settingsMap = sm + } + applySecureStringsToStruct(rv, settingsMap) + } + + // Handle tools secrets + tools, hasTools := asMapField(raw, "tools") + if !hasTools { + return + } + skills, hasSkills := asMapField(tools, "skills") + if !hasSkills { + return + } + if github, hasGithub := asMapField(skills, "github"); hasGithub { + if token, hasToken := getSecretString(github, "token"); hasToken { + cfg.Tools.Skills.Github.Token.Set(token) + } + } + if registries, hasRegistries := asMapField(skills, "registries"); hasRegistries { + for registryName, rawRegistry := range registries { + registryMap, ok := rawRegistry.(map[string]any) + if !ok { + continue + } + if authToken, hasAuthToken := getSecretString(registryMap, "auth_token"); hasAuthToken { + registryCfg, _ := cfg.Tools.Skills.Registries.Get(registryName) + registryCfg.AuthToken.Set(authToken) + cfg.Tools.Skills.Registries.Set(registryName, registryCfg) + } + } + return + } + + registriesList, hasRegistries := skills["registries"].([]any) + if !hasRegistries { + return + } + for _, rawRegistry := range registriesList { + registryMap, ok := rawRegistry.(map[string]any) + if !ok { + continue + } + name, _ := registryMap["name"].(string) + if name == "" { + continue + } + if authToken, hasAuthToken := getSecretString(registryMap, "auth_token"); hasAuthToken { + registryCfg, _ := cfg.Tools.Skills.Registries.Get(name) + registryCfg.AuthToken.Set(authToken) + cfg.Tools.Skills.Registries.Set(name, registryCfg) + } + } +} + +// applySecureStringsToStruct walks a struct and applies SecureString fields +// from the matching keys in rawMap. It recurses into nested maps and slices. +func applySecureStringsToStruct(rv reflect.Value, rawMap map[string]any) { + rt := rv.Type() + for jsonKey, rawVal := range rawMap { + for i := range rt.NumField() { + f := rt.Field(i) + if !f.IsExported() { + continue + } + tag := f.Tag.Get("json") + name := strings.Split(tag, ",")[0] + if name != jsonKey { + continue + } + sf := rv.Field(i) + if !sf.CanSet() { + continue + } + // Direct SecureString field + if s, ok := rawVal.(string); ok { + if f.Type == reflect.TypeOf(config.SecureString{}) { + sf.Set(reflect.ValueOf(*config.NewSecureString(s))) + } else if f.Type == reflect.TypeOf(&config.SecureString{}) { + sf.Set(reflect.ValueOf(config.NewSecureString(s))) + } + continue + } + // Recurse into nested struct + if sf.Kind() == reflect.Struct { + if nested, ok := rawVal.(map[string]any); ok { + applySecureStringsToStruct(sf, nested) + } + continue + } + // Recurse into map fields (e.g., map[string]SomeStruct) + if sf.Kind() == reflect.Map && sf.Type().Elem().Kind() == reflect.Struct { + if nestedMap, ok := rawVal.(map[string]any); ok { + for mapKey, mapVal := range nestedMap { + nested, ok := mapVal.(map[string]any) + if !ok { + continue + } + elemType := sf.Type().Elem() + // Get existing element or create a new zero value + var elem reflect.Value + existing := sf.MapIndex(reflect.ValueOf(mapKey)) + if existing.IsValid() { + if existing.Kind() == reflect.Interface { + existing = existing.Elem() + } + if existing.Kind() == reflect.Ptr && !existing.IsNil() { + elem = reflect.New(elemType) + elem.Elem().Set(existing.Elem()) + } else if existing.Kind() == reflect.Struct { + elem = reflect.New(elemType) + elem.Elem().Set(existing) + } + } + if !elem.IsValid() { + elem = reflect.New(elemType) + } + applySecureStringsToStruct(elem.Elem(), nested) + sf.SetMapIndex(reflect.ValueOf(mapKey), elem.Elem()) + } + } + continue + } + // Recurse into slice elements that are structs + if sf.Kind() == reflect.Slice && sf.Type().Elem().Kind() == reflect.Struct { + if sliceRaw, ok := rawVal.([]any); ok { + for idx, elemRaw := range sliceRaw { + if nested, ok := elemRaw.(map[string]any); ok { + if idx < sf.Len() { + applySecureStringsToStruct(sf.Index(idx), nested) + } + } + } + } + } + } + } +} diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go new file mode 100644 index 000000000..8377c2eca --- /dev/null +++ b/web/backend/api/config_test.go @@ -0,0 +1,1228 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +func assertGatewayLogLevelApplied(t *testing.T, method, body string, want logger.LogLevel) { + t.Helper() + + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + initialLevel := logger.GetLevel() + logger.SetLevel(logger.INFO) + t.Cleanup(func() { + logger.SetLevel(initialLevel) + }) + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(method, "/api/config", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s /api/config status = %d, want %d, body=%s", method, rec.Code, http.StatusOK, rec.Body.String()) + } + if got := logger.GetLevel(); got != want { + t.Fatalf("logger.GetLevel() = %v, want %v", got, want) + } +} + +func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ +"version": 3, + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace" + } + }, + "model_list": [ + { + "model_name": "custom-default", + "model": "openai/gpt-4o", + "api_keys": ["sk-default"] + } + ] + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("tools.exec.allow_remote should remain true when omitted from PUT /api/config") + } +} + +func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace" + } + }, + "model_list": [ + { + "model_name": "custom-default", + "model": "openai/gpt-4o", + "api_key": "sk-default" + } + ] + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := cfg.ModelList[0].APIBase; got != "" { + t.Fatalf("model_list[0].api_base = %q, want empty string", got) + } +} + +func TestHandlePatchConfig_RejectsInvalidExecRegexPatterns(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "tools": { + "exec": { + "custom_deny_patterns": ["("] + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte("custom_deny_patterns")) { + t.Fatalf("expected validation error mentioning custom_deny_patterns, body=%s", rec.Body.String()) + } +} + +func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "tools": { + "exec": { + "enabled": false, + "custom_deny_patterns": ["("], + "custom_allow_patterns": ["("] + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestHandlePatchConfig_SavesChannelListSettingsPatch(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "feishu": { + "enabled": true, + "allow_from": ["ou_patch_user"], + "settings": { + "app_id": "cli_patch_app", + "app_secret": "patch-secret", + "is_lark": true + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + bc := cfg.Channels[config.ChannelFeishu] + if !bc.Enabled { + t.Fatal("feishu should be enabled after PATCH") + } + if len(bc.AllowFrom) != 1 || bc.AllowFrom[0] != "ou_patch_user" { + t.Fatalf("feishu allow_from = %#v, want [\"ou_patch_user\"]", bc.AllowFrom) + } + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + feishuCfg := decoded.(*config.FeishuSettings) + if got := feishuCfg.AppID; got != "cli_patch_app" { + t.Fatalf("feishu app_id = %q, want %q", got, "cli_patch_app") + } + if got := feishuCfg.AppSecret.String(); got != "patch-secret" { + t.Fatalf("feishu app_secret = %q, want %q", got, "patch-secret") + } + if !feishuCfg.IsLark { + t.Fatal("feishu is_lark should be true after PATCH") + } +} + +func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "pico": { + "type": "pico", + "allow_from": " ou_a\u200b,\u2060ou_b\tou_c\u202e,ou_a ", + "group_trigger": { + "prefixes": "/,!;\n?,/" + }, + "settings": { + "allow_origins": "https://a.example.com,http://localhost:5173,https://a.example.com" + } + }, + "irc": { + "type": "irc", + "settings": { + "channels": "#ops,\n#dev,\n#ops", + "request_caps": "multi-prefix,echo-message\tbatch,multi-prefix" + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + picoChannel := cfg.Channels[config.ChannelPico] + if len(picoChannel.AllowFrom) != 3 || + picoChannel.AllowFrom[0] != "ou_a" || + picoChannel.AllowFrom[1] != "ou_b" || + picoChannel.AllowFrom[2] != "ou_c" { + t.Fatalf("pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]", picoChannel.AllowFrom) + } + if len(picoChannel.GroupTrigger.Prefixes) != 3 || + picoChannel.GroupTrigger.Prefixes[0] != "/" || + picoChannel.GroupTrigger.Prefixes[1] != "!;" || + picoChannel.GroupTrigger.Prefixes[2] != "?" { + t.Fatalf( + "pico group_trigger.prefixes = %#v, want [\"/\", \"!;\", \"?\"]", + picoChannel.GroupTrigger.Prefixes, + ) + } + + decoded, err := picoChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() pico error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + if len(picoCfg.AllowOrigins) != 2 || + picoCfg.AllowOrigins[0] != "https://a.example.com" || + picoCfg.AllowOrigins[1] != "http://localhost:5173" { + t.Fatalf( + "pico allow_origins = %#v, want [\"https://a.example.com\", \"http://localhost:5173\"]", + picoCfg.AllowOrigins, + ) + } + + ircChannel := cfg.Channels[config.ChannelIRC] + decoded, err = ircChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() irc error = %v", err) + } + ircCfg := decoded.(*config.IRCSettings) + if len(ircCfg.Channels) != 2 || + ircCfg.Channels[0] != "#ops" || + ircCfg.Channels[1] != "#dev" { + t.Fatalf("irc channels = %#v, want [\"#ops\", \"#dev\"]", ircCfg.Channels) + } + if len(ircCfg.RequestCaps) != 3 || + ircCfg.RequestCaps[0] != "multi-prefix" || + ircCfg.RequestCaps[1] != "echo-message" || + ircCfg.RequestCaps[2] != "batch" { + t.Fatalf( + "irc request_caps = %#v, want [\"multi-prefix\", \"echo-message\", \"batch\"]", + ircCfg.RequestCaps, + ) + } +} + +func TestHandlePatchConfig_NormalizesSingleNumericAllowFrom(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": 123456 + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "123456" { + t.Fatalf("telegram allow_from = %#v, want [\"123456\"]", telegramChannel.AllowFrom) + } +} + +func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + telegramChannel.AllowFrom = config.FlexibleStringSlice{"existing-user"} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + tests := []struct { + name string + body string + }{ + { + name: "object allow_from", + body: `{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": {"id": "bad"} + } + } + }`, + }, + { + name: "boolean allow_from", + body: `{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": true + } + } + }`, + }, + { + name: "object settings array", + body: `{ + "channel_list": { + "irc": { + "type": "irc", + "settings": { + "channels": {"name": "#ops"} + } + } + } + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusBadRequest, + rec.Body.String(), + ) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "existing-user" { + t.Fatalf("telegram allow_from = %#v, want unchanged [\"existing-user\"]", telegramChannel.AllowFrom) + } + }) + } +} + +func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + feishuChannel := cfg.Channels[config.ChannelFeishu] + feishuChannel.Enabled = true + feishuChannel.AllowFrom = config.FlexibleStringSlice{"ou_existing_user"} + decoded, err := feishuChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + feishuCfg := decoded.(*config.FeishuSettings) + feishuCfg.AppID = "cli_existing_app" + feishuCfg.AppSecret = *config.NewSecureString("existing-secret") + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "feishu": { + "enabled": true, + "allow_from": "", + "settings": { + "app_id": "cli_existing_app" + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err = config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + feishuChannel = cfg.Channels[config.ChannelFeishu] + if len(feishuChannel.AllowFrom) != 0 { + t.Fatalf("feishu allow_from = %#v, want empty slice", feishuChannel.AllowFrom) + } + + configData, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(configPath) error = %v", err) + } + if strings.Contains(string(configData), `"allow_from": [""]`) { + t.Fatalf("config file should not contain empty-string allow_from item: %s", string(configData)) + } +} + +func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + delete(cfg.Channels, config.ChannelIRC) + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "irc": { + "enabled": true, + "type": "irc", + "settings": { + "server": "irc.example.com", + "password": "irc-patch-password" + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err = config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + bc := cfg.Channels[config.ChannelIRC] + if bc == nil { + t.Fatal("irc channel should exist after PATCH") + } + if got := bc.Type; got != config.ChannelIRC { + t.Fatalf("irc type = %q, want %q", got, config.ChannelIRC) + } + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + ircCfg := decoded.(*config.IRCSettings) + if got := ircCfg.Server; got != "irc.example.com" { + t.Fatalf("irc server = %q, want %q", got, "irc.example.com") + } + if got := ircCfg.Password.String(); got != "irc-patch-password" { + t.Fatalf("irc password = %q, want %q", got, "irc-patch-password") + } + configData, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(configPath) error = %v", err) + } + if bytes.Contains(configData, []byte("irc-patch-password")) { + t.Fatalf("config file leaked irc password: %s", string(configData)) + } +} + +// setupPicoEnabledEnv creates a test environment with Pico channel enabled and +// its token stored only in .security.yml (not in the JSON payload). +func setupPicoEnabledEnv(t *testing.T) (string, func()) { + t.Helper() + + tmp := t.TempDir() + oldHome := os.Getenv("HOME") + oldPicoHome := os.Getenv("PICOCLAW_HOME") + + if err := os.Setenv("HOME", tmp); err != nil { + t.Fatalf("set HOME: %v", err) + } + if err := os.Setenv("PICOCLAW_HOME", filepath.Join(tmp, ".picoclaw")); err != nil { + t.Fatalf("set PICOCLAW_HOME: %v", err) + } + + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "custom-default", + Model: "openai/gpt-4o", + APIKeys: config.SimpleSecureStrings("sk-default"), + }} + cfg.Agents.Defaults.ModelName = "custom-default" + bc := cfg.Channels["pico"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + bc.Enabled = true + picoCfg.Token = *config.NewSecureString("test-pico-token") + + configPath := filepath.Join(tmp, "config.json") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + + cleanup := func() { + _ = os.Setenv("HOME", oldHome) + if oldPicoHome == "" { + _ = os.Unsetenv("PICOCLAW_HOME") + } else { + _ = os.Setenv("PICOCLAW_HOME", oldPicoHome) + } + } + return configPath, cleanup +} + +func TestHandleUpdateConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) { + configPath, cleanup := setupPicoEnabledEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + // PUT request with pico enabled but no token in JSON — token is in .security.yml + req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ + "version": 1, + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "custom-default" + } + }, + "channels": { + "pico": { + "enabled": true, + "ping_interval": 30, + "read_timeout": 60, + "write_timeout": 10, + "max_connections": 100 + } + }, + "model_list": [ + { + "model_name": "custom-default", + "model": "openai/gpt-4o", + "api_keys": ["sk-default"] + } + ] + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PUT /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) { + configPath, cleanup := setupPicoEnabledEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + // PATCH request changing an unrelated field — pico token still in .security.yml + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "gateway": { + "log_level": "info" + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestHandleUpdateConfig_AppliesGatewayLogLevel(t *testing.T) { + assertGatewayLogLevelApplied(t, http.MethodPut, `{ + "version": 1, + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "custom-default" + } + }, + "gateway": { + "log_level": "error" + }, + "model_list": [ + { + "model_name": "custom-default", + "model": "openai/gpt-4o", + "api_keys": ["sk-default"] + } + ] + }`, logger.ERROR) +} + +func TestHandlePatchConfig_AppliesGatewayLogLevel(t *testing.T) { + assertGatewayLogLevelApplied(t, http.MethodPatch, `{ + "gateway": { + "log_level": "debug" + } + }`, logger.DEBUG) +} + +func TestHandlePatchConfig_PreservesDebugFlagOverride(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + initialLevel := logger.GetLevel() + logger.SetLevel(logger.INFO) + t.Cleanup(func() { + logger.SetLevel(initialLevel) + }) + + h := NewHandler(configPath) + h.SetDebug(true) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "gateway": { + "log_level": "error" + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := logger.GetLevel(); got != logger.DEBUG { + t.Fatalf("logger.GetLevel() = %v, want %v", got, logger.DEBUG) + } +} + +func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) { + t.Skip("TODO: fix this test") + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": [ + { + "name":"discord", + "enabled": true, + "token": "discord-test-token" + } + ] + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + bc := cfg.Channels[config.ChannelDiscord] + if !bc.Enabled { + t.Fatal("discord should be enabled after PATCH") + } + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + if got := decoded.(*config.DiscordSettings).Token.String(); got != "discord-test-token" { + t.Fatalf("discord token = %q, want %q", got, "discord-test-token") + } +} + +func TestHandlePatchConfig_DoesNotPersistShadowRegistryAuthTokenField(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "tools": { + "skills": { + "registries": { + "github": { + "_auth_token": "ghp-shadow-token" + } + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github") + if !ok { + t.Fatal("github registry missing after PATCH") + } + if got := githubRegistry.AuthToken.String(); got != "ghp-shadow-token" { + t.Fatalf("github registry auth token = %q, want %q", got, "ghp-shadow-token") + } + if got := githubRegistry.BaseURL; got != "https://github.com" { + t.Fatalf("github registry base_url = %q, want %q", got, "https://github.com") + } + + rawConfig, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(configPath) error = %v", err) + } + if strings.Contains(string(rawConfig), "_auth_token") { + t.Fatalf("config.json should not persist _auth_token shadow field, got:\n%s", string(rawConfig)) + } +} + +func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "tools": { + "exec": { + "enabled": true, + "enable_deny_patterns": false, + "custom_deny_patterns": ["("] + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +// testCommandPatterns is a helper that sets up a handler and sends a test-command-patterns request. +func testCommandPatterns(t *testing.T, configPath string, body string) *httptest.ResponseRecorder { + t.Helper() + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + req := httptest.NewRequest(http.MethodPost, "/api/config/test-command-patterns", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +func TestHandleTestCommandPatterns_MatchesWhitelist(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + rec := testCommandPatterns(t, configPath, `{ + "allow_patterns": ["^echo\\s+hello"], + "deny_patterns": ["^rm\\s+-rf"], + "command": "echo hello world" + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { + t.Fatalf("expected allowed=true, body=%s", rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { + t.Fatalf("expected blocked=false when whitelist matches, body=%s", rec.Body.String()) + } +} + +func TestHandleTestCommandPatterns_MatchesBlacklistNotWhitelist(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + rec := testCommandPatterns(t, configPath, `{ + "allow_patterns": ["^echo\\s+hello"], + "deny_patterns": ["^rm\\s+-rf"], + "command": "rm -rf /tmp" + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { + t.Fatalf("expected blocked=true, body=%s", rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { + t.Fatalf("expected allowed=false when blacklist matches but not whitelist, body=%s", rec.Body.String()) + } +} + +func TestHandleTestCommandPatterns_MatchesNeither(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + rec := testCommandPatterns(t, configPath, `{ + "allow_patterns": ["^echo\\s+hello"], + "deny_patterns": ["^rm\\s+-rf"], + "command": "ls -la" + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { + t.Fatalf("expected allowed=false, body=%s", rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { + t.Fatalf("expected blocked=false, body=%s", rec.Body.String()) + } +} + +func TestHandleTestCommandPatterns_CaseInsensitiveWithGoFlag(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + rec := testCommandPatterns(t, configPath, `{ + "allow_patterns": ["(?i)^ECHO"], + "deny_patterns": [], + "command": "echo hello" + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { + t.Fatalf("expected allowed=true with Go (?i) flag, body=%s", rec.Body.String()) + } +} + +func TestHandleTestCommandPatterns_EmptyPatterns(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + rec := testCommandPatterns(t, configPath, `{ + "allow_patterns": [], + "deny_patterns": [], + "command": "rm -rf /tmp" + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { + t.Fatalf("expected allowed=false with empty patterns, body=%s", rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { + t.Fatalf("expected blocked=false with empty patterns, body=%s", rec.Body.String()) + } +} + +func TestHandleTestCommandPatterns_InvalidRegexSkipped(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + rec := testCommandPatterns(t, configPath, `{ + "allow_patterns": ["([[", "^echo"], + "deny_patterns": [], + "command": "echo hello" + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { + t.Fatalf("expected allowed=true, invalid pattern skipped and valid one matched, body=%s", rec.Body.String()) + } +} + +func TestHandleTestCommandPatterns_ReturnsMatchedPattern(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + rec := testCommandPatterns(t, configPath, `{ + "allow_patterns": [], + "deny_patterns": ["\\$(?i)[a-zA-Z_]*(SECRET|KEY|PASSWORD|TOKEN|AUTH)[a-zA-Z0-9_]*"], + "command": "echo $GITHUB_API_KEY" + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { + t.Fatalf("expected blocked=true, body=%s", rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`matched_blacklist`)) { + t.Fatalf("expected matched_blacklist field, body=%s", rec.Body.String()) + } +} + +func TestHandleTestCommandPatterns_InvalidJSON(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + req := httptest.NewRequest( + http.MethodPost, + "/api/config/test-command-patterns", + bytes.NewBufferString(`{invalid json}`), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +func TestApplyConfigSecretsFromMap_TelegramToken(t *testing.T) { + cfg := config.DefaultConfig() + bc := cfg.Channels["telegram"] + bc.Enabled = true + // Pre-decode so extend is populated + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + tgCfg := decoded.(*config.TelegramSettings) + tgCfg.Token = *config.NewSecureString("original-token") + + raw := map[string]any{ + "channel_list": map[string]any{ + "telegram": map[string]any{ + "enabled": true, + "token": "secret-from-api", + }, + }, + } + + applyConfigSecretsFromMap(cfg, raw) + + if got := tgCfg.Token.String(); got != "secret-from-api" { + t.Fatalf("telegram token = %q, want %q", got, "secret-from-api") + } +} + +func TestApplyConfigSecretsFromMap_TeamsWebhook(t *testing.T) { + // applyConfigSecretsFromMap recurses into nested maps to find + // SecureString fields at any depth (e.g. webhook_url inside webhooks map). + cfg := config.DefaultConfig() + bc := &config.Channel{Enabled: true, Type: config.ChannelTeamsWebHook} + cfg.Channels["teams_webhook"] = bc + target := &config.TeamsWebhookSettings{ + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/hook1"), + Title: "Default", + }, + }, + } + if err := bc.Decode(target); err != nil { + t.Fatalf("Decode() error = %v", err) + } + + raw := map[string]any{ + "channel_list": map[string]any{ + "teams_webhook": map[string]any{ + "enabled": true, + "settings": map[string]any{ + "webhooks": map[string]any{ + "default": map[string]any{ + "webhook_url": "https://example.com/hook-updated", + "title": "Default Updated", + }, + }, + }, + }, + }, + } + + applyConfigSecretsFromMap(cfg, raw) + + // Verify the decoded struct has the updated SecureString value + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + twCfg, ok := decoded.(*config.TeamsWebhookSettings) + if !ok { + t.Fatalf("expected *TeamsWebhookSettings, got %T", decoded) + } + + hookURL := twCfg.Webhooks["default"].WebhookURL + if got := hookURL.String(); got != "https://example.com/hook-updated" { + t.Fatalf("webhook_url = %q, want %q", got, "https://example.com/hook-updated") + } + // Note: title is a plain string, not a SecureString, so it is NOT updated + // by applyConfigSecretsFromMap (only secure fields are handled). +} + +func TestApplyConfigSecretsFromMap_MultipleChannels(t *testing.T) { + cfg := config.DefaultConfig() + + // Setup telegram + bc := cfg.Channels["telegram"] + bc.Enabled = true + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() telegram error = %v", err) + } + tgCfg := decoded.(*config.TelegramSettings) + tgCfg.Token = *config.NewSecureString("old-telegram-token") + + // Setup discord + bc = cfg.Channels["discord"] + bc.Enabled = true + decoded, err = bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() discord error = %v", err) + } + discCfg := decoded.(*config.DiscordSettings) + discCfg.Token = *config.NewSecureString("old-discord-token") + + raw := map[string]any{ + "channel_list": map[string]any{ + "telegram": map[string]any{ + "enabled": true, + "settings": map[string]any{ + "token": "new-telegram-token", + }, + }, + "discord": map[string]any{ + "enabled": true, + "settings": map[string]any{ + "token": "new-discord-token", + }, + }, + }, + } + + applyConfigSecretsFromMap(cfg, raw) + + if got := tgCfg.Token.String(); got != "new-telegram-token" { + t.Fatalf("telegram token = %q, want %q", got, "new-telegram-token") + } + if got := discCfg.Token.String(); got != "new-discord-token" { + t.Fatalf("discord token = %q, want %q", got, "new-discord-token") + } +} + +func TestApplyConfigSecretsFromMap_SkipsNonStringValues(t *testing.T) { + cfg := config.DefaultConfig() + bc := cfg.Channels["telegram"] + bc.Enabled = true + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + tgCfg := decoded.(*config.TelegramSettings) + tgCfg.Token = *config.NewSecureString("original-token") + + raw := map[string]any{ + "channel_list": map[string]any{ + "telegram": map[string]any{ + "enabled": true, + "token": 12345, // not a string, should be skipped + }, + }, + } + + applyConfigSecretsFromMap(cfg, raw) + + if got := tgCfg.Token.String(); got != "original-token" { + t.Fatalf("telegram token = %q, want %q", got, "original-token") + } +} + +func TestApplyConfigSecretsFromMap_ChannelNotDecodedYet(t *testing.T) { + cfg := config.DefaultConfig() + bc := cfg.Channels["telegram"] + bc.Enabled = true + // Don't decode — let the function handle lazy decoding + bc.Type = config.ChannelTelegram + + raw := map[string]any{ + "channel_list": map[string]any{ + "telegram": map[string]any{ + "enabled": true, + "token": "lazy-decoded-token", + }, + }, + } + + applyConfigSecretsFromMap(cfg, raw) + + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + tgCfg := decoded.(*config.TelegramSettings) + if got := tgCfg.Token.String(); got != "lazy-decoded-token" { + t.Fatalf("telegram token = %q, want %q", got, "lazy-decoded-token") + } +} diff --git a/web/backend/api/exec_nonwindows.go b/web/backend/api/exec_nonwindows.go new file mode 100644 index 000000000..0dc3c0e94 --- /dev/null +++ b/web/backend/api/exec_nonwindows.go @@ -0,0 +1,11 @@ +//go:build !windows + +package api + +import "os/exec" + +func launcherExecCommand(name string, args ...string) *exec.Cmd { + return exec.Command(name, args...) +} + +func applyLauncherProcAttrs(_ *exec.Cmd) {} diff --git a/web/backend/api/exec_windows.go b/web/backend/api/exec_windows.go new file mode 100644 index 000000000..86d3193a0 --- /dev/null +++ b/web/backend/api/exec_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package api + +import ( + "os/exec" + "syscall" +) + +func launcherExecCommand(name string, args ...string) *exec.Cmd { + cmd := exec.Command(name, args...) + applyLauncherProcAttrs(cmd) + return cmd +} + +func applyLauncherProcAttrs(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.HideWindow = true +} diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go new file mode 100644 index 000000000..45f7e6912 --- /dev/null +++ b/web/backend/api/gateway.go @@ -0,0 +1,1403 @@ +package api + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "reflect" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/health" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/netbind" + ppid "github.com/sipeed/picoclaw/pkg/pid" + "github.com/sipeed/picoclaw/web/backend/utils" +) + +// gateway holds the state for the managed gateway process. +var gateway = struct { + mu sync.Mutex + cmd *exec.Cmd + owned bool // true if we started the process, false if we attached to an existing one + bootDefaultModel string + bootConfigSignature string + runtimeStatus string + startupDeadline time.Time + logs *LogBuffer + pidData *ppid.PidFileData // pid file data read from picoclaw.pid.json + picoToken string // cached raw pico token for upstream gateway proxy injection +}{ + runtimeStatus: "stopped", + logs: NewLogBuffer(200), +} + +// refreshPicoTokensLocked reads the pico token from config and caches it. +// Caller must hold gateway.mu (or be sole writer). +func refreshPicoTokensLocked(configPath string) { + cfg, err := config.LoadConfig(configPath) + if err != nil { + return + } + var picoCfg config.PicoSettings + if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil { + decoded, err := bc.GetDecoded() + if err == nil && decoded != nil { + if p, ok := decoded.(*config.PicoSettings); ok { + picoCfg = *p + } + } + } + gateway.picoToken = picoCfg.Token.String() +} + +// ensurePicoTokenCachedLocked lazily fills the in-memory pico token cache when +// the launcher has already discovered a running gateway via pidData, but has +// not yet refreshed the token into memory. +func ensurePicoTokenCachedLocked(configPath string) { + if gateway.picoToken != "" { + return + } + refreshPicoTokensLocked(configPath) +} + +func (h *Handler) gatewayCommandArgs() []string { + args := []string{"gateway", "-E"} + if h.debug { + args = append(args, "-d") + } + return args +} + +const ( + protocolKey = "Sec-Websocket-Protocol" + tokenPrefix = "token." +) + +// picoGatewayProtocol returns the gateway-facing pico subprotocol that the +// launcher should inject when proxying browser traffic upstream. +func picoGatewayProtocol() string { + gateway.mu.Lock() + defer gateway.mu.Unlock() + if gateway.picoToken == "" { + return "" + } + return tokenPrefix + gateway.picoToken +} + +var ( + gatewayStartupWindow = 15 * time.Second + gatewayRestartGracePeriod = 5 * time.Second + gatewayRestartForceKillWindow = 3 * time.Second + gatewayRestartPollInterval = 100 * time.Millisecond + gatewayExecCommand = exec.Command +) + +var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) { + client := http.Client{Timeout: timeout} + return client.Get(url) +} + +var gatewayProcessMatcher = isLikelyGatewayProcess + +// getGatewayHealth checks the gateway health endpoint and returns the status response. +// Returns (*health.StatusResponse, statusCode, error). If error is not nil, the other values are not valid. +func (h *Handler) getGatewayHealth(cfg *config.Config, timeout time.Duration) (*health.StatusResponse, int, error) { + // Prefer port/host from pidData when available. + var port int + var host string + gateway.mu.Lock() + if d := gateway.pidData; d != nil && d.Port > 0 { + port = d.Port + host = gatewayProbeHost(d.Host) + } + gateway.mu.Unlock() + if port == 0 { + port = 18790 + if cfg != nil && cfg.Gateway.Port != 0 { + port = cfg.Gateway.Port + } + } + if host == "" { + host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) + } + + url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health" + + return getGatewayHealthByURL(url, timeout) +} + +func getGatewayHealthByURL(url string, timeout time.Duration) (*health.StatusResponse, int, error) { + resp, err := gatewayHealthGet(url, timeout) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + var healthResponse health.StatusResponse + if decErr := json.NewDecoder(resp.Body).Decode(&healthResponse); decErr != nil { + return nil, resp.StatusCode, decErr + } + + return &healthResponse, resp.StatusCode, nil +} + +// isLikelyGatewayProcess returns whether PID appears to be a picoclaw gateway +// process plus whether inspection was conclusive on this platform/environment. +func isLikelyGatewayProcess(pid int) (bool, bool) { + if pid <= 0 { + return false, true + } + + if runtime.GOOS == "windows" { + psCmd := fmt.Sprintf( + `$p=Get-CimInstance Win32_Process -Filter "ProcessId = %d"; if ($null -eq $p) { "" } else { $p.CommandLine }`, + pid, + ) + out, err := launcherExecCommand("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd).Output() + if err == nil { + cmdline := strings.TrimSpace(string(out)) + if cmdline != "" { + return looksLikeGatewayCommandLine(cmdline), true + } + } + + // Fallback: determine only whether the process still exists. + out, err = launcherExecCommand("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output() + if err != nil { + return false, false + } + line := strings.ToLower(strings.TrimSpace(string(out))) + if line == "" { + return false, true + } + // A CSV row means the process exists, but may have a custom executable + // name we cannot classify here. + if strings.HasPrefix(line, "\"") { + if strings.Contains(line, "\"picoclaw.exe\"") { + return true, true + } + return false, true + } + if strings.Contains(line, "no tasks are running") { + return false, true + } + return false, true + } + + out, err := launcherExecCommand("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output() + if err != nil { + return false, false + } + cmdline := strings.ToLower(strings.TrimSpace(string(out))) + if cmdline == "" { + return false, true + } + return looksLikeGatewayCommandLine(cmdline), true +} + +// looksLikeGatewayCommandLine checks whether a process command line likely +// represents "picoclaw gateway ..." regardless of executable filename. +func looksLikeGatewayCommandLine(cmdline string) bool { + fields := strings.Fields(strings.ToLower(strings.TrimSpace(cmdline))) + if len(fields) == 0 { + return false + } + for _, f := range fields { + token := strings.Trim(f, `"'`) + if token == "gateway" || strings.HasSuffix(token, "/gateway") || strings.HasSuffix(token, `\gateway`) { + return true + } + } + return false +} + +func (h *Handler) getGatewayHealthForPidData( + pidData *ppid.PidFileData, + cfg *config.Config, + timeout time.Duration, +) (*health.StatusResponse, int, error) { + if pidData == nil { + return nil, 0, errors.New("nil pid data") + } + + port := pidData.Port + if port == 0 { + port = 18790 + if cfg != nil && cfg.Gateway.Port != 0 { + port = cfg.Gateway.Port + } + } + + host := gatewayProbeHost(strings.TrimSpace(pidData.Host)) + if host == "" { + host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) + } + if host == "" { + host = netbind.ResolveAdaptiveLoopbackHost() + } + + url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health" + return getGatewayHealthByURL(url, timeout) +} + +func (h *Handler) validateGatewayPidData( + pidData *ppid.PidFileData, + cfg *config.Config, +) (ok bool, decisive bool, reason string) { + if pidData == nil || pidData.PID <= 0 { + return false, true, "invalid pid data" + } + + if gatewayProcess, inspected := gatewayProcessMatcher(pidData.PID); inspected { + if !gatewayProcess { + return false, true, "pid process command is not picoclaw gateway" + } + return true, true, "" + } + + healthResp, statusCode, err := h.getGatewayHealthForPidData(pidData, cfg, 800*time.Millisecond) + if err != nil { + return false, false, fmt.Sprintf("health probe failed: %v", err) + } + if statusCode != http.StatusOK { + return false, false, fmt.Sprintf("health endpoint returned status %d", statusCode) + } + if healthResp.PID > 0 && healthResp.PID != pidData.PID { + return false, true, fmt.Sprintf("health pid mismatch: pidFile=%d, health=%d", pidData.PID, healthResp.PID) + } + return true, true, "" +} + +func (h *Handler) sanitizeGatewayPidData(pidData *ppid.PidFileData, cfg *config.Config) *ppid.PidFileData { + if pidData == nil { + return nil + } + + ok, decisive, reason := h.validateGatewayPidData(pidData, cfg) + if ok { + return pidData + } + + logger.Warnf("ignore pid file for PID %d: %s", pidData.PID, reason) + if decisive && ppid.RemovePidFileIfPID(globalConfigDir(), pidData.PID) { + logger.Warnf("removed stale pid file for PID %d", pidData.PID) + } + return nil +} + +// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux. +func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus) + mux.HandleFunc("GET /api/gateway/logs", h.handleGatewayLogs) + mux.HandleFunc("POST /api/gateway/logs/clear", h.handleGatewayClearLogs) + mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart) + mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop) + mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart) +} + +// TryAutoStartGateway checks whether gateway start preconditions are met and +// starts it when possible. Intended to be called by the backend at startup. +func (h *Handler) TryAutoStartGateway() { + // Check PID file first to detect an already-running gateway. + pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil) + if pidData != nil { + gateway.mu.Lock() + ready, reason, err := h.gatewayStartReady() + if err != nil { + logger.ErrorC("gateway", fmt.Sprintf("Skip auto-starting gateway: %v", err)) + gateway.mu.Unlock() + return + } + logger.Infof("ready: %v, reason: %s", ready, reason) + if !ready { + logger.InfoC("gateway", fmt.Sprintf("Skip auto-starting gateway: %s", reason)) + gateway.mu.Unlock() + return + } + pid := pidData.PID + _, err = h.startGatewayLocked("starting", pid) + if err != nil { + logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err)) + } else { + gateway.pidData = pidData + refreshPicoTokensLocked(h.configPath) + logger.InfoC("gateway", fmt.Sprintf("Attached to running gateway via PID file (PID: %d)", pid)) + } + gateway.mu.Unlock() + return + } + + gateway.mu.Lock() + defer gateway.mu.Unlock() + + if gateway.cmd != nil && gateway.cmd.Process != nil { + gateway.cmd = nil + } + + ready, reason, err := h.gatewayStartReady() + if err != nil { + logger.ErrorC("gateway", fmt.Sprintf("Skip auto-starting gateway: %v", err)) + return + } + if !ready { + logger.InfoC("gateway", fmt.Sprintf("Skip auto-starting gateway: %s", reason)) + return + } + + pid, err := h.startGatewayLocked("starting", 0) + if err != nil { + logger.ErrorC("gateway", fmt.Sprintf("Failed to auto-start gateway: %v", err)) + return + } + logger.InfoC("gateway", fmt.Sprintf("Gateway auto-started (PID: %d)", pid)) +} + +// gatewayStartReady validates whether current config can start the gateway. +func (h *Handler) gatewayStartReady() (bool, string, error) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return false, "", fmt.Errorf("failed to load config: %w", err) + } + + modelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) + if modelName == "" { + return false, "no default model configured", nil + } + + modelCfg := lookupModelConfig(cfg, modelName) + if modelCfg == nil { + return false, fmt.Sprintf("default model %q is invalid", modelName), nil + } + if !defaultModelAllowedForModelConfig(modelCfg) { + return false, fmt.Sprintf("default model %q is not usable for chat", modelName), nil + } + + if !hasModelConfiguration(modelCfg) { + return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil + } + if requiresRuntimeProbe(modelCfg) && !probeLocalModelAvailability(modelCfg) { + return false, fmt.Sprintf("default model %q is not reachable", modelName), nil + } + + return true, "", nil +} + +func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig { + modelCfg, err := cfg.GetModelConfig(modelName) + if err != nil { + return nil + } + return modelCfg +} + +func computeConfigSignature(cfg *config.Config) string { + if cfg == nil { + return "" + } + var parts []string + defaultModel := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) + if defaultModel != "" { + parts = append(parts, "model:"+defaultModel) + } + toolSignatures := []string{} + if cfg.Tools.ReadFile.Enabled { + toolSignatures = append(toolSignatures, "read_file") + } + if cfg.Tools.WriteFile.Enabled { + toolSignatures = append(toolSignatures, "write_file") + } + if cfg.Tools.ListDir.Enabled { + toolSignatures = append(toolSignatures, "list_dir") + } + if cfg.Tools.EditFile.Enabled { + toolSignatures = append(toolSignatures, "edit_file") + } + if cfg.Tools.AppendFile.Enabled { + toolSignatures = append(toolSignatures, "append_file") + } + if cfg.Tools.Exec.Enabled { + toolSignatures = append(toolSignatures, "exec") + } + if cfg.Tools.Cron.Enabled { + toolSignatures = append(toolSignatures, "cron") + } + if cfg.Tools.Web.Enabled { + toolSignatures = append(toolSignatures, "web") + webConfig, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(cfg.Tools.Web))) + if err == nil { + parts = append(parts, "webcfg:"+string(webConfig)) + } + } + if cfg.Tools.WebFetch.Enabled { + toolSignatures = append(toolSignatures, "web_fetch") + } + if cfg.Tools.Message.Enabled { + toolSignatures = append(toolSignatures, "message") + } + if cfg.Tools.SendFile.Enabled { + toolSignatures = append(toolSignatures, "send_file") + } + if cfg.Tools.FindSkills.Enabled { + toolSignatures = append(toolSignatures, "find_skills") + } + if cfg.Tools.InstallSkill.Enabled { + toolSignatures = append(toolSignatures, "install_skill") + } + if cfg.Tools.Spawn.Enabled { + toolSignatures = append(toolSignatures, "spawn") + } + if cfg.Tools.SpawnStatus.Enabled { + toolSignatures = append(toolSignatures, "spawn_status") + } + if cfg.Tools.I2C.Enabled { + toolSignatures = append(toolSignatures, "i2c") + } + if cfg.Tools.SPI.Enabled { + toolSignatures = append(toolSignatures, "spi") + } + if cfg.Tools.MCP.Enabled { + toolSignatures = append(toolSignatures, "mcp") + } + if cfg.Tools.MCP.Discovery.Enabled { + toolSignatures = append(toolSignatures, "mcp_discovery") + } + if cfg.Tools.MCP.Discovery.UseRegex { + toolSignatures = append(toolSignatures, "mcp_discovery_regex") + } + if cfg.Tools.MCP.Discovery.UseBM25 { + toolSignatures = append(toolSignatures, "mcp_discovery_bm25") + } + if len(toolSignatures) > 0 { + parts = append(parts, "tools:"+strings.Join(toolSignatures, ",")) + } + channelSignatures := computeChannelSignatures(cfg.Channels) + if len(channelSignatures) > 0 { + parts = append(parts, "channels:"+strings.Join(channelSignatures, ",")) + } + return strings.Join(parts, ";") +} + +func computeChannelSignatures(channels config.ChannelsConfig) []string { + if len(channels) == 0 { + return nil + } + + keys := make([]string, 0, len(channels)) + for name := range channels { + keys = append(keys, name) + } + sort.Strings(keys) + + signatures := make([]string, 0, len(keys)) + for _, name := range keys { + channel := channels[name] + if channel == nil { + signatures = append(signatures, name+":<nil>") + continue + } + + payload := struct { + Enabled bool `json:"enabled"` + Type string `json:"type"` + AllowFrom config.FlexibleStringSlice `json:"allow_from,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id,omitempty"` + GroupTrigger config.GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing config.TypingConfig `json:"typing,omitempty"` + Placeholder config.PlaceholderConfig `json:"placeholder,omitempty"` + Settings json.RawMessage `json:"settings,omitempty"` + }{ + Enabled: channel.Enabled, + Type: channel.Type, + AllowFrom: channel.AllowFrom, + ReasoningChannelID: channel.ReasoningChannelID, + GroupTrigger: channel.GroupTrigger, + Typing: channel.Typing, + Placeholder: channel.Placeholder, + Settings: normalizeChannelSettings(channel), + } + + encoded, err := json.Marshal(payload) + if err != nil { + signatures = append(signatures, name+":<invalid>") + continue + } + signatures = append(signatures, name+":"+string(encoded)) + } + + return signatures +} + +func normalizeChannelSettings(channel *config.Channel) json.RawMessage { + if channel == nil { + return nil + } + + decoded, err := channel.GetDecoded() + if err == nil && decoded != nil { + normalized, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(decoded))) + if err == nil { + return normalized + } + } + + return normalizeRawJSON(channel.Settings) +} + +func normalizeRawJSON(raw config.RawNode) json.RawMessage { + if len(raw) == 0 { + return nil + } + + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return bytes.TrimSpace(raw) + } + + normalized, err := json.Marshal(value) + if err != nil { + return bytes.TrimSpace(raw) + } + return normalized +} + +func canonicalizeSignatureValue(value reflect.Value) any { + if !value.IsValid() { + return nil + } + + if value.CanInterface() { + switch typed := value.Interface().(type) { + case config.SecureString: + return typed.String() + case *config.SecureString: + if typed == nil { + return "" + } + return typed.String() + case config.SecureStrings: + return typed.Values() + case *config.SecureStrings: + if typed == nil { + return nil + } + return typed.Values() + } + } + + switch value.Kind() { + case reflect.Interface, reflect.Pointer: + if value.IsNil() { + return nil + } + return canonicalizeSignatureValue(value.Elem()) + case reflect.Struct: + result := make(map[string]any) + valueType := value.Type() + for i := 0; i < value.NumField(); i++ { + field := valueType.Field(i) + if field.PkgPath != "" { + continue + } + tag := field.Tag.Get("json") + name := field.Name + if tag != "" { + if comma := strings.Index(tag, ","); comma >= 0 { + tag = tag[:comma] + } + if tag == "-" { + continue + } + if tag != "" { + name = tag + } + } + result[name] = canonicalizeSignatureValue(value.Field(i)) + } + return result + case reflect.Slice, reflect.Array: + length := value.Len() + result := make([]any, 0, length) + for i := 0; i < length; i++ { + result = append(result, canonicalizeSignatureValue(value.Index(i))) + } + return result + case reflect.Map: + if value.Type().Key().Kind() != reflect.String { + return value.Interface() + } + result := make(map[string]any, value.Len()) + iter := value.MapRange() + for iter.Next() { + result[iter.Key().String()] = canonicalizeSignatureValue(iter.Value()) + } + return result + default: + if value.CanInterface() { + return value.Interface() + } + return nil + } +} + +func gatewayRestartRequiredBySignature(bootSignature, currentSignature, gatewayStatus string) bool { + if gatewayStatus != "running" { + return false + } + if bootSignature == "" || currentSignature == "" { + return false + } + return bootSignature != currentSignature +} + +func isCmdProcessAliveLocked(cmd *exec.Cmd) bool { + if cmd == nil || cmd.Process == nil { + return false + } + + // Wait() sets ProcessState when the process exits; use it when available. + if cmd.ProcessState != nil && cmd.ProcessState.Exited() { + return false + } + + // Windows does not support Signal(0) probing. If we still own cmd and it + // has not reported exit, treat it as alive. + if runtime.GOOS == "windows" { + return true + } + + err := cmd.Process.Signal(syscall.Signal(0)) + if err == nil { + return true + } + var errno syscall.Errno + // EPERM means the process exists but cannot be signaled by this user. + return errors.As(err, &errno) && errno == syscall.EPERM +} + +func setGatewayRuntimeStatusLocked(status string) { + gateway.runtimeStatus = status + if status == "starting" || status == "restarting" { + gateway.startupDeadline = time.Now().Add(gatewayStartupWindow) + return + } + gateway.startupDeadline = time.Time{} +} + +// attachToGatewayProcess attaches to an existing gateway process by PID +// and updates the gateway state accordingly. +// Assumes gateway.mu is held by the caller. +func attachToGatewayProcessLocked(pid int, cfg *config.Config) error { + process, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("failed to find process for PID %d: %w", pid, err) + } + + gateway.cmd = &exec.Cmd{Process: process} + gateway.owned = false // We didn't start this process + setGatewayRuntimeStatusLocked("running") + + // Update bootDefaultModel and bootConfigSignature from config + if cfg != nil { + defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) + gateway.bootDefaultModel = defaultModelName + gateway.bootConfigSignature = computeConfigSignature(cfg) + } + + logger.InfoC("gateway", fmt.Sprintf("Attached to gateway process (PID: %d)", pid)) + return nil +} + +func gatewayStatusWithoutHealthLocked() string { + if gateway.runtimeStatus == "starting" || gateway.runtimeStatus == "restarting" { + if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) { + return gateway.runtimeStatus + } + return "error" + } + if gateway.runtimeStatus == "running" { + // For attached processes there is no waiter goroutine; degrade stale + // running state once the tracked process exits. + if !isCmdProcessAliveLocked(gateway.cmd) { + gateway.cmd = nil + gateway.owned = false + gateway.bootDefaultModel = "" + gateway.bootConfigSignature = "" + return "stopped" + } + return "running" + } + if gateway.runtimeStatus == "error" { + return "error" + } + return "stopped" +} + +func waitForGatewayProcessExit(cmd *exec.Cmd, timeout time.Duration) bool { + if cmd == nil || cmd.Process == nil { + return true + } + + deadline := time.Now().Add(timeout) + for { + if !isCmdProcessAliveLocked(cmd) { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(gatewayRestartPollInterval) + } +} + +// StopGateway stops the gateway process if it was started by this handler. +// This method is called during application shutdown to ensure the gateway subprocess +// is properly terminated. It only stops processes that were started by this handler, +// not processes that were attached to from existing instances. +func (h *Handler) StopGateway() { + gateway.mu.Lock() + defer gateway.mu.Unlock() + + // Only stop if we own the process (started it ourselves) + if !gateway.owned || gateway.cmd == nil || gateway.cmd.Process == nil { + return + } + + pid, err := stopGatewayLocked() + if err != nil { + logger.ErrorC("gateway", fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, err)) + return + } + + logger.InfoC("gateway", fmt.Sprintf("Gateway stopped (PID: %d)", pid)) +} + +// stopGatewayLocked sends a stop signal to the gateway process. +// Assumes gateway.mu is held by the caller. +// Returns the PID of the stopped process and any error encountered. +func stopGatewayLocked() (int, error) { + if gateway.cmd == nil || gateway.cmd.Process == nil { + return 0, nil + } + + pid := gateway.cmd.Process.Pid + if !gateway.owned { + if isGateway, inspected := gatewayProcessMatcher(pid); inspected && !isGateway { + return pid, fmt.Errorf("refuse to stop non-gateway process (PID %d)", pid) + } + } + + // Send SIGTERM for graceful shutdown (SIGKILL on Windows) + var sigErr error + if runtime.GOOS == "windows" { + sigErr = gateway.cmd.Process.Kill() + } else { + sigErr = gateway.cmd.Process.Signal(syscall.SIGTERM) + } + + if sigErr != nil { + return pid, sigErr + } + + logger.InfoC("gateway", fmt.Sprintf("Sent stop signal to gateway (PID: %d)", pid)) + gateway.cmd = nil + gateway.owned = false + gateway.bootDefaultModel = "" + gateway.pidData = nil + setGatewayRuntimeStatusLocked("stopped") + + return pid, nil +} + +func stopGatewayProcessForRestart(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil || !isCmdProcessAliveLocked(cmd) { + return nil + } + + var stopErr error + if runtime.GOOS == "windows" { + stopErr = cmd.Process.Kill() + } else { + stopErr = cmd.Process.Signal(syscall.SIGTERM) + } + if stopErr != nil && isCmdProcessAliveLocked(cmd) { + return fmt.Errorf("failed to stop existing gateway: %w", stopErr) + } + + if waitForGatewayProcessExit(cmd, gatewayRestartGracePeriod) { + return nil + } + + if runtime.GOOS != "windows" { + killErr := cmd.Process.Signal(syscall.SIGKILL) + if killErr != nil && isCmdProcessAliveLocked(cmd) { + return fmt.Errorf("failed to force-stop existing gateway: %w", killErr) + } + if waitForGatewayProcessExit(cmd, gatewayRestartForceKillWindow) { + return nil + } + } + + return fmt.Errorf("existing gateway did not exit before restart") +} + +func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int, error) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return 0, fmt.Errorf("failed to load config: %w", err) + } + defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) + + var cmd *exec.Cmd + var pid int + + if existingPid > 0 { + // Attach to existing process + pid = existingPid + gateway.cmd = nil // Clear first to ensure clean state + if err = attachToGatewayProcessLocked(pid, cfg); err != nil { + logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to existing gateway (PID %d): %v", pid, err)) + return 0, err + } + + return pid, nil + } + + // Start new process + // Locate the picoclaw executable + execPath := utils.FindPicoclawBinary() + logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath)) + + cmd = gatewayExecCommand(execPath, h.gatewayCommandArgs()...) + applyLauncherProcAttrs(cmd) + cmd.Env = os.Environ() + // Forward the launcher's config path via the environment variable that + // GetConfigPath() already reads, so the gateway sub-process uses the same + // config file without requiring a --config flag on the gateway subcommand. + if h.configPath != "" { + cmd.Env = append(cmd.Env, config.EnvConfig+"="+h.configPath) + } + gatewayHostOverride := h.gatewayHostOverride() + if gatewayHostOverride != "" { + cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+gatewayHostOverride) + } + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return 0, fmt.Errorf("failed to create stdout pipe: %w", err) + } + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return 0, fmt.Errorf("failed to create stderr pipe: %w", err) + } + + // Clear old logs for this new run + gateway.logs.Reset() + + // Ensure Pico Channel is configured before starting gateway + changed, err := h.EnsurePicoChannel() + if err != nil { + logger.ErrorC("gateway", fmt.Sprintf("Warning: failed to ensure pico channel: %v", err)) + // Non-fatal: gateway can still start without pico channel + } + // Refresh cached pico token in case EnsurePicoChannel generated a new one. + // Already holding gateway.mu from caller. + if changed { + refreshPicoTokensLocked(h.configPath) + cfg, err = config.LoadConfig(h.configPath) + if err != nil { + return 0, fmt.Errorf("failed to reload config after ensuring pico channel: %w", err) + } + defaultModelName = strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) + } + + if err := cmd.Start(); err != nil { + return 0, fmt.Errorf("failed to start gateway: %w", err) + } + + gateway.cmd = cmd + gateway.owned = true // We started this process + gateway.bootDefaultModel = defaultModelName + gateway.bootConfigSignature = computeConfigSignature(cfg) + setGatewayRuntimeStatusLocked(initialStatus) + pid = cmd.Process.Pid + logger.InfoC("gateway", fmt.Sprintf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)) + + // Capture stdout/stderr in background + go scanPipe(stdoutPipe, gateway.logs) + go scanPipe(stderrPipe, gateway.logs) + + // Wait for exit in background and clean up + go func() { + if err := cmd.Wait(); err != nil { + logger.ErrorC("gateway", fmt.Sprintf("Gateway process exited: %v", err)) + } else { + logger.InfoC("gateway", "Gateway process exited normally") + } + + gateway.mu.Lock() + if gateway.cmd == cmd { + gateway.cmd = nil + gateway.bootDefaultModel = "" + gateway.bootConfigSignature = "" + if gateway.runtimeStatus != "restarting" { + setGatewayRuntimeStatusLocked("stopped") + } + } + gateway.mu.Unlock() + }() + + // Start a goroutine to probe pidFile and health, update runtime state once ready. + go func() { + healthConfirmed := false + for i := 0; i < 30; i++ { // try for up to 15 seconds + time.Sleep(500 * time.Millisecond) + gateway.mu.Lock() + stillOurs := gateway.cmd == cmd + gateway.mu.Unlock() + if !stillOurs { + return + } + + // Poll for pidFile first — once available we have port/host/token. + if pd := ppid.ReadPidFileWithCheck(globalConfigDir()); pd != nil && pd.PID == pid { + gateway.mu.Lock() + if gateway.cmd == cmd { + gateway.pidData = pd + var picoCfg config.PicoSettings + if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil { + decoded, err := bc.GetDecoded() + if err == nil && decoded != nil { + if p, ok := decoded.(*config.PicoSettings); ok { + picoCfg = *p + } + } + } + gateway.picoToken = picoCfg.Token.String() + setGatewayRuntimeStatusLocked("running") + } + gateway.mu.Unlock() + logger.InfoC("gateway", fmt.Sprintf("Gateway pidFile detected (PID: %d, port: %d)", pd.PID, pd.Port)) + return + } + + // Fallback: probe health endpoint to confirm liveness. + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + continue + } + _, statusCode, err := h.getGatewayHealth(cfg, 1*time.Second) + if err == nil && statusCode == http.StatusOK { + gateway.mu.Lock() + if gateway.cmd == cmd { + setGatewayRuntimeStatusLocked("running") + } + gateway.mu.Unlock() + if !healthConfirmed { + healthConfirmed = true + logger.InfoC("gateway", "Gateway health endpoint reachable; waiting for pid file") + } + continue + } + } + }() + + return pid, nil +} + +// handleGatewayStart starts the picoclaw gateway subprocess. +// +// POST /api/gateway/start +func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) { + // Check PID file first to detect an already-running gateway. + pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil) + if pidData != nil { + pid := pidData.PID + gateway.mu.Lock() + ready, reason, err := h.gatewayStartReady() + if err != nil { + gateway.mu.Unlock() + http.Error( + w, + fmt.Sprintf("Failed to validate gateway start conditions: %v", err), + http.StatusInternalServerError, + ) + return + } + if !ready { + gateway.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "status": "precondition_failed", + "message": reason, + }) + return + } + _, err = h.startGatewayLocked("starting", pid) + if err != nil { + gateway.mu.Unlock() + logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err)) + http.Error(w, fmt.Sprintf("Failed to attach to gateway: %v", err), http.StatusInternalServerError) + return + } + gateway.pidData = pidData + gateway.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "pid": pid, + }) + return + } + + gateway.mu.Lock() + defer gateway.mu.Unlock() + + if gateway.cmd != nil && gateway.cmd.Process != nil { + gateway.cmd = nil + setGatewayRuntimeStatusLocked("stopped") + } + + ready, reason, err := h.gatewayStartReady() + if err != nil { + http.Error( + w, + fmt.Sprintf("Failed to validate gateway start conditions: %v", err), + http.StatusInternalServerError, + ) + return + } + if !ready { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "status": "precondition_failed", + "message": reason, + }) + return + } + + pid, err := h.startGatewayLocked("starting", 0) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "pid": pid, + }) +} + +// handleGatewayStop stops the running gateway subprocess gracefully. +// Note: Unlike StopGateway (which only stops self-started processes), this API endpoint +// stops any gateway process, including attached ones. This is intentional for user control. +// +// POST /api/gateway/stop +func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) { + gateway.mu.Lock() + defer gateway.mu.Unlock() + + if gateway.cmd == nil || gateway.cmd.Process == nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "not_running", + }) + return + } + + pid, err := stopGatewayLocked() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "pid": pid, + }) +} + +// RestartGateway restarts the gateway process. This is a non-blocking operation +// that stops the current gateway (if running) and starts a new one. +// Returns the PID of the new gateway process or an error. +func (h *Handler) RestartGateway() (int, error) { + ready, reason, err := h.gatewayStartReady() + if err != nil { + return 0, fmt.Errorf("failed to validate gateway start conditions: %w", err) + } + if !ready { + return 0, &preconditionFailedError{reason: reason} + } + + gateway.mu.Lock() + previousCmd := gateway.cmd + previousOwned := gateway.owned + setGatewayRuntimeStatusLocked("restarting") + gateway.mu.Unlock() + + if previousCmd != nil && previousCmd.Process != nil && !previousOwned { + if isGateway, inspected := gatewayProcessMatcher(previousCmd.Process.Pid); inspected && !isGateway { + logger.Warnf("refuse restarting non-gateway process (PID: %d)", previousCmd.Process.Pid) + gateway.mu.Lock() + if gateway.cmd == previousCmd { + setGatewayRuntimeStatusLocked("running") + } + gateway.mu.Unlock() + return 0, fmt.Errorf("refuse to restart non-gateway process (PID %d)", previousCmd.Process.Pid) + } + } + + if err = stopGatewayProcessForRestart(previousCmd); err != nil { + gateway.mu.Lock() + if gateway.cmd == previousCmd { + if isCmdProcessAliveLocked(previousCmd) { + setGatewayRuntimeStatusLocked("running") + } else { + gateway.cmd = nil + gateway.bootDefaultModel = "" + setGatewayRuntimeStatusLocked("error") + } + } + gateway.mu.Unlock() + return 0, fmt.Errorf("failed to stop gateway: %w", err) + } + + gateway.mu.Lock() + if gateway.cmd == previousCmd { + gateway.cmd = nil + gateway.bootDefaultModel = "" + } + pid, err := h.startGatewayLocked("restarting", 0) + if err != nil { + gateway.cmd = nil + gateway.bootDefaultModel = "" + setGatewayRuntimeStatusLocked("error") + } + gateway.mu.Unlock() + if err != nil { + return 0, fmt.Errorf("failed to start gateway: %w", err) + } + + return pid, nil +} + +// preconditionFailedError is returned when gateway restart preconditions are not met +type preconditionFailedError struct { + reason string +} + +func (e *preconditionFailedError) Error() string { + return e.reason +} + +// IsBadRequest returns true if the error should result in a 400 Bad Request status +func (e *preconditionFailedError) IsBadRequest() bool { + return true +} + +// handleGatewayRestart stops the gateway (if running) and starts a new instance. +// +// POST /api/gateway/restart +func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { + pid, err := h.RestartGateway() + if err != nil { + // Check if it's a precondition failed error + var precondErr *preconditionFailedError + if errors.As(err, &precondErr) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "status": "precondition_failed", + "message": precondErr.reason, + }) + return + } + http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "pid": pid, + }) +} + +// handleGatewayClearLogs clears the in-memory gateway log buffer. +// +// POST /api/gateway/logs/clear +func (h *Handler) handleGatewayClearLogs(w http.ResponseWriter, r *http.Request) { + gateway.logs.Clear() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "cleared", + "log_total": 0, + "log_run_id": gateway.logs.RunID(), + }) +} + +// handleGatewayStatus returns the gateway run status and health info. +// +// GET /api/gateway/status +func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) { + data := h.gatewayStatusData() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} + +func (h *Handler) gatewayStatusData() map[string]any { + data := map[string]any{} + var configDefaultModel string + cfg, cfgErr := config.LoadConfig(h.configPath) + if cfgErr == nil && cfg != nil { + configDefaultModel = strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) + if configDefaultModel != "" { + data["config_default_model"] = configDefaultModel + } + } + + // Primary detection: read PID file and check if process is alive. + pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), cfg) + if pidData != nil { + gateway.mu.Lock() + gateway.pidData = pidData + if pidData.Version != "" { + data["gateway_version"] = pidData.Version + } + setGatewayRuntimeStatusLocked("running") + + // Attach if we don't already track this PID. + if gateway.cmd == nil || gateway.cmd.Process == nil || gateway.cmd.Process.Pid != pidData.PID { + _ = attachToGatewayProcessLocked(pidData.PID, cfg) + } + + bootDefaultModel := gateway.bootDefaultModel + if bootDefaultModel != "" { + data["boot_default_model"] = bootDefaultModel + } + data["gateway_status"] = "running" + data["pid"] = pidData.PID + gateway.mu.Unlock() + } else { + // Intentionally skip health probe here; the startup goroutine + // (startGatewayLocked) already handles liveness detection via + // pidFile polling and health fallback. + gateway.mu.Lock() + status := gatewayStatusWithoutHealthLocked() + data["gateway_status"] = status + // Keep last known pidData while gateway is still in a transient + // running state; otherwise websocket proxy may lose auth token + // during short pid-file races. + if status == "stopped" || status == "error" { + gateway.pidData = nil + } + gateway.mu.Unlock() + } + + gatewayStatus, _ := data["gateway_status"].(string) + currentConfigSignature := computeConfigSignature(cfg) + gateway.mu.Lock() + bootConfigSignature := gateway.bootConfigSignature + gateway.mu.Unlock() + data["gateway_restart_required"] = gatewayRestartRequiredBySignature( + bootConfigSignature, + currentConfigSignature, + gatewayStatus, + ) + + ready, reason, readyErr := h.gatewayStartReady() + if readyErr != nil { + data["gateway_start_allowed"] = false + data["gateway_start_reason"] = readyErr.Error() + } else { + data["gateway_start_allowed"] = ready + if !ready { + data["gateway_start_reason"] = reason + } + } + + return data +} + +// handleGatewayLogs returns buffered gateway logs, optionally incrementally. +// +// GET /api/gateway/logs +func (h *Handler) handleGatewayLogs(w http.ResponseWriter, r *http.Request) { + data := gatewayLogsData(r) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} + +// gatewayLogsData reads log_offset and log_run_id query params from the request +// and returns incremental log lines. +func gatewayLogsData(r *http.Request) map[string]any { + data := map[string]any{} + clientOffset := 0 + clientRunID := -1 + + if v := r.URL.Query().Get("log_offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + clientOffset = n + } + } + + if v := r.URL.Query().Get("log_run_id"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + clientRunID = n + } + } + + runID := gateway.logs.RunID() + + if runID == 0 { + data["logs"] = []string{} + data["log_total"] = 0 + data["log_run_id"] = 0 + return data + } + + // If runID changed, reset offset to get all logs from new run + offset := clientOffset + if clientRunID != runID { + offset = 0 + } + + lines, total, runID := gateway.logs.LinesSince(offset) + if lines == nil { + lines = []string{} + } + + data["logs"] = lines + data["log_total"] = total + data["log_run_id"] = runID + return data +} + +// scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF. +func scanPipe(r io.Reader, buf *LogBuffer) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + buf.Append(scanner.Text()) + } +} diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go new file mode 100644 index 000000000..03af7a9d3 --- /dev/null +++ b/web/backend/api/gateway_host.go @@ -0,0 +1,252 @@ +package api + +import ( + "net" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/netbind" +) + +func (h *Handler) effectiveLauncherPublic() bool { + if h.serverHostExplicit { + // -host takes precedence over -public and launcher-config public setting. + return false + } + + if h.serverPublicExplicit { + return h.serverPublic + } + + cfg, err := h.loadLauncherConfig() + if err == nil { + return cfg.Public + } + + return h.serverPublic +} + +func (h *Handler) gatewayHostOverride() string { + if h.serverHostExplicit { + return strings.TrimSpace(h.serverHostInput) + } + if h.effectiveLauncherPublic() { + return "*" + } + return "" +} + +func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string { + if override := h.gatewayHostOverride(); override != "" { + return override + } + if cfg == nil { + return "" + } + return strings.TrimSpace(cfg.Gateway.Host) +} + +func gatewayProbeHost(bindHost string) string { + plan, err := netbind.BuildPlan(bindHost, netbind.DefaultLoopback) + if err != nil || strings.TrimSpace(plan.ProbeHost) == "" { + return netbind.ResolveAdaptiveLoopbackHost() + } + return plan.ProbeHost +} + +func (h *Handler) gatewayProxyURL() *url.URL { + cfg, err := config.LoadConfig(h.configPath) + port := 18790 + bindHost := "" + if err == nil && cfg != nil { + if cfg.Gateway.Port != 0 { + port = cfg.Gateway.Port + } + bindHost = h.effectiveGatewayBindHost(cfg) + } + + return &url.URL{ + Scheme: "http", + Host: net.JoinHostPort(gatewayProbeHost(bindHost), strconv.Itoa(port)), + } +} + +func requestHostName(r *http.Request) string { + reqHost, _, err := net.SplitHostPort(r.Host) + if err == nil { + return reqHost + } + if strings.TrimSpace(r.Host) != "" { + return r.Host + } + return netbind.ResolveAdaptiveLoopbackHost() +} + +func forwardedProtoFirst(r *http.Request) string { + raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) + if raw == "" { + raw = forwardedRFC7239Proto(r) + } + if raw == "" { + return "" + } + if i := strings.IndexByte(raw, ','); i >= 0 { + raw = strings.TrimSpace(raw[:i]) + } + return strings.ToLower(raw) +} + +func requestWSScheme(r *http.Request) string { + if forwarded := forwardedProtoFirst(r); forwarded != "" { + proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) + if proto == "https" || proto == "wss" { + return "wss" + } + if proto == "http" || proto == "ws" { + return "ws" + } + } + + if r.TLS != nil { + return "wss" + } + + return "ws" +} + +// requestHTTPScheme returns http or https for URLs that are not WebSockets (e.g. SSE). +func requestHTTPScheme(r *http.Request) string { + if forwarded := forwardedProtoFirst(r); forwarded != "" { + proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) + if proto == "https" || proto == "wss" { + return "https" + } + if proto == "http" || proto == "ws" { + return "http" + } + } + if r.TLS != nil { + return "https" + } + + return "http" +} + +// forwardedHostFirst returns the client-visible host from reverse-proxy / tunnel headers +// (e.g. VS Code port forwarding, nginx). Empty if unset. +func forwardedHostFirst(r *http.Request) string { + raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")) + if raw == "" { + raw = forwardedRFC7239Host(r) + } + if raw == "" { + return "" + } + if i := strings.IndexByte(raw, ','); i >= 0 { + raw = strings.TrimSpace(raw[:i]) + } + return raw +} + +// forwardedRFC7239Host parses host= from the first Forwarded header element (RFC 7239). +func forwardedRFC7239Host(r *http.Request) string { + return forwardedRFC7239Param(r, "host") +} + +func forwardedRFC7239Proto(r *http.Request) string { + return forwardedRFC7239Param(r, "proto") +} + +func forwardedRFC7239Param(r *http.Request, key string) string { + v := strings.TrimSpace(r.Header.Get("Forwarded")) + if v == "" { + return "" + } + first := strings.TrimSpace(strings.Split(v, ",")[0]) + for _, part := range strings.Split(first, ";") { + part = strings.TrimSpace(part) + low := strings.ToLower(part) + if !strings.HasPrefix(low, key+"=") { + continue + } + val := strings.TrimSpace(part[strings.IndexByte(part, '=')+1:]) + if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' { + val = val[1 : len(val)-1] + } + return val + } + return "" +} + +// forwardedPortFirst returns the first X-Forwarded-Port value, or empty. +func forwardedPortFirst(r *http.Request) string { + raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Port")) + if raw == "" { + return "" + } + if i := strings.IndexByte(raw, ','); i >= 0 { + raw = strings.TrimSpace(raw[:i]) + } + return raw +} + +// clientVisiblePort picks the TCP port the browser uses to reach this app (after proxies). +// Used by picoWebUIAddr → buildWsURL / buildPicoEventsURL / buildPicoSendURL so WebSocket and +// HTTP URLs match the dashboard page origin (cookies / token flow behind tunnels and reverse proxies). +func clientVisiblePort(r *http.Request, serverListenPort int) string { + if p := forwardedPortFirst(r); p != "" { + return p + } + if fwdHost := forwardedHostFirst(r); fwdHost != "" { + if _, port, err := net.SplitHostPort(fwdHost); err == nil && port != "" { + return port + } + } + if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" { + return port + } + if strings.TrimSpace(r.Host) == "" && forwardedHostFirst(r) == "" { + return strconv.Itoa(serverListenPort) + } + if requestHTTPScheme(r) == "https" { + return "443" + } + return "80" +} + +// joinClientVisibleHostPort builds host:port for absolute URLs returned to the browser. +func joinClientVisibleHostPort(r *http.Request, host string, serverListenPort int) string { + if h, p, err := net.SplitHostPort(host); err == nil { + return net.JoinHostPort(h, p) + } + return net.JoinHostPort(host, clientVisiblePort(r, serverListenPort)) +} + +// picoWebUIAddr is host:port for URLs returned to the browser (/pico/ws, /pico/events, /pico/send). +// It must match the HTTP Host the client used (or X-Forwarded-*), not cfg.Gateway.Host — otherwise +// e.g. page on localhost with ws_url 127.0.0.1 omits cookies and the dashboard auth handshake fails. +func (h *Handler) picoWebUIAddr(r *http.Request) string { + wsPort := h.serverPort + if wsPort == 0 { + wsPort = 18800 + } + if fwdHost := forwardedHostFirst(r); fwdHost != "" { + return joinClientVisibleHostPort(r, fwdHost, wsPort) + } + return joinClientVisibleHostPort(r, requestHostName(r), wsPort) +} + +func (h *Handler) buildWsURL(r *http.Request) string { + return requestWSScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/ws" +} + +func (h *Handler) buildPicoEventsURL(r *http.Request) string { + return requestHTTPScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/events" +} + +func (h *Handler) buildPicoSendURL(r *http.Request) string { + return requestHTTPScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/send" +} diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go new file mode 100644 index 000000000..54d1010d2 --- /dev/null +++ b/web/backend/api/gateway_host_test.go @@ -0,0 +1,330 @@ +package api + +import ( + "crypto/tls" + "errors" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/netbind" + "github.com/sipeed/picoclaw/web/backend/launcherconfig" +) + +func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + launcherPath := launcherconfig.PathForAppConfig(configPath) + if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ + Port: 18800, + Public: false, + }); err != nil { + t.Fatalf("launcherconfig.Save() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, true, true, nil) + + if got := h.gatewayHostOverride(); got != "*" { + t.Fatalf("gatewayHostOverride() = %q, want %q", got, "*") + } +} + +func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + launcherPath := launcherconfig.PathForAppConfig(configPath) + if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ + Port: 18800, + Public: true, + }); err != nil { + t.Fatalf("launcherconfig.Save() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil) + req.Host = "192.168.1.9:18800" + + if got := h.buildWsURL(req); got != "ws://192.168.1.9:18800/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18800/pico/ws") + } + + if got := h.buildPicoEventsURL(req); got != "http://192.168.1.9:18800/pico/events" { + t.Fatalf("buildPicoEventsURL() = %q, want %q", got, "http://192.168.1.9:18800/pico/events") + } + if got := h.buildPicoSendURL(req); got != "http://192.168.1.9:18800/pico/send" { + t.Fatalf("buildPicoSendURL() = %q, want %q", got, "http://192.168.1.9:18800/pico/send") + } +} + +func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) { + want := "127.0.0.1" + if got := gatewayProbeHost("0.0.0.0"); got != want { + t.Fatalf("gatewayProbeHost() = %q, want %q", got, want) + } +} + +func TestGatewayProbeHostUsesPreferredLoopbackForEmptyBind(t *testing.T) { + want := netbind.ResolveAdaptiveLoopbackHost() + if got := gatewayProbeHost(""); got != want { + t.Fatalf("gatewayProbeHost(empty) = %q, want %q", got, want) + } +} + +func TestGatewayProbeHostUsesPreferredLoopbackForLocalhostBind(t *testing.T) { + want := netbind.ResolveAdaptiveLoopbackHost() + if got := gatewayProbeHost("localhost"); got != want { + t.Fatalf("gatewayProbeHost(localhost) = %q, want %q", got, want) + } +} + +func TestGatewayProbeHostUsesLoopbackForIPv6WildcardBind(t *testing.T) { + want := "::1" + if got := gatewayProbeHost("::"); got != want { + t.Fatalf("gatewayProbeHost(::) = %q, want %q", got, want) + } +} + +func TestGatewayProbeHostUsesFirstConcreteHostForMultiHostBind(t *testing.T) { + if got := gatewayProbeHost("127.0.0.1,::1"); got != "127.0.0.1" { + t.Fatalf("gatewayProbeHost(multi) = %q, want %q", got, "127.0.0.1") + } +} + +func TestGatewayProxyURLUsesConfiguredHost(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "192.168.1.10" + cfg.Gateway.Port = 18791 + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + if got := h.gatewayProxyURL().String(); got != "http://192.168.1.10:18791" { + t.Fatalf("gatewayProxyURL() = %q, want %q", got, "http://192.168.1.10:18791") + } +} + +func TestGetGatewayHealthUsesConfiguredHost(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "192.168.1.10" + cfg.Gateway.Port = 18791 + + originalHealthGet := gatewayHealthGet + t.Cleanup(func() { + gatewayHealthGet = originalHealthGet + }) + + var requestedURL string + gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) { + requestedURL = url + return nil, errors.New("probe failed") + } + + _, statusCode, err := h.getGatewayHealth(cfg, time.Second) + _ = statusCode + _ = err + + if requestedURL != "http://192.168.1.10:18791/health" { + t.Fatalf("health url = %q, want %q", requestedURL, "http://192.168.1.10:18791/health") + } +} + +func TestGetGatewayHealthUsesProbeHostForPublicLauncher(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + h.SetServerOptions(18800, true, true, nil) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = 18791 + + originalHealthGet := gatewayHealthGet + t.Cleanup(func() { + gatewayHealthGet = originalHealthGet + }) + + var requestedURL string + gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) { + requestedURL = url + return nil, errors.New("probe failed") + } + + _, statusCode, err := h.getGatewayHealth(cfg, time.Second) + _ = statusCode + _ = err + + want := "http://" + net.JoinHostPort(netbind.ResolveAdaptiveLoopbackHost(), "18791") + "/health" + if requestedURL != want { + t.Fatalf("health url = %q, want %q", requestedURL, want) + } +} + +func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "0.0.0.0" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil) + req.Host = "chat.example.com" + req.Header.Set("X-Forwarded-Proto", "https") + + if got := h.buildWsURL(req); got != "wss://chat.example.com:443/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:443/pico/ws") + } +} + +func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "0.0.0.0" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "https://launcher.local/api/pico/info", nil) + req.Host = "secure.example.com" + req.TLS = &tls.ConnectionState{} + + if got := h.buildWsURL(req); got != "wss://secure.example.com:443/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:443/pico/ws") + } +} + +func TestBuildPicoURLsPreferXForwardedHost(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + launcherPath := launcherconfig.PathForAppConfig(configPath) + if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ + Port: 18800, + Public: true, + }); err != nil { + t.Fatalf("launcherconfig.Save() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "0.0.0.0" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/info", nil) + req.Host = "127.0.0.1:18800" + req.Header.Set("X-Forwarded-Host", "vscode-tunnel.example.com") + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Port", "443") + + if got := h.buildPicoEventsURL(req); got != "https://vscode-tunnel.example.com:443/pico/events" { + t.Fatalf("buildPicoEventsURL() = %q, want %q", got, "https://vscode-tunnel.example.com:443/pico/events") + } + if got := h.buildPicoSendURL(req); got != "https://vscode-tunnel.example.com:443/pico/send" { + t.Fatalf("buildPicoSendURL() = %q, want %q", got, "https://vscode-tunnel.example.com:443/pico/send") + } + if got := h.buildWsURL(req); got != "wss://vscode-tunnel.example.com:443/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://vscode-tunnel.example.com:443/pico/ws") + } +} + +func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "0.0.0.0" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "https://launcher.local/api/pico/info", nil) + req.Host = "chat.example.com" + req.TLS = &tls.ConnectionState{} + req.Header.Set("X-Forwarded-Proto", "http") + + if got := h.buildWsURL(req); got != "ws://chat.example.com:80/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:80/pico/ws") + } +} + +func TestBuildWsURLDoesNotTrustOriginWhenProxyOmitsForwardedProto(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil) + req.Host = "fs-952210-xwj.picoclaw.lan.sipeed.com" + req.Header.Set("Origin", "https://fs-952210-xwj.picoclaw.lan.sipeed.com") + + if got := h.buildWsURL(req); got != "ws://fs-952210-xwj.picoclaw.lan.sipeed.com:80/pico/ws" { + t.Fatalf( + "buildWsURL() = %q, want %q", + got, + "ws://fs-952210-xwj.picoclaw.lan.sipeed.com:80/pico/ws", + ) + } +} + +func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + + req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/info", nil) + req.Host = "localhost:18800" + + if got := h.buildWsURL(req); got != "ws://localhost:18800/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://localhost:18800/pico/ws") + } +} + +func TestGatewayHostOverrideWithExplicitHostAndAlignedGatewayHost(t *testing.T) { + h := NewHandler(filepath.Join(t.TempDir(), "config.json")) + h.SetServerOptions(18800, false, false, nil) + h.SetServerBindHost("0.0.0.0", true) + + if got := h.gatewayHostOverride(); got != "0.0.0.0" { + t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0") + } +} + +func TestGatewayHostOverrideWithExplicitHostAndLocalhostGatewayHost(t *testing.T) { + h := NewHandler(filepath.Join(t.TempDir(), "config.json")) + h.SetServerOptions(18800, false, false, nil) + h.SetServerBindHost("::", true) + + if got := h.gatewayHostOverride(); got != "::" { + t.Fatalf("gatewayHostOverride() = %q, want %q", got, "::") + } +} + +func TestGatewayHostOverrideWithExplicitMultiHost(t *testing.T) { + h := NewHandler(filepath.Join(t.TempDir(), "config.json")) + h.SetServerOptions(18800, false, false, nil) + h.SetServerBindHost("127.0.0.1,::1", true) + + if got := h.gatewayHostOverride(); got != "127.0.0.1,::1" { + t.Fatalf("gatewayHostOverride() = %q, want %q", got, "127.0.0.1,::1") + } +} + +func TestGatewayHostExplicitIgnoresPublicFlag(t *testing.T) { + h := NewHandler(filepath.Join(t.TempDir(), "config.json")) + h.SetServerOptions(18800, true, true, nil) + h.SetServerBindHost("127.0.0.1", true) + + if got := h.effectiveLauncherPublic(); got { + t.Fatalf("effectiveLauncherPublic() = %t, want false when explicit host is set", got) + } +} diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go new file mode 100644 index 000000000..f383089a6 --- /dev/null +++ b/web/backend/api/gateway_test.go @@ -0,0 +1,1863 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" + ppid "github.com/sipeed/picoclaw/pkg/pid" + "github.com/sipeed/picoclaw/web/backend/utils" +) + +func startLongRunningProcess(t *testing.T) *exec.Cmd { + t.Helper() + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("powershell", "-NoProfile", "-Command", "Start-Sleep -Seconds 30") + } else { + cmd = exec.Command("sleep", "30") + } + + if err := cmd.Start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + + return cmd +} + +func startGatewayLikeProcess(t *testing.T) *exec.Cmd { + t.Helper() + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + t.Skip("gateway-like process commandline check is not deterministic on Windows tests") + } + cmd = exec.Command("sh", "-c", "sleep 30 # picoclaw gateway") + + if err := cmd.Start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + + return cmd +} + +func writeTestPidFile(t *testing.T, data ppid.PidFileData) string { + t.Helper() + + path := filepath.Join(globalConfigDir(), ".picoclaw.pid") + raw, err := json.MarshalIndent(data, "", " ") + if err != nil { + t.Fatalf("marshal pid file: %v", err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatalf("write pid file: %v", err) + } + return path +} + +func mockGatewayHealthResponse(statusCode, pid int) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Body: io.NopCloser(strings.NewReader( + `{"status":"ok","uptime":"1s","pid":` + strconv.Itoa(pid) + `}`, + )), + } +} + +func startIgnoringTermProcess(t *testing.T) *exec.Cmd { + t.Helper() + + if runtime.GOOS == "windows" { + t.Skip("TERM handling differs on Windows") + } + + cmd := exec.Command("sh", "-c", "trap '' TERM; sleep 30") + if err := cmd.Start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + + return cmd +} + +func resetGatewayTestState(t *testing.T) { + t.Helper() + + originalHealthGet := gatewayHealthGet + originalProcessMatcher := gatewayProcessMatcher + originalExecCommand := gatewayExecCommand + originalRestartGracePeriod := gatewayRestartGracePeriod + originalRestartForceKillWindow := gatewayRestartForceKillWindow + originalRestartPollInterval := gatewayRestartPollInterval + t.Setenv("PICOCLAW_HOME", t.TempDir()) + t.Cleanup(func() { + gatewayHealthGet = originalHealthGet + gatewayProcessMatcher = originalProcessMatcher + gatewayExecCommand = originalExecCommand + gatewayRestartGracePeriod = originalRestartGracePeriod + gatewayRestartForceKillWindow = originalRestartForceKillWindow + gatewayRestartPollInterval = originalRestartPollInterval + + gateway.mu.Lock() + gateway.cmd = nil + gateway.pidData = nil + gateway.owned = false + gateway.bootDefaultModel = "" + gateway.bootConfigSignature = "" + setGatewayRuntimeStatusLocked("stopped") + gateway.mu.Unlock() + }) +} + +func TestPicoGatewayProtocol(t *testing.T) { + resetGatewayTestState(t) + + gateway.mu.Lock() + gateway.picoToken = "ui-token" + gateway.mu.Unlock() + + if got := picoGatewayProtocol(); got != tokenPrefix+"ui-token" { + t.Fatalf("picoGatewayProtocol() = %q, want %q", got, tokenPrefix+"ui-token") + } +} + +type gatewayStartEnvSnapshot struct { + GatewayHost string `json:"gateway_host"` + GatewayHostSet bool `json:"gateway_host_set"` + ConfigPath string `json:"config_path"` +} + +func TestGatewayStartHelperProcess(t *testing.T) { + var envPath string + for i, arg := range os.Args { + if arg == "--" && i+2 < len(os.Args) && os.Args[i+1] == "gateway-env-helper" { + envPath = os.Args[i+2] + break + } + } + if envPath == "" { + t.Skip("helper process") + } + + host, ok := os.LookupEnv(config.EnvGatewayHost) + raw, err := json.Marshal(gatewayStartEnvSnapshot{ + GatewayHost: host, + GatewayHostSet: ok, + ConfigPath: os.Getenv(config.EnvConfig), + }) + if err != nil { + _, _ = io.WriteString(os.Stderr, err.Error()) + os.Exit(2) + } + if err := os.WriteFile(envPath, raw, 0o600); err != nil { + _, _ = io.WriteString(os.Stderr, err.Error()) + os.Exit(2) + } + os.Exit(0) +} + +func unsetGatewayStartEnvForTest(t *testing.T, key string) { + t.Helper() + + prev, hadPrev := os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + t.Fatalf("Unsetenv(%q) error = %v", key, err) + } + t.Cleanup(func() { + if hadPrev { + _ = os.Setenv(key, prev) + return + } + _ = os.Unsetenv(key) + }) +} + +func newGatewayStartTestHandler(t *testing.T) *Handler { + t.Helper() + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + return h +} + +func startGatewayAndCaptureEnv(t *testing.T, h *Handler) gatewayStartEnvSnapshot { + t.Helper() + + unsetGatewayStartEnvForTest(t, config.EnvGatewayHost) + + envPath := filepath.Join(t.TempDir(), "gateway-child-env.json") + gatewayExecCommand = func(_ string, _ ...string) *exec.Cmd { + return exec.Command( + os.Args[0], + "-test.run=TestGatewayStartHelperProcess", + "--", + "gateway-env-helper", + envPath, + ) + } + + pid, err := h.startGatewayLocked("starting", 0) + if err != nil { + t.Fatalf("startGatewayLocked() error = %v", err) + } + if pid <= 0 { + t.Fatalf("startGatewayLocked() pid = %d, want > 0", pid) + } + + deadline := time.Now().Add(3 * time.Second) + for { + raw, err := os.ReadFile(envPath) + if err == nil { + var snapshot gatewayStartEnvSnapshot + err = json.Unmarshal(raw, &snapshot) + if err != nil { + t.Fatalf("Unmarshal(child env) error = %v", err) + } + return snapshot + } + if !os.IsNotExist(err) { + t.Fatalf("ReadFile(%q) error = %v", envPath, err) + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for gateway child env snapshot %q", envPath) + } + time.Sleep(20 * time.Millisecond) + } +} + +func TestStartGatewayLocked_ForwardsLauncherHostOverrideToGatewayEnv(t *testing.T) { + h := newGatewayStartTestHandler(t) + h.SetServerBindHost("127.0.0.1,::1", true) + + snapshot := startGatewayAndCaptureEnv(t, h) + if !snapshot.GatewayHostSet { + t.Fatal("gateway host env was not set") + } + if snapshot.GatewayHost != "127.0.0.1,::1" { + t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "127.0.0.1,::1") + } + if snapshot.ConfigPath != h.configPath { + t.Fatalf("config env = %q, want %q", snapshot.ConfigPath, h.configPath) + } +} + +func TestStartGatewayLocked_ForwardsLauncherHostFromEnvironmentToGatewayEnv(t *testing.T) { + h := newGatewayStartTestHandler(t) + h.SetServerBindHost("::", true) + + snapshot := startGatewayAndCaptureEnv(t, h) + if !snapshot.GatewayHostSet { + t.Fatal("gateway host env was not set") + } + if snapshot.GatewayHost != "::" { + t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "::") + } +} + +func TestStartGatewayLocked_ForwardsWildcardHostForPublicLauncher(t *testing.T) { + h := newGatewayStartTestHandler(t) + h.SetServerOptions(18800, true, true, nil) + + snapshot := startGatewayAndCaptureEnv(t, h) + if !snapshot.GatewayHostSet { + t.Fatal("gateway host env was not set") + } + if snapshot.GatewayHost != "*" { + t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "*") + } +} + +func TestStartGatewayLocked_UsesReloadedConfigForBootSignature(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("sleep command differs on Windows") + } + + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + delete(cfg.Channels, "pico") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + gatewayExecCommand = func(_ string, _ ...string) *exec.Cmd { + return exec.Command("sleep", "30") + } + + originalSignature := computeConfigSignature(cfg) + pid, err := h.startGatewayLocked("starting", 0) + if err != nil { + t.Fatalf("startGatewayLocked() error = %v", err) + } + if pid <= 0 { + t.Fatalf("startGatewayLocked() pid = %d, want > 0", pid) + } + + gateway.mu.Lock() + cmd := gateway.cmd + bootSignature := gateway.bootConfigSignature + gateway.mu.Unlock() + t.Cleanup(func() { + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + if cmd != nil { + _ = cmd.Wait() + } + }) + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + expectedSignature := computeConfigSignature(updatedCfg) + if expectedSignature == originalSignature { + t.Fatal("expected EnsurePicoChannel() to change the config signature during gateway start") + } + if bootSignature != expectedSignature { + t.Fatalf("bootConfigSignature = %q, want %q", bootSignature, expectedSignature) + } +} + +func TestGatewayStartReady_NoDefaultModel(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatalf("gatewayStartReady() ready = true, want false") + } + if reason != "no default model configured" { + t.Fatalf("gatewayStartReady() reason = %q, want %q", reason, "no default model configured") + } +} + +func TestGatewayStartReady_RejectsASROnlyDefaultModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + cfg.Agents.Defaults.ModelName = "elevenlabs-asr" + + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatal("gatewayStartReady() ready = true, want false") + } + if reason != `default model "elevenlabs-asr" is not usable for chat` { + t.Fatalf( + "gatewayStartReady() reason = %q, want %q", + reason, + `default model "elevenlabs-asr" is not usable for chat`, + ) + } +} + +func TestLooksLikeGatewayCommandLine(t *testing.T) { + cases := []struct { + name string + cmdline string + want bool + }{ + { + name: "default picoclaw gateway", + cmdline: "/usr/local/bin/picoclaw gateway -E", + want: true, + }, + { + name: "renamed binary with gateway subcommand", + cmdline: "/opt/bin/custom-claw gateway -E -d", + want: true, + }, + { + name: "standalone gateway binary path", + cmdline: "/opt/bin/gateway -E", + want: true, + }, + { + name: "non gateway process", + cmdline: "/bin/sleep 30", + want: false, + }, + { + name: "gateway substring only", + cmdline: "/opt/bin/gatewayd --serve", + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := looksLikeGatewayCommandLine(tc.cmdline) + if got != tc.want { + t.Fatalf("looksLikeGatewayCommandLine(%q) = %v, want %v", tc.cmdline, got, tc.want) + } + }) + } +} + +func TestValidateGatewayPidDataAcceptsHealthWhenMatcherInconclusive(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + const testPID = 34567 + pidData := &ppid.PidFileData{ + PID: testPID, + Host: "127.0.0.1", + Port: 18790, + } + + gatewayProcessMatcher = func(int) (bool, bool) { return false, false } + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, testPID), nil + } + + ok, decisive, reason := h.validateGatewayPidData(pidData, nil) + if !ok { + t.Fatalf("validateGatewayPidData() ok = false, want true (reason=%q)", reason) + } + if !decisive { + t.Fatalf("validateGatewayPidData() decisive = false, want true") + } +} + +func TestValidateGatewayPidDataRejectsHealthPidMismatchWhenMatcherInconclusive(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + pidData := &ppid.PidFileData{ + PID: 34567, + Host: "127.0.0.1", + Port: 18790, + } + + gatewayProcessMatcher = func(int) (bool, bool) { return false, false } + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, 99999), nil + } + + ok, decisive, reason := h.validateGatewayPidData(pidData, nil) + if ok { + t.Fatalf("validateGatewayPidData() ok = true, want false") + } + if !decisive { + t.Fatalf("validateGatewayPidData() decisive = false, want true") + } + if !strings.Contains(reason, "health pid mismatch") { + t.Fatalf("validateGatewayPidData() reason = %q, want contains %q", reason, "health pid mismatch") + } +} + +func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = "missing-model" + err := config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatalf("gatewayStartReady() ready = true, want false") + } + if reason == "" { + t.Fatalf("gatewayStartReady() reason is empty") + } +} + +func TestGatewayStartReady_ValidDefaultModel(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + err := config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if !ready { + t.Fatalf("gatewayStartReady() ready = false, want true (reason=%q)", reason) + } +} + +func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("") + cfg.ModelList[0].AuthMethod = "" + err := config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatalf("gatewayStartReady() ready = true, want false") + } + if !strings.Contains(reason, "no credentials configured") { + t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured") + } +} + +func TestGatewayCommandArgsIncludesDebugFlagWhenEnabled(t *testing.T) { + h := NewHandler(filepath.Join(t.TempDir(), "config.json")) + h.SetDebug(true) + + args := h.gatewayCommandArgs() + want := []string{"gateway", "-E", "-d"} + if strings.Join(args, " ") != strings.Join(want, " ") { + t.Fatalf("gatewayCommandArgs() = %v, want %v", args, want) + } +} + +func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetModelProbeHooks(t) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + return false + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://localhost:8000/v1", + }} + cfg.Agents.Defaults.ModelName = "local-vllm" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatalf("gatewayStartReady() ready = true, want false without a running local service") + } + if !strings.Contains(reason, "not reachable") { + t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "not reachable") + } +} + +func TestGatewayStartReady_LocalModelWithRunningService(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetModelProbeHooks(t) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" && apiKey == "" + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + }} + cfg.Agents.Defaults.ModelName = "local-vllm" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if !ready { + t.Fatalf("gatewayStartReady() ready = false, want true with a running local service (reason=%q)", reason) + } +} + +func TestGatewayStartReady_RemoteVLLMWithAPIKeyDoesNotProbe(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetModelProbeHooks(t) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + t.Fatalf("unexpected OpenAI-compatible probe for %q (%q)", apiBase, modelID) + return false + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "remote-vllm", + Model: "vllm/custom-model", + APIBase: "https://models.example.com/v1", + }} + cfg.ModelList[0o0].SetAPIKey("remote-key") + cfg.Agents.Defaults.ModelName = "remote-vllm" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if !ready { + t.Fatalf("gatewayStartReady() ready = false, want true for remote vllm with api key (reason=%q)", reason) + } +} + +func TestGatewayStartReady_LocalOllamaUsesDefaultProbeBase(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetModelProbeHooks(t) + + probeOllamaModelFunc = func(apiBase, modelID string) bool { + return apiBase == "http://localhost:11434/v1" && modelID == "llama3" + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "local-ollama", + Model: "ollama/llama3", + }} + cfg.Agents.Defaults.ModelName = "local-ollama" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if !ready { + t.Fatalf("gatewayStartReady() ready = false, want true with default Ollama probe base (reason=%q)", reason) + } +} + +func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "openai-oauth", + Model: "openai/gpt-5.4", + AuthMethod: "oauth", + }} + cfg.Agents.Defaults.ModelName = "openai-oauth" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatalf("gatewayStartReady() ready = true, want false without stored credential") + } + if !strings.Contains(reason, "no credentials configured") { + t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured") + } + + err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{ + AccessToken: "openai-token", + Provider: oauthProviderOpenAI, + AuthMethod: "oauth", + }) + if err != nil { + t.Fatalf("SetCredential() error = %v", err) + } + + ready, reason, err = h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if !ready { + t.Fatalf("gatewayStartReady() ready = false, want true with stored credential (reason=%q)", reason) + } +} + +func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + allowed, ok := body["gateway_start_allowed"].(bool) + if !ok { + t.Fatalf("gateway_start_allowed missing or not bool: %#v", body["gateway_start_allowed"]) + } + if allowed { + t.Fatalf("gateway_start_allowed = true, want false") + } + if _, ok := body["gateway_start_reason"].(string); !ok { + t.Fatalf("gateway_start_reason missing or not string: %#v", body["gateway_start_reason"]) + } +} + +func TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + cmd := startLongRunningProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + gateway.mu.Lock() + gateway.cmd = cmd + gateway.bootDefaultModel = "existing-model" + // Simulate a process that has already reached the running state. + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return nil, errors.New("probe failed") + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "running" { + t.Fatalf("gateway_status = %#v, want %q", got, "running") + } +} + +func TestGatewayStatusKeepsPidDataWhileTrackedProcessAliveWhenPidFileUnavailable(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + cmd := startLongRunningProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + gateway.mu.Lock() + gateway.cmd = cmd + gateway.pidData = &ppid.PidFileData{ + PID: cmd.Process.Pid, + Token: "existing-token", + } + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + gateway.mu.Lock() + defer gateway.mu.Unlock() + if gateway.pidData == nil { + t.Fatal("gateway.pidData was cleared while runtime status remained running") + } +} + +func TestGatewayStatusDowngradesRunningWhenTrackedProcessExitedAndPidFileMissing(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + cmd := startLongRunningProcess(t) + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + + gateway.mu.Lock() + gateway.cmd = cmd + gateway.pidData = &ppid.PidFileData{ + PID: cmd.Process.Pid, + Token: "stale-token", + } + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if got := body["gateway_status"]; got != "stopped" { + t.Fatalf("gateway_status = %#v, want %q", got, "stopped") + } + + gateway.mu.Lock() + defer gateway.mu.Unlock() + if gateway.pidData != nil { + t.Fatal("gateway.pidData should be cleared when tracked process has exited") + } +} + +func TestGatewayStatusIgnoresAndRemovesPidFileForNonGatewayProcess(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + cmd := startLongRunningProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + pidPath := writeTestPidFile(t, ppid.PidFileData{ + PID: cmd.Process.Pid, + Token: "stale-token", + Host: "127.0.0.1", + Port: 18790, + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if got := body["gateway_status"]; got != "stopped" { + t.Fatalf("gateway_status = %#v, want %q", got, "stopped") + } + if _, err := os.Stat(pidPath); !os.IsNotExist(err) { + t.Fatal("stale pid file should be removed for non-gateway process") + } +} + +func TestGatewayStopRefusesNonGatewayAttachedProcess(t *testing.T) { + resetGatewayTestState(t) + if runtime.GOOS == "windows" { + t.Skip("commandline-based process type check is best-effort on Windows") + } + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + cmd := startLongRunningProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + gateway.mu.Lock() + gateway.cmd = cmd + gateway.owned = false + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/gateway/stop", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) + } + if !isCmdProcessAliveLocked(cmd) { + t.Fatal("non-gateway process should not be terminated by /api/gateway/stop") + } +} + +func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) { + resetGatewayTestState(t) + gatewayProcessMatcher = func(int) (bool, bool) { return true, true } + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + cmd := startGatewayLikeProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + gateway.mu.Lock() + setGatewayRuntimeStatusLocked("stopped") + gateway.mu.Unlock() + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, cmd.Process.Pid), nil + } + + writeTestPidFile(t, ppid.PidFileData{ + PID: cmd.Process.Pid, + Token: "test-token", + Host: "127.0.0.1", + Port: 18790, + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "running" { + t.Fatalf("gateway_status = %#v, want %q", got, "running") + } + if got := body["gateway_restart_required"]; got != false { + t.Fatalf("gateway_restart_required = %#v, want false", got) + } +} + +func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) { + resetGatewayTestState(t) + gatewayProcessMatcher = func(int) (bool, bool) { return true, true } + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{ + ModelName: "second-model", + Model: "openai/gpt-4.1", + }) + cfg.ModelList[len(cfg.ModelList)-1].SetAPIKey("second-key") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + cmd := startGatewayLikeProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + writeTestPidFile(t, ppid.PidFileData{ + PID: cmd.Process.Pid, + Token: "test-token", + Host: "127.0.0.1", + Port: 18790, + }) + + bootSignature := computeConfigSignature(cfg) + gateway.mu.Lock() + gateway.cmd = cmd + gateway.bootDefaultModel = cfg.ModelList[0].ModelName + gateway.bootConfigSignature = bootSignature + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + updatedCfg.Agents.Defaults.ModelName = "second-model" + if err := config.SaveConfig(configPath, updatedCfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "running" { + t.Fatalf("gateway_status = %#v, want %q", got, "running") + } + if got := body["boot_default_model"]; got != cfg.ModelList[0].ModelName { + t.Fatalf("boot_default_model = %#v, want %q", got, cfg.ModelList[0].ModelName) + } + if got := body["config_default_model"]; got != "second-model" { + t.Fatalf("config_default_model = %#v, want %q", got, "second-model") + } + if got := body["gateway_restart_required"]; got != true { + t.Fatalf("gateway_restart_required = %#v, want true", got) + } +} + +func TestGatewayStatusRequiresRestartAfterToolChange(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + cfg.Tools.WriteFile.Enabled = true + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + process, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("FindProcess() error = %v", err) + } + + bootSignature := computeConfigSignature(cfg) + gateway.mu.Lock() + gateway.cmd = &exec.Cmd{Process: process} + gateway.bootDefaultModel = cfg.ModelList[0].ModelName + gateway.bootConfigSignature = bootSignature + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + updatedCfg.Tools.WriteFile.Enabled = false + if err := config.SaveConfig(configPath, updatedCfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "running" { + t.Fatalf("gateway_status = %#v, want %q", got, "running") + } + if got := body["gateway_restart_required"]; got != true { + t.Fatalf("gateway_restart_required = %#v, want true", got) + } +} + +func TestGatewayStatusRequiresRestartAfterChannelChange(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + process, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("FindProcess() error = %v", err) + } + + bootSignature := computeConfigSignature(cfg) + gateway.mu.Lock() + gateway.cmd = &exec.Cmd{Process: process} + gateway.bootDefaultModel = cfg.ModelList[0].ModelName + gateway.bootConfigSignature = bootSignature + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegram := updatedCfg.Channels.Get("telegram") + if telegram == nil { + t.Fatalf("expected default telegram channel config") + } + telegram.Enabled = !telegram.Enabled + if err := config.SaveConfig(configPath, updatedCfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "running" { + t.Fatalf("gateway_status = %#v, want %q", got, "running") + } + if got := body["gateway_restart_required"]; got != true { + t.Fatalf("gateway_restart_required = %#v, want true", got) + } +} + +func TestGatewayStatusRequiresRestartAfterWebSearchConfigChange(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + cfg.Tools.Web.Enabled = true + cfg.Tools.Web.Provider = "sogou" + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + process, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("FindProcess() error = %v", err) + } + + bootSignature := computeConfigSignature(cfg) + gateway.mu.Lock() + gateway.cmd = &exec.Cmd{Process: process} + gateway.bootDefaultModel = cfg.ModelList[0].ModelName + gateway.bootConfigSignature = bootSignature + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + updatedCfg.Tools.Web.Provider = "duckduckgo" + if err := config.SaveConfig(configPath, updatedCfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "running" { + t.Fatalf("gateway_status = %#v, want %q", got, "running") + } + if got := body["gateway_restart_required"]; got != true { + t.Fatalf("gateway_restart_required = %#v, want true", got) + } +} + +func TestGatewayStatusNoRestartRequiredForNonSensitiveChanges(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + cfg.Agents.Defaults.MaxTokens = 1000 + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + process, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("FindProcess() error = %v", err) + } + + bootSignature := computeConfigSignature(cfg) + gateway.mu.Lock() + gateway.cmd = &exec.Cmd{Process: process} + gateway.bootDefaultModel = cfg.ModelList[0].ModelName + gateway.bootConfigSignature = bootSignature + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + updatedCfg.Agents.Defaults.MaxTokens = 2000 + if err := config.SaveConfig(configPath, updatedCfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "running" { + t.Fatalf("gateway_status = %#v, want %q", got, "running") + } + if got := body["gateway_restart_required"]; got != false { + t.Fatalf("gateway_restart_required = %#v, want false", got) + } +} + +func TestGatewayStatusNoRestartRequiredWhenNotRunning(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + gateway.mu.Lock() + gateway.cmd = nil + gateway.bootDefaultModel = "" + gateway.bootConfigSignature = "" + setGatewayRuntimeStatusLocked("stopped") + gateway.mu.Unlock() + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + updatedCfg.Agents.Defaults.ModelName = "different-model" + if err := config.SaveConfig(configPath, updatedCfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return nil, errors.New("no gateway running") + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "stopped" { + t.Fatalf("gateway_status = %#v, want %q", got, "stopped") + } + if got := body["gateway_restart_required"]; got != false { + t.Fatalf("gateway_restart_required = %#v, want false", got) + } +} + +func TestGatewayStatusReturnsErrorAfterStartupWindowExpires(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + cmd := startLongRunningProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + gateway.mu.Lock() + gateway.cmd = cmd + gateway.bootDefaultModel = "existing-model" + setGatewayRuntimeStatusLocked("starting") + gateway.startupDeadline = time.Now().Add(-time.Second) + gateway.mu.Unlock() + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return nil, errors.New("probe failed") + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "error" { + t.Fatalf("gateway_status = %#v, want %q", got, "error") + } +} + +func TestGatewayStatusReturnsRestartingDuringRestartGap(t *testing.T) { + resetGatewayTestState(t) + + // Mock health check to return error, so it won't override our "restarting" status + gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) { + return nil, errors.New("mock health check error") + } + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + gateway.mu.Lock() + setGatewayRuntimeStatusLocked("restarting") + gateway.mu.Unlock() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "restarting" { + t.Fatalf("gateway_status = %#v, want %q", got, "restarting") + } +} + +func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("") + cfg.ModelList[0].AuthMethod = "" + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + cmd := startLongRunningProcess(t) + t.Cleanup(func() { + gateway.mu.Lock() + if gateway.cmd == cmd { + gateway.cmd = nil + gateway.bootDefaultModel = "" + } + gateway.mu.Unlock() + + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + gateway.mu.Lock() + gateway.cmd = cmd + gateway.bootDefaultModel = "existing-model" + gateway.mu.Unlock() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + + gateway.mu.Lock() + stillRunning := gateway.cmd == cmd && isCmdProcessAliveLocked(cmd) + gateway.mu.Unlock() + + if !stillRunning { + t.Fatalf("gateway process was stopped when restart preconditions failed") + } +} + +func TestGatewayRestartKeepsOldProcessWhenItDoesNotExitInTime(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + cmd := startIgnoringTermProcess(t) + t.Cleanup(func() { + gateway.mu.Lock() + if gateway.cmd == cmd { + gateway.cmd = nil + gateway.bootDefaultModel = "" + } + gateway.mu.Unlock() + + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + gatewayRestartGracePeriod = 150 * time.Millisecond + gatewayRestartForceKillWindow = 150 * time.Millisecond + gatewayRestartPollInterval = 10 * time.Millisecond + + gateway.mu.Lock() + gateway.cmd = cmd + gateway.bootDefaultModel = "existing-model" + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) + } + + gateway.mu.Lock() + stillRunning := gateway.cmd == cmd && isCmdProcessAliveLocked(cmd) + status := gateway.runtimeStatus + gateway.mu.Unlock() + + if !stillRunning { + t.Fatalf("gateway process was replaced before the old process exited") + } + if status != "running" { + t.Fatalf("runtimeStatus = %q, want %q", status, "running") + } +} + +func TestGatewayRestartReturnsErrorStatusWhenReplacementFailsToStart(t *testing.T) { + resetGatewayTestState(t) + + // Mock health check to return error, so it won't override our "error" status + gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) { + return nil, errors.New("mock health check error") + } + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + invalidBinaryPath := filepath.Join(t.TempDir(), "fake-picoclaw") + if err := os.WriteFile(invalidBinaryPath, []byte("#!/bin/sh\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + t.Setenv("PICOCLAW_BINARY", invalidBinaryPath) + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("restart status = %d, want %d", rec.Code, http.StatusInternalServerError) + } + + statusRec := httptest.NewRecorder() + statusReq := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(statusRec, statusReq) + + if statusRec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", statusRec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(statusRec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "error" { + t.Fatalf("gateway_status = %#v, want %q", got, "error") + } +} + +func TestGatewayStatusExcludesLogsFields(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if _, ok := body["logs"]; ok { + t.Fatalf("logs unexpectedly present in status response: %#v", body["logs"]) + } + if _, ok := body["log_total"]; ok { + t.Fatalf("log_total unexpectedly present in status response: %#v", body["log_total"]) + } + if _, ok := body["log_run_id"]; ok { + t.Fatalf("log_run_id unexpectedly present in status response: %#v", body["log_run_id"]) + } +} + +func TestGatewayLogsReturnsIncrementalHistory(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + gateway.logs.Clear() + gateway.logs.Append("first line") + gateway.logs.Append("second line") + runID := gateway.logs.RunID() + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodGet, + "/api/gateway/logs?log_offset=1&log_run_id="+strconv.Itoa(runID), + nil, + ) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("logs status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal logs response: %v", err) + } + + logs, ok := body["logs"].([]any) + if !ok { + t.Fatalf("logs missing or not array: %#v", body["logs"]) + } + if len(logs) != 1 || logs[0] != "second line" { + t.Fatalf("logs = %#v, want [\"second line\"]", logs) + } + if got := body["log_total"]; got != float64(2) { + t.Fatalf("log_total = %#v, want 2", got) + } + if got := body["log_run_id"]; got != float64(runID) { + t.Fatalf("log_run_id = %#v, want %d", got, runID) + } +} + +func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + gateway.logs.Clear() + gateway.logs.Append("first line") + gateway.logs.Append("second line") + previousRunID := gateway.logs.RunID() + + clearRec := httptest.NewRecorder() + clearReq := httptest.NewRequest(http.MethodPost, "/api/gateway/logs/clear", nil) + mux.ServeHTTP(clearRec, clearReq) + + if clearRec.Code != http.StatusOK { + t.Fatalf("clear status = %d, want %d", clearRec.Code, http.StatusOK) + } + + var clearBody map[string]any + if err := json.Unmarshal(clearRec.Body.Bytes(), &clearBody); err != nil { + t.Fatalf("unmarshal clear response: %v", err) + } + + if got := clearBody["status"]; got != "cleared" { + t.Fatalf("clear status body = %#v, want %q", got, "cleared") + } + + clearRunID, ok := clearBody["log_run_id"].(float64) + if !ok { + t.Fatalf("log_run_id missing or not number: %#v", clearBody["log_run_id"]) + } + if int(clearRunID) <= previousRunID { + t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID) + } + + logsRec := httptest.NewRecorder() + logsReq := httptest.NewRequest( + http.MethodGet, + "/api/gateway/logs?log_offset=0&log_run_id="+strconv.Itoa(previousRunID), + nil, + ) + mux.ServeHTTP(logsRec, logsReq) + + if logsRec.Code != http.StatusOK { + t.Fatalf("logs code = %d, want %d", logsRec.Code, http.StatusOK) + } + + var logsBody map[string]any + if err := json.Unmarshal(logsRec.Body.Bytes(), &logsBody); err != nil { + t.Fatalf("unmarshal logs response: %v", err) + } + + logs, ok := logsBody["logs"].([]any) + if !ok { + t.Fatalf("logs missing or not array: %#v", logsBody["logs"]) + } + if len(logs) != 0 { + t.Fatalf("logs len = %d, want 0", len(logs)) + } + if got := logsBody["log_total"]; got != float64(0) { + t.Fatalf("log_total = %#v, want 0", got) + } + if got := logsBody["log_run_id"]; got != clearBody["log_run_id"] { + t.Fatalf("log_run_id = %#v, want %#v", got, clearBody["log_run_id"]) + } +} + +func TestFindPicoclawBinary_EnvOverride(t *testing.T) { + // Create a temporary file to act as the mock binary + tmpDir := t.TempDir() + mockBinary := filepath.Join(tmpDir, "picoclaw-mock") + if err := os.WriteFile(mockBinary, []byte("mock"), 0o755); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + t.Setenv("PICOCLAW_BINARY", mockBinary) + + got := utils.FindPicoclawBinary() + if got != mockBinary { + t.Errorf("FindPicoclawBinary() = %q, want %q", got, mockBinary) + } +} + +func TestFindPicoclawBinary_EnvOverride_InvalidPath(t *testing.T) { + // When PICOCLAW_BINARY points to a non-existent path, fall through to next strategy + t.Setenv("PICOCLAW_BINARY", "/nonexistent/picoclaw-binary") + + got := utils.FindPicoclawBinary() + // Should not return the invalid path; falls back to "picoclaw" or another found path + if got == "/nonexistent/picoclaw-binary" { + t.Errorf("FindPicoclawBinary() returned invalid env path %q, expected fallback", got) + } +} diff --git a/web/backend/api/launcher_config.go b/web/backend/api/launcher_config.go new file mode 100644 index 000000000..92911157c --- /dev/null +++ b/web/backend/api/launcher_config.go @@ -0,0 +1,89 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/sipeed/picoclaw/web/backend/launcherconfig" +) + +type launcherConfigPayload struct { + Port int `json:"port"` + Public bool `json:"public"` + AllowedCIDRs []string `json:"allowed_cidrs"` +} + +func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/system/launcher-config", h.handleGetLauncherConfig) + mux.HandleFunc("PUT /api/system/launcher-config", h.handleUpdateLauncherConfig) +} + +func (h *Handler) launcherConfigPath() string { + return launcherconfig.PathForAppConfig(h.configPath) +} + +func (h *Handler) launcherFallbackConfig() launcherconfig.Config { + port := h.serverPort + if port <= 0 { + port = launcherconfig.DefaultPort + } + return launcherconfig.Config{ + Port: port, + Public: h.serverPublic, + AllowedCIDRs: append([]string(nil), h.serverCIDRs...), + } +} + +func (h *Handler) loadLauncherConfig() (launcherconfig.Config, error) { + return launcherconfig.Load(h.launcherConfigPath(), h.launcherFallbackConfig()) +} + +func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request) { + cfg, err := h.loadLauncherConfig() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(launcherConfigPayload{ + Port: cfg.Port, + Public: cfg.Public, + AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), + }) +} + +func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Request) { + var payload launcherConfigPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + cfg, err := h.loadLauncherConfig() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError) + return + } + cfg.Port = payload.Port + cfg.Public = payload.Public + cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...) + cfg.LegacyLauncherToken = "" + if err := launcherconfig.Validate(cfg); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := launcherconfig.Save(h.launcherConfigPath(), cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save launcher config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(launcherConfigPayload{ + Port: cfg.Port, + Public: cfg.Public, + AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), + }) +} diff --git a/web/backend/api/launcher_config_test.go b/web/backend/api/launcher_config_test.go new file mode 100644 index 000000000..68ab1be42 --- /dev/null +++ b/web/backend/api/launcher_config_test.go @@ -0,0 +1,131 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/web/backend/launcherconfig" +) + +func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + h.SetServerOptions(19999, true, false, []string{"192.168.1.0/24"}) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/system/launcher-config", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var got launcherConfigPayload + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if got.Port != 19999 || !got.Public { + t.Fatalf("response = %+v, want port=19999 public=true", got) + } + if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" { + t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs) + } +} + +func TestPutLauncherConfigPersists(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + path := launcherconfig.PathForAppConfig(configPath) + if err := os.WriteFile( + path, + []byte(`{"port":18800,"public":false,"dashboard_password_hash":"saved-hash","launcher_token":"legacy-token"}`), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + h := NewHandler(configPath) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/system/launcher-config", + strings.NewReader( + `{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`, + ), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := launcherconfig.Load(path, launcherconfig.Default()) + if err != nil { + t.Fatalf("launcherconfig.Load() error = %v", err) + } + if cfg.Port != 18080 || !cfg.Public { + t.Fatalf("saved config = %+v, want port=18080 public=true", cfg) + } + if cfg.DashboardPasswordHash != "saved-hash" { + t.Fatalf("saved dashboard_password_hash = %q, want saved-hash", cfg.DashboardPasswordHash) + } + if cfg.LegacyLauncherToken != "" { + t.Fatalf("saved legacy launcher_token = %q, want empty", cfg.LegacyLauncherToken) + } + if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" { + t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs) + } +} + +func TestPutLauncherConfigRejectsInvalidPort(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/system/launcher-config", + strings.NewReader(`{"port":70000,"public":false}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +func TestPutLauncherConfigRejectsInvalidCIDR(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/system/launcher-config", + strings.NewReader(`{"port":18080,"public":false,"allowed_cidrs":["bad-cidr"]}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} diff --git a/cmd/picoclaw-launcher/internal/server/logbuffer.go b/web/backend/api/log.go similarity index 88% rename from cmd/picoclaw-launcher/internal/server/logbuffer.go rename to web/backend/api/log.go index 4d70f6466..f83f6f34c 100644 --- a/cmd/picoclaw-launcher/internal/server/logbuffer.go +++ b/web/backend/api/log.go @@ -1,10 +1,10 @@ -package server +package api import "sync" // LogBuffer is a thread-safe ring buffer that stores the most recent N log lines. // It supports incremental reads via LinesSince and tracks a runID that increments -// on each Reset (used to detect gateway restarts). +// whenever the buffer is reset or cleared so clients can detect log history resets. type LogBuffer struct { mu sync.RWMutex lines []string @@ -45,6 +45,12 @@ func (b *LogBuffer) Reset() { b.runID++ } +// Clear removes all buffered lines and increments the runID so clients treat +// subsequent reads as a new log stream. +func (b *LogBuffer) Clear() { + b.Reset() +} + // LinesSince returns lines appended after the given offset, the current total count, and the runID. // If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned. func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) { @@ -89,11 +95,3 @@ func (b *LogBuffer) RunID() int { return b.runID } - -// Total returns the total number of lines appended in the current run. -func (b *LogBuffer) Total() int { - b.mu.RLock() - defer b.mu.RUnlock() - - return b.total -} diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go new file mode 100644 index 000000000..302231d80 --- /dev/null +++ b/web/backend/api/model_status.go @@ -0,0 +1,683 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "hash/fnv" + "net" + "net/http" + "net/url" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/sync/singleflight" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +const ( + modelProbeTimeout = 800 * time.Millisecond + modelProbeSuccessBaseInterval = 2 * time.Second + modelProbeSuccessMaxInterval = 60 * time.Second + modelProbeFailureBaseInterval = 1 * time.Second + modelProbeFailureMaxInterval = 30 * time.Second + modelProbeBackoffMaxShift = 8 + modelProbeCacheMaxEntries = 1024 + modelProbeCacheEntryTTL = 30 * time.Minute + modelProbeCacheTrimToEntries = modelProbeCacheMaxEntries * 8 / 10 + modelProbeTTLGCInterval = 1 * time.Minute +) + +const ( + modelStatusAvailable = "available" + modelStatusUnconfigured = "unconfigured" + modelStatusUnreachable = "unreachable" +) + +type modelConfigurationSummary struct { + Available bool + Status string +} + +var ( + probeTCPServiceFunc = probeTCPService + probeOllamaModelFunc = probeOllamaModel + probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel + probeCommandAvailableFunc = probeCommandAvailable + modelProbeNowFunc = time.Now + modelProbeState = newModelProbeCacheState() +) + +type modelProbeCacheState struct { + mu sync.RWMutex + cache map[string]*modelProbeCacheEntry + group singleflight.Group + nextTTLGCAt time.Time +} + +type modelProbeCacheEntry struct { + lastResult bool + hasResult bool + successStreak int + failureStreak int + nextProbeAt time.Time + updatedAt time.Time +} + +func newModelProbeCacheState() *modelProbeCacheState { + return &modelProbeCacheState{cache: map[string]*modelProbeCacheEntry{}} +} + +func resetModelProbeCache() { + modelProbeState.resetForTest() +} + +func (s *modelProbeCacheState) resetForTest() { + s.mu.Lock() + defer s.mu.Unlock() + s.cache = map[string]*modelProbeCacheEntry{} + s.nextTTLGCAt = time.Time{} +} + +func hasModelConfiguration(m *config.ModelConfig) bool { + protocol := modelProtocol(m) + authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) + apiKey := strings.TrimSpace(m.APIKey()) + + if authMethod == "oauth" || authMethod == "token" { + if configured, checked := hasStoredOAuthCredential(m); checked { + return configured + } + } + + if authMethod == "" && providerUsesImplicitOAuth(protocol) { + if configured, checked := hasStoredOAuthCredential(m); checked { + return configured + } + } + + if providerUsesAmbientCredentials(protocol) { + return true + } + + if requiresRuntimeProbe(m) { + return true + } + + return apiKey != "" +} + +func hasStoredOAuthCredential(m *config.ModelConfig) (bool, bool) { + provider, ok := oauthProviderForModel(m) + if !ok { + return false, false + } + cred, err := oauthGetCredential(provider) + if err != nil || cred == nil { + return false, true + } + return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "", true +} + +func providerUsesImplicitOAuth(protocol string) bool { + switch protocol { + case "antigravity", "google-antigravity": + return true + default: + return false + } +} + +func providerUsesAmbientCredentials(protocol string) bool { + switch protocol { + case "bedrock": + // Bedrock relies on the AWS SDK credential chain instead of an explicit + // API key stored in ModelConfig. We cannot reliably preflight every AWS + // credential source here, so avoid misclassifying valid environments as + // "unconfigured" and defer concrete credential failures to runtime. + return true + default: + return false + } +} + +func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary { + if !hasModelConfiguration(m) { + return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured} + } + if requiresRuntimeProbe(m) { + if probeLocalModelAvailability(m) { + return modelConfigurationSummary{Available: true, Status: modelStatusAvailable} + } + return modelConfigurationSummary{Available: false, Status: modelStatusUnreachable} + } + return modelConfigurationSummary{Available: true, Status: modelStatusAvailable} +} + +func requiresRuntimeProbe(m *config.ModelConfig) bool { + authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) + if authMethod == "local" { + return true + } + + protocol := modelProtocol(m) + + switch protocol { + case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot": + return true + } + + if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { + apiBase := strings.TrimSpace(m.APIBase) + return apiBase == "" || hasLocalAPIBase(apiBase) + } + + if hasLocalAPIBase(m.APIBase) { + return true + } + + return false +} + +func probeLocalModelAvailability(m *config.ModelConfig) bool { + cacheKey := modelProbeCacheKey(m) + return modelProbeState.probe(cacheKey, func() bool { + return runLocalModelProbe(m) + }) +} + +func (s *modelProbeCacheState) probe(cacheKey string, probeFunc func() bool) bool { + now := modelProbeNowFunc() + if cachedResult, ok := s.getCachedResult(cacheKey, now); ok { + return cachedResult + } + + v, _, _ := s.group.Do(cacheKey, func() (any, error) { + now = modelProbeNowFunc() + if cachedResult, ok := s.getCachedResult(cacheKey, now); ok { + return cachedResult, nil + } + + result := probeFunc() + s.setCachedResult(cacheKey, result, now) + return result, nil + }) + + result, _ := v.(bool) + return result +} + +func runLocalModelProbe(m *config.ModelConfig) bool { + apiBase := modelProbeAPIBase(m) + protocol, modelID := splitModel(m) + switch protocol { + case "ollama": + return probeOllamaModelFunc(apiBase, modelID) + case "vllm", "lmstudio": + return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) + case "github-copilot", "copilot": + return probeTCPServiceFunc(apiBase) + case "claude-cli", "claudecli": + return probeCommandAvailableFunc("claude") + case "codex-cli", "codexcli": + return probeCommandAvailableFunc("codex") + default: + if hasLocalAPIBase(apiBase) { + return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) + } + return false + } +} + +func probeCommandAvailable(command string) bool { + _, err := exec.LookPath(command) + return err == nil +} + +func modelProbeCacheKey(m *config.ModelConfig) string { + protocol, modelID := splitModel(m) + + apiBaseRaw := modelProbeAPIBase(m) + apiBase := strings.ToLower(strings.TrimRight(strings.TrimSpace(apiBaseRaw), "/")) + apiKeyFingerprint := modelProbeAPIKeyFingerprint(m.APIKey()) + + var b strings.Builder + b.Grow(len(protocol) + len(modelID) + len(apiBase) + len(apiKeyFingerprint) + 8) + b.WriteString(protocol) + b.WriteByte('|') + b.WriteString(modelID) + b.WriteByte('|') + b.WriteString(apiBase) + b.WriteByte('|') + b.WriteString(apiKeyFingerprint) + + return b.String() +} + +func modelProbeAPIKeyFingerprint(raw string) string { + apiKey := strings.TrimSpace(raw) + if apiKey == "" { + return "none" + } + + h := fnv.New64a() + _, _ = h.Write([]byte(apiKey)) + return strconv.FormatUint(h.Sum64(), 36) +} + +func (s *modelProbeCacheState) getCachedResult(cacheKey string, now time.Time) (bool, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + entry, ok := s.cache[cacheKey] + if !ok || !entry.hasResult { + return false, false + } + if now.Before(entry.nextProbeAt) { + return entry.lastResult, true + } + return false, false +} + +func (s *modelProbeCacheState) setCachedResult(cacheKey string, result bool, now time.Time) { + s.mu.Lock() + + entry, ok := s.cache[cacheKey] + if !ok { + entry = &modelProbeCacheEntry{} + s.cache[cacheKey] = entry + } + + entry.lastResult = result + entry.hasResult = true + entry.updatedAt = now + + var delay time.Duration + if result { + entry.successStreak++ + entry.failureStreak = 0 + delay = modelProbeBackoffDelay( + modelProbeSuccessBaseInterval, + modelProbeSuccessMaxInterval, + entry.successStreak, + ) + } else { + entry.failureStreak++ + entry.successStreak = 0 + delay = modelProbeBackoffDelay( + modelProbeFailureBaseInterval, + modelProbeFailureMaxInterval, + entry.failureStreak, + ) + } + + entry.nextProbeAt = now.Add(delay) + + shouldRunTTLGC := modelProbeCacheEntryTTL > 0 && (s.nextTTLGCAt.IsZero() || !now.Before(s.nextTTLGCAt)) + if shouldRunTTLGC { + s.nextTTLGCAt = now.Add(modelProbeTTLGCInterval) + } + shouldRunSizeGC := len(s.cache) > modelProbeCacheMaxEntries + s.mu.Unlock() + + if shouldRunTTLGC || shouldRunSizeGC { + s.gc(now, shouldRunTTLGC) + } +} + +func (s *modelProbeCacheState) gc(now time.Time, runTTL bool) { + type evictionCandidate struct { + key string + updatedAt time.Time + } + + var expireBefore time.Time + if runTTL && modelProbeCacheEntryTTL > 0 { + expireBefore = now.Add(-modelProbeCacheEntryTTL) + } + + s.mu.RLock() + cacheLen := len(s.cache) + if cacheLen == 0 { + s.mu.RUnlock() + return + } + + expiredKeys := make([]string, 0) + if !expireBefore.IsZero() { + expiredKeys = make([]string, 0, min(cacheLen/8+1, 64)) + for key, entry := range s.cache { + if entry.updatedAt.Before(expireBefore) { + expiredKeys = append(expiredKeys, key) + } + } + } + + effectiveLen := cacheLen - len(expiredKeys) + removeCount := max(effectiveLen-modelProbeCacheTrimToEntries, 0) + + candidates := make([]evictionCandidate, 0) + if removeCount > 0 { + candidates = make([]evictionCandidate, 0, effectiveLen) + for key, entry := range s.cache { + if !expireBefore.IsZero() && entry.updatedAt.Before(expireBefore) { + continue + } + candidates = append(candidates, evictionCandidate{key: key, updatedAt: entry.updatedAt}) + } + } + s.mu.RUnlock() + + if len(expiredKeys) == 0 && len(candidates) == 0 { + return + } + + toEvict := map[string]time.Time{} + for i := 0; i < removeCount && len(candidates) > 0; i++ { + oldest := 0 + for j := 1; j < len(candidates); j++ { + if candidates[j].updatedAt.Before(candidates[oldest].updatedAt) { + oldest = j + } + } + victim := candidates[oldest] + toEvict[victim.key] = victim.updatedAt + candidates[oldest] = candidates[len(candidates)-1] + candidates = candidates[:len(candidates)-1] + } + + s.mu.Lock() + defer s.mu.Unlock() + + if !expireBefore.IsZero() { + for _, key := range expiredKeys { + entry, ok := s.cache[key] + if ok && entry.updatedAt.Before(expireBefore) { + delete(s.cache, key) + } + } + } + + for key, victimUpdatedAt := range toEvict { + entry, ok := s.cache[key] + if ok && !entry.updatedAt.After(victimUpdatedAt) { + delete(s.cache, key) + } + } +} + +func modelProbeBackoffDelay(base, maxDelay time.Duration, streak int) time.Duration { + if streak <= 0 { + streak = 1 + } + + shift := min(streak-1, modelProbeBackoffMaxShift) + + delay := base * time.Duration(1<<shift) + if maxDelay > 0 && (delay > maxDelay || delay < 0) { + return maxDelay + } + if delay <= 0 { + return base + } + return delay +} + +func modelProbeAPIBase(m *config.ModelConfig) string { + if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" { + return normalizeModelProbeAPIBase(apiBase) + } + + protocol := modelProtocol(m) + if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { + return providers.DefaultAPIBaseForProtocol(protocol) + } + + switch protocol { + case "github-copilot", "copilot": + return "localhost:4321" + default: + return "" + } +} + +func normalizeModelProbeAPIBase(raw string) string { + u, err := parseAPIBase(raw) + if err != nil { + return strings.TrimSpace(raw) + } + + switch strings.ToLower(u.Hostname()) { + case "0.0.0.0": + u.Host = net.JoinHostPort("127.0.0.1", u.Port()) + case "::": + u.Host = net.JoinHostPort("::1", u.Port()) + default: + return strings.TrimSpace(raw) + } + + if u.Port() == "" { + u.Host = u.Hostname() + } + + return u.String() +} + +func oauthProviderForModel(m *config.ModelConfig) (string, bool) { + switch modelProtocol(m) { + case "openai": + return oauthProviderOpenAI, true + case "anthropic": + return oauthProviderAnthropic, true + case "antigravity", "google-antigravity": + return oauthProviderGoogleAntigravity, true + default: + return "", false + } +} + +func modelProtocol(m *config.ModelConfig) string { + protocol, _ := splitModel(m) + return protocol +} + +func splitModel(m *config.ModelConfig) (protocol, modelID string) { + protocol, modelID = providers.ExtractProtocol(m) + return strings.ToLower(strings.TrimSpace(protocol)), strings.ToLower(strings.TrimSpace(modelID)) +} + +func hasLocalAPIBase(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" { + return false + } + + u, err := url.Parse(raw) + if err != nil || u.Hostname() == "" { + u, err = url.Parse("//" + raw) + if err != nil { + return false + } + } + + switch strings.ToLower(u.Hostname()) { + case "localhost", "127.0.0.1", "::1", "0.0.0.0": + return true + default: + return false + } +} + +func probeTCPService(raw string) bool { + hostPort, err := hostPortFromAPIBase(raw) + if err != nil { + return false + } + + ctx, cancel := context.WithTimeout(context.Background(), modelProbeTimeout) + defer cancel() + + dialer := &net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", hostPort) + if err != nil { + return false + } + _ = conn.Close() + return true +} + +func probeOllamaModel(apiBase, modelID string) bool { + root, err := apiRootFromAPIBase(apiBase) + if err != nil { + return false + } + + var resp struct { + Models []struct { + Name string `json:"name"` + Model string `json:"model"` + } `json:"models"` + } + if err := getJSON(root+"/api/tags", &resp, ""); err != nil { + return false + } + + for _, model := range resp.Models { + if ollamaModelMatches(model.Name, modelID) || ollamaModelMatches(model.Model, modelID) { + return true + } + } + return false +} + +func probeOpenAICompatibleModel(apiBase, modelID, apiKey string) bool { + if strings.TrimSpace(apiBase) == "" { + return false + } + + var resp struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := getJSON(strings.TrimRight(strings.TrimSpace(apiBase), "/")+"/models", &resp, apiKey); err != nil { + return false + } + + for _, model := range resp.Data { + if strings.EqualFold(strings.TrimSpace(model.ID), modelID) { + return true + } + } + return false +} + +func getJSON(rawURL string, out any, apiKey string) error { + ctx, cancel := context.WithTimeout(context.Background(), modelProbeTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return err + } + if apiKey = strings.TrimSpace(apiKey); apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + return json.NewDecoder(resp.Body).Decode(out) +} + +func apiRootFromAPIBase(raw string) (string, error) { + u, err := parseAPIBase(raw) + if err != nil { + return "", err + } + return (&url.URL{Scheme: u.Scheme, Host: u.Host}).String(), nil +} + +func hostPortFromAPIBase(raw string) (string, error) { + u, err := parseAPIBase(raw) + if err != nil { + return "", err + } + + if port := u.Port(); port != "" { + return u.Host, nil + } + switch strings.ToLower(u.Scheme) { + case "https": + return net.JoinHostPort(u.Hostname(), "443"), nil + default: + return net.JoinHostPort(u.Hostname(), "80"), nil + } +} + +func parseAPIBase(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("empty api base") + } + + u, err := url.Parse(raw) + if err == nil && u.Hostname() != "" { + return u, nil + } + + u, err = url.Parse("//" + raw) + if err != nil || u.Hostname() == "" { + return nil, fmt.Errorf("invalid api base %q", raw) + } + if u.Scheme == "" { + u.Scheme = "http" + } + return u, nil +} + +func ollamaModelMatches(candidate, want string) bool { + candidate = strings.TrimSpace(candidate) + want = strings.TrimSpace(want) + if candidate == "" || want == "" { + return false + } + + candidateBase, candidateTag := splitOllamaModel(candidate) + wantBase, wantTag := splitOllamaModel(want) + if candidateBase == "" || wantBase == "" { + return false + } + + if candidateTag == "" { + candidateTag = "latest" + } + if wantTag == "" { + wantTag = "latest" + } + + return strings.EqualFold(candidateBase, wantBase) && strings.EqualFold(candidateTag, wantTag) +} + +func splitOllamaModel(raw string) (base, tag string) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "" + } + + base, tag, _ = strings.Cut(raw, ":") + return strings.TrimSpace(base), strings.TrimSpace(tag) +} diff --git a/web/backend/api/model_status_test.go b/web/backend/api/model_status_test.go new file mode 100644 index 000000000..d5463a856 --- /dev/null +++ b/web/backend/api/model_status_test.go @@ -0,0 +1,394 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T) { + const apiKey = "test-api-key" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Fatalf("path = %q, want %q", r.URL.Path, "/v1/models") + } + if got := r.Header.Get("Authorization"); got != "Bearer "+apiKey { + http.Error(w, "missing auth", http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"custom-model"}]}`)) + })) + defer srv.Close() + + model := &config.ModelConfig{ + Model: "openai/custom-model", + APIBase: srv.URL + "/v1", + } + model.SetAPIKey(apiKey) + + if !probeLocalModelAvailability(model) { + t.Fatal("probeLocalModelAvailability() = false, want true when api_key is configured") + } +} + +func TestRequiresRuntimeProbe_LMStudio(t *testing.T) { + if !requiresRuntimeProbe(&config.ModelConfig{ + Model: "lmstudio/openai/gpt-oss-20b", + }) { + t.Fatal("requiresRuntimeProbe(lmstudio with default base) = false, want true") + } + + if requiresRuntimeProbe(&config.ModelConfig{ + Model: "lmstudio/openai/gpt-oss-20b", + APIBase: "https://api.example.com/v1", + }) { + t.Fatal("requiresRuntimeProbe(lmstudio with remote base) = true, want false") + } +} + +func TestModelProbeAPIBase_LMStudioDefault(t *testing.T) { + got := modelProbeAPIBase(&config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"}) + if got != "http://localhost:1234/v1" { + t.Fatalf("modelProbeAPIBase(lmstudio) = %q, want %q", got, "http://localhost:1234/v1") + } +} + +func TestProbeLocalModelAvailability_LMStudioUsesOpenAICompatibleProbe(t *testing.T) { + originalProbe := probeOpenAICompatibleModelFunc + defer func() { probeOpenAICompatibleModelFunc = originalProbe }() + + called := false + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + called = true + if apiBase != "http://localhost:1234/v1" { + t.Fatalf("apiBase = %q, want %q", apiBase, "http://localhost:1234/v1") + } + if modelID != "openai/gpt-oss-20b" { + t.Fatalf("modelID = %q, want %q", modelID, "openai/gpt-oss-20b") + } + if apiKey != "" { + t.Fatalf("apiKey = %q, want empty", apiKey) + } + return true + } + + model := &config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"} + if !probeLocalModelAvailability(model) { + t.Fatal("probeLocalModelAvailability(lmstudio) = false, want true") + } + if !called { + t.Fatal("probeOpenAICompatibleModelFunc was not called for lmstudio") + } +} + +func TestModelProbeCacheKey_DifferentAPIKeysProduceDifferentKeys(t *testing.T) { + base := &config.ModelConfig{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + AuthMethod: "local", + ConnectMode: "", + } + + m1 := *base + m1.SetAPIKey("key-a") + m2 := *base + m2.SetAPIKey("key-b") + + k1 := modelProbeCacheKey(&m1) + k2 := modelProbeCacheKey(&m2) + if k1 == k2 { + t.Fatal("modelProbeCacheKey() should differ when api key changes") + } +} + +func TestModelProbeCacheKey_NormalizesTrailingSlashInAPIBase(t *testing.T) { + m1 := &config.ModelConfig{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + } + m2 := &config.ModelConfig{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1/", + } + + k1 := modelProbeCacheKey(m1) + k2 := modelProbeCacheKey(m2) + if k1 != k2 { + t.Fatalf("modelProbeCacheKey() mismatch for equivalent api_base values: %q vs %q", k1, k2) + } +} + +func TestModelProbeCacheKey_IgnoresDisplayAndConnectionFields(t *testing.T) { + base := &config.ModelConfig{ + ModelName: "vllm-one", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + AuthMethod: "none", + ConnectMode: "http", + } + changed := &config.ModelConfig{ + ModelName: "vllm-two", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + AuthMethod: "token", + ConnectMode: "ws", + } + + k1 := modelProbeCacheKey(base) + k2 := modelProbeCacheKey(changed) + if k1 != k2 { + t.Fatalf("modelProbeCacheKey() should ignore non-probe fields, got %q vs %q", k1, k2) + } +} + +func TestProbeLocalModelAvailability_SuccessBackoff(t *testing.T) { + resetModelProbeHooks(t) + + now := time.Unix(1700000000, 0) + modelProbeNowFunc = func() time.Time { return now } + + calls := 0 + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + calls++ + return true + } + + model := &config.ModelConfig{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + } + + if !probeLocalModelAvailability(model) { + t.Fatal("first probe result = false, want true") + } + if calls != 1 { + t.Fatalf("probe calls after first probe = %d, want 1", calls) + } + + if !probeLocalModelAvailability(model) { + t.Fatal("cached probe result = false, want true") + } + if calls != 1 { + t.Fatalf("probe calls after immediate re-check = %d, want 1", calls) + } + + now = now.Add(modelProbeSuccessBaseInterval) + if !probeLocalModelAvailability(model) { + t.Fatal("second probe result = false, want true") + } + if calls != 2 { + t.Fatalf("probe calls after success backoff window = %d, want 2", calls) + } + + now = now.Add(modelProbeSuccessBaseInterval) + if !probeLocalModelAvailability(model) { + t.Fatal("cached result after doubled backoff = false, want true") + } + if calls != 2 { + t.Fatalf("probe calls before doubled backoff expires = %d, want 2", calls) + } + + now = now.Add(modelProbeSuccessBaseInterval) + if !probeLocalModelAvailability(model) { + t.Fatal("third probe result = false, want true") + } + if calls != 3 { + t.Fatalf("probe calls after doubled backoff expires = %d, want 3", calls) + } +} + +func TestProbeLocalModelAvailability_FailureBackoff(t *testing.T) { + resetModelProbeHooks(t) + + now := time.Unix(1700000100, 0) + modelProbeNowFunc = func() time.Time { return now } + + calls := 0 + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + calls++ + return false + } + + model := &config.ModelConfig{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + } + + if probeLocalModelAvailability(model) { + t.Fatal("first probe result = true, want false") + } + if calls != 1 { + t.Fatalf("probe calls after first failure = %d, want 1", calls) + } + + if probeLocalModelAvailability(model) { + t.Fatal("cached failed probe result = true, want false") + } + if calls != 1 { + t.Fatalf("probe calls after immediate failed re-check = %d, want 1", calls) + } + + now = now.Add(modelProbeFailureBaseInterval) + if probeLocalModelAvailability(model) { + t.Fatal("second failed probe result = true, want false") + } + if calls != 2 { + t.Fatalf("probe calls after failure backoff window = %d, want 2", calls) + } + + now = now.Add(modelProbeFailureBaseInterval) + if probeLocalModelAvailability(model) { + t.Fatal("cached failure after doubled backoff = true, want false") + } + if calls != 2 { + t.Fatalf("probe calls before doubled failure backoff expires = %d, want 2", calls) + } + + now = now.Add(modelProbeFailureBaseInterval) + if probeLocalModelAvailability(model) { + t.Fatal("third failed probe result = true, want false") + } + if calls != 3 { + t.Fatalf("probe calls after doubled failure backoff expires = %d, want 3", calls) + } +} + +func TestProbeLocalModelAvailability_ResultFlipResetsBackoff(t *testing.T) { + resetModelProbeHooks(t) + + now := time.Unix(1700000200, 0) + modelProbeNowFunc = func() time.Time { return now } + + results := []bool{true, false, false} + index := 0 + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + if index >= len(results) { + return false + } + result := results[index] + index++ + return result + } + + model := &config.ModelConfig{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + } + + if !probeLocalModelAvailability(model) { + t.Fatal("first probe result = false, want true") + } + + now = now.Add(modelProbeSuccessBaseInterval) + if probeLocalModelAvailability(model) { + t.Fatal("second probe result = true, want false") + } + + now = now.Add(modelProbeFailureBaseInterval) + if probeLocalModelAvailability(model) { + t.Fatal("third probe result = true, want false") + } + + if index != 3 { + t.Fatalf("probe invocations = %d, want 3", index) + } +} + +func TestProbeLocalModelAvailability_DeduplicatesInflightProbe(t *testing.T) { + resetModelProbeHooks(t) + + now := time.Unix(1700000300, 0) + modelProbeNowFunc = func() time.Time { return now } + + var calls int32 + probeStarted := make(chan struct{}) + releaseProbe := make(chan struct{}) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + if atomic.AddInt32(&calls, 1) == 1 { + close(probeStarted) + } + <-releaseProbe + return true + } + + model := &config.ModelConfig{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + } + + const workers = 8 + var wg sync.WaitGroup + results := make(chan bool, workers) + workerStarted := make(chan struct{}, workers) + + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + workerStarted <- struct{}{} + results <- probeLocalModelAvailability(model) + }() + } + + for range workers { + <-workerStarted + } + + select { + case <-probeStarted: + case <-time.After(200 * time.Millisecond): + t.Fatal("probe did not start in time") + } + + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("concurrent probe calls = %d, want 1", got) + } + + close(releaseProbe) + wg.Wait() + close(results) + + for result := range results { + if !result { + t.Fatal("deduplicated probe result = false, want true") + } + } + + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("final probe calls = %d, want 1", got) + } +} + +func TestOllamaModelMatches_WithTagRequiresExactTag(t *testing.T) { + if ollamaModelMatches("llama3:8b", "llama3:7b") { + t.Fatal("ollamaModelMatches() = true, want false for mismatched tags") + } + if !ollamaModelMatches("llama3:7b", "llama3:7b") { + t.Fatal("ollamaModelMatches() = false, want true for exact tagged match") + } + if ollamaModelMatches("llama3:8b", "llama3") { + t.Fatal("ollamaModelMatches() = true, want false when request omits tag (defaults to latest)") + } + if !ollamaModelMatches("llama3:latest", "llama3") { + t.Fatal("ollamaModelMatches() = false, want true when request omits tag and candidate is latest") + } + if !ollamaModelMatches("llama3", "llama3") { + t.Fatal("ollamaModelMatches() = false, want true when both candidate and request omit tag (latest)") + } +} diff --git a/web/backend/api/models.go b/web/backend/api/models.go new file mode 100644 index 000000000..8a66918f9 --- /dev/null +++ b/web/backend/api/models.go @@ -0,0 +1,616 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/audio/asr" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// registerModelRoutes binds model list management endpoints to the ServeMux. +func (h *Handler) registerModelRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/models", h.handleListModels) + mux.HandleFunc("POST /api/models", h.handleAddModel) + mux.HandleFunc("POST /api/models/default", h.handleSetDefaultModel) + mux.HandleFunc("PUT /api/models/{index}", h.handleUpdateModel) + mux.HandleFunc("DELETE /api/models/{index}", h.handleDeleteModel) +} + +// modelResponse is the JSON structure returned for each model in the list. +// All ModelConfig fields are included so the frontend can display and edit them. +type modelResponse struct { + Index int `json:"index"` + ModelName string `json:"model_name"` + Provider string `json:"provider,omitempty"` + Model string `json:"model"` + APIBase string `json:"api_base,omitempty"` + APIKey string `json:"api_key"` + Proxy string `json:"proxy,omitempty"` + AuthMethod string `json:"auth_method,omitempty"` + // Advanced fields + ConnectMode string `json:"connect_mode,omitempty"` + Workspace string `json:"workspace,omitempty"` + RPM int `json:"rpm,omitempty"` + MaxTokensField string `json:"max_tokens_field,omitempty"` + RequestTimeout int `json:"request_timeout,omitempty"` + ThinkingLevel string `json:"thinking_level,omitempty"` + ToolSchemaTransform string `json:"tool_schema_transform,omitempty"` + ExtraBody map[string]any `json:"extra_body,omitempty"` + CustomHeaders map[string]string `json:"custom_headers,omitempty"` + // Meta + Enabled bool `json:"enabled"` + Available bool `json:"available"` + Status string `json:"status"` + IsDefault bool `json:"is_default"` + IsVirtual bool `json:"is_virtual"` + DefaultModelAllowed bool `json:"default_model_allowed"` +} + +func normalizeStoredModelConfig(mc *config.ModelConfig) bool { + if mc == nil { + return false + } + + changed := false + model := strings.TrimSpace(mc.Model) + if model != mc.Model { + mc.Model = model + changed = true + } + provider := strings.TrimSpace(mc.Provider) + if provider != mc.Provider { + mc.Provider = provider + changed = true + } + authMethod := strings.ToLower(strings.TrimSpace(mc.AuthMethod)) + if authMethod != mc.AuthMethod { + mc.AuthMethod = authMethod + changed = true + } + + if provider != "" { + normalizedProvider := providers.NormalizeProvider(provider) + if providers.IsSupportedModelProvider(normalizedProvider) && normalizedProvider != provider { + mc.Provider = normalizedProvider + changed = true + } + if mc.Provider == "elevenlabs" { + if _, strippedModel, found := strings.Cut( + model, + "/", + ); found && + providers.NormalizeProvider(strings.TrimSpace(provider)) == "elevenlabs" { + strippedModel = strings.TrimSpace(strippedModel) + if strippedModel != "" && strippedModel != mc.Model { + mc.Model = strippedModel + changed = true + } + } + if strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() { + mc.Model = asr.ElevenLabsSupportedModelID() + changed = true + } + } + return changed + } + + effectiveProvider, modelID := providers.SplitModelProviderAndID(model, "openai") + if effectiveProvider == "" { + return changed + } + if mc.Provider != effectiveProvider { + mc.Provider = effectiveProvider + changed = true + } + if mc.Model != modelID { + mc.Model = modelID + changed = true + } + return changed +} + +func normalizeIncomingModelConfig(mc *config.ModelConfig) { + if mc == nil { + return + } + + mc.Model = strings.TrimSpace(mc.Model) + mc.Provider = strings.TrimSpace(mc.Provider) + mc.AuthMethod = strings.ToLower(strings.TrimSpace(mc.AuthMethod)) + if mc.Provider == "" { + mc.Provider, mc.Model = providers.SplitModelProviderAndID(mc.Model, "openai") + } else { + mc.Provider = providers.NormalizeProvider(mc.Provider) + if mc.Provider == "elevenlabs" { + if _, strippedModel, found := strings.Cut(mc.Model, "/"); found { + strippedModel = strings.TrimSpace(strippedModel) + if strippedModel != "" { + mc.Model = strippedModel + } + } + } + } + if mc.Provider == "antigravity" && mc.AuthMethod == "" { + mc.AuthMethod = "oauth" + } +} + +func createAllowedForProvider(provider string) bool { + normalized := providers.NormalizeProvider(provider) + switch normalized { + case "bedrock": + // Bedrock currently authenticates through the AWS SDK credential chain + // (env vars, shared profiles, IAM roles, etc.), and this Web layer does + // not yet have a reliable preflight check for those credential sources. + // Keep it creatable in the catalog and let provider construction/runtime + // return the concrete AWS error when the environment is incomplete. + return true + case "claude-cli", "codex-cli": + return cliProviderCreateAllowedFromCurrentStatus(normalized) + default: + return providers.IsCreatableModelProvider(normalized) + } +} + +// cliProviderCreateAllowedFromCurrentStatus intentionally reuses the existing +// local model status pipeline so provider catalog gating follows the same CLI +// executable probe used by launcher readiness. +func cliProviderCreateAllowedFromCurrentStatus(provider string) bool { + status := modelConfigurationStatus(&config.ModelConfig{ + Provider: provider, + Model: provider, + }) + return status.Available +} + +func modelProviderOptionsForResponse() []providers.ModelProviderOption { + options := providers.ModelProviderOptions() + for i := range options { + options[i].CreateAllowed = createAllowedForProvider(options[i].ID) + } + return options +} + +func defaultModelAllowedForModelConfig(mc *config.ModelConfig) bool { + provider, _ := providers.ExtractProtocol(mc) + return providers.IsDefaultModelProvider(provider) +} + +func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelConfig) error { + if mc == nil { + return fmt.Errorf("model config is required") + } + if err := mc.Validate(); err != nil { + return err + } + if strings.TrimSpace(mc.Provider) == "" { + return fmt.Errorf("provider is required") + } + if !providers.IsSupportedModelProvider(mc.Provider) { + return fmt.Errorf("provider %q is not supported", mc.Provider) + } + if mc.Provider == "elevenlabs" && strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() { + return fmt.Errorf("provider %q only supports model %q", mc.Provider, asr.ElevenLabsSupportedModelID()) + } + if !createAllowedForProvider(mc.Provider) { + if existing == nil { + return fmt.Errorf("provider %q is not available for new models", mc.Provider) + } + existingProvider, _ := providers.ExtractProtocol(existing) + if providers.NormalizeProvider(existingProvider) != mc.Provider { + return fmt.Errorf("provider %q is not available for selection", mc.Provider) + } + } + return nil +} + +func normalizeStoredModelProviders(cfg *config.Config) bool { + if cfg == nil { + return false + } + + changed := false + for _, model := range cfg.ModelList { + if normalizeStoredModelConfig(model) { + changed = true + } + } + return changed +} + +// handleListModels returns all model_list entries with masked API keys. +// +// GET /api/models +func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + // Normalize legacy provider/model storage in memory so GET can round-trip + // through the current API shape without mutating the on-disk config. + normalizeStoredModelProviders(cfg) + + defaultModel := cfg.Agents.Defaults.GetModelName() + modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList)) + + var wg sync.WaitGroup + wg.Add(len(cfg.ModelList)) + for i, m := range cfg.ModelList { + go func(i int, m *config.ModelConfig) { + defer wg.Done() + modelStatuses[i] = modelConfigurationStatus(m) + }(i, m) + } + wg.Wait() + + models := make([]modelResponse, 0, len(cfg.ModelList)) + for i, m := range cfg.ModelList { + provider, modelID := providers.ExtractProtocol(m) + models = append(models, modelResponse{ + Index: i, + ModelName: m.ModelName, + Provider: provider, + Model: modelID, + APIBase: m.APIBase, + APIKey: maskAPIKey(m.APIKey()), + Proxy: m.Proxy, + AuthMethod: m.AuthMethod, + ConnectMode: m.ConnectMode, + Workspace: m.Workspace, + RPM: m.RPM, + MaxTokensField: m.MaxTokensField, + RequestTimeout: m.RequestTimeout, + ThinkingLevel: m.ThinkingLevel, + ToolSchemaTransform: m.ToolSchemaTransform, + ExtraBody: m.ExtraBody, + CustomHeaders: m.CustomHeaders, + Enabled: m.Enabled, + Available: modelStatuses[i].Available, + Status: modelStatuses[i].Status, + IsDefault: m.ModelName == defaultModel, + IsVirtual: m.IsVirtual(), + DefaultModelAllowed: defaultModelAllowedForModelConfig(m), + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "models": models, + "total": len(models), + "default_model": defaultModel, + "provider_options": modelProviderOptionsForResponse(), + }) +} + +// handleAddModel appends a new model configuration entry. +// +// POST /api/models +func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + type custom struct { + config.ModelConfig + APIKey string `json:"api_key"` + } + + var mc custom + if err = json.Unmarshal(body, &mc); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + normalizeIncomingModelConfig(&mc.ModelConfig) + + if err = validateIncomingModelConfig(&mc.ModelConfig, nil); err != nil { + http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) + return + } + + if mc.APIKey != "" { + mc.ModelConfig.SetAPIKey(mc.APIKey) + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + cfg.ModelList = append(cfg.ModelList, &mc.ModelConfig) + normalizeStoredModelProviders(cfg) + + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "index": len(cfg.ModelList) - 1, + }) +} + +// handleUpdateModel replaces a model configuration entry at the given index. +// If the request body omits api_key (or sends an empty string), the existing +// stored key is preserved so callers can update only api_base / proxy without +// exposing or clearing the secret. +// +// PUT /api/models/{index} +func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { + idx, err := strconv.Atoi(r.PathValue("index")) + if err != nil { + http.Error(w, "Invalid index", http.StatusBadRequest) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var rawFields map[string]json.RawMessage + if err = json.Unmarshal(body, &rawFields); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + type custom struct { + config.ModelConfig + APIKey string `json:"api_key"` + } + + var mc custom + if err = json.Unmarshal(body, &mc); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + if idx < 0 || idx >= len(cfg.ModelList) { + http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound) + return + } + + // Preserve the existing API key when the caller omits it (empty string). + // This lets the UI update api_base / proxy without clearing the stored secret. + if mc.APIKey == "" { + mc.ModelConfig.SetAPIKey(cfg.ModelList[idx].APIKey()) + } else { + mc.ModelConfig.SetAPIKey(mc.APIKey) + } + // Preserve existing ExtraBody when omitted (nil), but clear it when + // the frontend sends an empty object {} to indicate the field should + // be removed. + if mc.ExtraBody == nil { + mc.ExtraBody = cfg.ModelList[idx].ExtraBody + } else if len(mc.ExtraBody) == 0 { + mc.ExtraBody = nil + } + // Preserve existing CustomHeaders when omitted (nil), but clear it when + // the frontend sends an empty object {} to indicate the field should + // be removed. + if mc.CustomHeaders == nil { + mc.CustomHeaders = cfg.ModelList[idx].CustomHeaders + } else if len(mc.CustomHeaders) == 0 { + mc.CustomHeaders = nil + } + if _, ok := rawFields["tool_schema_transform"]; !ok { + mc.ToolSchemaTransform = cfg.ModelList[idx].ToolSchemaTransform + } + // Preserve the existing Provider when the caller omits it. This keeps the + // update API backward-compatible for clients that haven't started sending + // the new field yet, while still allowing explicit clearing via "". + if _, ok := rawFields["provider"]; !ok { + mc.Provider = cfg.ModelList[idx].Provider + // Older clients still round-trip the legacy model field only. When the + // stored config encodes provider/model in Model and has no explicit + // Provider field yet, continue preserving that hidden provider prefix. + // This keeps provider-omitted updates backward-compatible even when an + // older client edits the visible model ID. + if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" { + existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model) + incomingModel := strings.TrimSpace(mc.Model) + existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx]) + if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" { + if incomingModel == existingModelID { + mc.Model = existingRawModel + } else if strings.Contains(incomingModel, "/") && !strings.Contains(existingModelID, "/") { + // Older clients never saw the hidden provider prefix for simple + // legacy entries such as "openai/gpt-4o". If they now send an + // explicit provider/model string, treat it as the caller's full + // intent instead of re-applying the old hidden prefix. + mc.Model = incomingModel + } else if !strings.HasPrefix(incomingModel, existingProtocol+"/") { + mc.Model = existingProtocol + "/" + incomingModel + } + } + } + } + + normalizeIncomingModelConfig(&mc.ModelConfig) + if err = validateIncomingModelConfig(&mc.ModelConfig, cfg.ModelList[idx]); err != nil { + http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) + return + } + if cfg.Agents.Defaults.ModelName == cfg.ModelList[idx].ModelName && + !defaultModelAllowedForModelConfig(&mc.ModelConfig) { + // Allow users to recover from legacy/invalid defaults by saving the model + // and clearing the default chat model reference in the same write. + cfg.Agents.Defaults.ModelName = "" + } + + cfg.ModelList[idx] = &mc.ModelConfig + normalizeStoredModelProviders(cfg) + + logger.Debugf("update model config: %#v", mc.ModelConfig) + + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// handleDeleteModel removes a model configuration entry at the given index. +// +// DELETE /api/models/{index} +func (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) { + idx, err := strconv.Atoi(r.PathValue("index")) + if err != nil { + http.Error(w, "Invalid index", http.StatusBadRequest) + return + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + if idx < 0 || idx >= len(cfg.ModelList) { + http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound) + return + } + + deletedModelName := cfg.ModelList[idx].ModelName + + cfg.ModelList = append(cfg.ModelList[:idx], cfg.ModelList[idx+1:]...) + + // If the deleted model was the default, clear it. + if cfg.Agents.Defaults.ModelName == deletedModelName { + cfg.Agents.Defaults.ModelName = "" + } + + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// handleSetDefaultModel sets the default model for all agents. +// +// POST /api/models/default +func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var req struct { + ModelName string `json:"model_name"` + } + if err = json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if req.ModelName == "" { + http.Error(w, "model_name is required", http.StatusBadRequest) + return + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + // Verify the model_name exists in model_list and is not a virtual model + found := false + isVirtual := false + for _, m := range cfg.ModelList { + if m.ModelName == req.ModelName { + found = true + isVirtual = m.IsVirtual() + break + } + } + if !found { + http.Error(w, fmt.Sprintf("Model %q not found in model_list", req.ModelName), http.StatusNotFound) + return + } + if isVirtual { + http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest) + return + } + for _, m := range cfg.ModelList { + if m.ModelName == req.ModelName { + if !defaultModelAllowedForModelConfig(m) { + http.Error( + w, + fmt.Sprintf("Model %q cannot be used as the default chat model", req.ModelName), + http.StatusBadRequest, + ) + return + } + break + } + } + + cfg.Agents.Defaults.ModelName = req.ModelName + + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "default_model": req.ModelName, + }) +} + +// maskAPIKey returns a masked version of an API key for safe display. +// Keys longer than 12 chars show prefix + last 4 chars: "sk-****abcd". +// Keys 9-12 chars show prefix + last 2 chars: "sk-****cd". +// Shorter keys are fully masked as "****". +// Empty keys return empty string. +// Ensure at least 40% of the key will not be displayed. +func maskAPIKey(key string) string { + if key == "" { + return "" + } + + if len(key) <= 8 { + return "****" + } + + // Show first 3 chars and last 2 chars + if len(key) <= 12 { + return key[:3] + "****" + key[len(key)-2:] + } + + // Show first 3 chars and last 4 chars + return key[:3] + "****" + key[len(key)-4:] +} diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go new file mode 100644 index 000000000..0b1f04848 --- /dev/null +++ b/web/backend/api/models_test.go @@ -0,0 +1,2221 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func resetModelProbeHooks(t *testing.T) { + t.Helper() + + origTCPProbe := probeTCPServiceFunc + origOllamaProbe := probeOllamaModelFunc + origOpenAIProbe := probeOpenAICompatibleModelFunc + origCommandProbe := probeCommandAvailableFunc + origNow := modelProbeNowFunc + resetModelProbeCache() + t.Cleanup(func() { + probeTCPServiceFunc = origTCPProbe + probeOllamaModelFunc = origOllamaProbe + probeOpenAICompatibleModelFunc = origOpenAIProbe + probeCommandAvailableFunc = origCommandProbe + modelProbeNowFunc = origNow + resetModelProbeCache() + }) +} + +func addModelAndLoadLatest(t *testing.T, configPath string, body string) *config.ModelConfig { + t.Helper() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if len(cfg.ModelList) == 0 { + t.Fatal("model_list should contain the newly added model") + } + + return cfg.ModelList[len(cfg.ModelList)-1] +} + +func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + var mu sync.Mutex + var openAIProbes []string + var ollamaProbes []string + var tcpProbes []string + + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + mu.Lock() + openAIProbes = append(openAIProbes, apiBase+"|"+modelID+"|"+apiKey) + mu.Unlock() + return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" && apiKey == "" + } + probeOllamaModelFunc = func(apiBase, modelID string) bool { + mu.Lock() + ollamaProbes = append(ollamaProbes, apiBase+"|"+modelID) + mu.Unlock() + return apiBase == "http://localhost:11434/v1" && modelID == "llama3" + } + probeTCPServiceFunc = func(apiBase string) bool { + mu.Lock() + tcpProbes = append(tcpProbes, apiBase) + mu.Unlock() + return apiBase == "http://127.0.0.1:4321" + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "openai-oauth", + Model: "openai/gpt-5.4", + AuthMethod: "oauth", + }, + { + ModelName: "vllm-local", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + }, + { + ModelName: "ollama-default", + Model: "ollama/llama3", + }, + { + ModelName: "vllm-remote", + Model: "vllm/custom-model", + APIBase: "https://models.example.com/v1", + APIKeys: config.SimpleSecureStrings("remote-key"), + }, + { + ModelName: "copilot-gpt-5.4", + Model: "github-copilot/gpt-5.4", + APIBase: "http://127.0.0.1:4321", + AuthMethod: "oauth", + }, + } + cfg.Agents.Defaults.ModelName = "openai-oauth" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + gotAvailable := make(map[string]bool, len(resp.Models)) + gotStatus := make(map[string]string, len(resp.Models)) + for _, model := range resp.Models { + gotAvailable[model.ModelName] = model.Available + gotStatus[model.ModelName] = model.Status + } + + if gotAvailable["openai-oauth"] { + t.Fatalf("openai oauth model available = true, want false without stored credential") + } + if !gotAvailable["vllm-local"] { + t.Fatalf("vllm local model available = false, want true when local probe succeeds") + } + if !gotAvailable["ollama-default"] { + t.Fatalf("ollama default model available = false, want true when default local probe succeeds") + } + if !gotAvailable["vllm-remote"] { + t.Fatalf("remote vllm model available = false, want true with api_key") + } + if !gotAvailable["copilot-gpt-5.4"] { + t.Fatalf("copilot model available = false, want true when local bridge probe succeeds") + } + if gotStatus["openai-oauth"] != modelStatusUnconfigured { + t.Fatalf("openai oauth model status = %q, want %q", gotStatus["openai-oauth"], modelStatusUnconfigured) + } + if gotStatus["vllm-local"] != modelStatusAvailable { + t.Fatalf("vllm local model status = %q, want %q", gotStatus["vllm-local"], modelStatusAvailable) + } + if gotStatus["ollama-default"] != modelStatusAvailable { + t.Fatalf("ollama default model status = %q, want %q", gotStatus["ollama-default"], modelStatusAvailable) + } + if gotStatus["vllm-remote"] != modelStatusAvailable { + t.Fatalf("remote vllm model status = %q, want %q", gotStatus["vllm-remote"], modelStatusAvailable) + } + if gotStatus["copilot-gpt-5.4"] != modelStatusAvailable { + t.Fatalf("copilot model status = %q, want %q", gotStatus["copilot-gpt-5.4"], modelStatusAvailable) + } + if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model|" { + t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes) + } + if len(ollamaProbes) != 1 || ollamaProbes[0] != "http://localhost:11434/v1|llama3" { + t.Fatalf("ollama probes = %#v, want default local probe", ollamaProbes) + } + if len(tcpProbes) != 1 || tcpProbes[0] != "http://127.0.0.1:4321" { + t.Fatalf("tcp probes = %#v, want only local copilot probe", tcpProbes) + } +} + +func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "claude-oauth", + Model: "anthropic/claude-sonnet-4.6", + AuthMethod: "oauth", + }} + cfg.Agents.Defaults.ModelName = "claude-oauth" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + if setCredentialErr := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{ + AccessToken: "anthropic-token", + Provider: oauthProviderAnthropic, + AuthMethod: "oauth", + }); setCredentialErr != nil { + t.Fatalf("SetCredential() error = %v", setCredentialErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Available { + t.Fatalf("oauth model available = false, want true with stored credential") + } +} + +func TestHasModelConfiguration_OAuthWithoutMappedCredentialFallsBackToAPIKey(t *testing.T) { + noKey := &config.ModelConfig{ + Provider: "gemini", + Model: "gemini-2.5-flash", + AuthMethod: "oauth", + } + if hasModelConfiguration(noKey) { + t.Fatal("oauth model without credential mapping and api key should be unconfigured") + } + + withKey := &config.ModelConfig{ + Provider: "gemini", + Model: "gemini-2.5-flash", + AuthMethod: "oauth", + APIKeys: config.SimpleSecureStrings("gemini-key"), + } + if !hasModelConfiguration(withKey) { + t.Fatal("oauth model without credential mapping should fall back to api key configuration") + } +} + +func TestHandleListModels_AntigravityImplicitOAuthAvailability(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "gemini-flash", + Provider: "antigravity", + Model: "gemini-3-flash", + }} + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + if err := auth.SetCredential(oauthProviderGoogleAntigravity, &auth.AuthCredential{ + AccessToken: "antigravity-token", + Provider: oauthProviderGoogleAntigravity, + AuthMethod: "oauth", + }); err != nil { + t.Fatalf("SetCredential() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Available { + t.Fatal("antigravity model available = false, want true with stored credential even without auth_method") + } +} + +func TestHandleListModels_BedrockUsesAmbientCredentialStatus(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Provider: "bedrock", + Model: "us.anthropic.claude-sonnet-4-20250514-v1:0", + }} + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Available { + t.Fatal("bedrock model available = false, want true because Bedrock uses ambient AWS credentials") + } + if resp.Models[0].Status != modelStatusAvailable { + t.Fatalf("bedrock model status = %q, want %q", resp.Models[0].Status, modelStatusAvailable) + } +} + +func TestHandleListModels_CLIProvidersRequireInstalledCommands(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + probeCommandAvailableFunc = func(command string) bool { + switch command { + case "claude": + return false + case "codex": + return true + default: + return false + } + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "claude-cli-model", + Provider: "claude-cli", + Model: "claude-cli", + }, + { + ModelName: "codex-cli-model", + Provider: "codex-cli", + Model: "codex-cli", + }, + } + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + + modelsByName := make(map[string]modelResponse, len(resp.Models)) + for _, model := range resp.Models { + modelsByName[model.ModelName] = model + } + if model := modelsByName["claude-cli-model"]; model.Available || model.Status != modelStatusUnreachable { + t.Fatalf( + "claude-cli status = (%t, %q), want (%t, %q)", + model.Available, + model.Status, + false, + modelStatusUnreachable, + ) + } + if model := modelsByName["codex-cli-model"]; !model.Available || model.Status != modelStatusAvailable { + t.Fatalf( + "codex-cli status = (%t, %q), want (%t, %q)", + model.Available, + model.Status, + true, + modelStatusAvailable, + ) + } + + optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions)) + for _, option := range resp.ProviderOptions { + optionsByID[option.ID] = option + } + if option, ok := optionsByID["claude-cli"]; !ok { + t.Fatal("claude-cli provider option missing") + } else if option.CreateAllowed { + t.Fatal("claude-cli should not be creatable when the claude command is missing") + } + if option, ok := optionsByID["codex-cli"]; !ok { + t.Fatal("codex-cli provider option missing") + } else if !option.CreateAllowed { + t.Fatal("codex-cli should be creatable when the codex command is available") + } +} + +func TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + started := make(chan string, 2) + release := make(chan struct{}) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + started <- apiBase + "|" + modelID + <-release + return true + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "local-vllm-a", + Model: "vllm/custom-a", + APIBase: "http://127.0.0.1:8000/v1", + }, + { + ModelName: "local-vllm-b", + Model: "vllm/custom-b", + APIBase: "http://127.0.0.1:8001/v1", + }, + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recCh := make(chan *httptest.ResponseRecorder, 1) + go func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + recCh <- rec + }() + + for i := 0; i < 2; i++ { + select { + case <-started: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected both local probes to start before the first one completed") + } + } + close(release) + + rec := <-recCh + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + var gotProbe string + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + gotProbe = apiBase + "|" + modelID + "|" + apiKey + return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" && apiKey == "" + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "vllm-local", + Model: "vllm/custom-model", + APIBase: "http://0.0.0.0:8000/v1", + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Available { + t.Fatal("wildcard-bound local model available = false, want true after probe host normalization") + } + if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" { + t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|") + } +} + +func TestHandleListModels_StatusMarksUnreachableLocalModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + return false + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "vllm-local-down", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + APIKeys: config.SimpleSecureStrings("test-key"), + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + + if resp.Models[0].Available { + t.Fatal("unreachable local model available = true, want false") + } + if resp.Models[0].Status != modelStatusUnreachable { + t.Fatalf("unreachable local model status = %q, want %q", resp.Models[0].Status, modelStatusUnreachable) + } + if resp.Models[0].APIKey == "" { + t.Fatal("masked API key preview should still be returned when API key is configured") + } +} + +func TestHandleListModels_RuntimeProbeUsesExplicitProviderField(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + var gotProbe string + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + gotProbe = apiBase + "|" + modelID + "|" + apiKey + return true + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "vllm-local", + Provider: "vllm", + Model: "custom-model", + APIBase: "http://127.0.0.1:8000/v1", + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" { + t.Fatalf("probe = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|") + } +} + +func TestHandleAddModel_PersistsAPIKey(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model", + "model":"openai/gpt-4o-mini", + "api_key":"sk-new-model-key" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if len(cfg.ModelList) != 2 { + t.Fatalf("len(model_list) = %d, want 2", len(cfg.ModelList)) + } + + added := cfg.ModelList[1] + if added.ModelName != "new-model" { + t.Fatalf("model_name = %q, want %q", added.ModelName, "new-model") + } + if added.APIKey() != "sk-new-model-key" { + t.Fatalf("api_key = %q, want %q", added.APIKey(), "sk-new-model-key") + } +} + +func TestHandleAddModel_PersistsProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"nvidia-glm", + "provider":"nvidia", + "model":"z-ai/glm-5.1", + "api_key":"nv-key" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + added := cfg.ModelList[len(cfg.ModelList)-1] + if added.Provider != "nvidia" { + t.Fatalf("provider = %q, want %q", added.Provider, "nvidia") + } + if added.Model != "z-ai/glm-5.1" { + t.Fatalf("model = %q, want %q", added.Model, "z-ai/glm-5.1") + } +} + +func TestHandleAddModel_RejectsUnsupportedProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"bad-provider", + "provider":"not-supported", + "model":"gpt-4o-mini" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "not-supported" is not supported`) { + t.Fatalf("body = %q, want unsupported provider error", rec.Body.String()) + } +} + +func TestHandleAddModel_AllowsBedrockProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"bedrock-claude", + "provider":"bedrock", + "model":"us.anthropic.claude-sonnet-4-20250514-v1:0" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + added := cfg.ModelList[len(cfg.ModelList)-1] + if got := added.Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := added.Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want bedrock model ID", got) + } +} + +func TestHandleAddModel_NormalizesLegacyElevenLabsASRConfig(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model", + "provider":"openai", + "model":"gpt-4o-mini", + "api_key":"sk-new-model-key" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if len(updated.ModelList) != 2 { + t.Fatalf("len(model_list) = %d, want 2", len(updated.ModelList)) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1") + } +} + +func TestHandleAddModel_NormalizesExplicitElevenLabsUnsupportedModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v2", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model", + "provider":"openai", + "model":"gpt-4o-mini", + "api_key":"sk-new-model-key" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1") + } +} + +func TestHandleAddModel_RejectsMissingCLIProviderCommand(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + probeCommandAvailableFunc = func(command string) bool { + return false + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"claude-cli-model", + "provider":"claude-cli", + "model":"claude-cli" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "claude-cli" is not available for new models`) { + t.Fatalf("body = %q, want missing cli command error", rec.Body.String()) + } +} + +func TestHandleAddModel_DefaultsAntigravityToOAuth(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + added := addModelAndLoadLatest(t, configPath, `{ + "model_name":"gemini-flash", + "provider":"antigravity", + "model":"gemini-3-flash" + }`) + if got := added.AuthMethod; got != "oauth" { + t.Fatalf("auth_method = %q, want %q", got, "oauth") + } +} + +func TestHandleAddModel_NormalizesMixedCaseAuthMethod(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + added := addModelAndLoadLatest(t, configPath, `{ + "model_name":"openai-oauth", + "provider":"openai", + "model":"gpt-5.4", + "auth_method":"OAuth" + }`) + if got := added.AuthMethod; got != "oauth" { + t.Fatalf("auth_method = %q, want %q", got, "oauth") + } +} + +func TestHandleAddModel_PreservesExplicitProviderPrefixedModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"openai-gpt", + "provider":"openai", + "model":"openai/gpt-4o-mini", + "api_key":"sk-openai" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + added := cfg.ModelList[len(cfg.ModelList)-1] + if got := added.Provider; got != "openai" { + t.Fatalf("provider = %q, want %q", got, "openai") + } + if got := added.Model; got != "openai/gpt-4o-mini" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-4o-mini") + } +} + +func TestHandleAddModel_PersistsCustomHeaders(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model-headers", + "model":"openai/gpt-4o-mini", + "custom_headers":{"X-Source":"coding-plan","X-Agent":"openclaw"} + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if len(cfg.ModelList) != 2 { + t.Fatalf("len(model_list) = %d, want 2", len(cfg.ModelList)) + } + + added := cfg.ModelList[1] + if added.CustomHeaders == nil { + t.Fatal("custom_headers should not be nil") + } + if got := added.CustomHeaders["X-Source"]; got != "coding-plan" { + t.Fatalf("custom_headers[X-Source] = %q, want %q", got, "coding-plan") + } + if got := added.CustomHeaders["X-Agent"]; got != "openclaw" { + t.Fatalf("custom_headers[X-Agent] = %q, want %q", got, "openclaw") + } +} + +func TestHandleAddModel_PersistsToolSchemaTransform(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model-transform", + "model":"openai/gpt-4o-mini", + "tool_schema_transform":"simple" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + added := cfg.ModelList[len(cfg.ModelList)-1] + if got := added.ToolSchemaTransform; got != "simple" { + t.Fatalf("tool_schema_transform = %q, want %q", got, "simple") + } +} + +func TestHandleUpdateModel_CustomHeadersPreserveAndClear(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "editable", + Model: "openai/gpt-4o-mini", + APIKeys: config.SimpleSecureStrings("sk-existing"), + CustomHeaders: map[string]string{"X-Source": "coding-plan"}, + }} + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + // Omitted custom_headers should preserve existing value. + recPreserve := httptest.NewRecorder() + reqPreserve := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"editable", + "model":"openai/gpt-4o-mini" + }`)) + reqPreserve.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recPreserve, reqPreserve) + if recPreserve.Code != http.StatusOK { + t.Fatalf("preserve status = %d, want %d, body=%s", recPreserve.Code, http.StatusOK, recPreserve.Body.String()) + } + + afterPreserve, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() after preserve error = %v", err) + } + if got := afterPreserve.ModelList[0].CustomHeaders["X-Source"]; got != "coding-plan" { + t.Fatalf("preserved custom_headers[X-Source] = %q, want %q", got, "coding-plan") + } + + // Empty object should clear custom_headers. + recClear := httptest.NewRecorder() + reqClear := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"editable", + "model":"openai/gpt-4o-mini", + "custom_headers":{} + }`)) + reqClear.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recClear, reqClear) + if recClear.Code != http.StatusOK { + t.Fatalf("clear status = %d, want %d, body=%s", recClear.Code, http.StatusOK, recClear.Body.String()) + } + + afterClear, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() after clear error = %v", err) + } + if afterClear.ModelList[0].CustomHeaders != nil { + t.Fatalf("custom_headers = %#v, want nil", afterClear.ModelList[0].CustomHeaders) + } +} + +func TestHandleUpdateModel_ToolSchemaTransformPreserveAndClear(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "editable", + Model: "openai/gpt-4o-mini", + APIKeys: config.SimpleSecureStrings("sk-existing"), + ToolSchemaTransform: "simple", + }} + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recPreserve := httptest.NewRecorder() + reqPreserve := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"editable", + "model":"openai/gpt-4o-mini" + }`)) + reqPreserve.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recPreserve, reqPreserve) + if recPreserve.Code != http.StatusOK { + t.Fatalf("preserve status = %d, want %d, body=%s", recPreserve.Code, http.StatusOK, recPreserve.Body.String()) + } + + afterPreserve, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() after preserve error = %v", err) + } + if got := afterPreserve.ModelList[0].ToolSchemaTransform; got != "simple" { + t.Fatalf("preserved tool_schema_transform = %q, want %q", got, "simple") + } + + recClear := httptest.NewRecorder() + reqClear := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"editable", + "model":"openai/gpt-4o-mini", + "tool_schema_transform":"" + }`)) + reqClear.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recClear, reqClear) + if recClear.Code != http.StatusOK { + t.Fatalf("clear status = %d, want %d, body=%s", recClear.Code, http.StatusOK, recClear.Body.String()) + } + + afterClear, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() after clear error = %v", err) + } + if afterClear.ModelList[0].ToolSchemaTransform != "" { + t.Fatalf("tool_schema_transform = %q, want empty", afterClear.ModelList[0].ToolSchemaTransform) + } +} + +func TestHandleUpdateModel_PersistsProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "editable", + Model: "gpt-4o", + Provider: "openai", + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"editable", + "provider":"openrouter", + "model":"openai/gpt-4o" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") + } +} + +func TestHandleUpdateModel_PreservesExplicitProviderPrefixedModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "editable", + Model: "gpt-4o", + Provider: "openai", + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"editable", + "provider":"openai", + "model":"openai/gpt-5.4" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "openai" { + t.Fatalf("provider = %q, want %q", got, "openai") + } + if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") + } +} + +func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "openrouter-auto-explicit", + Provider: "openrouter", + Model: "openrouter/auto", + }} + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") + } + if got := resp.Models[0].Model; got != "openrouter/auto" { + t.Fatalf("model = %q, want %q", got, "openrouter/auto") + } +} + +func TestHandleListModels_ExposesElevenLabsASRProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := resp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if resp.Models[0].DefaultModelAllowed { + t.Fatal("elevenlabs ASR model should not be allowed as the default chat model") + } +} + +func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "legacy-openrouter", + Model: "openrouter/openai/gpt-5.4", + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + // Simulate an older client: it reads GET /api/models, ignores the new + // provider field, then PUTs the visible model string back unchanged. + recList := httptest.NewRecorder() + reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(recList, reqList) + + if recList.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) + } + + var listResp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(listResp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) + } + if got := listResp.Models[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") + } + if got := listResp.Models[0].Model; got != "openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") + } + + recUpdate := httptest.NewRecorder() + reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"legacy-openrouter", + "model":"openai/gpt-5.4" + }`)) + reqUpdate.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recUpdate, reqUpdate) + + if recUpdate.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") + } + if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") + } +} + +func TestHandleUpdateModel_MigratesLegacyElevenLabsASRWhenProviderOmitted(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recList := httptest.NewRecorder() + reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(recList, reqList) + + if recList.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) + } + + var listResp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(listResp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) + } + if got := listResp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := listResp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + + recUpdate := httptest.NewRecorder() + reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + reqUpdate.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recUpdate, reqUpdate) + + if recUpdate.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" { + t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io") + } +} + +func TestHandleUpdateModel_RoundTripsExplicitLegacyElevenLabsModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v2", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recList := httptest.NewRecorder() + reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(recList, reqList) + + if recList.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) + } + + var listResp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(listResp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) + } + if got := listResp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := listResp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after GET normalization", got, "scribe_v1") + } + + recUpdate := httptest.NewRecorder() + reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + reqUpdate.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recUpdate, reqUpdate) + + if recUpdate.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" { + t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io") + } +} + +func TestHandleUpdateModel_ClearsDefaultWhenSavingASROnlyModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + cfg.Agents.Defaults.ModelName = "elevenlabs-asr" + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.Agents.Defaults.ModelName; got != "" { + t.Fatalf("default model = %q, want cleared default", got) + } +} + +func TestHandleAddModel_RejectsUnsupportedElevenLabsModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v2" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "elevenlabs" only supports model "scribe_v1"`) { + t.Fatalf("body = %q, want elevenlabs model validation error", rec.Body.String()) + } +} + +func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "legacy-openrouter", + Model: "openrouter/openai/gpt-5.4", + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"legacy-openrouter", + "model":"openai/gpt-5.5" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") + } + if got := updated.ModelList[0].Model; got != "openai/gpt-5.5" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.5") + } +} + +func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "legacy-openrouter", + Model: "openrouter/openai/gpt-5.4", + }} + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") + } + if got := resp.Models[0].Model; got != "openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") + } + + optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions)) + for _, option := range resp.ProviderOptions { + optionsByID[option.ID] = option + } + if len(optionsByID) == 0 { + t.Fatal("provider_options should not be empty") + } + if option, ok := optionsByID["openai"]; !ok { + t.Fatal("openai provider option missing") + } else if option.DefaultAPIBase != "https://api.openai.com/v1" { + t.Fatalf("openai default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.openai.com/v1") + } + if option, ok := optionsByID["anthropic"]; !ok { + t.Fatal("anthropic provider option missing") + } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" { + t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1") + } + if _, ok := optionsByID["azure"]; !ok { + t.Fatal("azure provider option missing") + } + if option, ok := optionsByID["github-copilot"]; !ok { + t.Fatal("github-copilot provider option missing") + } else if option.DefaultAPIBase != "localhost:4321" { + t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") + } + if option, ok := optionsByID["elevenlabs"]; !ok { + t.Fatal("elevenlabs provider option missing") + } else { + if option.DefaultAPIBase != "https://api.elevenlabs.io" { + t.Fatalf("elevenlabs default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.elevenlabs.io") + } + if option.DefaultModelAllowed { + t.Fatal("elevenlabs should be marked as not allowed for default chat model selection") + } + } + if option, ok := optionsByID["lmstudio"]; !ok { + t.Fatal("lmstudio provider option missing") + } else if !option.EmptyAPIKeyAllowed { + t.Fatal("lmstudio should allow empty api keys") + } + if option, ok := optionsByID["bedrock"]; !ok { + t.Fatal("bedrock provider option missing") + } else if !option.CreateAllowed { + t.Fatal("bedrock should stay creatable and defer AWS credential failures to runtime") + } + if option, ok := optionsByID["antigravity"]; !ok { + t.Fatal("antigravity provider option missing") + } else { + if option.DefaultAuthMethod != "oauth" { + t.Fatalf("antigravity default_auth_method = %q, want %q", option.DefaultAuthMethod, "oauth") + } + if !option.AuthMethodLocked { + t.Fatal("antigravity auth method should be locked") + } + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "" { + t.Fatalf("persisted provider = %q, want unchanged empty provider", got) + } + if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" { + t.Fatalf("persisted model = %q, want unchanged legacy model", got) + } +} + +func TestHandleListModels_ReturnsProviderField(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "nvidia-glm", + Provider: "nvidia", + Model: "z-ai/glm-5.1", + APIKeys: config.SimpleSecureStrings("nv-key"), + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "nvidia" { + t.Fatalf("provider = %q, want %q", got, "nvidia") + } +} + +func TestHandleListModels_PreservesKnownProviderInCatalog(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := resp.Models[0].Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want %q", got, "us.anthropic.claude-sonnet-4-20250514-v1:0") + } + foundBedrock := false + for _, option := range resp.ProviderOptions { + if option.ID == "bedrock" { + foundBedrock = true + if !option.CreateAllowed { + t.Fatal("bedrock should stay creatable in provider_options") + } + } + } + if !foundBedrock { + t.Fatal("bedrock should be included in provider_options for compatibility") + } +} + +func TestHandleUpdateModel_AllowsExistingBedrockProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Provider: "bedrock", + Model: "us.anthropic.claude-sonnet-4-20250514-v1:0", + APIBase: "us-west-2", + }} + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"bedrock-claude", + "provider":"bedrock", + "model":"us.anthropic.claude-3-7-sonnet-20250219-v1:0", + "api_base":"us-east-1" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := updated.ModelList[0].Model; got != "us.anthropic.claude-3-7-sonnet-20250219-v1:0" { + t.Fatalf("model = %q, want updated bedrock model", got) + } + if got := updated.ModelList[0].APIBase; got != "us-east-1" { + t.Fatalf("api_base = %q, want %q", got, "us-east-1") + } +} + +func TestHandleListModels_ReturnsEffectiveProviderField(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "plain-openai", + Model: "gpt-4o", + }, + { + ModelName: "explicit-google", + Provider: "google", + Model: "gemini-2.5-pro", + }, + { + ModelName: "explicit-qwen-intl", + Provider: "qwen-international", + Model: "qwen3-coder-plus", + }, + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(resp.Models) != 3 { + t.Fatalf("len(models) = %d, want 3", len(resp.Models)) + } + + if got := resp.Models[0].Provider; got != "openai" { + t.Fatalf("provider[0] = %q, want %q", got, "openai") + } + if got := resp.Models[0].Model; got != "gpt-4o" { + t.Fatalf("model[0] = %q, want %q", got, "gpt-4o") + } + if got := resp.Models[1].Provider; got != "gemini" { + t.Fatalf("provider[1] = %q, want %q", got, "gemini") + } + if got := resp.Models[1].Model; got != "gemini-2.5-pro" { + t.Fatalf("model[1] = %q, want %q", got, "gemini-2.5-pro") + } + if got := resp.Models[2].Provider; got != "qwen-intl" { + t.Fatalf("provider[2] = %q, want %q", got, "qwen-intl") + } + if got := resp.Models[2].Model; got != "qwen3-coder-plus" { + t.Fatalf("model[2] = %q, want %q", got, "qwen3-coder-plus") + } +} + +// TestHandleSetDefaultModel_RejectsNonexistentModel tests that setting a non-existent +// model as default returns 404. This covers the case where virtual models (which are +// filtered by SaveConfig) cannot be set as default. +func TestHandleSetDefaultModel_RejectsNonexistentModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + // First save a valid config with a primary model + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + {ModelName: "gpt-4", Model: "openai/gpt-4o"}, + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + // Try to set a non-existent model (like a virtual model name) as default + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models/default", bytes.NewBufferString(`{ + "model_name": "gpt-4__key_1" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + // Should return 404 because the virtual model doesn't exist in the persisted config + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNotFound, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "not found") { + t.Fatalf("error message should mention 'not found', got: %s", rec.Body.String()) + } +} + +func TestHandleSetDefaultModel_RejectsElevenLabsASRProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }, + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models/default", bytes.NewBufferString(`{ + "model_name": "elevenlabs-asr" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "cannot be used as the default chat model") { + t.Fatalf("body = %q, want default chat model rejection", rec.Body.String()) + } +} + +func TestMaskAPIKey(t *testing.T) { + tests := []struct { + name string + key string + want string + }{ + { + name: "empty key", + key: "", + want: "", + }, + { + name: "short key fully masked", + key: "abcd", + want: "****", + }, + { + name: "length 8 boundary fully masked", + key: "12345678", + want: "****", + }, + { + name: "length 9 boundary shows last 2", + key: "123456789", + want: "123****89", + }, + { + name: "length 12 boundary shows last 2", + key: "abcdefghijkl", + want: "abc****kl", + }, + { + name: "length 13 boundary shows last 4", + key: "abcdefghijklm", + want: "abc****jklm", + }, + { + name: "typical api key", + key: "sk-1234567890abcd", + want: "sk-****abcd", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := maskAPIKey(tc.key) + if got != tc.want { + t.Fatalf("maskAPIKey(%q) = %q, want %q", tc.key, got, tc.want) + } + + if tc.key != "" { + displayed := strings.Replace(tc.want, "****", "", 1) + if len(tc.key) <= 8 { + if displayed != "" { + t.Fatalf("maskAPIKey(%q) displayed part = %q, want empty", tc.key, displayed) + } + } else { + if len(displayed)*10 > len(tc.key)*6 { + t.Fatalf( + "maskAPIKey(%q) displayed length = %d, want at most 60%% of %d", + tc.key, + len(displayed), + len(tc.key), + ) + } + } + } + }) + } +} diff --git a/web/backend/api/oauth.go b/web/backend/api/oauth.go new file mode 100644 index 000000000..116e304b1 --- /dev/null +++ b/web/backend/api/oauth.go @@ -0,0 +1,833 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "html" + "io" + "net/http" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +const ( + oauthProviderOpenAI = "openai" + oauthProviderAnthropic = "anthropic" + oauthProviderGoogleAntigravity = "google-antigravity" + + oauthMethodBrowser = "browser" + oauthMethodDeviceCode = "device_code" + oauthMethodToken = "token" + + oauthFlowPending = "pending" + oauthFlowSuccess = "success" + oauthFlowError = "error" + oauthFlowExpired = "expired" +) + +const ( + oauthBrowserFlowTTL = 10 * time.Minute + oauthDeviceCodeFlowTTL = 15 * time.Minute + oauthTerminalFlowGC = 30 * time.Minute +) + +var oauthProviderOrder = []string{ + oauthProviderOpenAI, + oauthProviderAnthropic, + oauthProviderGoogleAntigravity, +} + +var oauthProviderMethods = map[string][]string{ + oauthProviderOpenAI: {oauthMethodBrowser, oauthMethodDeviceCode, oauthMethodToken}, + oauthProviderAnthropic: {oauthMethodToken}, + oauthProviderGoogleAntigravity: {oauthMethodBrowser}, +} + +var oauthProviderLabels = map[string]string{ + oauthProviderOpenAI: "OpenAI", + oauthProviderAnthropic: "Anthropic", + oauthProviderGoogleAntigravity: "Google Antigravity", +} + +var ( + oauthNow = time.Now + oauthGeneratePKCE = auth.GeneratePKCE + oauthGenerateState = auth.GenerateState + oauthBuildAuthorizeURL = auth.BuildAuthorizeURL + oauthRequestDeviceCode = auth.RequestDeviceCode + oauthPollDeviceCodeOnce = auth.PollDeviceCodeOnce + oauthExchangeCodeForTokens = auth.ExchangeCodeForTokens + oauthGetCredential = auth.GetCredential + oauthSetCredential = auth.SetCredential + oauthDeleteCredential = auth.DeleteCredential + oauthLoadConfig = config.LoadConfig + oauthSaveConfig = config.SaveConfig + oauthFetchAntigravityProject = providers.FetchAntigravityProjectID + oauthFetchGoogleUserEmailFunc = fetchGoogleUserEmail +) + +type oauthFlow struct { + ID string + Provider string + Method string + Status string + CreatedAt time.Time + UpdatedAt time.Time + ExpiresAt time.Time + Error string + CodeVerifier string + OAuthState string + RedirectURI string + DeviceAuthID string + UserCode string + VerifyURL string + Interval int +} + +type oauthProviderStatus struct { + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + Methods []string `json:"methods"` + LoggedIn bool `json:"logged_in"` + Status string `json:"status"` + AuthMethod string `json:"auth_method,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + AccountID string `json:"account_id,omitempty"` + Email string `json:"email,omitempty"` + ProjectID string `json:"project_id,omitempty"` +} + +type oauthFlowResponse struct { + FlowID string `json:"flow_id"` + Provider string `json:"provider"` + Method string `json:"method"` + Status string `json:"status"` + ExpiresAt string `json:"expires_at,omitempty"` + Error string `json:"error,omitempty"` + UserCode string `json:"user_code,omitempty"` + VerifyURL string `json:"verify_url,omitempty"` + Interval int `json:"interval,omitempty"` +} + +// registerOAuthRoutes binds OAuth login/logout endpoints to the ServeMux. +func (h *Handler) registerOAuthRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/oauth/providers", h.handleListOAuthProviders) + mux.HandleFunc("POST /api/oauth/login", h.handleOAuthLogin) + mux.HandleFunc("GET /api/oauth/flows/{id}", h.handleGetOAuthFlow) + mux.HandleFunc("POST /api/oauth/flows/{id}/poll", h.handlePollOAuthFlow) + mux.HandleFunc("POST /api/oauth/logout", h.handleOAuthLogout) + mux.HandleFunc("GET /oauth/callback", h.handleOAuthCallback) +} + +func (h *Handler) handleListOAuthProviders(w http.ResponseWriter, r *http.Request) { + providersResp := make([]oauthProviderStatus, 0, len(oauthProviderOrder)) + + for _, provider := range oauthProviderOrder { + cred, err := oauthGetCredential(provider) + if err != nil { + http.Error(w, fmt.Sprintf("failed to load credentials: %v", err), http.StatusInternalServerError) + return + } + + item := oauthProviderStatus{ + Provider: provider, + DisplayName: oauthProviderLabels[provider], + Methods: oauthProviderMethods[provider], + Status: "not_logged_in", + } + if cred != nil { + item.LoggedIn = true + item.AuthMethod = cred.AuthMethod + item.AccountID = cred.AccountID + item.Email = cred.Email + item.ProjectID = cred.ProjectID + if !cred.ExpiresAt.IsZero() { + item.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339) + } + switch { + case cred.IsExpired(): + item.Status = "expired" + case cred.NeedsRefresh(): + item.Status = "needs_refresh" + default: + item.Status = "connected" + } + } + + providersResp = append(providersResp, item) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "providers": providersResp, + }) +} + +func (h *Handler) handleOAuthLogin(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var req struct { + Provider string `json:"provider"` + Method string `json:"method"` + Token string `json:"token"` + } + if err = json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest) + return + } + + provider, err := normalizeOAuthProvider(req.Provider) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + method := strings.ToLower(strings.TrimSpace(req.Method)) + if !isOAuthMethodSupported(provider, method) { + http.Error( + w, + fmt.Sprintf("unsupported login method %q for provider %q", method, provider), + http.StatusBadRequest, + ) + return + } + + switch method { + case oauthMethodToken: + token := strings.TrimSpace(req.Token) + if token == "" { + http.Error(w, "token is required", http.StatusBadRequest) + return + } + + cred := &auth.AuthCredential{ + AccessToken: token, + Provider: provider, + AuthMethod: oauthMethodToken, + } + if err := h.persistCredentialAndConfig(provider, oauthMethodToken, cred); err != nil { + http.Error(w, fmt.Sprintf("token login failed: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "provider": provider, + "method": method, + }) + return + + case oauthMethodDeviceCode: + cfg := auth.OpenAIOAuthConfig() + info, err := oauthRequestDeviceCode(cfg) + if err != nil { + http.Error(w, fmt.Sprintf("failed to request device code: %v", err), http.StatusInternalServerError) + return + } + + now := oauthNow() + flow := &oauthFlow{ + ID: newOAuthFlowID(), + Provider: provider, + Method: method, + Status: oauthFlowPending, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: now.Add(oauthDeviceCodeFlowTTL), + DeviceAuthID: info.DeviceAuthID, + UserCode: info.UserCode, + VerifyURL: info.VerifyURL, + Interval: info.Interval, + } + h.storeOAuthFlow(flow) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "provider": provider, + "method": method, + "flow_id": flow.ID, + "user_code": flow.UserCode, + "verify_url": flow.VerifyURL, + "interval": flow.Interval, + "expires_at": flow.ExpiresAt.Format(time.RFC3339), + }) + return + + case oauthMethodBrowser: + cfg, err := oauthConfigForProvider(provider) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + pkce, err := oauthGeneratePKCE() + if err != nil { + http.Error(w, fmt.Sprintf("failed to generate PKCE: %v", err), http.StatusInternalServerError) + return + } + state, err := oauthGenerateState() + if err != nil { + http.Error(w, fmt.Sprintf("failed to generate state: %v", err), http.StatusInternalServerError) + return + } + + redirectURI := buildOAuthRedirectURI(r) + authURL := oauthBuildAuthorizeURL(cfg, pkce, state, redirectURI) + + now := oauthNow() + flow := &oauthFlow{ + ID: newOAuthFlowID(), + Provider: provider, + Method: method, + Status: oauthFlowPending, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: now.Add(oauthBrowserFlowTTL), + CodeVerifier: pkce.CodeVerifier, + OAuthState: state, + RedirectURI: redirectURI, + } + h.storeOAuthFlow(flow) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "provider": provider, + "method": method, + "flow_id": flow.ID, + "auth_url": authURL, + "expires_at": flow.ExpiresAt.Format(time.RFC3339), + }) + return + default: + http.Error(w, "unsupported login method", http.StatusBadRequest) + } +} + +func (h *Handler) handleGetOAuthFlow(w http.ResponseWriter, r *http.Request) { + flowID := strings.TrimSpace(r.PathValue("id")) + if flowID == "" { + http.Error(w, "missing flow id", http.StatusBadRequest) + return + } + + flow, ok := h.getOAuthFlow(flowID) + if !ok { + http.Error(w, "flow not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(flowToResponse(flow)) +} + +func (h *Handler) handlePollOAuthFlow(w http.ResponseWriter, r *http.Request) { + flowID := strings.TrimSpace(r.PathValue("id")) + if flowID == "" { + http.Error(w, "missing flow id", http.StatusBadRequest) + return + } + + flow, ok := h.getOAuthFlow(flowID) + if !ok { + http.Error(w, "flow not found", http.StatusNotFound) + return + } + + if flow.Method != oauthMethodDeviceCode { + http.Error(w, "flow does not support polling", http.StatusBadRequest) + return + } + if flow.Status != oauthFlowPending { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(flowToResponse(flow)) + return + } + + cfg := auth.OpenAIOAuthConfig() + cred, err := oauthPollDeviceCodeOnce(cfg, flow.DeviceAuthID, flow.UserCode) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "pending") { + updated, _ := h.getOAuthFlow(flowID) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(flowToResponse(updated)) + return + } + h.setOAuthFlowError(flowID, fmt.Sprintf("device code poll failed: %v", err)) + updated, _ := h.getOAuthFlow(flowID) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(flowToResponse(updated)) + return + } + if cred == nil { + updated, _ := h.getOAuthFlow(flowID) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(flowToResponse(updated)) + return + } + + if err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil { + h.setOAuthFlowError(flowID, fmt.Sprintf("failed to save credential: %v", err)) + updated, _ := h.getOAuthFlow(flowID) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(flowToResponse(updated)) + return + } + + h.setOAuthFlowSuccess(flowID) + updated, _ := h.getOAuthFlow(flowID) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(flowToResponse(updated)) +} + +func (h *Handler) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { + state := strings.TrimSpace(r.URL.Query().Get("state")) + if state == "" { + renderOAuthCallbackPage(w, "", oauthFlowError, "Missing state", "missing_state") + return + } + + flow, ok := h.getOAuthFlowByState(state) + if !ok { + renderOAuthCallbackPage(w, "", oauthFlowError, "OAuth flow not found", "flow_not_found") + return + } + + if flow.Status != oauthFlowPending { + renderOAuthCallbackPage(w, flow.ID, flow.Status, "Flow already completed", flow.Error) + return + } + + if errMsg := strings.TrimSpace(r.URL.Query().Get("error")); errMsg != "" { + if desc := strings.TrimSpace(r.URL.Query().Get("error_description")); desc != "" { + errMsg += ": " + desc + } + h.setOAuthFlowError(flow.ID, errMsg) + renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Authorization failed", errMsg) + return + } + + code := strings.TrimSpace(r.URL.Query().Get("code")) + if code == "" { + h.setOAuthFlowError(flow.ID, "missing authorization code") + renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Missing authorization code", "missing_code") + return + } + + cfg, err := oauthConfigForProvider(flow.Provider) + if err != nil { + h.setOAuthFlowError(flow.ID, err.Error()) + renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Unsupported provider", err.Error()) + return + } + + cred, err := oauthExchangeCodeForTokens(cfg, code, flow.CodeVerifier, flow.RedirectURI) + if err != nil { + h.setOAuthFlowError(flow.ID, fmt.Sprintf("token exchange failed: %v", err)) + renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Token exchange failed", err.Error()) + return + } + + if err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil { + h.setOAuthFlowError(flow.ID, fmt.Sprintf("failed to save credential: %v", err)) + renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Failed to save credential", err.Error()) + return + } + + h.setOAuthFlowSuccess(flow.ID) + renderOAuthCallbackPage(w, flow.ID, oauthFlowSuccess, "Authentication successful", "") +} + +func (h *Handler) handleOAuthLogout(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var req struct { + Provider string `json:"provider"` + } + if err = json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest) + return + } + + provider, err := normalizeOAuthProvider(req.Provider) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := oauthDeleteCredential(provider); err != nil { + http.Error(w, fmt.Sprintf("failed to delete credential: %v", err), http.StatusInternalServerError) + return + } + if err := h.syncProviderAuthMethod(provider, ""); err != nil { + http.Error(w, fmt.Sprintf("failed to update config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "provider": provider, + }) +} + +func renderOAuthCallbackPage(w http.ResponseWriter, flowID, status, title, errMsg string) { + payload := map[string]string{ + "type": "picoclaw-oauth-result", + "flowId": flowID, + "status": status, + } + if errMsg != "" { + payload["error"] = errMsg + } + payloadJSON, _ := json.Marshal(payload) + + message := title + if errMsg != "" { + message = fmt.Sprintf("%s: %s", title, errMsg) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if status == oauthFlowSuccess { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusBadRequest) + } + + _, _ = fmt.Fprintf( + w, + "<!doctype html><html><head><meta charset=\"utf-8\"><title>PicoClaw OAuth

%s

%s

You can close this window.

", + string(payloadJSON), + html.EscapeString(title), + html.EscapeString(message), + ) +} + +func normalizeOAuthProvider(raw string) (string, error) { + provider := strings.ToLower(strings.TrimSpace(raw)) + switch provider { + case "antigravity": + return oauthProviderGoogleAntigravity, nil + case oauthProviderOpenAI, oauthProviderAnthropic, oauthProviderGoogleAntigravity: + return provider, nil + default: + return "", fmt.Errorf("unsupported provider %q", raw) + } +} + +func isOAuthMethodSupported(provider, method string) bool { + methods := oauthProviderMethods[provider] + for _, m := range methods { + if m == method { + return true + } + } + return false +} + +func oauthConfigForProvider(provider string) (auth.OAuthProviderConfig, error) { + switch provider { + case oauthProviderOpenAI: + return auth.OpenAIOAuthConfig(), nil + case oauthProviderGoogleAntigravity: + return auth.GoogleAntigravityOAuthConfig(), nil + default: + return auth.OAuthProviderConfig{}, fmt.Errorf("provider %q does not support browser oauth", provider) + } +} + +func oauthMethodTokenOrOAuth(method string) string { + if method == oauthMethodToken { + return oauthMethodToken + } + return "oauth" +} + +func buildOAuthRedirectURI(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + scheme = strings.Split(forwarded, ",")[0] + } + return fmt.Sprintf("%s://%s/oauth/callback", scheme, r.Host) +} + +func flowToResponse(flow *oauthFlow) oauthFlowResponse { + resp := oauthFlowResponse{ + FlowID: flow.ID, + Provider: flow.Provider, + Method: flow.Method, + Status: flow.Status, + Error: flow.Error, + } + if !flow.ExpiresAt.IsZero() { + resp.ExpiresAt = flow.ExpiresAt.Format(time.RFC3339) + } + if flow.Method == oauthMethodDeviceCode { + resp.UserCode = flow.UserCode + resp.VerifyURL = flow.VerifyURL + resp.Interval = flow.Interval + } + return resp +} + +func newOAuthFlowID() string { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("oauth_%d", time.Now().UnixNano()) + } + return hex.EncodeToString(buf) +} + +func (h *Handler) storeOAuthFlow(flow *oauthFlow) { + now := oauthNow() + h.oauthMu.Lock() + defer h.oauthMu.Unlock() + + h.gcOAuthFlowsLocked(now) + h.oauthFlows[flow.ID] = flow + if flow.OAuthState != "" { + h.oauthState[flow.OAuthState] = flow.ID + } +} + +func (h *Handler) getOAuthFlow(flowID string) (*oauthFlow, bool) { + now := oauthNow() + h.oauthMu.Lock() + defer h.oauthMu.Unlock() + + h.gcOAuthFlowsLocked(now) + flow, ok := h.oauthFlows[flowID] + if !ok { + return nil, false + } + cp := *flow + return &cp, true +} + +func (h *Handler) getOAuthFlowByState(state string) (*oauthFlow, bool) { + now := oauthNow() + h.oauthMu.Lock() + defer h.oauthMu.Unlock() + + h.gcOAuthFlowsLocked(now) + flowID, ok := h.oauthState[state] + if !ok { + return nil, false + } + flow, ok := h.oauthFlows[flowID] + if !ok { + delete(h.oauthState, state) + return nil, false + } + cp := *flow + return &cp, true +} + +func (h *Handler) setOAuthFlowSuccess(flowID string) { + now := oauthNow() + h.oauthMu.Lock() + defer h.oauthMu.Unlock() + + flow, ok := h.oauthFlows[flowID] + if !ok { + return + } + flow.Status = oauthFlowSuccess + flow.Error = "" + flow.UpdatedAt = now + if flow.OAuthState != "" { + delete(h.oauthState, flow.OAuthState) + } +} + +func (h *Handler) setOAuthFlowError(flowID, errMsg string) { + now := oauthNow() + h.oauthMu.Lock() + defer h.oauthMu.Unlock() + + flow, ok := h.oauthFlows[flowID] + if !ok { + return + } + flow.Status = oauthFlowError + flow.Error = errMsg + flow.UpdatedAt = now + if flow.OAuthState != "" { + delete(h.oauthState, flow.OAuthState) + } +} + +func (h *Handler) gcOAuthFlowsLocked(now time.Time) { + for id, flow := range h.oauthFlows { + if flow.Status == oauthFlowPending && !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) { + flow.Status = oauthFlowExpired + flow.Error = "flow expired" + flow.UpdatedAt = now + if flow.OAuthState != "" { + delete(h.oauthState, flow.OAuthState) + } + } + + if flow.Status != oauthFlowPending && now.Sub(flow.UpdatedAt) > oauthTerminalFlowGC { + if flow.OAuthState != "" { + delete(h.oauthState, flow.OAuthState) + } + delete(h.oauthFlows, id) + } + } +} + +func (h *Handler) persistCredentialAndConfig(provider, authMethod string, cred *auth.AuthCredential) error { + if cred == nil { + return fmt.Errorf("empty credential") + } + + cp := *cred + cp.Provider = provider + if cp.AuthMethod == "" { + cp.AuthMethod = authMethod + } + + if provider == oauthProviderGoogleAntigravity { + if cp.Email == "" { + email, err := oauthFetchGoogleUserEmailFunc(cp.AccessToken) + if err != nil { + logger.ErrorC("oauth", fmt.Sprintf("oauth warning: could not fetch google email: %v", err)) + } else { + cp.Email = email + } + } + if cp.ProjectID == "" { + projectID, err := oauthFetchAntigravityProject(cp.AccessToken) + if err != nil { + logger.ErrorC("oauth", fmt.Sprintf("oauth warning: could not fetch antigravity project id: %v", err)) + } else { + cp.ProjectID = projectID + } + } + } + + if err := oauthSetCredential(provider, &cp); err != nil { + return fmt.Errorf("saving credential: %w", err) + } + if err := h.syncProviderAuthMethod(provider, authMethod); err != nil { + return fmt.Errorf("syncing provider auth config: %w", err) + } + return nil +} + +func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error { + cfg, err := oauthLoadConfig(h.configPath) + if err != nil { + return err + } + + found := false + for i := range cfg.ModelList { + if modelBelongsToProvider(provider, cfg.ModelList[i]) { + cfg.ModelList[i].AuthMethod = authMethod + found = true + } + } + + if !found && authMethod != "" { + cfg.ModelList = append(cfg.ModelList, defaultModelConfigForProvider(provider, authMethod)) + } + + return oauthSaveConfig(h.configPath, cfg) +} + +func modelBelongsToProvider(provider string, modelCfg *config.ModelConfig) bool { + protocol, _ := providers.ExtractProtocol(modelCfg) + switch provider { + case oauthProviderOpenAI: + return protocol == "openai" + case oauthProviderAnthropic: + return protocol == "anthropic" + case oauthProviderGoogleAntigravity: + return protocol == "antigravity" || protocol == "google-antigravity" + default: + return false + } +} + +func defaultModelConfigForProvider(provider, authMethod string) *config.ModelConfig { + switch provider { + case oauthProviderOpenAI: + return &config.ModelConfig{ + ModelName: "gpt-5.4", + Provider: "openai", + Model: "gpt-5.4", + AuthMethod: authMethod, + } + case oauthProviderAnthropic: + return &config.ModelConfig{ + ModelName: "claude-sonnet-4.6", + Provider: "anthropic", + Model: "claude-sonnet-4.6", + AuthMethod: authMethod, + } + case oauthProviderGoogleAntigravity: + return &config.ModelConfig{ + ModelName: "gemini-flash", + Provider: "antigravity", + Model: "gemini-3-flash", + AuthMethod: authMethod, + } + default: + return &config.ModelConfig{} + } +} + +func fetchGoogleUserEmail(accessToken string) (string, error) { + req, err := http.NewRequest(http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("userinfo request failed: %s", string(body)) + } + + var userInfo struct { + Email string `json:"email"` + } + if err := json.Unmarshal(body, &userInfo); err != nil { + return "", err + } + if userInfo.Email == "" { + return "", fmt.Errorf("empty email in userinfo response") + } + return userInfo.Email, nil +} diff --git a/web/backend/api/oauth_test.go b/web/backend/api/oauth_test.go new file mode 100644 index 000000000..9468c8873 --- /dev/null +++ b/web/backend/api/oauth_test.go @@ -0,0 +1,337 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestOAuthLoginRejectsUnsupportedMethod(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/oauth/login", + strings.NewReader(`{"provider":"anthropic","method":"browser"}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +func TestOAuthBrowserFlowCreatedAndQueried(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + + oauthGeneratePKCE = func() (auth.PKCECodes, error) { + return auth.PKCECodes{CodeVerifier: "verifier-1", CodeChallenge: "challenge-1"}, nil + } + oauthGenerateState = func() (string, error) { return "state-1", nil } + oauthBuildAuthorizeURL = func(cfg auth.OAuthProviderConfig, pkce auth.PKCECodes, state, redirectURI string) string { + return "https://example.com/authorize?state=" + state + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/oauth/login", + strings.NewReader(`{"provider":"openai","method":"browser"}`), + ) + req.Host = "localhost:18800" + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var loginResp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &loginResp); err != nil { + t.Fatalf("unmarshal login response: %v", err) + } + flowID, _ := loginResp["flow_id"].(string) + if flowID == "" { + t.Fatalf("flow_id is empty: %v", loginResp) + } + if loginResp["auth_url"] != "https://example.com/authorize?state=state-1" { + t.Fatalf("unexpected auth_url: %v", loginResp["auth_url"]) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/oauth/flows/"+flowID, nil) + mux.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("flow status code = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String()) + } + var flowResp oauthFlowResponse + if err := json.Unmarshal(rec2.Body.Bytes(), &flowResp); err != nil { + t.Fatalf("unmarshal flow response: %v", err) + } + if flowResp.Status != oauthFlowPending { + t.Fatalf("flow status = %q, want %q", flowResp.Status, oauthFlowPending) + } + if flowResp.Method != oauthMethodBrowser { + t.Fatalf("flow method = %q, want %q", flowResp.Method, oauthMethodBrowser) + } +} + +func TestOAuthFlowExpiresWhenQueried(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + + now := time.Date(2026, 3, 6, 12, 0, 0, 0, time.UTC) + oauthNow = func() time.Time { return now } + + h := NewHandler(configPath) + h.storeOAuthFlow(&oauthFlow{ + ID: "expired-flow", + Provider: oauthProviderOpenAI, + Method: oauthMethodBrowser, + Status: oauthFlowPending, + CreatedAt: now.Add(-20 * time.Minute), + UpdatedAt: now.Add(-20 * time.Minute), + ExpiresAt: now.Add(-1 * time.Minute), + }) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/oauth/flows/expired-flow", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var flowResp oauthFlowResponse + if err := json.Unmarshal(rec.Body.Bytes(), &flowResp); err != nil { + t.Fatalf("unmarshal flow response: %v", err) + } + if flowResp.Status != oauthFlowExpired { + t.Fatalf("flow status = %q, want %q", flowResp.Status, oauthFlowExpired) + } +} + +func TestOAuthCallbackUnknownState(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?state=unknown&code=abc", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + if !strings.Contains(rec.Body.String(), "OAuth flow not found") { + t.Fatalf("unexpected body: %s", rec.Body.String()) + } +} + +func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{ + ModelName: "gpt-5.4", + Model: "openai/gpt-5.4", + AuthMethod: "oauth", + }) + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + if err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{ + AccessToken: "token-before-logout", + Provider: oauthProviderOpenAI, + AuthMethod: "oauth", + }); err != nil { + t.Fatalf("SetCredential error: %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/oauth/logout", bytes.NewBufferString(`{"provider":"openai"}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cred, err := auth.GetCredential(oauthProviderOpenAI) + if err != nil { + t.Fatalf("GetCredential error: %v", err) + } + if cred != nil { + t.Fatalf("expected credential deleted, got %#v", cred) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + for _, m := range updated.ModelList { + if strings.HasPrefix(m.Model, "openai/") && m.AuthMethod != "" { + t.Fatalf("openai model auth_method = %q, want empty", m.AuthMethod) + } + } +} + +func TestOAuthLogoutClearsAuthMethodForExplicitProviderField(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{ + ModelName: "gpt-5.4", + Provider: "openai", + Model: "gpt-5.4", + AuthMethod: "oauth", + }) + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + if err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{ + AccessToken: "token-before-logout", + Provider: oauthProviderOpenAI, + AuthMethod: "oauth", + }); err != nil { + t.Fatalf("SetCredential error: %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/oauth/logout", bytes.NewBufferString(`{"provider":"openai"}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + if got := updated.ModelList[len(updated.ModelList)-1].AuthMethod; got != "" { + t.Fatalf("auth_method = %q, want empty", got) + } +} + +func setupOAuthTestEnv(t *testing.T) (string, func()) { + t.Helper() + + tmp := t.TempDir() + oldHome := os.Getenv("HOME") + oldPicoHome := os.Getenv("PICOCLAW_HOME") + + if err := os.Setenv("HOME", tmp); err != nil { + t.Fatalf("set HOME: %v", err) + } + if err := os.Setenv("PICOCLAW_HOME", filepath.Join(tmp, ".picoclaw")); err != nil { + t.Fatalf("set PICOCLAW_HOME: %v", err) + } + + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "custom-default", + Model: "openai/gpt-4o", + APIKeys: config.SimpleSecureStrings("sk-default"), + }} + cfg.Agents.Defaults.ModelName = "custom-default" + + configPath := filepath.Join(tmp, "config.json") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + + cleanup := func() { + _ = os.Setenv("HOME", oldHome) + if oldPicoHome == "" { + _ = os.Unsetenv("PICOCLAW_HOME") + } else { + _ = os.Setenv("PICOCLAW_HOME", oldPicoHome) + } + } + return configPath, cleanup +} + +func resetOAuthHooks(t *testing.T) { + t.Helper() + + origNow := oauthNow + origGeneratePKCE := oauthGeneratePKCE + origGenerateState := oauthGenerateState + origBuildAuthorizeURL := oauthBuildAuthorizeURL + origRequestDeviceCode := oauthRequestDeviceCode + origPollDeviceCodeOnce := oauthPollDeviceCodeOnce + origExchangeCodeForTokens := oauthExchangeCodeForTokens + origGetCredential := oauthGetCredential + origSetCredential := oauthSetCredential + origDeleteCredential := oauthDeleteCredential + origLoadConfig := oauthLoadConfig + origSaveConfig := oauthSaveConfig + origFetchProject := oauthFetchAntigravityProject + origFetchGoogleEmail := oauthFetchGoogleUserEmailFunc + + t.Cleanup(func() { + oauthNow = origNow + oauthGeneratePKCE = origGeneratePKCE + oauthGenerateState = origGenerateState + oauthBuildAuthorizeURL = origBuildAuthorizeURL + oauthRequestDeviceCode = origRequestDeviceCode + oauthPollDeviceCodeOnce = origPollDeviceCodeOnce + oauthExchangeCodeForTokens = origExchangeCodeForTokens + oauthGetCredential = origGetCredential + oauthSetCredential = origSetCredential + oauthDeleteCredential = origDeleteCredential + oauthLoadConfig = origLoadConfig + oauthSaveConfig = origSaveConfig + oauthFetchAntigravityProject = origFetchProject + oauthFetchGoogleUserEmailFunc = origFetchGoogleEmail + }) +} diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go new file mode 100644 index 000000000..8eeff4041 --- /dev/null +++ b/web/backend/api/pico.go @@ -0,0 +1,310 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httputil" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + ppid "github.com/sipeed/picoclaw/pkg/pid" +) + +// registerPicoRoutes binds Pico Channel management endpoints to the ServeMux. +func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/pico/info", h.handleGetPicoInfo) + mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken) + mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup) + + // WebSocket proxy: forward /pico/ws to gateway + // This allows the frontend to connect via the same port as the web UI, + // avoiding the need to expose extra ports for WebSocket communication. + mux.HandleFunc("GET /pico/ws", h.handleWebSocketProxy()) + mux.HandleFunc("GET /pico/media/{id}", h.handlePicoMediaProxy()) + mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy()) +} + +// createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint. +// The gateway bind host and port are resolved from the latest configuration. +func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *httputil.ReverseProxy { + wsProxy := &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + target := h.gatewayProxyURL() + r.SetURL(target) + r.Out.Header.Del(protocolKey) + if upstreamProtocol != "" { + r.Out.Header.Set(protocolKey, upstreamProtocol) + } + }, + ModifyResponse: func(r *http.Response) error { + if prot := r.Header.Values(protocolKey); len(prot) > 0 { + r.Header.Del(protocolKey) + if origProtocol != "" { + r.Header.Set(protocolKey, origProtocol) + } + } + return nil + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + logger.Errorf("Failed to proxy WebSocket: %v", err) + http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway) + }, + } + return wsProxy +} + +func (h *Handler) createPicoHTTPProxy(token string) *httputil.ReverseProxy { + return &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + target := h.gatewayProxyURL() + r.SetURL(target) + r.Out.Header.Set("Authorization", "Bearer "+token) + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + logger.Errorf("Failed to proxy Pico HTTP request: %v", err) + http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway) + }, + } +} + +func (h *Handler) gatewayAvailableForProxy() bool { + gateway.mu.Lock() + ensurePicoTokenCachedLocked(h.configPath) + cachedPID := gateway.pidData + trackedCmd := gateway.cmd + gateway.mu.Unlock() + + if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil { + gateway.mu.Lock() + gateway.pidData = pidData + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + return true + } + + if cachedPID == nil { + return false + } + + if isCmdProcessAliveLocked(trackedCmd) { + return true + } + + gateway.mu.Lock() + if gateway.cmd == trackedCmd { + gateway.pidData = nil + setGatewayRuntimeStatusLocked("stopped") + } + available := gateway.pidData != nil + gateway.mu.Unlock() + return available +} + +func decodePicoSettings(cfg *config.Config) (config.PicoSettings, bool) { + if cfg == nil { + return config.PicoSettings{}, false + } + + bc := cfg.Channels.GetByType(config.ChannelPico) + if bc == nil { + return config.PicoSettings{}, false + } + + var picoCfg config.PicoSettings + if err := bc.Decode(&picoCfg); err != nil { + return config.PicoSettings{}, false + } + + return picoCfg, bc.Enabled +} + +func (h *Handler) writePicoInfoResponse( + w http.ResponseWriter, + r *http.Request, + cfg *config.Config, + changed *bool, +) { + picoCfg, enabled := decodePicoSettings(cfg) + + resp := map[string]any{ + "ws_url": h.buildWsURL(r), + "enabled": enabled, + } + if changed != nil { + resp["changed"] = *changed + } + if picoCfg.Token.String() != "" { + resp["configured"] = true + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections. +// It relies on launcher dashboard auth, then injects the raw pico token only +// on the upstream gateway request. +func (h *Handler) handleWebSocketProxy() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !h.gatewayAvailableForProxy() { + logger.Warnf("Gateway not available for WebSocket proxy") + http.Error(w, "Gateway not available", http.StatusServiceUnavailable) + return + } + + upstreamProtocol := picoGatewayProtocol() + if upstreamProtocol == "" { + logger.Warn("Pico token unavailable for WebSocket proxy") + http.Error(w, "Pico channel not configured", http.StatusServiceUnavailable) + return + } + + var origProtocol string + if prot := r.Header.Values(protocolKey); len(prot) > 0 { + origProtocol = prot[0] + } + + h.createWsProxy(origProtocol, upstreamProtocol).ServeHTTP(w, r) + } +} + +func (h *Handler) handlePicoMediaProxy() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !h.gatewayAvailableForProxy() { + logger.Warnf("Gateway not available for Pico media proxy") + http.Error(w, "Gateway not available", http.StatusServiceUnavailable) + return + } + + gateway.mu.Lock() + picoToken := gateway.picoToken + gateway.mu.Unlock() + + if picoToken == "" { + logger.Warnf("Missing Pico token for media proxy") + http.Error(w, "Invalid Pico token", http.StatusForbidden) + return + } + + h.createPicoHTTPProxy(picoToken).ServeHTTP(w, r) + } +} + +// handleGetPicoInfo returns non-secret Pico connection info for the launcher UI. +// +// GET /api/pico/info +func (h *Handler) handleGetPicoInfo(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + h.writePicoInfoResponse(w, r, cfg, nil) +} + +// handleRegenPicoToken rotates the raw Pico WebSocket token and returns +// non-secret connection info for the launcher UI. +// +// POST /api/pico/token +func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + token := generateSecureToken() + if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil { + decoded, err := bc.GetDecoded() + if err == nil && decoded != nil { + if settings, ok := decoded.(*config.PicoSettings); ok { + settings.Token = *config.NewSecureString(token) + } + } + } + + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + gateway.mu.Lock() + gateway.picoToken = token + gateway.mu.Unlock() + + h.writePicoInfoResponse(w, r, cfg, nil) +} + +// EnsurePicoChannel enables the Pico channel with sane defaults if it isn't +// already configured. Returns true when the config was modified. +func (h *Handler) EnsurePicoChannel() (bool, error) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return false, fmt.Errorf("failed to load config: %w", err) + } + + changed := false + + bc := cfg.Channels.GetByType(config.ChannelPico) + if bc == nil { + bc = &config.Channel{Type: config.ChannelPico} + cfg.Channels["pico"] = bc + } + + if !bc.Enabled { + bc.Enabled = true + changed = true + } + + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + if picoCfg, ok := decoded.(*config.PicoSettings); ok { + if picoCfg.Token.String() == "" { + picoCfg.Token = *config.NewSecureString(generateSecureToken()) + changed = true + } + } + } + + if changed { + if err := config.SaveConfig(h.configPath, cfg); err != nil { + return false, fmt.Errorf("failed to save config: %w", err) + } + } + + return changed, nil +} + +// handlePicoSetup automatically configures everything needed for the Pico Channel to work. +// +// POST /api/pico/setup +func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { + changed, err := h.EnsurePicoChannel() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Reload config (EnsurePicoChannel may have modified it). + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + h.writePicoInfoResponse(w, r, cfg, &changed) +} + +// generateSecureToken creates a random 32-character hex string. +func generateSecureToken() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // Fallback to something pseudo-random if crypto/rand fails + return fmt.Sprintf("%032x", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go new file mode 100644 index 000000000..6f7cefd4d --- /dev/null +++ b/web/backend/api/pico_test.go @@ -0,0 +1,977 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + ppid "github.com/sipeed/picoclaw/pkg/pid" +) + +func newPicoProxyRequest(method, path string) *http.Request { + req := httptest.NewRequest(method, "http://launcher.local:18800"+path, nil) + req.Header.Set("Origin", "http://launcher.local:18800") + return req +} + +func TestEnsurePicoChannel_FreshConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + changed, err := h.EnsurePicoChannel() + if err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + if !changed { + t.Fatal("EnsurePicoChannel() should report changed on a fresh config") + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + bc := cfg.Channels["pico"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + if !bc.Enabled { + t.Error("expected Pico to be enabled after setup") + } + if picoCfg.Token.String() == "" { + t.Error("expected a non-empty token after setup") + } +} + +func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.EnsurePicoChannel(); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + bc := cfg.Channels["pico"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + if picoCfg.AllowTokenQuery { + t.Error("setup must not enable allow_token_query by default") + } +} + +func TestEnsurePicoChannel_LeavesAllowOriginsEmptyByDefault(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.EnsurePicoChannel(); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + bc := cfg.Channels["pico"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + if len(picoCfg.AllowOrigins) != 0 { + t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins) + } +} + +func TestEnsurePicoChannel_NoOriginConfigurationRequired(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.EnsurePicoChannel(); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + bc := cfg.Channels["pico"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + if len(picoCfg.AllowOrigins) != 0 { + t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins) + } +} + +func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + + // Pre-configure with custom user settings + cfg := config.DefaultConfig() + bc := cfg.Channels["pico"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + bc.Enabled = true + picoCfg.SetToken("user-custom-token") + picoCfg.AllowTokenQuery = true + picoCfg.AllowOrigins = []string{"https://myapp.example.com"} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + + changed, err := h.EnsurePicoChannel() + if err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + if changed { + t.Error("EnsurePicoChannel() should not change a fully configured config") + } + + cfg, err = config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + bc = cfg.Channels["pico"] + decoded, err = bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + picoCfg = decoded.(*config.PicoSettings) + if picoCfg.Token.String() != "user-custom-token" { + t.Errorf("token = %q, want %q", picoCfg.Token.String(), "user-custom-token") + } + if !picoCfg.AllowTokenQuery { + t.Error("user's allow_token_query=true must be preserved") + } + if len(picoCfg.AllowOrigins) != 1 || picoCfg.AllowOrigins[0] != "https://myapp.example.com" { + t.Errorf("allow_origins = %v, want [https://myapp.example.com]", picoCfg.AllowOrigins) + } +} + +func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + + cfg := config.DefaultConfig() + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if err = os.WriteFile(configPath, raw, 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + h := NewHandler(configPath) + + changed, err := h.EnsurePicoChannel() + if err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + if !changed { + t.Fatal("EnsurePicoChannel() should report changed when pico is missing") + } + + cfg, err = config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + bc := cfg.Channels["pico"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + if !bc.Enabled { + t.Error("expected Pico to be enabled after setup") + } + if picoCfg.Token.String() == "" { + t.Error("expected a non-empty token after setup") + } + if _, err := os.Stat(filepath.Join(filepath.Dir(configPath), config.SecurityConfigFile)); err != nil { + t.Fatalf("expected .security.yml to be created: %v", err) + } +} + +func TestEnsurePicoChannel_ConfiguresPicoWithoutGateway(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = "" + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + if _, err := h.EnsurePicoChannel(); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + bc := cfg.Channels["pico"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + if !bc.Enabled { + t.Error("expected Pico to be enabled after launcher startup setup") + } + if picoCfg.Token.String() == "" { + t.Error("expected a non-empty token after launcher startup setup") + } +} + +func TestEnsurePicoChannel_Idempotent(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + // First call sets things up + if _, err := h.EnsurePicoChannel(); err != nil { + t.Fatalf("first EnsurePicoChannel() error = %v", err) + } + + cfg1, _ := config.LoadConfig(configPath) + bc := cfg1.Channels["pico"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + token1 := picoCfg.Token.String() + + // Second call should be a no-op + changed, err := h.EnsurePicoChannel() + if err != nil { + t.Fatalf("second EnsurePicoChannel() error = %v", err) + } + if changed { + t.Error("second EnsurePicoChannel() should not report changed") + } + + cfg2, _ := config.LoadConfig(configPath) + bc = cfg2.Channels["pico"] + decoded, err = bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + picoCfg = decoded.(*config.PicoSettings) + if picoCfg.Token.String() != token1 { + t.Error("token should not change on subsequent calls") + } +} + +func TestHandlePicoSetup_DoesNotPersistRequestOrigin(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + req := httptest.NewRequest("POST", "/api/pico/setup", nil) + req.Header.Set("Origin", "http://10.0.0.5:3000") + rec := httptest.NewRecorder() + + h.handlePicoSetup(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + bc := cfg.Channels["pico"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + if len(picoCfg.AllowOrigins) != 0 { + t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins) + } +} + +func TestHandlePicoSetup_Response(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + req := httptest.NewRequest("POST", "/api/pico/setup", nil) + rec := httptest.NewRecorder() + + h.handlePicoSetup(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var resp map[string]any + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if _, ok := resp["token"]; ok { + t.Error("response must not expose the raw pico token") + } + if resp["ws_url"] == nil || resp["ws_url"] == "" { + t.Error("response should contain ws_url") + } + if resp["enabled"] != true { + t.Error("response should have enabled=true") + } + if resp["changed"] != true { + t.Error("response should have changed=true on first setup") + } + if resp["configured"] != true { + t.Error("response should have configured=true") + } +} + +func TestHandleGetPicoInfo_OmitsToken(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.EnsurePicoChannel(); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://launcher.local/api/pico/info", nil) + rec := httptest.NewRecorder() + + h.handleGetPicoInfo(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var resp map[string]any + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if _, ok := resp["token"]; ok { + t.Fatal("info response must not expose the raw pico token") + } + if resp["enabled"] != true { + t.Fatalf("enabled = %#v, want true", resp["enabled"]) + } + if resp["configured"] != true { + t.Fatalf("configured = %#v, want true", resp["configured"]) + } + if resp["ws_url"] == nil || resp["ws_url"] == "" { + t.Fatal("response should contain ws_url") + } +} + +func TestHandleRegenPicoToken_RefreshesGatewayTokenCache(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.EnsurePicoChannel(); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + + origPicoToken := gateway.picoToken + t.Cleanup(func() { + gateway.mu.Lock() + gateway.picoToken = origPicoToken + gateway.mu.Unlock() + }) + + gateway.mu.Lock() + gateway.picoToken = "stale-token" + gateway.mu.Unlock() + + req := httptest.NewRequest(http.MethodPost, "http://launcher.local/api/pico/token", nil) + rec := httptest.NewRecorder() + h.handleRegenPicoToken(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + bc := cfg.Channels["pico"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + token := decoded.(*config.PicoSettings).Token.String() + if token == "" { + t.Fatal("expected regenerated pico token to be persisted") + } + if token == "stale-token" { + t.Fatal("expected regenerated pico token to differ from stale cache") + } + + gateway.mu.Lock() + defer gateway.mu.Unlock() + if gateway.picoToken != token { + t.Fatalf("gateway.picoToken = %q, want %q", gateway.picoToken, token) + } +} + +func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { + origMatcher := gatewayProcessMatcher + gatewayProcessMatcher = func(int) (bool, bool) { return true, true } + t.Cleanup(func() { gatewayProcessMatcher = origMatcher }) + + home := t.TempDir() + t.Setenv("PICOCLAW_HOME", home) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + handler := h.handleWebSocketProxy() + + server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pico/ws" { + t.Fatalf("server1 path = %q, want %q", r.URL.Path, "/pico/ws") + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "server1") + })) + defer server1.Close() + + server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pico/ws" { + t.Fatalf("server2 path = %q, want %q", r.URL.Path, "/pico/ws") + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "server2") + })) + defer server2.Close() + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = mustGatewayTestPort(t, server1.URL) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + cmd := startGatewayLikeProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + writeTestPidFile(t, ppid.PidFileData{ + PID: cmd.Process.Pid, + Token: "test-token", + Host: cfg.Gateway.Host, + Port: cfg.Gateway.Port, + }) + origPidData := gateway.pidData + origPicoToken := gateway.picoToken + t.Cleanup(func() { + ppid.RemovePidFile(globalConfigDir()) + gateway.pidData = origPidData + gateway.picoToken = origPicoToken + }) + + gateway.pidData = &ppid.PidFileData{} + gateway.picoToken = "pico" + req1 := newPicoProxyRequest(http.MethodGet, "/pico/ws") + rec1 := httptest.NewRecorder() + handler(rec1, req1) + + if rec1.Code != http.StatusOK { + t.Fatalf("first status = %d, want %d", rec1.Code, http.StatusOK) + } + if body := rec1.Body.String(); body != "server1" { + t.Fatalf("first body = %q, want %q", body, "server1") + } + + cfg.Gateway.Port = mustGatewayTestPort(t, server2.URL) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + req2 := newPicoProxyRequest(http.MethodGet, "/pico/ws") + rec2 := httptest.NewRecorder() + handler(rec2, req2) + + if rec2.Code != http.StatusOK { + t.Fatalf("second status = %d, want %d", rec2.Code, http.StatusOK) + } + if body := rec2.Body.String(); body != "server2" { + t.Fatalf("second body = %q, want %q", body, "server2") + } +} + +func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) { + origMatcher := gatewayProcessMatcher + gatewayProcessMatcher = func(int) (bool, bool) { return true, true } + t.Cleanup(func() { gatewayProcessMatcher = origMatcher }) + + home := t.TempDir() + t.Setenv("PICOCLAW_HOME", home) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + handler := h.handleWebSocketProxy() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pico/ws" { + t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws") + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "proxied") + })) + defer server.Close() + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = mustGatewayTestPort(t, server.URL) + bc := cfg.Channels["pico"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + bc.Enabled = true + picoCfg.SetToken("cached-token") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + cmd := startGatewayLikeProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + writeTestPidFile(t, ppid.PidFileData{ + PID: cmd.Process.Pid, + Token: "test-token", + Host: cfg.Gateway.Host, + Port: cfg.Gateway.Port, + }) + t.Cleanup(func() { + ppid.RemovePidFile(globalConfigDir()) + }) + + origPidData := gateway.pidData + origPicoToken := gateway.picoToken + t.Cleanup(func() { + gateway.pidData = origPidData + gateway.picoToken = origPicoToken + }) + + gateway.pidData = &ppid.PidFileData{} + gateway.picoToken = "" + + req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session") + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if body := rec.Body.String(); body != "proxied" { + t.Fatalf("body = %q, want %q", body, "proxied") + } + if gateway.picoToken != "cached-token" { + t.Fatalf("gateway.picoToken = %q, want %q", gateway.picoToken, "cached-token") + } +} + +func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) { + origMatcher := gatewayProcessMatcher + gatewayProcessMatcher = func(int) (bool, bool) { return true, true } + t.Cleanup(func() { gatewayProcessMatcher = origMatcher }) + + home := t.TempDir() + t.Setenv("PICOCLAW_HOME", home) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + handler := h.handleWebSocketProxy() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pico/ws" { + t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws") + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, r.Header.Get(protocolKey)) + })) + defer server.Close() + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = mustGatewayTestPort(t, server.URL) + bc := cfg.Channels["pico"] + bc.Enabled = true + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + decoded.(*config.PicoSettings).SetToken("ui-token") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + cmd := startGatewayLikeProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + pidData := ppid.PidFileData{ + PID: cmd.Process.Pid, + Token: "test-token", + Host: cfg.Gateway.Host, + Port: cfg.Gateway.Port, + } + writeTestPidFile(t, pidData) + t.Cleanup(func() { + ppid.RemovePidFile(globalConfigDir()) + }) + + origPidData := gateway.pidData + origPicoToken := gateway.picoToken + origStatus := gateway.runtimeStatus + t.Cleanup(func() { + gateway.mu.Lock() + gateway.pidData = origPidData + gateway.picoToken = origPicoToken + gateway.runtimeStatus = origStatus + gateway.mu.Unlock() + }) + + gateway.mu.Lock() + gateway.pidData = nil + gateway.picoToken = "" + setGatewayRuntimeStatusLocked("stopped") + gateway.mu.Unlock() + + req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session") + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + expected := tokenPrefix + "ui-token" + if got := rec.Body.String(); got != expected { + t.Fatalf("forwarded protocol = %q, want %q", got, expected) + } + + gateway.mu.Lock() + defer gateway.mu.Unlock() + if gateway.pidData == nil { + t.Fatal("gateway.pidData should be loaded from pid file") + } + if gateway.runtimeStatus != "running" { + t.Fatalf("runtimeStatus = %q, want %q", gateway.runtimeStatus, "running") + } +} + +func TestCreatePicoHTTPProxyInjectsGatewayAuth(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = 18790 + bc := cfg.Channels["pico"] + bc.Enabled = true + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + decoded.(*config.PicoSettings).SetToken("ui-token") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + proxy := h.createPicoHTTPProxy("ui-token") + var capturedPath string + var capturedAuth string + proxy.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + capturedPath = req.URL.Path + capturedAuth = req.Header.Get("Authorization") + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("proxied")), + Request: req, + }, nil + }) + + req := httptest.NewRequest(http.MethodGet, "/pico/media/attachment-1", nil) + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if capturedPath != "/pico/media/attachment-1" { + t.Fatalf("capturedPath = %q, want %q", capturedPath, "/pico/media/attachment-1") + } + expected := "Bearer ui-token" + if capturedAuth != expected { + t.Fatalf("Authorization = %q, want %q", capturedAuth, expected) + } +} + +func TestHandlePicoMediaProxyUsesRawBearerToken(t *testing.T) { + home := t.TempDir() + t.Setenv("PICOCLAW_HOME", home) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + handler := h.handlePicoMediaProxy() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pico/media/attachment-1" { + t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/media/attachment-1") + } + if got := r.Header.Get("Authorization"); got != "Bearer ui-token" { + t.Fatalf("Authorization = %q, want %q", got, "Bearer ui-token") + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "proxied-media") + })) + defer server.Close() + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = mustGatewayTestPort(t, server.URL) + bc := cfg.Channels["pico"] + bc.Enabled = true + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + decoded.(*config.PicoSettings).SetToken("ui-token") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + cmd := startGatewayLikeProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + origPidData := gateway.pidData + origPicoToken := gateway.picoToken + origCmd := gateway.cmd + t.Cleanup(func() { + gateway.mu.Lock() + gateway.pidData = origPidData + gateway.picoToken = origPicoToken + gateway.cmd = origCmd + gateway.mu.Unlock() + }) + + gateway.mu.Lock() + gateway.pidData = &ppid.PidFileData{PID: cmd.Process.Pid} + gateway.picoToken = "ui-token" + gateway.cmd = cmd + gateway.mu.Unlock() + + req := newPicoProxyRequest(http.MethodGet, "/pico/media/attachment-1") + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if body := rec.Body.String(); body != "proxied-media" { + t.Fatalf("body = %q, want %q", body, "proxied-media") + } +} + +func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("PICOCLAW_HOME", filepath.Join(tmpDir, ".picoclaw")) + + configPath := filepath.Join(tmpDir, "config.json") + h := NewHandler(configPath) + handler := h.handleWebSocketProxy() + + cfg := config.DefaultConfig() + bc := cfg.Channels["pico"] + bc.Enabled = true + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + decoded.(*config.PicoSettings).SetToken("ui-token") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + cmd := startLongRunningProcess(t) + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + + origPidData := gateway.pidData + origPicoToken := gateway.picoToken + origCmd := gateway.cmd + origStatus := gateway.runtimeStatus + t.Cleanup(func() { + gateway.mu.Lock() + gateway.pidData = origPidData + gateway.picoToken = origPicoToken + gateway.cmd = origCmd + gateway.runtimeStatus = origStatus + gateway.mu.Unlock() + }) + + gateway.mu.Lock() + gateway.pidData = &ppid.PidFileData{PID: cmd.Process.Pid, Token: "stale-token"} + gateway.picoToken = "ui-token" + gateway.cmd = cmd + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session") + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable) + } + gateway.mu.Lock() + defer gateway.mu.Unlock() + if gateway.pidData != nil { + t.Fatal("gateway.pidData should be cleared after stale process exit is detected") + } +} + +func TestHandleWebSocketProxy_AllowsArbitraryOrigin(t *testing.T) { + origMatcher := gatewayProcessMatcher + gatewayProcessMatcher = func(int) (bool, bool) { return true, true } + t.Cleanup(func() { gatewayProcessMatcher = origMatcher }) + + home := t.TempDir() + t.Setenv("PICOCLAW_HOME", home) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + handler := h.handleWebSocketProxy() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pico/ws" { + t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws") + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "proxied") + })) + defer server.Close() + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = mustGatewayTestPort(t, server.URL) + bc := cfg.Channels["pico"] + bc.Enabled = true + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + decoded.(*config.PicoSettings).SetToken("ui-token") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + cmd := startGatewayLikeProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + writeTestPidFile(t, ppid.PidFileData{ + PID: cmd.Process.Pid, + Token: "test-token", + Host: cfg.Gateway.Host, + Port: cfg.Gateway.Port, + }) + t.Cleanup(func() { + ppid.RemovePidFile(globalConfigDir()) + }) + + origPidData := gateway.pidData + origPicoToken := gateway.picoToken + t.Cleanup(func() { + gateway.pidData = origPidData + gateway.picoToken = origPicoToken + }) + + gateway.pidData = &ppid.PidFileData{} + gateway.picoToken = "ui-token" + + req := httptest.NewRequest(http.MethodGet, "http://launcher.local/pico/ws?session_id=test-session", nil) + req.Header.Set("Origin", "http://evil.example") + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func mustGatewayTestPort(t *testing.T, rawURL string) int { + t.Helper() + + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("url.Parse() error = %v", err) + } + + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatalf("Atoi(%q) error = %v", parsed.Port(), err) + } + + return port +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go new file mode 100644 index 000000000..76f63607e --- /dev/null +++ b/web/backend/api/router.go @@ -0,0 +1,115 @@ +package api + +import ( + "net/http" + "strings" + "sync" + + "github.com/sipeed/picoclaw/web/backend/launcherconfig" +) + +// Handler serves HTTP API requests. +type Handler struct { + configPath string + serverPort int + serverPublic bool + serverPublicExplicit bool + serverHostInput string + serverHostExplicit bool + serverCIDRs []string + debug bool + oauthMu sync.Mutex + oauthFlows map[string]*oauthFlow + oauthState map[string]string + weixinMu sync.Mutex + weixinFlows map[string]*weixinFlow + wecomMu sync.Mutex + wecomFlows map[string]*wecomFlow +} + +// NewHandler creates an instance of the API handler. +func NewHandler(configPath string) *Handler { + return &Handler{ + configPath: configPath, + serverPort: launcherconfig.DefaultPort, + oauthFlows: make(map[string]*oauthFlow), + oauthState: make(map[string]string), + weixinFlows: make(map[string]*weixinFlow), + wecomFlows: make(map[string]*wecomFlow), + } +} + +// SetServerOptions stores current backend listen options for fallback behavior. +func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, allowedCIDRs []string) { + h.serverPort = port + h.serverPublic = public + h.serverPublicExplicit = publicExplicit + h.serverHostInput = "" + h.serverHostExplicit = false + h.serverCIDRs = append([]string(nil), allowedCIDRs...) +} + +// SetServerBindHost stores the launcher's effective bind host. +// When explicit is true, hostInput is the normalized -host / PICOCLAW_LAUNCHER_HOST value. +func (h *Handler) SetServerBindHost(hostInput string, explicit bool) { + h.serverHostInput = strings.TrimSpace(hostInput) + if !explicit { + h.serverHostInput = "" + } + h.serverHostExplicit = explicit +} + +func (h *Handler) SetDebug(debug bool) { + h.debug = debug +} + +// RegisterRoutes binds all API endpoint handlers to the ServeMux. +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + // Config CRUD + h.registerConfigRoutes(mux) + + // Pico Channel (WebSocket chat) + h.registerPicoRoutes(mux) + + // Gateway process lifecycle + h.registerGatewayRoutes(mux) + + // Session history + h.registerSessionRoutes(mux) + + // OAuth login and credential management + h.registerOAuthRoutes(mux) + + // Model list management + h.registerModelRoutes(mux) + + // Channel catalog (for frontend navigation/config pages) + h.registerChannelRoutes(mux) + + // Skills and tools support/actions + h.registerSkillRoutes(mux) + h.registerToolRoutes(mux) + + // OS startup / launch-at-login + h.registerStartupRoutes(mux) + + // Launcher service parameters (port/public) + h.registerLauncherConfigRoutes(mux) + + // Self-update endpoint (requires dashboard auth) + h.registerUpdateRoutes(mux) + + // Runtime build/version metadata + h.registerVersionRoutes(mux) + + // WeChat QR login flow + h.registerWeixinRoutes(mux) + + // WeCom QR login flow + h.registerWecomRoutes(mux) +} + +// Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler. +func (h *Handler) Shutdown() { + h.StopGateway() +} diff --git a/web/backend/api/session.go b/web/backend/api/session.go new file mode 100644 index 000000000..cc18ee6e1 --- /dev/null +++ b/web/backend/api/session.go @@ -0,0 +1,981 @@ +package api + +import ( + "bufio" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/memory" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// registerSessionRoutes binds session list and detail endpoints to the ServeMux. +func (h *Handler) registerSessionRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/sessions", h.handleListSessions) + mux.HandleFunc("GET /api/sessions/{id}", h.handleGetSession) + mux.HandleFunc("DELETE /api/sessions/{id}", h.handleDeleteSession) +} + +// sessionFile mirrors the on-disk session JSON structure from pkg/session. +type sessionFile struct { + Key string `json:"key"` + Messages []providers.Message `json:"messages"` + Summary string `json:"summary,omitempty"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +// sessionListItem is a lightweight summary returned by GET /api/sessions. +type sessionListItem struct { + ID string `json:"id"` + Title string `json:"title"` + Preview string `json:"preview"` + MessageCount int `json:"message_count"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +type sessionChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` + Kind string `json:"kind,omitempty"` + Media []string `json:"media,omitempty"` + Attachments []sessionChatAttachment `json:"attachments,omitempty"` + ToolCalls []utils.VisibleToolCall `json:"tool_calls,omitempty"` +} + +type sessionChatAttachment struct { + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` + Filename string `json:"filename,omitempty"` + ContentType string `json:"content_type,omitempty"` +} + +// legacyPicoSessionPrefix is the legacy key prefix used by older Pico JSON/JSONL +// sessions before structured scope metadata existed. +const ( + legacyPicoSessionPrefix = "agent:main:pico:direct:pico:" + picoSessionPrefix = legacyPicoSessionPrefix + + // Keep the session API aligned with the shared JSONL store reader limit in + // pkg/memory/jsonl.go so oversized lines fail consistently everywhere. + maxSessionJSONLLineSize = 10 * 1024 * 1024 + maxSessionTitleRunes = 60 + + handledToolResponseSummaryText = "Requested output delivered via tool attachment." +) + +func defaultToolFeedbackMaxArgsLength() int { + defaults := config.AgentDefaults{} + return defaults.GetToolFeedbackMaxArgsLength() +} + +// extractLegacyPicoSessionID extracts the session UUID from an old Pico key. +// Returns the UUID and true if the key matches the Pico session pattern. +func extractLegacyPicoSessionID(key string) (string, bool) { + if strings.HasPrefix(key, legacyPicoSessionPrefix) { + return strings.TrimPrefix(key, legacyPicoSessionPrefix), true + } + return "", false +} + +func sanitizeSessionKey(key string) string { + key = strings.ReplaceAll(key, ":", "_") + key = strings.ReplaceAll(key, "/", "_") + key = strings.ReplaceAll(key, "\\", "_") + return key +} + +func (h *Handler) readLegacySession(path string) (sessionFile, error) { + data, err := os.ReadFile(path) + if err != nil { + return sessionFile{}, err + } + + var sess sessionFile + if err := json.Unmarshal(data, &sess); err != nil { + return sessionFile{}, err + } + return sess, nil +} + +func (h *Handler) readSessionMeta(path, sessionKey string) (memory.SessionMeta, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return memory.SessionMeta{Key: sessionKey}, nil + } + if err != nil { + return memory.SessionMeta{}, err + } + + var meta memory.SessionMeta + if err := json.Unmarshal(data, &meta); err != nil { + return memory.SessionMeta{}, err + } + if meta.Key == "" { + meta.Key = sessionKey + } + return meta, nil +} + +func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Message, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + msgs := make([]providers.Message, 0) + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), maxSessionJSONLLineSize) + + seen := 0 + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + seen++ + if seen <= skip { + continue + } + + var msg providers.Message + if err := json.Unmarshal(line, &msg); err != nil { + continue + } + if messageutil.IsTransientAssistantThoughtMessage(msg) { + continue + } + msgs = append(msgs, msg) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return msgs, nil +} + +func (h *Handler) readJSONLSession(dir, sessionKey string) (sessionFile, error) { + base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) + jsonlPath := base + ".jsonl" + metaPath := base + ".meta.json" + + meta, err := h.readSessionMeta(metaPath, sessionKey) + if err != nil { + return sessionFile{}, err + } + + messages, err := h.readSessionMessages(jsonlPath, meta.Skip) + if err != nil { + return sessionFile{}, err + } + + updated := meta.UpdatedAt + created := meta.CreatedAt + if created.IsZero() || updated.IsZero() { + if info, statErr := os.Stat(jsonlPath); statErr == nil { + if created.IsZero() { + created = info.ModTime() + } + if updated.IsZero() { + updated = info.ModTime() + } + } + } + + return sessionFile{ + Key: meta.Key, + Messages: messages, + Summary: meta.Summary, + Created: created, + Updated: updated, + }, nil +} + +type picoJSONLSessionRef struct { + ID string + Key string +} + +type picoLegacySessionRef struct { + ID string + Path string +} + +func extractPicoSessionIDFromScope(scope session.SessionScope) (string, bool) { + if !strings.EqualFold(strings.TrimSpace(scope.Channel), "pico") { + return "", false + } + + candidates := []string{ + strings.TrimSpace(scope.Values["sender"]), + strings.TrimSpace(scope.Values["chat"]), + } + for _, candidate := range candidates { + if candidate == "" { + continue + } + if idx := strings.Index(candidate, "pico:"); idx >= 0 { + sessionID := strings.TrimSpace(candidate[idx+len("pico:"):]) + if sessionID != "" { + return sessionID, true + } + } + } + return "", false +} + +func sessionRefFromMeta(meta memory.SessionMeta) (picoJSONLSessionRef, bool) { + if len(meta.Scope) == 0 { + if sessionID, ok := extractLegacyPicoSessionID(meta.Key); ok { + return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true + } + for _, alias := range meta.Aliases { + if sessionID, ok := extractLegacyPicoSessionID(alias); ok { + return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true + } + } + return picoJSONLSessionRef{}, false + } + var scope session.SessionScope + if err := json.Unmarshal(meta.Scope, &scope); err != nil { + return picoJSONLSessionRef{}, false + } + sessionID, ok := extractPicoSessionIDFromScope(scope) + if !ok { + if legacySessionID, ok := extractLegacyPicoSessionID(meta.Key); ok { + return picoJSONLSessionRef{ID: legacySessionID, Key: meta.Key}, true + } + for _, alias := range meta.Aliases { + if legacySessionID, ok := extractLegacyPicoSessionID(alias); ok { + return picoJSONLSessionRef{ID: legacySessionID, Key: meta.Key}, true + } + } + return picoJSONLSessionRef{}, false + } + return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true +} + +func (h *Handler) findPicoJSONLSessions(dir string) ([]picoJSONLSessionRef, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + refs := make([]picoJSONLSessionRef, 0) + seen := make(map[string]struct{}) + metaBackedBases := make(map[string]struct{}) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") { + continue + } + name := entry.Name() + metaPath := filepath.Join(dir, name) + meta, err := h.readSessionMeta(metaPath, "") + if err != nil { + continue + } + ref, ok := sessionRefFromMeta(meta) + if !ok || ref.Key == "" || ref.ID == "" { + continue + } + metaBackedBases[strings.TrimSuffix(name, ".meta.json")] = struct{}{} + if _, exists := seen[ref.ID]; exists { + continue + } + seen[ref.ID] = struct{}{} + refs = append(refs, ref) + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + name := entry.Name() + base := strings.TrimSuffix(name, ".jsonl") + if _, ok := metaBackedBases[base]; ok { + continue + } + ref, ok := jsonlSessionRefFromFilename(name) + if !ok || ref.Key == "" || ref.ID == "" { + continue + } + if _, exists := seen[ref.ID]; exists { + continue + } + seen[ref.ID] = struct{}{} + refs = append(refs, ref) + } + return refs, nil +} + +func (h *Handler) findPicoJSONLSession(dir, sessionID string) (picoJSONLSessionRef, error) { + refs, err := h.findPicoJSONLSessions(dir) + if err != nil { + return picoJSONLSessionRef{}, err + } + for _, ref := range refs { + if ref.ID == sessionID { + return ref, nil + } + } + return picoJSONLSessionRef{}, os.ErrNotExist +} + +func (h *Handler) findLegacyPicoSessions(dir string) ([]picoLegacySessionRef, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + refs := make([]picoLegacySessionRef, 0) + seen := make(map[string]struct{}) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || filepath.Ext(name) != ".json" || strings.HasSuffix(name, ".meta.json") { + continue + } + + path := filepath.Join(dir, entry.Name()) + sess, err := h.readLegacySession(path) + if err != nil || isEmptySession(sess) { + continue + } + + sessionID, ok := extractLegacyPicoSessionID(sess.Key) + if !ok || sessionID == "" { + continue + } + if _, exists := seen[sessionID]; exists { + continue + } + seen[sessionID] = struct{}{} + refs = append(refs, picoLegacySessionRef{ID: sessionID, Path: path}) + } + return refs, nil +} + +func jsonlSessionRefFromFilename(name string) (picoJSONLSessionRef, bool) { + if !strings.HasSuffix(name, ".jsonl") { + return picoJSONLSessionRef{}, false + } + base := strings.TrimSuffix(name, ".jsonl") + if base == "" { + return picoJSONLSessionRef{}, false + } + + legacyPrefix := sanitizeSessionKey(legacyPicoSessionPrefix) + if strings.HasPrefix(base, legacyPrefix) { + sessionID := strings.TrimPrefix(base, legacyPrefix) + if sessionID == "" { + return picoJSONLSessionRef{}, false + } + return picoJSONLSessionRef{ + ID: sessionID, + Key: legacyPicoSessionPrefix + sessionID, + }, true + } + + if session.IsOpaqueSessionKey(base) { + return picoJSONLSessionRef{ + ID: base, + Key: base, + }, true + } + + return picoJSONLSessionRef{}, false +} + +func (h *Handler) findLegacyPicoSession(dir, sessionID string) (picoLegacySessionRef, error) { + refs, err := h.findLegacyPicoSessions(dir) + if err != nil { + return picoLegacySessionRef{}, err + } + for _, ref := range refs { + if ref.ID == sessionID { + return ref, nil + } + } + return picoLegacySessionRef{}, os.ErrNotExist +} + +func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArgsLength int) sessionListItem { + transcript := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength) + + preview := "" + for _, msg := range transcript { + if msg.Role == "user" { + preview = sessionChatMessagePreview(msg) + } + if preview != "" { + break + } + } + preview = truncateRunes(preview, maxSessionTitleRunes) + + if preview == "" { + preview = "(empty)" + } + title := preview + + return sessionListItem{ + ID: sessionID, + Title: title, + Preview: preview, + MessageCount: len(transcript), + Created: sess.Created.Format(time.RFC3339), + Updated: sess.Updated.Format(time.RFC3339), + } +} + +func isEmptySession(sess sessionFile) bool { + return len(sess.Messages) == 0 && strings.TrimSpace(sess.Summary) == "" +} + +func truncateRunes(s string, maxLen int) string { + if maxLen <= 0 { + return "" + } + runes := []rune(strings.TrimSpace(s)) + if len(runes) <= maxLen { + return string(runes) + } + return string(runes[:maxLen]) + "..." +} + +func sessionChatMessageVisible(msg sessionChatMessage) bool { + return strings.TrimSpace(msg.Content) != "" || + len(msg.Media) > 0 || + len(msg.Attachments) > 0 || + len(msg.ToolCalls) > 0 +} + +func sessionChatMessagePreview(msg sessionChatMessage) string { + if content := strings.TrimSpace(msg.Content); content != "" { + return content + } + if len(msg.Attachments) > 0 { + if strings.EqualFold(strings.TrimSpace(msg.Attachments[0].Type), "image") { + return "[image]" + } + return "[attachment]" + } + if len(msg.Media) > 0 { + if strings.HasPrefix(strings.TrimSpace(msg.Media[0]), "data:image/") { + return "[image]" + } + return "[attachment]" + } + if len(msg.ToolCalls) > 0 { + return "[tool call]" + } + return "" +} + +func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage { + return sessionTranscriptMessages(messages, toolFeedbackMaxArgsLength, false) +} + +func detailSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage { + return sessionTranscriptMessages(messages, toolFeedbackMaxArgsLength, true) +} + +func sessionTranscriptMessages( + messages []providers.Message, + toolFeedbackMaxArgsLength int, + includeThoughts bool, +) []sessionChatMessage { + transcript := make([]sessionChatMessage, 0, len(messages)) + + for _, msg := range messages { + attachments := sessionAttachments(msg) + + switch msg.Role { + case "tool": + continue + + case "user": + chatMsg := sessionChatMessage{ + Role: "user", + Content: msg.Content, + Media: append([]string(nil), msg.Media...), + Attachments: attachments, + } + if sessionChatMessageVisible(chatMsg) { + transcript = append(transcript, chatMsg) + } + + case "assistant": + if messageutil.IsTransientAssistantThoughtMessage(msg) { + continue + } + if includeThoughts { + if thoughtMsg, ok := assistantThoughtMessage(msg); ok { + transcript = append(transcript, thoughtMsg) + } + } + + toolCallsMsg, hasToolCallsMsg := assistantToolCallsMessage( + msg.ToolCalls, + toolFeedbackMaxArgsLength, + ) + visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls) + + // Pico web chat can persist both visible `message` tool output and a + // later plain assistant reply in the same turn. Hide only the fixed + // internal summary that marks handled tool delivery. + content := msg.Content + if assistantMessageInternalOnly(msg) { + if len(attachments) == 0 { + if hasToolCallsMsg { + transcript = append(transcript, toolCallsMsg) + } + if len(visibleToolMessages) > 0 { + transcript = append(transcript, visibleToolMessages...) + } + continue + } + content = "" + } + if hasToolCallsMsg && utils.ToolCallExplanationDuplicatesContent(content, msg.ToolCalls) { + content = "" + } + + chatMsg := sessionChatMessage{ + Role: "assistant", + Content: content, + Media: append([]string(nil), msg.Media...), + Attachments: attachments, + } + if !sessionChatMessageVisible(chatMsg) { + if hasToolCallsMsg { + transcript = append(transcript, toolCallsMsg) + } + if len(visibleToolMessages) > 0 { + transcript = append(transcript, visibleToolMessages...) + } + continue + } + + transcript = append(transcript, chatMsg) + if hasToolCallsMsg { + transcript = append(transcript, toolCallsMsg) + } + if len(visibleToolMessages) > 0 { + transcript = append(transcript, visibleToolMessages...) + } + } + } + + return filterSessionChatMessages(transcript) +} + +func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessage { + filtered := messages[:0] + for _, msg := range messages { + if msg.Role != "user" && msg.Role != "assistant" { + continue + } + filtered = append(filtered, msg) + } + return filtered +} + +func sessionAttachments(msg providers.Message) []sessionChatAttachment { + if len(msg.Attachments) == 0 { + return nil + } + + attachments := make([]sessionChatAttachment, 0, len(msg.Attachments)) + for _, attachment := range msg.Attachments { + urlValue, ok := sessionAttachmentURL(attachment) + if !ok { + continue + } + attachmentType := strings.TrimSpace(attachment.Type) + if attachmentType == "" { + attachmentType = sessionAttachmentType(attachment) + } + attachments = append(attachments, sessionChatAttachment{ + Type: attachmentType, + URL: urlValue, + Filename: strings.TrimSpace(attachment.Filename), + ContentType: strings.TrimSpace(attachment.ContentType), + }) + } + + if len(attachments) == 0 { + return nil + } + return attachments +} + +func sessionAttachmentURL(attachment providers.Attachment) (string, bool) { + if rawURL := strings.TrimSpace(attachment.URL); rawURL != "" { + return rawURL, true + } + + ref := strings.TrimSpace(attachment.Ref) + if ref == "" { + return "", false + } + if strings.HasPrefix(ref, "media://") { + // Persisted session history must only expose durable attachment locations. + // media:// refs depend on the live in-memory MediaStore and may stop + // resolving after a restart or cleanup, so omit them from reopened history. + return "", false + } + return ref, true +} + +func sessionAttachmentType(attachment providers.Attachment) string { + contentType := strings.ToLower(strings.TrimSpace(attachment.ContentType)) + filename := strings.ToLower(strings.TrimSpace(attachment.Filename)) + rawRef := strings.ToLower(strings.TrimSpace(attachment.Ref)) + rawURL := strings.ToLower(strings.TrimSpace(attachment.URL)) + + switch { + case strings.HasPrefix(contentType, "image/"), + strings.HasPrefix(rawRef, "data:image/"), + strings.HasPrefix(rawURL, "data:image/"): + return "image" + case strings.HasPrefix(contentType, "audio/"): + return "audio" + case strings.HasPrefix(contentType, "video/"): + return "video" + } + + switch ext := filepath.Ext(filename); ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": + return "image" + case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": + return "audio" + case ".mp4", ".avi", ".mov", ".webm", ".mkv": + return "video" + default: + return "file" + } +} + +func assistantMessageInternalOnly(msg providers.Message) bool { + return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText +} + +func assistantThoughtMessage(msg providers.Message) (sessionChatMessage, bool) { + reasoning := strings.TrimSpace(msg.ReasoningContent) + if reasoning == "" { + return sessionChatMessage{}, false + } + if reasoning == strings.TrimSpace(msg.Content) { + return sessionChatMessage{}, false + } + return sessionChatMessage{ + Role: "assistant", + Content: reasoning, + Kind: "thought", + }, true +} + +func assistantToolCallsMessage( + toolCalls []providers.ToolCall, + toolFeedbackMaxArgsLength int, +) (sessionChatMessage, bool) { + if len(toolCalls) == 0 { + return sessionChatMessage{}, false + } + if toolFeedbackMaxArgsLength <= 0 { + toolFeedbackMaxArgsLength = defaultToolFeedbackMaxArgsLength() + } + + visibleToolCalls := utils.BuildVisibleToolCalls(toolCalls, toolFeedbackMaxArgsLength) + if len(visibleToolCalls) == 0 { + return sessionChatMessage{}, false + } + + return sessionChatMessage{ + Role: "assistant", + Kind: "tool_calls", + ToolCalls: visibleToolCalls, + }, true +} + +func visibleAssistantToolArgsPreview( + tc providers.ToolCall, + toolFeedbackMaxArgsLength int, +) string { + return utils.VisibleToolCallArgumentsPreview(tc, toolFeedbackMaxArgsLength) +} + +func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage { + if len(toolCalls) == 0 { + return nil + } + + messages := make([]sessionChatMessage, 0, len(toolCalls)) + for _, tc := range toolCalls { + name, argsJSON := utils.VisibleToolCallNameAndArguments(tc) + if name != "message" { + continue + } + content, ok := parseMessageToolContent(argsJSON) + if !ok { + continue + } + messages = append(messages, sessionChatMessage{ + Role: "assistant", + Content: content, + }) + } + + return messages +} + +func parseMessageToolContent(argsJSON string) (string, bool) { + var args struct { + Content string `json:"content"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", false + } + if strings.TrimSpace(args.Content) == "" { + return "", false + } + return args.Content, true +} + +// sessionsDir resolves the path to the gateway's session storage directory. +// It reads the workspace from config, falling back to ~/.picoclaw/workspace. +func (h *Handler) sessionsDir() (string, error) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return "", err + } + + return resolveSessionsDir(cfg.Agents.Defaults.Workspace), nil +} + +func (h *Handler) sessionRuntimeSettings() (string, int, error) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return "", 0, err + } + + return resolveSessionsDir(cfg.Agents.Defaults.Workspace), cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), nil +} + +func resolveSessionsDir(workspace string) string { + if workspace == "" { + home, _ := os.UserHomeDir() + workspace = filepath.Join(home, ".picoclaw", "workspace") + } + + // Expand ~ prefix + if len(workspace) > 0 && workspace[0] == '~' { + home, _ := os.UserHomeDir() + if len(workspace) > 1 && workspace[1] == '/' { + workspace = home + workspace[1:] + } else { + workspace = home + } + } + + return filepath.Join(workspace, "sessions") +} + +// handleListSessions returns a list of Pico session summaries. +// +// GET /api/sessions +func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { + dir, toolFeedbackMaxArgsLength, err := h.sessionRuntimeSettings() + if err != nil { + http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) + return + } + + if _, err := os.ReadDir(dir); err != nil { + // Directory doesn't exist yet = no sessions + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]sessionListItem{}) + return + } + + items := []sessionListItem{} + seen := make(map[string]struct{}) + + if refs, findErr := h.findPicoJSONLSessions(dir); findErr == nil { + for _, ref := range refs { + sess, loadErr := h.readJSONLSession(dir, ref.Key) + if loadErr != nil || isEmptySession(sess) { + continue + } + seen[ref.ID] = struct{}{} + items = append(items, buildSessionListItem(ref.ID, sess, toolFeedbackMaxArgsLength)) + } + } + + if legacyRefs, findErr := h.findLegacyPicoSessions(dir); findErr == nil { + for _, ref := range legacyRefs { + if _, exists := seen[ref.ID]; exists { + continue + } + sess, loadErr := h.readLegacySession(ref.Path) + if loadErr != nil || isEmptySession(sess) { + continue + } + seen[ref.ID] = struct{}{} + items = append(items, buildSessionListItem(ref.ID, sess, toolFeedbackMaxArgsLength)) + } + } + + // Sort by updated descending (most recent first) + sort.Slice(items, func(i, j int) bool { + return items[i].Updated > items[j].Updated + }) + + // Pagination parameters + offsetStr := r.URL.Query().Get("offset") + limitStr := r.URL.Query().Get("limit") + + offset := 0 + limit := 20 // Default limit + + if val, err := strconv.Atoi(offsetStr); err == nil && val >= 0 { + offset = val + } + if val, err := strconv.Atoi(limitStr); err == nil && val > 0 { + limit = val + } + + totalItems := len(items) + + end := offset + limit + if offset >= totalItems { + items = []sessionListItem{} // Out of bounds, return empty + } else { + if end > totalItems { + end = totalItems + } + items = items[offset:end] + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(items) +} + +// handleGetSession returns the full message history for a specific session. +// +// GET /api/sessions/{id} +func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { + sessionID := r.PathValue("id") + if sessionID == "" { + http.Error(w, "missing session id", http.StatusBadRequest) + return + } + + dir, toolFeedbackMaxArgsLength, err := h.sessionRuntimeSettings() + if err != nil { + http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) + return + } + + ref, refErr := h.findPicoJSONLSession(dir, sessionID) + var sess sessionFile + err = refErr + if refErr == nil { + sess, err = h.readJSONLSession(dir, ref.Key) + } + if err == nil && isEmptySession(sess) { + err = os.ErrNotExist + } + if err != nil { + if errors.Is(err, os.ErrNotExist) { + if legacyRef, legacyErr := h.findLegacyPicoSession(dir, sessionID); legacyErr == nil { + sess, err = h.readLegacySession(legacyRef.Path) + } + if err == nil && isEmptySession(sess) { + err = os.ErrNotExist + } + } + if err != nil { + if errors.Is(err, os.ErrNotExist) { + http.Error(w, "session not found", http.StatusNotFound) + } else { + http.Error(w, "failed to parse session", http.StatusInternalServerError) + } + return + } + } + + messages := detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": sessionID, + "messages": messages, + "summary": sess.Summary, + "created": sess.Created.Format(time.RFC3339), + "updated": sess.Updated.Format(time.RFC3339), + }) +} + +// handleDeleteSession deletes a specific session. +// +// DELETE /api/sessions/{id} +func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) { + sessionID := r.PathValue("id") + if sessionID == "" { + http.Error(w, "missing session id", http.StatusBadRequest) + return + } + + dir, err := h.sessionsDir() + if err != nil { + http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) + return + } + + removed := false + if ref, err := h.findPicoJSONLSession(dir, sessionID); err == nil { + base := filepath.Join(dir, sanitizeSessionKey(ref.Key)) + for _, path := range []string{base + ".jsonl", base + ".meta.json"} { + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + continue + } + http.Error(w, "failed to delete session", http.StatusInternalServerError) + return + } + removed = true + } + } + + if legacyRef, err := h.findLegacyPicoSession(dir, sessionID); err == nil { + if err := os.Remove(legacyRef.Path); err != nil { + if !os.IsNotExist(err) { + http.Error(w, "failed to delete session", http.StatusInternalServerError) + return + } + } else { + removed = true + } + } + + if !removed { + http.Error(w, "session not found", http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go new file mode 100644 index 000000000..760935db7 --- /dev/null +++ b/web/backend/api/session_test.go @@ -0,0 +1,1751 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/memory" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/utils" +) + +func sessionsTestDir(t *testing.T, configPath string) string { + t.Helper() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + dir := filepath.Join(cfg.Agents.Defaults.Workspace, "sessions") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + return dir +} + +func assertVisibleToolCallMessage( + t *testing.T, + msg sessionChatMessage, + toolName string, +) utils.VisibleToolCall { + t.Helper() + + if msg.Role != "assistant" || msg.Kind != "tool_calls" { + t.Fatalf("message = %#v, want assistant/tool_calls", msg) + } + if len(msg.ToolCalls) != 1 { + t.Fatalf("len(message.ToolCalls) = %d, want 1", len(msg.ToolCalls)) + } + if got := msg.ToolCalls[0].Function; got == nil || got.Name != toolName { + t.Fatalf("tool call = %#v, want function %q", msg.ToolCalls[0], toolName) + } + return msg.ToolCalls[0] +} + +func TestHandleListSessions_JSONLStorage(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, storeErr := memory.NewJSONLStore(dir) + if storeErr != nil { + t.Fatalf("NewJSONLStore() error = %v", storeErr) + } + + sessionKey := legacyPicoSessionPrefix + "history-jsonl" + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "user", + Content: "Explain why the history API is empty after migration.", + }); err != nil { + t.Fatalf("AddFullMessage(user) error = %v", err) + } + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "assistant", + Content: "Because the API still reads only legacy JSON session files.", + }); err != nil { + t.Fatalf("AddFullMessage(assistant) error = %v", err) + } + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "tool", + Content: "ignored", + }); err != nil { + t.Fatalf("AddFullMessage(tool) error = %v", err) + } + if err := store.SetSummary(nil, sessionKey, "JSONL-backed session"); err != nil { + t.Fatalf("SetSummary() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].ID != "history-jsonl" { + t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "history-jsonl") + } + if items[0].MessageCount != 2 { + t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount) + } + if items[0].Title != "Explain why the history API is empty after migration." { + t.Fatalf( + "items[0].Title = %q, want %q", + items[0].Title, + "Explain why the history API is empty after migration.", + ) + } + if items[0].Preview != "Explain why the history API is empty after migration." { + t.Fatalf("items[0].Preview = %q", items[0].Preview) + } +} + +func TestHandleListSessions_TransientThoughtDoesNotInflateMessageCount(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + sessionKey := legacyPicoSessionPrefix + "history-jsonl-transient" + base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) + now := time.Now().UTC() + + rawJSONL := strings.Join([]string{ + `{"role":"user","content":"keep me"}`, + `{"role":"assistant","content":"","reasoning_content":"dangling thought"}`, + `{"role":"assistant","content":"and me"}`, + }, "\n") + "\n" + if err := os.WriteFile(base+".jsonl", []byte(rawJSONL), 0o644); err != nil { + t.Fatalf("WriteFile(jsonl) error = %v", err) + } + metaData, err := json.Marshal(memory.SessionMeta{ + Key: sessionKey, + Count: 3, + Skip: 0, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + t.Fatalf("Marshal(meta) error = %v", err) + } + if err := os.WriteFile(base+".meta.json", metaData, 0o644); err != nil { + t.Fatalf("WriteFile(meta) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].ID != "history-jsonl-transient" { + t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "history-jsonl-transient") + } + if items[0].MessageCount != 2 { + t.Fatalf("items[0].MessageCount = %d, want 2 after dropping transient thought", items[0].MessageCount) + } +} + +func TestHandleListSessions_TitleUsesFirstUserMessage(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, storeErr := memory.NewJSONLStore(dir) + if storeErr != nil { + t.Fatalf("NewJSONLStore() error = %v", storeErr) + } + + sessionKey := legacyPicoSessionPrefix + "summary-title" + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "user", + Content: "fallback preview", + }); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + if err := store.SetSummary( + nil, + sessionKey, + " This summary is intentionally longer than sixty characters so it must be truncated in the history menu. ", + ); err != nil { + t.Fatalf("SetSummary() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + expectedTitle := truncateRunes("fallback preview", maxSessionTitleRunes) + if items[0].Title != expectedTitle { + t.Fatalf("items[0].Title = %q", items[0].Title) + } + if items[0].Preview != "fallback preview" { + t.Fatalf("items[0].Preview = %q, want %q", items[0].Preview, "fallback preview") + } +} + +func TestHandleGetSession_JSONLStorage(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := legacyPicoSessionPrefix + "detail-jsonl" + for _, msg := range []providers.Message{ + {Role: "user", Content: "first"}, + {Role: "assistant", Content: "second"}, + {Role: "tool", Content: "ignored"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + if err := store.SetSummary(nil, sessionKey, "detail summary"); err != nil { + t.Fatalf("SetSummary() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-jsonl", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + ID string `json:"id"` + Summary string `json:"summary"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.ID != "detail-jsonl" { + t.Fatalf("resp.ID = %q, want %q", resp.ID, "detail-jsonl") + } + if resp.Summary != "detail summary" { + t.Fatalf("resp.Summary = %q, want %q", resp.Summary, "detail summary") + } + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) + } + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "first" { + t.Fatalf("first message = %#v, want user/first", resp.Messages[0]) + } + if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "second" { + t.Fatalf("second message = %#v, want assistant/second", resp.Messages[1]) + } +} + +func TestHandleGetSession_HidesHandledToolAttachmentsBackedByMediaRefs(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := legacyPicoSessionPrefix + "attachment-history" + for _, msg := range []providers.Message{ + {Role: "user", Content: "send me the report"}, + { + Role: "assistant", + Content: handledToolResponseSummaryText, + Attachments: []providers.Attachment{{ + Type: "file", + Ref: "media://attachment-1", + Filename: "report.txt", + ContentType: "text/plain", + }}, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(resp.Messages) != 1 { + t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages)) + } + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "send me the report" { + t.Fatalf("message = %#v, want only user request", resp.Messages[0]) + } +} + +func TestHandleGetSession_ExposesHandledToolAttachmentsWithDurableURL(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := legacyPicoSessionPrefix + "attachment-history-durable" + for _, msg := range []providers.Message{ + {Role: "user", Content: "send me the report"}, + { + Role: "assistant", + Content: handledToolResponseSummaryText, + Attachments: []providers.Attachment{{ + Type: "file", + URL: "https://example.com/report.txt", + Filename: "report.txt", + ContentType: "text/plain", + }}, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history-durable", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) + } + + assistant := resp.Messages[1] + if assistant.Role != "assistant" { + t.Fatalf("assistant role = %q, want assistant", assistant.Role) + } + if assistant.Content != "" { + t.Fatalf("assistant content = %q, want empty string", assistant.Content) + } + if len(assistant.Attachments) != 1 { + t.Fatalf("len(assistant.Attachments) = %d, want 1", len(assistant.Attachments)) + } + if assistant.Attachments[0].URL != "https://example.com/report.txt" { + t.Fatalf( + "attachment url = %q, want %q", + assistant.Attachments[0].URL, + "https://example.com/report.txt", + ) + } + if assistant.Attachments[0].Filename != "report.txt" { + t.Fatalf("attachment filename = %q, want %q", assistant.Attachments[0].Filename, "report.txt") + } +} + +func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, storeErr := memory.NewJSONLStore(dir) + if storeErr != nil { + t.Fatalf("NewJSONLStore() error = %v", storeErr) + } + + sessionKey := "sk_v1_scope_discovery" + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "user", + Content: "scope discovered session", + }); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + if err := store.SetSummary(nil, sessionKey, "scope summary"); err != nil { + t.Fatalf("SetSummary() error = %v", err) + } + + scopeData, err := json.Marshal(session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "main", + Channel: "pico", + Account: "default", + Dimensions: []string{"sender"}, + Values: map[string]string{ + "sender": "pico:scope-jsonl", + }, + }) + if err != nil { + t.Fatalf("Marshal(scope) error = %v", err) + } + if err := store.UpsertSessionMeta(nil, sessionKey, scopeData, nil); err != nil { + t.Fatalf("UpsertSessionMeta() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + listRec := httptest.NewRecorder() + listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal(list) error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].ID != "scope-jsonl" { + t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "scope-jsonl") + } + + detailRec := httptest.NewRecorder() + detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/scope-jsonl", nil) + mux.ServeHTTP(detailRec, detailReq) + if detailRec.Code != http.StatusOK { + t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusOK, detailRec.Body.String()) + } + + deleteRec := httptest.NewRecorder() + deleteReq := httptest.NewRequest(http.MethodDelete, "/api/sessions/scope-jsonl", nil) + mux.ServeHTTP(deleteRec, deleteReq) + if deleteRec.Code != http.StatusNoContent { + t.Fatalf("delete status = %d, want %d, body=%s", deleteRec.Code, http.StatusNoContent, deleteRec.Body.String()) + } +} + +func TestHandleGetSession_SkipsTransientThoughtMessages(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-transient-thought" + for _, msg := range []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", ReasoningContent: "internal chain of thought"}, + {Role: "assistant", Content: "final visible answer"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-transient-thought", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) + } + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "hello" { + t.Fatalf("first message = %#v, want user/hello", resp.Messages[0]) + } + if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "final visible answer" { + t.Fatalf("second message = %#v, want assistant/final visible answer", resp.Messages[1]) + } +} + +func TestHandleGetSession_ReconstructsThoughtFromAssistantReasoningContent(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-reasoning-content" + for _, msg := range []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "final visible answer", ReasoningContent: "internal chain of thought"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-reasoning-content", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + } + if resp.Messages[1].Role != "assistant" || + resp.Messages[1].Content != "internal chain of thought" || + resp.Messages[1].Kind != "thought" { + t.Fatalf("thought message = %#v, want assistant thought/internal chain of thought", resp.Messages[1]) + } + if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final visible answer" { + t.Fatalf("final message = %#v, want assistant/final visible answer", resp.Messages[2]) + } +} + +func TestHandleGetSession_ReconstructsRefreshMatrixForThoughtAndToolSummary(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-refresh-matrix" + for _, msg := range []providers.Message{ + {Role: "user", Content: "turn1"}, + {Role: "assistant", Content: "plain visible", ReasoningContent: "plain thought"}, + {Role: "user", Content: "turn2"}, + { + Role: "assistant", + ReasoningContent: "tool thought", + ToolCalls: []providers.ToolCall{{ + ID: "call_read_file", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_read_file", Content: "file result"}, + {Role: "user", Content: "turn3"}, + { + Role: "assistant", + Content: "tool visible only", + ToolCalls: []providers.ToolCall{{ + ID: "call_list_dir", + Type: "function", + Function: &providers.FunctionCall{ + Name: "list_dir", + Arguments: `{"path":"."}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_list_dir", Content: "dir result"}, + {Role: "user", Content: "turn4"}, + { + Role: "assistant", + Content: "tool visible and thought", + ReasoningContent: "tool mixed thought", + ToolCalls: []providers.ToolCall{{ + ID: "call_exec", + Type: "function", + Function: &providers.FunctionCall{ + Name: "exec", + Arguments: `{"command":"pwd"}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_exec", Content: "pwd result"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-refresh-matrix", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(resp.Messages) != 13 { + t.Fatalf("len(resp.Messages) = %d, want 13", len(resp.Messages)) + } + + assertMessage := func(index int, role, kind, content string) { + t.Helper() + msg := resp.Messages[index] + if msg.Role != role || msg.Kind != kind || msg.Content != content { + t.Fatalf("messages[%d] = %#v, want role=%q kind=%q content=%q", index, msg, role, kind, content) + } + } + + assertMessage(0, "user", "", "turn1") + assertMessage(1, "assistant", "thought", "plain thought") + assertMessage(2, "assistant", "", "plain visible") + assertMessage(3, "user", "", "turn2") + assertMessage(4, "assistant", "thought", "tool thought") + assertVisibleToolCallMessage(t, resp.Messages[5], "read_file") + assertMessage(6, "user", "", "turn3") + assertMessage(7, "assistant", "", "tool visible only") + assertVisibleToolCallMessage(t, resp.Messages[8], "list_dir") + assertMessage(9, "user", "", "turn4") + assertMessage(10, "assistant", "thought", "tool mixed thought") + assertMessage(11, "assistant", "", "tool visible and thought") + assertVisibleToolCallMessage(t, resp.Messages[12], "exec") +} + +func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-message-tool" + for _, msg := range []providers.Message{ + {Role: "user", Content: "test"}, + { + Role: "assistant", + Content: "", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "message", + Arguments: `{"content":"visible tool output"}`, + }, + }, + }, + }, + {Role: "tool", Content: "Message sent to pico:pico:detail-message-tool", ToolCallID: "call_1"}, + {Role: "assistant", Content: handledToolResponseSummaryText}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-message-tool", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + } + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" { + t.Fatalf("first message = %#v, want user/test", resp.Messages[0]) + } + assertVisibleToolCallMessage(t, resp.Messages[1], "message") + if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { + t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2]) + } +} + +func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-message-tool-final-reply" + for _, msg := range []providers.Message{ + {Role: "user", Content: "test"}, + { + Role: "assistant", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "message", + Arguments: `{"content":"visible tool output"}`, + }, + }, + }, + }, + {Role: "tool", Content: "Message sent to pico:pico:detail-message-tool-final-reply", ToolCallID: "call_1"}, + {Role: "assistant", Content: "final assistant reply"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-message-tool-final-reply", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 4 { + t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages)) + } + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" { + t.Fatalf("first message = %#v, want user/test", resp.Messages[0]) + } + assertVisibleToolCallMessage(t, resp.Messages[1], "message") + if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { + t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[2]) + } + if resp.Messages[3].Role != "assistant" || resp.Messages[3].Content != "final assistant reply" { + t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[3]) + } +} + +func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "list-visible-count" + for _, msg := range []providers.Message{ + {Role: "user", Content: "test"}, + { + Role: "assistant", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "message", + Arguments: `{"content":"visible tool output"}`, + }, + }, + }, + }, + {Role: "tool", Content: "Message sent to pico:pico:list-visible-count", ToolCallID: "call_1"}, + {Role: "assistant", Content: handledToolResponseSummaryText}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].MessageCount != 3 { + t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount) + } +} + +func TestHandleListSessions_DeduplicatesAssistantToolCallContentFromVisibleTranscript(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "list-deduped-tool-content" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check file"}, + { + Role: "assistant", + Content: "Read the file before replying.", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }, + }, + }, + {Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].MessageCount != 2 { + t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount) + } +} + +func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-tool-summary-and-content" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check file"}, + { + Role: "assistant", + Content: "Read the file before replying.", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md","start_line":1,"end_line":10}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }, + }, + }, + {Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-and-content", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) + } + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" { + t.Fatalf("first message = %#v, want user/check file", resp.Messages[0]) + } + toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file") + if toolCall.ExtraContent == nil || + toolCall.ExtraContent.ToolFeedbackExplanation != "Read the file before replying." { + t.Fatalf("tool call = %#v, want explanation", toolCall) + } +} + +func TestHandleGetSession_PreservesDistinctAssistantToolCallContent(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-tool-summary-distinct-content" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check file"}, + { + Role: "assistant", + Content: "I will summarize the findings after reading the file.", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md","start_line":1,"end_line":10}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }, + }, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-distinct-content", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + } + if resp.Messages[1].Role != "assistant" || + resp.Messages[1].Content != "I will summarize the findings after reading the file." { + t.Fatalf("assistant content = %#v, want preserved distinct content", resp.Messages[1]) + } + assertVisibleToolCallMessage(t, resp.Messages[2], "read_file") +} + +func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-tool-summary-duplicate-content-with-media" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check screenshot"}, + { + Role: "assistant", + Content: "Reviewing the generated screenshot.", + Media: []string{"data:image/png;base64,abc123"}, + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "view_image", + Arguments: `{"path":"artifact.png"}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Reviewing the generated screenshot.", + }, + }, + }, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-duplicate-content-with-media", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + } + if resp.Messages[1].Role != "assistant" { + t.Fatalf("assistant message role = %q, want assistant", resp.Messages[1].Role) + } + if resp.Messages[1].Content != "" { + t.Fatalf("assistant content = %q, want duplicate content suppressed", resp.Messages[1].Content) + } + if len(resp.Messages[1].Media) != 1 || resp.Messages[1].Media[0] != "data:image/png;base64,abc123" { + t.Fatalf("assistant media = %#v, want preserved media", resp.Messages[1].Media) + } + assertVisibleToolCallMessage(t, resp.Messages[2], "view_image") +} + +func TestHandleGetSession_PreservesAttachmentsWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-tool-summary-duplicate-content-with-attachments" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check report"}, + { + Role: "assistant", + Content: "Reviewing the generated report.", + Attachments: []providers.Attachment{{ + Type: "file", + URL: "https://example.com/report.txt", + Filename: "report.txt", + ContentType: "text/plain", + }}, + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"report.txt"}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Reviewing the generated report.", + }, + }, + }, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodGet, + "/api/sessions/detail-tool-summary-duplicate-content-with-attachments", + nil, + ) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + } + if resp.Messages[1].Role != "assistant" { + t.Fatalf("assistant message role = %q, want assistant", resp.Messages[1].Role) + } + if resp.Messages[1].Content != "" { + t.Fatalf("assistant content = %q, want duplicate content suppressed", resp.Messages[1].Content) + } + if len(resp.Messages[1].Attachments) != 1 { + t.Fatalf("len(assistant.Attachments) = %d, want 1", len(resp.Messages[1].Attachments)) + } + if resp.Messages[1].Attachments[0].URL != "https://example.com/report.txt" { + t.Fatalf("attachment url = %q, want report URL", resp.Messages[1].Attachments[0].URL) + } + assertVisibleToolCallMessage(t, resp.Messages[2], "read_file") +} + +func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20 + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}` + explanation := "Read README.md first to confirm the current project structure before editing the config example." + sessionKey := picoSessionPrefix + "detail-tool-summary-max-args" + err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"}) + if err != nil { + t.Fatalf("AddFullMessage(user) error = %v", err) + } + err = store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "assistant", + ToolCalls: []providers.ToolCall{{ + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: argsJSON, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: explanation, + }, + }}, + }) + if err != nil { + t.Fatalf("AddFullMessage(assistant) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-max-args", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) < 2 { + t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages)) + } + + wantArgsPreview := visibleAssistantToolArgsPreview(providers.ToolCall{ + Function: &providers.FunctionCall{Arguments: argsJSON}, + }, 20) + toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file") + if toolCall.ExtraContent == nil || toolCall.ExtraContent.ToolFeedbackExplanation != explanation { + t.Fatalf("tool call = %#v, want full explanation %q", toolCall, explanation) + } + if toolCall.Function == nil || toolCall.Function.Arguments != wantArgsPreview { + t.Fatalf("tool call = %#v, want args preview %q", toolCall, wantArgsPreview) + } +} + +func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20 + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}` + sessionKey := picoSessionPrefix + "detail-tool-summary-legacy-args" + if err := store.AddFullMessage( + nil, + sessionKey, + providers.Message{Role: "user", Content: "check file"}, + ); err != nil { + t.Fatalf("AddFullMessage(user) error = %v", err) + } + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "assistant", + ToolCalls: []providers.ToolCall{{ + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: argsJSON, + }, + }}, + }); err != nil { + t.Fatalf("AddFullMessage(assistant) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-legacy-args", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) < 2 { + t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages)) + } + + wantPreview := visibleAssistantToolArgsPreview(providers.ToolCall{ + Function: &providers.FunctionCall{Arguments: argsJSON}, + }, 20) + toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file") + if toolCall.Function == nil || toolCall.Function.Arguments != wantPreview { + t.Fatalf("tool call = %#v, want legacy args preview %q", toolCall, wantPreview) + } +} + +func TestHandleGetSession_IncludesMediaOnlyMessages(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-media-only" + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "user", + Media: []string{"data:image/png;base64,abc123"}, + }); err != nil { + t.Fatalf("AddFullMessage(user) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-media-only", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + Media []string `json:"media"` + } `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 1 { + t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages)) + } + if resp.Messages[0].Role != "user" || len(resp.Messages[0].Media) != 1 { + t.Fatalf("message = %#v, want user message with media", resp.Messages[0]) + } +} + +func TestHandleSessions_SupportsJSONLMessagesUpToStoreCap(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-large-jsonl" + largeContent := strings.Repeat("x", 9*1024*1024) + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "user", + Content: largeContent, + }); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + listRec := httptest.NewRecorder() + listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(listRec, listReq) + + if listRec.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil { + t.Fatalf("list Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + + detailRec := httptest.NewRecorder() + detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-large-jsonl", nil) + mux.ServeHTTP(detailRec, detailReq) + + if detailRec.Code != http.StatusOK { + t.Fatalf( + "detail status = %d, want %d, body=%s", + detailRec.Code, + http.StatusOK, + detailRec.Body.String(), + ) + } + + var resp struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(detailRec.Body.Bytes(), &resp); err != nil { + t.Fatalf("detail Unmarshal() error = %v", err) + } + if len(resp.Messages) != 1 { + t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages)) + } + if resp.Messages[0].Role != "user" { + t.Fatalf("resp.Messages[0].Role = %q, want %q", resp.Messages[0].Role, "user") + } + if got := len(resp.Messages[0].Content); got != len(largeContent) { + t.Fatalf("len(resp.Messages[0].Content) = %d, want %d", got, len(largeContent)) + } +} + +func TestHandleListSessions_UsesImagePreviewForMediaOnlyMessage(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "preview-media-only" + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "user", + Media: []string{"data:image/png;base64,abc123"}, + }); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].Preview != "[image]" { + t.Fatalf("items[0].Preview = %q, want %q", items[0].Preview, "[image]") + } + if items[0].MessageCount != 1 { + t.Fatalf("items[0].MessageCount = %d, want 1", items[0].MessageCount) + } +} + +func TestHandleDeleteSession_JSONLStorage(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := legacyPicoSessionPrefix + "delete-jsonl" + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "user", + Content: "delete me", + }); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + if err := store.SetSummary(nil, sessionKey, "delete summary"); err != nil { + t.Fatalf("SetSummary() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/sessions/delete-jsonl", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) + } + + base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) + for _, path := range []string{base + ".jsonl", base + ".meta.json"} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s to be removed, stat err = %v", path, err) + } + } +} + +func TestHandleGetSession_LegacyJSONFallback(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + manager := session.NewSessionManager(dir) + sessionKey := legacyPicoSessionPrefix + "legacy-json" + manager.AddMessage(sessionKey, "user", "legacy user") + manager.AddMessage(sessionKey, "assistant", "legacy assistant") + if err := manager.Save(sessionKey); err != nil { + t.Fatalf("Save() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/legacy-json", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestHandleSessions_FiltersEmptyJSONLFiles(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + base := filepath.Join(dir, sanitizeSessionKey(legacyPicoSessionPrefix+"empty-jsonl")) + if err := os.WriteFile(base+".jsonl", []byte{}, 0o644); err != nil { + t.Fatalf("WriteFile(jsonl) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + listRec := httptest.NewRecorder() + listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(listRec, listReq) + + if listRec.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal(list) error = %v", err) + } + if len(items) != 0 { + t.Fatalf("len(items) = %d, want 0", len(items)) + } + + detailRec := httptest.NewRecorder() + detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/empty-jsonl", nil) + mux.ServeHTTP(detailRec, detailReq) + + if detailRec.Code != http.StatusNotFound { + t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusNotFound, detailRec.Body.String()) + } +} + +func TestHandleSessions_ListsLegacyJSONLWithoutMeta(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + sessionKey := legacyPicoSessionPrefix + "missing-meta" + base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) + line, err := json.Marshal(providers.Message{Role: "user", Content: "recover me"}) + if err != nil { + t.Fatalf("Marshal(message) error = %v", err) + } + if err := os.WriteFile(base+".jsonl", append(line, '\n'), 0o644); err != nil { + t.Fatalf("WriteFile(jsonl) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + listRec := httptest.NewRecorder() + listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(listRec, listReq) + + if listRec.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal(list) error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].ID != "missing-meta" { + t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "missing-meta") + } + + detailRec := httptest.NewRecorder() + detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/missing-meta", nil) + mux.ServeHTTP(detailRec, detailReq) + + if detailRec.Code != http.StatusOK { + t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusOK, detailRec.Body.String()) + } +} + +func TestHandleSessions_IgnoresMetaJSONInLegacyFallback(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + metaOnly := filepath.Join(dir, "agent_main_pico_direct_pico_meta-only.meta.json") + metaOnlyContent := []byte(`{"key":"agent:main:pico:direct:pico:meta-only","summary":"meta only"}`) + if err := os.WriteFile(metaOnly, metaOnlyContent, 0o644); err != nil { + t.Fatalf("WriteFile(meta) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + listRec := httptest.NewRecorder() + listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(listRec, listReq) + + if listRec.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal(list) error = %v", err) + } + if len(items) != 0 { + t.Fatalf("len(items) = %d, want 0", len(items)) + } +} diff --git a/web/backend/api/skills.go b/web/backend/api/skills.go new file mode 100644 index 000000000..e89ff7c30 --- /dev/null +++ b/web/backend/api/skills.go @@ -0,0 +1,1108 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/utils" +) + +const defaultInstallSkillRegistry = "github" + +type skillSupportResponse struct { + Skills []skillSupportItem `json:"skills"` +} + +type skillSupportItem struct { + Name string `json:"name"` + Path string `json:"path"` + Source string `json:"source"` + Description string `json:"description"` + OriginKind string `json:"origin_kind"` + RegistryName string `json:"registry_name,omitempty"` + RegistryURL string `json:"registry_url,omitempty"` + InstalledVersion string `json:"installed_version,omitempty"` + InstalledAt int64 `json:"installed_at,omitempty"` +} + +type skillDetailResponse struct { + skillSupportItem + Content string `json:"content"` +} + +type skillSearchResultItem struct { + Score float64 `json:"score"` + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Summary string `json:"summary"` + Version string `json:"version"` + RegistryName string `json:"registry_name"` + URL string `json:"url,omitempty"` + Installed bool `json:"installed"` + InstalledName string `json:"installed_name,omitempty"` +} + +type skillSearchResponse struct { + Results []skillSearchResultItem `json:"results"` + Limit int `json:"limit"` + Offset int `json:"offset"` + NextOffset int `json:"next_offset,omitempty"` + HasMore bool `json:"has_more"` +} + +type installSkillRequest struct { + Slug string `json:"slug"` + Registry string `json:"registry"` + Version string `json:"version,omitempty"` + Force bool `json:"force,omitempty"` +} + +type installSkillResponse struct { + Status string `json:"status"` + Slug string `json:"slug"` + Registry string `json:"registry"` + Version string `json:"version"` + Summary string `json:"summary,omitempty"` + IsSuspicious bool `json:"is_suspicious,omitempty"` + InstalledSkill *skillSupportItem `json:"skill,omitempty"` +} + +type installedSkillOriginMeta struct { + Version int `json:"version"` + OriginKind string `json:"origin_kind,omitempty"` + Registry string `json:"registry,omitempty"` + Slug string `json:"slug,omitempty"` + RegistryURL string `json:"registry_url,omitempty"` + InstalledVersion string `json:"installed_version,omitempty"` + InstalledAt int64 `json:"installed_at"` +} + +var ( + skillNameSanitizer = regexp.MustCompile(`[^a-z0-9-]+`) + importedSkillFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) + skillFrontmatterStripper = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) + persistSkillOriginMeta = writeSkillOriginMeta + workspaceSkillWriteMu sync.Mutex + errImportedSkillExists = errors.New("skill already exists") +) + +const ( + maxImportedSkillSize = 1 << 20 + maxRegistrySearchFanout = 1000 +) + +func (h *Handler) registerSkillRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/skills", h.handleListSkills) + mux.HandleFunc("GET /api/skills/{name}", h.handleGetSkill) + mux.HandleFunc("GET /api/skills/search", h.handleSearchSkills) + mux.HandleFunc("POST /api/skills/install", h.handleInstallSkill) + mux.HandleFunc("POST /api/skills/import", h.handleImportSkill) + mux.HandleFunc("DELETE /api/skills/{name}", h.handleDeleteSkill) +} + +func (h *Handler) handleListSkills(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + items, err := buildSkillSupportItems(cfg) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to build skill list: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(skillSupportResponse{ + Skills: items, + }) +} + +func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + skillItems, err := buildSkillSupportItems(cfg) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to build skill list: %v", err), http.StatusInternalServerError) + return + } + name := r.PathValue("name") + for _, skillItem := range skillItems { + if skillItem.Name != name { + continue + } + + content, err := loadSkillContent(skillItem.Path) + if err != nil { + http.Error(w, "Skill content not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(skillDetailResponse{ + skillSupportItem: skillItem, + Content: content, + }) + return + } + + http.Error(w, "Skill not found", http.StatusNotFound) +} + +func (h *Handler) handleSearchSkills(w http.ResponseWriter, r *http.Request) { + cfg, loadErr := config.LoadConfig(h.configPath) + if loadErr != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError) + return + } + if registryErr := ensureSkillRegistryToolEnabled(cfg, "find_skills"); registryErr != nil { + http.Error(w, registryErr.Error(), http.StatusBadRequest) + return + } + + query := strings.TrimSpace(r.URL.Query().Get("q")) + + limit := 20 + if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" { + parsedLimit, parseErr := strconv.Atoi(rawLimit) + if parseErr != nil || parsedLimit < 1 || parsedLimit > 50 { + http.Error(w, "limit must be between 1 and 50", http.StatusBadRequest) + return + } + limit = parsedLimit + } + offset := 0 + if rawOffset := strings.TrimSpace(r.URL.Query().Get("offset")); rawOffset != "" { + parsedOffset, parseErr := strconv.Atoi(rawOffset) + if parseErr != nil || parsedOffset < 0 { + http.Error(w, "offset must be 0 or greater", http.StatusBadRequest) + return + } + offset = parsedOffset + } + + installedSkills, err := buildOccupiedWorkspaceSkillsByDirectory(cfg) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to inspect installed skills: %v", err), http.StatusInternalServerError) + return + } + + if query == "" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(skillSearchResponse{ + Results: []skillSearchResultItem{}, + Limit: limit, + Offset: offset, + HasMore: false, + }) + return + } + + registryMgr := newSkillsRegistryManager(cfg) + searchLimit := offset + limit + 1 + if searchLimit > maxRegistrySearchFanout { + searchLimit = maxRegistrySearchFanout + } + results, err := registryMgr.SearchAll(r.Context(), query, searchLimit) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to search skills: %v", err), http.StatusBadGateway) + return + } + + if offset > len(results) { + offset = len(results) + } + + end := offset + limit + if end > len(results) { + end = len(results) + } + + pageResults := results[offset:end] + response := make([]skillSearchResultItem, 0, len(pageResults)) + for _, result := range pageResults { + installedSkill, installed := installedSkills[result.Slug] + if !installed { + registry := registryMgr.GetRegistry(result.RegistryName) + if registry != nil { + dirName, err := registry.ResolveInstallDirName(result.Slug) + if err == nil { + installedSkill, installed = installedSkills[dirName] + } + } + } + item := skillSearchResultItem{ + Score: result.Score, + Slug: result.Slug, + DisplayName: result.DisplayName, + Summary: result.Summary, + Version: result.Version, + RegistryName: result.RegistryName, + URL: registrySkillURL(cfg, result.RegistryName, result.Slug, result.Version), + Installed: installed, + } + if installed { + item.InstalledName = installedSkill.Name + } + response = append(response, item) + } + + w.Header().Set("Content-Type", "application/json") + nextOffset := 0 + hasMore := len(results) > end + if hasMore { + nextOffset = end + } + json.NewEncoder(w).Encode(skillSearchResponse{ + Results: response, + Limit: limit, + Offset: offset, + NextOffset: nextOffset, + HasMore: hasMore, + }) +} + +func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) { + cfg, loadErr := config.LoadConfig(h.configPath) + if loadErr != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError) + return + } + if registryErr := ensureSkillRegistryToolEnabled(cfg, "install_skill"); registryErr != nil { + http.Error(w, registryErr.Error(), http.StatusBadRequest) + return + } + + var req installSkillRequest + if decodeErr := json.NewDecoder(r.Body).Decode(&req); decodeErr != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", decodeErr), http.StatusBadRequest) + return + } + + req.Slug = strings.TrimSpace(req.Slug) + req.Registry = strings.TrimSpace(req.Registry) + req.Version = strings.TrimSpace(req.Version) + if req.Registry == "" { + req.Registry = defaultInstallSkillRegistry + } + + if validateErr := utils.ValidateSkillIdentifier(req.Registry); validateErr != nil { + http.Error( + w, + fmt.Sprintf("invalid registry %q: error: %s", req.Registry, validateErr.Error()), + http.StatusBadRequest, + ) + return + } + + registryMgr := newSkillsRegistryManager(cfg) + registry := registryMgr.GetRegistry(req.Registry) + if registry == nil { + http.Error(w, fmt.Sprintf("registry %q not found", req.Registry), http.StatusBadRequest) + return + } + dirName, err := registry.ResolveInstallDirName(req.Slug) + if err != nil { + http.Error(w, fmt.Sprintf("invalid slug %q: error: %s", req.Slug, err.Error()), http.StatusBadRequest) + return + } + + workspace := cfg.WorkspacePath() + skillsRoot := filepath.Join(workspace, "skills") + targetDir := filepath.Join(workspace, "skills", dirName) + workspaceSkillWriteMu.Lock() + defer workspaceSkillWriteMu.Unlock() + + targetExists := false + if _, statErr := os.Stat(targetDir); statErr == nil { + targetExists = true + } else if !os.IsNotExist(statErr) { + http.Error(w, fmt.Sprintf("Failed to inspect install target: %v", statErr), http.StatusInternalServerError) + return + } + + if !req.Force && targetExists { + http.Error(w, fmt.Sprintf("skill %q already installed at %s", dirName, targetDir), http.StatusConflict) + return + } + if mkdirErr := os.MkdirAll(skillsRoot, 0o755); mkdirErr != nil { + http.Error(w, fmt.Sprintf("Failed to create skills directory: %v", mkdirErr), http.StatusInternalServerError) + return + } + + stagedWorkspaceRoot, stagedTargetDir, err := createStagedSkillInstall(skillsRoot, dirName) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to prepare staged install: %v", err), http.StatusInternalServerError) + return + } + defer os.RemoveAll(stagedWorkspaceRoot) + + result, err := registry.DownloadAndInstall(r.Context(), req.Slug, req.Version, stagedTargetDir) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to install skill: %v", err), http.StatusBadGateway) + return + } + if result.IsMalwareBlocked { + http.Error( + w, + fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", req.Slug), + http.StatusForbidden, + ) + return + } + + if findWorkspaceSkillInfoByDirectory(stagedWorkspaceRoot, dirName) == nil { + http.Error( + w, + fmt.Sprintf("Failed to install skill: registry archive for %q is not a valid skill", req.Slug), + http.StatusBadGateway, + ) + return + } + + installedAt := time.Now().UnixMilli() + normalizedSlug, registryURL := skills.BuildInstallMetadataForRegistryInstance(registry, req.Slug, result.Version) + if err := persistSkillOriginMeta(stagedTargetDir, installedSkillOriginMeta{ + Version: 1, + OriginKind: "third_party", + Registry: registry.Name(), + Slug: normalizedSlug, + RegistryURL: registryURL, + InstalledVersion: result.Version, + InstalledAt: installedAt, + }); err != nil { + http.Error(w, fmt.Sprintf("Failed to persist skill metadata: %v", err), http.StatusInternalServerError) + return + } + + if err := commitStagedSkillInstall( + stagedWorkspaceRoot, + stagedTargetDir, + targetDir, + req.Force && targetExists, + ); err != nil { + http.Error(w, fmt.Sprintf("Failed to activate installed skill: %v", err), http.StatusInternalServerError) + return + } + + validatedSkill := findWorkspaceSkillByDirectory(cfg, dirName) + if validatedSkill == nil { + http.Error( + w, + fmt.Sprintf("Failed to install skill: activated archive for %q is not a valid skill", req.Slug), + http.StatusBadGateway, + ) + return + } + + installedSkill := &skillSupportItem{ + Name: validatedSkill.Name, + Path: validatedSkill.Path, + Source: validatedSkill.Source, + Description: validatedSkill.Description, + OriginKind: "third_party", + RegistryName: registry.Name(), + RegistryURL: registryURL, + InstalledVersion: result.Version, + InstalledAt: installedAt, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(installSkillResponse{ + Status: "ok", + Slug: req.Slug, + Registry: registry.Name(), + Version: result.Version, + Summary: result.Summary, + IsSuspicious: result.IsSuspicious, + InstalledSkill: installedSkill, + }) +} + +func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + err = r.ParseMultipartForm(2 << 20) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid multipart form: %v", err), http.StatusBadRequest) + return + } + + uploadedFile, fileHeader, err := r.FormFile("file") + if err != nil { + http.Error(w, "file is required", http.StatusBadRequest) + return + } + defer uploadedFile.Close() + + content, err := io.ReadAll(io.LimitReader(uploadedFile, maxImportedSkillSize+1)) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest) + return + } + if len(content) > maxImportedSkillSize { + http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest) + return + } + workspaceSkillWriteMu.Lock() + defer workspaceSkillWriteMu.Unlock() + + importedSkill, statusCode, err := importUploadedSkill(cfg, fileHeader.Filename, content) + if err != nil { + http.Error(w, err.Error(), statusCode) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(importedSkill) +} + +func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + loader := newSkillsLoader(cfg.WorkspacePath()) + name := r.PathValue("name") + workspaceSkillWriteMu.Lock() + defer workspaceSkillWriteMu.Unlock() + + var matchedNonWorkspace bool + for _, skill := range loader.ListSkills() { + if skill.Name != name { + continue + } + if skill.Source != "workspace" { + matchedNonWorkspace = true + continue + } + if err := os.RemoveAll(filepath.Dir(skill.Path)); err != nil { + http.Error(w, fmt.Sprintf("Failed to delete skill: %v", err), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + return + } + if matchedNonWorkspace { + http.Error(w, "only workspace skills can be deleted", http.StatusBadRequest) + return + } + + http.Error(w, "Skill not found", http.StatusNotFound) +} + +func newSkillsLoader(workspace string) *skills.SkillsLoader { + return skills.NewSkillsLoader( + workspace, + filepath.Join(globalConfigDir(), "skills"), + builtinSkillsDir(), + ) +} + +func newSkillsRegistryManager(cfg *config.Config) *skills.RegistryManager { + return skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills) +} + +func ensureSkillRegistryToolEnabled(cfg *config.Config, toolName string) error { + if !cfg.Tools.IsToolEnabled("skills") { + return fmt.Errorf("tools.skills is disabled") + } + if !cfg.Tools.IsToolEnabled(toolName) { + return fmt.Errorf("%s is disabled", toolName) + } + return nil +} + +func buildSkillSupportItems(cfg *config.Config) ([]skillSupportItem, error) { + rawSkills := newSkillsLoader(cfg.WorkspacePath()).ListSkills() + items := make([]skillSupportItem, 0, len(rawSkills)) + for _, skill := range rawSkills { + item, err := enrichSkillInfo(cfg, skill) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, nil +} + +func buildWorkspaceSkillItemsByDirectory(cfg *config.Config) (map[string]skillSupportItem, error) { + result := make(map[string]skillSupportItem) + items, err := buildSkillSupportItems(cfg) + if err != nil { + return nil, err + } + for _, skill := range items { + if skill.Source != "workspace" { + continue + } + dir := filepath.Base(filepath.Dir(skill.Path)) + if dir == "" { + continue + } + result[dir] = skill + } + return result, nil +} + +func buildOccupiedWorkspaceSkillsByDirectory(cfg *config.Config) (map[string]skillSupportItem, error) { + result := make(map[string]skillSupportItem) + items, err := buildSkillSupportItems(cfg) + if err != nil { + return nil, err + } + for _, skill := range items { + if skill.Source != "workspace" { + continue + } + + dirName := filepath.Base(filepath.Dir(skill.Path)) + if dirName != "" { + result[dirName] = skill + } + if meta, err := readInstalledSkillOriginMeta(skill.Path); err == nil && meta != nil && meta.Slug != "" { + key := skills.NormalizeInstallTargetForRegistry(cfg.Tools.Skills, meta.Registry, meta.Slug) + if key == "" { + key = meta.Slug + } + if key != "" { + result[key] = skill + } + } + } + return result, nil +} + +func findWorkspaceSkillByDirectory(cfg *config.Config, directory string) *skillSupportItem { + items, err := buildWorkspaceSkillItemsByDirectory(cfg) + if err != nil { + return nil + } + skill, ok := items[directory] + if !ok { + return nil + } + return &skill +} + +func findWorkspaceSkillInfoByDirectory(workspace, directory string) *skills.SkillInfo { + loader := skills.NewSkillsLoader(workspace, "", "") + for _, skill := range loader.ListSkills() { + if skill.Source != "workspace" { + continue + } + if filepath.Base(filepath.Dir(skill.Path)) != directory { + continue + } + skillCopy := skill + return &skillCopy + } + return nil +} + +func createStagedSkillInstall(skillsRoot, slug string) (string, string, error) { + stagedWorkspaceRoot, err := os.MkdirTemp(skillsRoot, "."+slug+"-install-*") + if err != nil { + return "", "", err + } + stagedTargetDir := filepath.Join(stagedWorkspaceRoot, "skills", slug) + return stagedWorkspaceRoot, stagedTargetDir, nil +} + +func commitStagedSkillInstall(stagedWorkspaceRoot, stagedTargetDir, targetDir string, replaceExisting bool) error { + if !replaceExisting { + return os.Rename(stagedTargetDir, targetDir) + } + + backupDir, err := reserveTempDirPath(filepath.Dir(targetDir), "."+filepath.Base(targetDir)+"-backup-*") + if err != nil { + return err + } + + if err := os.Rename(targetDir, backupDir); err != nil { + return fmt.Errorf("failed to move existing skill aside: %w", err) + } + + if err := os.Rename(stagedTargetDir, targetDir); err != nil { + if rollbackErr := os.Rename(backupDir, targetDir); rollbackErr != nil { + return fmt.Errorf("failed to activate replacement: %w (rollback failed: %v)", err, rollbackErr) + } + return fmt.Errorf("failed to activate replacement: %w", err) + } + + _ = os.RemoveAll(backupDir) + _ = os.RemoveAll(stagedWorkspaceRoot) + return nil +} + +func reserveTempDirPath(parent, pattern string) (string, error) { + tempDir, err := os.MkdirTemp(parent, pattern) + if err != nil { + return "", err + } + if err := os.Remove(tempDir); err != nil { + return "", err + } + return tempDir, nil +} + +func enrichSkillInfo(cfg *config.Config, skill skills.SkillInfo) (skillSupportItem, error) { + item := skillSupportItem{ + Name: skill.Name, + Path: skill.Path, + Source: skill.Source, + Description: skill.Description, + OriginKind: "builtin", + } + + switch skill.Source { + case "builtin": + item.OriginKind = "builtin" + case "global": + item.OriginKind = "builtin" + case "workspace": + meta, err := readInstalledSkillOriginMeta(skill.Path) + if err == nil && meta != nil { + switch meta.OriginKind { + case "manual": + item.OriginKind = "manual" + item.InstalledAt = meta.InstalledAt + case "third_party": + item.OriginKind = "third_party" + item.RegistryName = meta.Registry + item.RegistryURL = registrySkillURLFromMeta(cfg, meta) + item.InstalledVersion = meta.InstalledVersion + item.InstalledAt = meta.InstalledAt + default: + if meta.Registry != "" || meta.Slug != "" || meta.InstalledVersion != "" { + item.OriginKind = "third_party" + item.RegistryName = meta.Registry + item.RegistryURL = registrySkillURLFromMeta(cfg, meta) + item.InstalledVersion = meta.InstalledVersion + item.InstalledAt = meta.InstalledAt + } else { + item.OriginKind = "builtin" + item.InstalledAt = meta.InstalledAt + } + } + } else { + item.OriginKind = "builtin" + } + default: + item.OriginKind = "builtin" + } + + return item, nil +} + +func readInstalledSkillOriginMeta(skillPath string) (*installedSkillOriginMeta, error) { + metaPath := filepath.Join(filepath.Dir(skillPath), ".skill-origin.json") + data, err := os.ReadFile(metaPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var meta installedSkillOriginMeta + if err := json.Unmarshal(data, &meta); err != nil { + return nil, err + } + return &meta, nil +} + +func writeSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error { + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600) +} + +func registrySkillURL(cfg *config.Config, registryName, slug, version string) string { + if cfg == nil || registryName == "" || slug == "" { + return "" + } + registry := skills.LookupRegistryFromToolsConfig(cfg.Tools.Skills, registryName) + if registry == nil { + return "" + } + return registry.SkillURL(slug, version) +} + +func registrySkillURLFromMeta(cfg *config.Config, meta *installedSkillOriginMeta) string { + if meta == nil || meta.Slug == "" { + return "" + } + if meta.RegistryURL != "" { + return meta.RegistryURL + } + if cfg == nil || meta.Registry == "" { + return "" + } + return registrySkillURL(cfg, meta.Registry, meta.Slug, meta.InstalledVersion) +} + +func normalizeImportedSkillName(filename string, content []byte) (string, error) { + return normalizeImportedSkillNameWithHint(filename, "", content) +} + +func normalizeImportedSkillNameWithHint(filename, directoryHint string, content []byte) (string, error) { + rawContent := strings.ReplaceAll(string(content), "\r\n", "\n") + rawContent = strings.ReplaceAll(rawContent, "\r", "\n") + metadata, _ := extractImportedSkillMetadata(rawContent) + + raw := strings.TrimSpace(metadata["name"]) + if raw == "" { + raw = strings.TrimSpace(directoryHint) + } + if raw == "" { + raw = strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))) + } + raw = strings.ToLower(raw) + raw = strings.ReplaceAll(raw, "_", "-") + raw = strings.ReplaceAll(raw, " ", "-") + raw = skillNameSanitizer.ReplaceAllString(raw, "-") + raw = strings.Trim(raw, "-") + raw = strings.Join(strings.FieldsFunc(raw, func(r rune) bool { return r == '-' }), "-") + + if raw == "" { + return "", fmt.Errorf("skill name is required in frontmatter or filename") + } + if len(raw) > 64 { + return "", fmt.Errorf("skill name exceeds 64 characters") + } + matched, err := regexp.MatchString(`^[a-z0-9]+(-[a-z0-9]+)*$`, raw) + if err != nil || !matched { + return "", fmt.Errorf("skill name must be alphanumeric with hyphens") + } + return raw, nil +} + +func normalizeImportedSkillContent(content []byte, skillName string) []byte { + raw := strings.ReplaceAll(string(content), "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + + metadata, body := extractImportedSkillMetadata(raw) + description := strings.TrimSpace(metadata["description"]) + if description == "" { + description = inferImportedSkillDescription(body) + } + if description == "" { + description = "Imported skill" + } + if len(description) > 1024 { + description = strings.TrimSpace(description[:1024]) + } + + body = strings.TrimLeft(body, "\n") + var builder strings.Builder + builder.WriteString("---\n") + builder.WriteString("name: ") + builder.WriteString(skillName) + builder.WriteString("\n") + builder.WriteString("description: ") + builder.WriteString(description) + builder.WriteString("\n") + builder.WriteString("---\n\n") + builder.WriteString(body) + if !strings.HasSuffix(builder.String(), "\n") { + builder.WriteString("\n") + } + return []byte(builder.String()) +} + +func importUploadedSkill(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) { + if isImportedSkillArchive(filename, content) { + return importUploadedSkillArchive(cfg, filename, content) + } + return importUploadedMarkdownSkill(cfg, filename, content) +} + +func importUploadedMarkdownSkill(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) { + skillName, err := normalizeImportedSkillName(filename, content) + if err != nil { + return nil, http.StatusBadRequest, err + } + + normalizedContent := normalizeImportedSkillContent(content, skillName) + workspace := cfg.WorkspacePath() + skillDir := filepath.Join(workspace, "skills", skillName) + skillFile := filepath.Join(skillDir, "SKILL.md") + + if err := ensureWorkspaceSkillDoesNotExist(skillDir); err != nil { + return nil, statusCodeForImportedSkillWriteError(err), err + } + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("Failed to create skill directory: %v", err) + } + if err := fileutil.WriteFileAtomic(skillFile, normalizedContent, 0o644); err != nil { + _ = os.RemoveAll(skillDir) + return nil, http.StatusInternalServerError, fmt.Errorf("Failed to save skill: %v", err) + } + + return finalizeImportedSkill(cfg, skillDir, skillName, false) +} + +func importUploadedSkillArchive(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) { + tmpDir, tempDirErr := os.MkdirTemp("", "picoclaw-skill-import-*") + if tempDirErr != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("Failed to create temp directory: %v", tempDirErr) + } + defer os.RemoveAll(tmpDir) + + archivePath := filepath.Join(tmpDir, "import.zip") + if writeErr := fileutil.WriteFileAtomic(archivePath, content, 0o600); writeErr != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("Failed to stage uploaded archive: %v", writeErr) + } + + extractDir := filepath.Join(tmpDir, "extract") + if extractErr := utils.ExtractZipFile(archivePath, extractDir); extractErr != nil { + return nil, http.StatusBadRequest, fmt.Errorf("invalid ZIP archive: %w", extractErr) + } + + skillRoot, err := findImportedSkillRoot(extractDir) + if err != nil { + return nil, http.StatusBadRequest, err + } + + skillFile := filepath.Join(skillRoot, "SKILL.md") + skillContent, err := os.ReadFile(skillFile) + if err != nil { + return nil, http.StatusBadRequest, fmt.Errorf("failed to read SKILL.md from archive: %w", err) + } + + directoryHint := "" + if filepath.Clean(skillRoot) != filepath.Clean(extractDir) { + directoryHint = filepath.Base(skillRoot) + } + skillName, err := normalizeImportedSkillNameWithHint(filename, directoryHint, skillContent) + if err != nil { + return nil, http.StatusBadRequest, err + } + + workspace := cfg.WorkspacePath() + skillDir := filepath.Join(workspace, "skills", skillName) + if err := ensureWorkspaceSkillDoesNotExist(skillDir); err != nil { + return nil, statusCodeForImportedSkillWriteError(err), err + } + if err := copyImportedSkillTree(skillRoot, skillDir); err != nil { + _ = os.RemoveAll(skillDir) + return nil, http.StatusInternalServerError, fmt.Errorf("Failed to save skill: %v", err) + } + + normalizedContent := normalizeImportedSkillContent(skillContent, skillName) + if err := fileutil.WriteFileAtomic(filepath.Join(skillDir, "SKILL.md"), normalizedContent, 0o644); err != nil { + _ = os.RemoveAll(skillDir) + return nil, http.StatusInternalServerError, fmt.Errorf("Failed to normalize skill: %v", err) + } + + return finalizeImportedSkill(cfg, skillDir, skillName, true) +} + +func isImportedSkillArchive(filename string, content []byte) bool { + if strings.EqualFold(filepath.Ext(filename), ".zip") { + return true + } + return len(content) >= 4 && bytes.HasPrefix(content, []byte("PK\x03\x04")) +} + +func ensureWorkspaceSkillDoesNotExist(skillDir string) error { + if _, err := os.Stat(skillDir); err == nil { + return errImportedSkillExists + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to inspect skill directory: %w", err) + } + return nil +} + +func statusCodeForImportedSkillWriteError(err error) int { + if err == nil { + return http.StatusOK + } + if errors.Is(err, errImportedSkillExists) { + return http.StatusConflict + } + return http.StatusInternalServerError +} + +func finalizeImportedSkill( + cfg *config.Config, + skillDir string, + skillName string, + requireValidatedSkill bool, +) (*skillSupportItem, int, error) { + if err := persistSkillOriginMeta(skillDir, installedSkillOriginMeta{ + Version: 1, + OriginKind: "manual", + InstalledAt: time.Now().UnixMilli(), + }); err != nil { + _ = os.RemoveAll(skillDir) + return nil, http.StatusInternalServerError, fmt.Errorf("Failed to persist skill metadata: %v", err) + } + + if importedSkill := findWorkspaceSkillByDirectory(cfg, skillName); importedSkill != nil { + return importedSkill, http.StatusOK, nil + } + + if requireValidatedSkill { + _ = os.RemoveAll(skillDir) + return nil, http.StatusBadRequest, fmt.Errorf("imported archive is not a valid skill") + } + + return &skillSupportItem{ + Name: skillName, + Path: filepath.Join(skillDir, "SKILL.md"), + Source: "workspace", + Description: "Imported skill", + OriginKind: "manual", + }, http.StatusOK, nil +} + +func findImportedSkillRoot(extractDir string) (string, error) { + skillFiles := make([]string, 0, 1) + err := filepath.WalkDir(extractDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + if d.Name() == "SKILL.md" { + skillFiles = append(skillFiles, path) + } + return nil + }) + if err != nil { + return "", fmt.Errorf("failed to inspect ZIP archive: %w", err) + } + + switch len(skillFiles) { + case 0: + return "", fmt.Errorf("ZIP archive must contain a SKILL.md file") + case 1: + return filepath.Dir(skillFiles[0]), nil + default: + return "", fmt.Errorf("ZIP archive must contain exactly one SKILL.md file") + } +} + +func copyImportedSkillTree(srcDir, destDir string) error { + return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + if relPath == "." { + return os.MkdirAll(destDir, 0o755) + } + + destPath := filepath.Join(destDir, relPath) + info, err := d.Info() + if err != nil { + return err + } + if d.IsDir() { + return os.MkdirAll(destPath, 0o755) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("archive contains unsupported file %q", relPath) + } + return fileutil.CopyFile(path, destPath, info.Mode().Perm()) + }) +} + +func extractImportedSkillMetadata(raw string) (map[string]string, string) { + matches := importedSkillFrontmatter.FindStringSubmatch(raw) + if len(matches) != 2 { + return map[string]string{}, raw + } + meta := parseImportedSkillYAML(matches[1]) + body := importedSkillFrontmatter.ReplaceAllString(raw, "") + return meta, body +} + +func parseImportedSkillYAML(frontmatter string) map[string]string { + result := make(map[string]string) + for _, line := range strings.Split(frontmatter, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + result[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`) + } + return result +} + +func inferImportedSkillDescription(body string) string { + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + line = strings.TrimLeft(line, "#-*0123456789. ") + line = strings.TrimSpace(line) + if line != "" { + return line + } + } + return "" +} + +func loadSkillContent(path string) (string, error) { + content, err := os.ReadFile(path) + if err != nil { + return "", err + } + return skillFrontmatterStripper.ReplaceAllString(string(content), ""), nil +} + +func globalConfigDir() string { + return config.GetHome() +} + +func builtinSkillsDir() string { + if path := os.Getenv(config.EnvBuiltinSkills); path != "" { + return path + } + wd, err := os.Getwd() + if err != nil { + return "" + } + return filepath.Join(wd, "skills") +} diff --git a/web/backend/api/skills_test.go b/web/backend/api/skills_test.go new file mode 100644 index 000000000..977ec693f --- /dev/null +++ b/web/backend/api/skills_test.go @@ -0,0 +1,1863 @@ +package api + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func setClawHubBaseURL(cfg *config.Config, baseURL string) { + registryCfg, _ := cfg.Tools.Skills.Registries.Get("clawhub") + registryCfg.BaseURL = baseURL + cfg.Tools.Skills.Registries.Set("clawhub", registryCfg) +} + +func setGithubBaseURL(cfg *config.Config, baseURL string) { + registryCfg, ok := cfg.Tools.Skills.Registries.Get("github") + if !ok { + return + } + registryCfg.BaseURL = baseURL + cfg.Tools.Skills.Registries.Set("github", registryCfg) +} + +func TestHandleListSkills(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + if err := os.MkdirAll(filepath.Join(workspace, "skills", "workspace-skill"), 0o755); err != nil { + t.Fatalf("MkdirAll(workspace skill) error = %v", err) + } + if err := os.WriteFile( + filepath.Join(workspace, "skills", "workspace-skill", "SKILL.md"), + []byte("---\nname: workspace-skill\ndescription: Workspace skill\n---\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(workspace skill) error = %v", err) + } + + globalSkillDir := filepath.Join(globalConfigDir(), "skills", "global-skill") + if err := os.MkdirAll(globalSkillDir, 0o755); err != nil { + t.Fatalf("MkdirAll(global skill) error = %v", err) + } + if err := os.WriteFile( + filepath.Join(globalSkillDir, "SKILL.md"), + []byte("---\nname: global-skill\ndescription: Global skill\n---\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(global skill) error = %v", err) + } + + builtinRoot := filepath.Join(t.TempDir(), "builtin-skills") + oldBuiltin := os.Getenv("PICOCLAW_BUILTIN_SKILLS") + if err := os.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot); err != nil { + t.Fatalf("Setenv(PICOCLAW_BUILTIN_SKILLS) error = %v", err) + } + defer func() { + if oldBuiltin == "" { + _ = os.Unsetenv("PICOCLAW_BUILTIN_SKILLS") + } else { + _ = os.Setenv("PICOCLAW_BUILTIN_SKILLS", oldBuiltin) + } + }() + + builtinSkillDir := filepath.Join(builtinRoot, "builtin-skill") + if err := os.MkdirAll(builtinSkillDir, 0o755); err != nil { + t.Fatalf("MkdirAll(builtin skill) error = %v", err) + } + if err := os.WriteFile( + filepath.Join(builtinSkillDir, "SKILL.md"), + []byte("---\nname: builtin-skill\ndescription: Builtin skill\n---\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(builtin skill) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillSupportResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Skills) != 3 { + t.Fatalf("skills count = %d, want 3", len(resp.Skills)) + } + + gotSkills := make(map[string]string, len(resp.Skills)) + gotOriginKinds := make(map[string]string, len(resp.Skills)) + for _, skill := range resp.Skills { + gotSkills[skill.Name] = skill.Source + gotOriginKinds[skill.Name] = skill.OriginKind + } + if gotSkills["workspace-skill"] != "workspace" { + t.Fatalf("workspace-skill source = %q, want workspace", gotSkills["workspace-skill"]) + } + if gotSkills["global-skill"] != "global" { + t.Fatalf("global-skill source = %q, want global", gotSkills["global-skill"]) + } + if gotSkills["builtin-skill"] != "builtin" { + t.Fatalf("builtin-skill source = %q, want builtin", gotSkills["builtin-skill"]) + } + if gotOriginKinds["workspace-skill"] != "builtin" { + t.Fatalf("workspace-skill origin_kind = %q, want builtin", gotOriginKinds["workspace-skill"]) + } + if gotOriginKinds["global-skill"] != "builtin" { + t.Fatalf("global-skill origin_kind = %q, want builtin", gotOriginKinds["global-skill"]) + } + if gotOriginKinds["builtin-skill"] != "builtin" { + t.Fatalf("builtin-skill origin_kind = %q, want builtin", gotOriginKinds["builtin-skill"]) + } +} + +func TestHandleGetSkill(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + skillDir := filepath.Join(workspace, "skills", "viewer-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte( + "---\nname: viewer-skill\ndescription: Viewable skill\n---\n# Viewer Skill\n\nThis is visible content.\n", + ), + 0o644, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/viewer-skill", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillDetailResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Name != "viewer-skill" || resp.Source != "workspace" || resp.Description != "Viewable skill" { + t.Fatalf("unexpected response: %#v", resp) + } + if resp.OriginKind != "builtin" { + t.Fatalf("resp.OriginKind = %q, want builtin", resp.OriginKind) + } + if resp.Content != "# Viewer Skill\n\nThis is visible content.\n" { + t.Fatalf("content = %q", resp.Content) + } +} + +func TestHandleGetSkillUsesResolvedPath(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + skillDir := filepath.Join(workspace, "skills", "folder-name") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: display-name\ndescription: Mismatched path skill\n---\n# Display Name\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/display-name", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillDetailResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Name != "display-name" { + t.Fatalf("resp.Name = %q, want display-name", resp.Name) + } + if resp.Content != "# Display Name\n" { + t.Fatalf("content = %q", resp.Content) + } +} + +func TestHandleImportSkill(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "Plain Skill.md") + if err != nil { + t.Fatalf("CreateFormFile() error = %v", err) + } + _, err = io.WriteString(part, "# Plain Skill\n\nUse this skill to test imports.\n") + if err != nil { + t.Fatalf("WriteString() error = %v", err) + } + err = writer.Close() + if err != nil { + t.Fatalf("Close() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + skillFile := filepath.Join(workspace, "skills", "plain-skill", "SKILL.md") + content, err := os.ReadFile(skillFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + expected := "---\nname: plain-skill\ndescription: Plain Skill\n---\n\n# Plain Skill\n\nUse this skill to test imports.\n" + if string(content) != expected { + t.Fatalf("saved skill content mismatch:\n%s", string(content)) + } + metaContent, err := os.ReadFile(filepath.Join(workspace, "skills", "plain-skill", ".skill-origin.json")) + if err != nil { + t.Fatalf("ReadFile(origin metadata) error = %v", err) + } + var originMeta installedSkillOriginMeta + if err := json.Unmarshal(metaContent, &originMeta); err != nil { + t.Fatalf("Unmarshal(origin metadata) error = %v", err) + } + if originMeta.OriginKind != "manual" { + t.Fatalf("originMeta.OriginKind = %q, want manual", originMeta.OriginKind) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/skills", nil) + mux.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String()) + } + var listResp skillSupportResponse + if err := json.Unmarshal(rec2.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal list response error = %v", err) + } + found := false + for _, skill := range listResp.Skills { + if skill.Name == "plain-skill" && skill.Source == "workspace" && skill.Description == "Plain Skill" { + found = true + } + } + if !found { + t.Fatalf("plain-skill should be listed after import, got %#v", listResp.Skills) + } +} + +func TestHandleImportSkillZip(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + zipContent := buildSkillZip(t, map[string]string{ + "Wrapped Skill/SKILL.md": "---\nname: wrapped-skill\ndescription: Wrapped skill\n---\n# Wrapped Skill\n\nUse this skill from zip.\n", + "Wrapped Skill/docs/README.md": "# Extra file\n", + }) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, createErr := writer.CreateFormFile("file", "Wrapped Skill.zip") + if createErr != nil { + t.Fatalf("CreateFormFile() error = %v", createErr) + } + if _, writeErr := part.Write(zipContent); writeErr != nil { + t.Fatalf("Write(zipContent) error = %v", writeErr) + } + if closeErr := writer.Close(); closeErr != nil { + t.Fatalf("Close() error = %v", closeErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + skillDir := filepath.Join(workspace, "skills", "wrapped-skill") + skillFile := filepath.Join(skillDir, "SKILL.md") + content, err := os.ReadFile(skillFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + expected := "---\nname: wrapped-skill\ndescription: Wrapped skill\n---\n\n# Wrapped Skill\n\nUse this skill from zip.\n" + if string(content) != expected { + t.Fatalf("saved skill content mismatch:\n%s", string(content)) + } + + extraFile := filepath.Join(skillDir, "docs", "README.md") + extraContent, err := os.ReadFile(extraFile) + if err != nil { + t.Fatalf("ReadFile(extra file) error = %v", err) + } + if string(extraContent) != "# Extra file\n" { + t.Fatalf("extra file content = %q", string(extraContent)) + } +} + +func TestHandleImportSkillZipRejectsArchiveWithoutSkill(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + zipContent := buildSkillZip(t, map[string]string{ + "README.md": "# Not a skill\n", + }) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "invalid.zip") + if err != nil { + t.Fatalf("CreateFormFile() error = %v", err) + } + if _, err := part.Write(zipContent); err != nil { + t.Fatalf("Write(zipContent) error = %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(workspace, "skills", "invalid")); !os.IsNotExist(err) { + t.Fatalf("invalid archive should not leave behind a skill dir, stat err=%v", err) + } +} + +func TestHandleImportSkillRollsBackOnOriginMetadataWriteFailure(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + previousPersist := persistSkillOriginMeta + persistSkillOriginMeta = func(targetDir string, meta installedSkillOriginMeta) error { + return errors.New("forced metadata failure") + } + defer func() { + persistSkillOriginMeta = previousPersist + }() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "Rollback Skill.md") + if err != nil { + t.Fatalf("CreateFormFile() error = %v", err) + } + if _, err := io.WriteString(part, "# Rollback Skill\n"); err != nil { + t.Fatalf("WriteString() error = %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String()) + } + + skillDir := filepath.Join(workspace, "skills", "rollback-skill") + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Fatalf("skill directory should be removed after metadata write failure, stat err=%v", err) + } +} + +func TestHandleDeleteSkill(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + skillDir := filepath.Join(workspace, "skills", "delete-me") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: delete-me\ndescription: delete me\n---\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/skills/delete-me", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Fatalf("skill directory should be removed, stat err=%v", err) + } +} + +func TestHandleDeleteSkillPrefersWorkspaceMatch(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + homeDir := t.TempDir() + t.Setenv(config.EnvHome, homeDir) + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + workspaceSkillDir := filepath.Join(workspace, "skills", "delete-me-workspace") + if err := os.MkdirAll(workspaceSkillDir, 0o755); err != nil { + t.Fatalf("MkdirAll(workspace) error = %v", err) + } + if err := os.WriteFile( + filepath.Join(workspaceSkillDir, "SKILL.md"), + []byte("---\nname: delete-me\ndescription: workspace delete me\n---\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(workspace) error = %v", err) + } + + globalSkillDir := filepath.Join(homeDir, "skills", "delete-me-global") + if err := os.MkdirAll(globalSkillDir, 0o755); err != nil { + t.Fatalf("MkdirAll(global) error = %v", err) + } + if err := os.WriteFile( + filepath.Join(globalSkillDir, "SKILL.md"), + []byte("---\nname: delete-me\ndescription: global delete me\n---\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(global) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/skills/delete-me", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if _, err := os.Stat(workspaceSkillDir); !os.IsNotExist(err) { + t.Fatalf("workspace skill directory should be removed, stat err=%v", err) + } + if _, err := os.Stat(globalSkillDir); err != nil { + t.Fatalf("global skill directory should remain, stat err=%v", err) + } +} + +func TestHandleSearchSkills(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + if err := os.MkdirAll(filepath.Join(workspace, "skills", "github"), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile( + filepath.Join(workspace, "skills", "github", "SKILL.md"), + []byte("---\nname: github\ndescription: Installed GitHub skill\n---\n# GitHub\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/search" { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("q"); got != "github" { + t.Fatalf("query = %q, want github", got) + } + json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + { + "score": 0.95, + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub integration skill", + "version": "1.2.3", + }, + { + "score": 0.87, + "slug": "jira", + "displayName": "Jira", + "summary": "Issue tracker skill", + "version": "0.9.0", + }, + }, + }) + })) + defer server.Close() + + setClawHubBaseURL(cfg, server.URL) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=5", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillSearchResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Limit != 5 { + t.Fatalf("limit = %d, want 5", resp.Limit) + } + if resp.Offset != 0 { + t.Fatalf("offset = %d, want 0", resp.Offset) + } + if resp.HasMore { + t.Fatalf("has_more = true, want false") + } + if len(resp.Results) != 2 { + t.Fatalf("results count = %d, want 2", len(resp.Results)) + } + if resp.Results[0].URL != server.URL+"/skills/github" { + t.Fatalf("first result URL = %q, want %q", resp.Results[0].URL, server.URL+"/skills/github") + } + if !resp.Results[0].Installed || resp.Results[0].InstalledName != "github" { + t.Fatalf("first result should be treated as occupying the workspace slug, got %#v", resp.Results[0]) + } + if resp.Results[1].Installed { + t.Fatalf("second result should not be installed, got %#v", resp.Results[1]) + } +} + +func TestHandleSearchSkillsUsesGitHubResultVersionInURL(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/search/code" { + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "path": "skills/pr-review/SKILL.md", + "score": 10, + "repository": map[string]any{ + "full_name": "foo/bar", + "name": "bar", + "description": "Review pull requests", + "default_branch": "master", + }, + }, + }, + }) + })) + defer server.Close() + + setGithubBaseURL(cfg, server.URL) + clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub") + clawHubRegistry.Enabled = false + cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillSearchResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Results) != 1 { + t.Fatalf("results count = %d, want 1", len(resp.Results)) + } + if resp.Results[0].URL != server.URL+"/foo/bar/tree/master/skills/pr-review" { + t.Fatalf("result URL = %q", resp.Results[0].URL) + } +} + +func TestHandleSearchSkillsGitHubRateLimitDegradesGracefully(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/search/code" { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"API rate limit exceeded for 1.2.3.4"}`)) + })) + defer server.Close() + + setGithubBaseURL(cfg, server.URL) + clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub") + clawHubRegistry.Enabled = false + cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillSearchResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Results) != 0 { + t.Fatalf("results count = %d, want 0", len(resp.Results)) + } +} + +func TestHandleSearchSkillsPagination(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/search" { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("limit"); got != "5" { + t.Fatalf("limit = %q, want 5", got) + } + json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + { + "score": 0.99, + "slug": "skill-1", + "displayName": "Skill 1", + "summary": "Summary 1", + "version": "1.0.0", + }, + { + "score": 0.98, + "slug": "skill-2", + "displayName": "Skill 2", + "summary": "Summary 2", + "version": "1.0.0", + }, + { + "score": 0.97, + "slug": "skill-3", + "displayName": "Skill 3", + "summary": "Summary 3", + "version": "1.0.0", + }, + { + "score": 0.96, + "slug": "skill-4", + "displayName": "Skill 4", + "summary": "Summary 4", + "version": "1.0.0", + }, + }, + }) + })) + defer server.Close() + + setClawHubBaseURL(cfg, server.URL) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=2&offset=2", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillSearchResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Limit != 2 { + t.Fatalf("limit = %d, want 2", resp.Limit) + } + if resp.Offset != 2 { + t.Fatalf("offset = %d, want 2", resp.Offset) + } + if resp.HasMore { + t.Fatalf("has_more = true, want false") + } + if len(resp.Results) != 2 { + t.Fatalf("results count = %d, want 2", len(resp.Results)) + } + if resp.Results[0].Slug != "skill-3" || resp.Results[1].Slug != "skill-4" { + t.Fatalf("unexpected paged results: %#v", resp.Results) + } + if resp.NextOffset != 0 { + t.Fatalf("next_offset = %d, want 0", resp.NextOffset) + } +} + +func TestHandleSearchSkillsClampsRegistryFanout(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/search" { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("limit"); got != strconv.Itoa(maxRegistrySearchFanout) { + t.Fatalf("limit = %q, want %d", got, maxRegistrySearchFanout) + } + json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + { + "score": 0.99, + "slug": "skill-1", + "displayName": "Skill 1", + "summary": "Summary 1", + "version": "1.0.0", + }, + }, + }) + })) + defer server.Close() + + setClawHubBaseURL(cfg, server.URL) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=20&offset=100000", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillSearchResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Results) != 0 { + t.Fatalf("results count = %d, want 0", len(resp.Results)) + } +} + +func TestHandleInstallSkill(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + zipContent := buildSkillZip(t, map[string]string{ + "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n\nUse this skill.\n", + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/search": + json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + { + "score": 0.95, + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub registry skill", + "version": "1.2.3", + }, + }, + }) + case "/api/v1/skills/github": + json.NewEncoder(w).Encode(map[string]any{ + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub registry skill", + "latestVersion": map[string]any{ + "version": "1.2.3", + }, + "moderation": map[string]any{ + "isMalwareBlocked": false, + "isSuspicious": false, + }, + }) + case "/api/v1/download": + if got := r.URL.Query().Get("slug"); got != "github" { + t.Fatalf("slug = %q, want github", got) + } + if got := r.URL.Query().Get("version"); got != "1.2.3" { + t.Fatalf("version = %q, want 1.2.3", got) + } + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + setClawHubBaseURL(cfg, server.URL) + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + body, err := json.Marshal(installSkillRequest{ + Slug: "github", + Registry: "clawhub", + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp installSkillResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Status != "ok" || resp.Version != "1.2.3" || resp.InstalledSkill == nil { + t.Fatalf("unexpected response: %#v", resp) + } + if resp.InstalledSkill.OriginKind != "third_party" { + t.Fatalf("resp.InstalledSkill.OriginKind = %q, want third_party", resp.InstalledSkill.OriginKind) + } + if resp.InstalledSkill.RegistryURL != server.URL+"/skills/github" { + t.Fatalf( + "resp.InstalledSkill.RegistryURL = %q, want %q", + resp.InstalledSkill.RegistryURL, + server.URL+"/skills/github", + ) + } + + skillFile := filepath.Join(workspace, "skills", "github", "SKILL.md") + if _, err := os.Stat(skillFile); err != nil { + t.Fatalf("installed skill file missing: %v", err) + } + if _, err := os.Stat(filepath.Join(workspace, "skills", "github", ".skill-origin.json")); err != nil { + t.Fatalf("origin metadata missing: %v", err) + } + + detailRec := httptest.NewRecorder() + detailReq := httptest.NewRequest(http.MethodGet, "/api/skills/github", nil) + mux.ServeHTTP(detailRec, detailReq) + + if detailRec.Code != http.StatusOK { + t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusOK, detailRec.Body.String()) + } + + var detailResp skillDetailResponse + if err := json.Unmarshal(detailRec.Body.Bytes(), &detailResp); err != nil { + t.Fatalf("Unmarshal(detail response) error = %v", err) + } + if detailResp.RegistryURL != server.URL+"/skills/github" { + t.Fatalf("detailResp.RegistryURL = %q, want %q", detailResp.RegistryURL, server.URL+"/skills/github") + } + + searchRec := httptest.NewRecorder() + searchReq := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=5", nil) + mux.ServeHTTP(searchRec, searchReq) + + if searchRec.Code != http.StatusOK { + t.Fatalf("search status = %d, want %d, body=%s", searchRec.Code, http.StatusOK, searchRec.Body.String()) + } + + var searchResp skillSearchResponse + if err := json.Unmarshal(searchRec.Body.Bytes(), &searchResp); err != nil { + t.Fatalf("Unmarshal(search response) error = %v", err) + } + if len(searchResp.Results) != 1 { + t.Fatalf("search results count = %d, want 1", len(searchResp.Results)) + } + if !searchResp.Results[0].Installed || searchResp.Results[0].InstalledName != "github" { + t.Fatalf("search result should be treated as installed after registry install, got %#v", searchResp.Results[0]) + } +} + +func TestHandleInstallSkillForcePreservesExistingSkillOnFailure(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + skillDir := filepath.Join(workspace, "skills", "github") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + oldContent := []byte("---\nname: github\ndescription: Existing skill\n---\n# Existing\n") + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), oldContent, 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/skills/github": + json.NewEncoder(w).Encode(map[string]any{ + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub registry skill", + "latestVersion": map[string]any{ + "version": "1.2.3", + }, + "moderation": map[string]any{ + "isMalwareBlocked": false, + "isSuspicious": false, + }, + }) + case "/api/v1/download": + http.Error(w, "upstream download failed", http.StatusBadGateway) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + setClawHubBaseURL(cfg, server.URL) + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + body, err := json.Marshal(installSkillRequest{ + Slug: "github", + Registry: "clawhub", + Force: true, + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadGateway, rec.Body.String()) + } + + gotContent, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md")) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if !bytes.Equal(gotContent, oldContent) { + t.Fatalf("existing skill should remain unchanged, got:\n%s", string(gotContent)) + } +} + +func TestHandleInstallSkillDefaultsRegistryToGitHub(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/repos/foo/bar": + json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"}) + case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review": + assert.Equal(t, "ref=master", r.URL.RawQuery) + json.NewEncoder(w).Encode([]map[string]any{ + { + "type": "file", + "name": "SKILL.md", + "download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md", + }, + }) + case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md": + _, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github") + if !ok { + t.Fatalf("github registry missing from default config") + } + githubRegistry.BaseURL = server.URL + cfg.Tools.Skills.Registries.Set("github", githubRegistry) + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + body, err := json.Marshal(installSkillRequest{ + Slug: "foo/bar/.agents/skills/pr-review", + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp installSkillResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Registry != "github" { + t.Fatalf("resp.Registry = %q, want github", resp.Registry) + } +} + +func TestHandleInstallSkillTracksGitHubURLInstallsAsInstalled(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/repos/foo/bar": + json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"}) + case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review": + assert.Equal(t, "ref=master", r.URL.RawQuery) + json.NewEncoder(w).Encode([]map[string]any{{ + "type": "file", + "name": "SKILL.md", + "download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md", + }}) + case "/api/v3/search/code": + json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{{ + "path": ".agents/skills/pr-review/SKILL.md", + "score": 10, + "repository": map[string]any{ + "full_name": "foo/bar", + "name": "bar", + "description": "PR review skill", + "default_branch": "master", + }, + }}, + }) + case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md": + _, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + setGithubBaseURL(cfg, server.URL) + clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub") + clawHubRegistry.Enabled = false + cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry) + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + installBody, err := json.Marshal(installSkillRequest{ + Slug: server.URL + "/foo/bar/tree/master/.agents/skills/pr-review", + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + installRec := httptest.NewRecorder() + installReq := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(installBody)) + installReq.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(installRec, installReq) + + if installRec.Code != http.StatusOK { + t.Fatalf("install status = %d, want %d, body=%s", installRec.Code, http.StatusOK, installRec.Body.String()) + } + + searchRec := httptest.NewRecorder() + searchReq := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil) + mux.ServeHTTP(searchRec, searchReq) + + if searchRec.Code != http.StatusOK { + t.Fatalf("search status = %d, want %d, body=%s", searchRec.Code, http.StatusOK, searchRec.Body.String()) + } + + var searchResp skillSearchResponse + if err := json.Unmarshal(searchRec.Body.Bytes(), &searchResp); err != nil { + t.Fatalf("Unmarshal(search response) error = %v", err) + } + if len(searchResp.Results) != 1 { + t.Fatalf("search results count = %d, want 1", len(searchResp.Results)) + } + if !searchResp.Results[0].Installed || searchResp.Results[0].InstalledName != "pr-review" { + t.Fatalf("search result should be treated as installed after URL install, got %#v", searchResp.Results[0]) + } +} + +func TestHandleSearchSkillsMarksDirectoryCollisionAsInstalled(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + skillDir := filepath.Join(workspace, "skills", "pr-review") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: pr-review\ndescription: Workspace PR review skill\n---\n# PR Review\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(SKILL.md) error = %v", err) + } + if err := writeSkillOriginMeta(skillDir, installedSkillOriginMeta{ + Version: 1, + OriginKind: "third_party", + Registry: "github", + Slug: "foo/bar/.agents/skills/pr-review", + RegistryURL: "https://github.com/foo/bar/tree/master/.agents/skills/pr-review", + InstalledVersion: "master", + InstalledAt: time.Now().UnixMilli(), + }); err != nil { + t.Fatalf("writeSkillOriginMeta() error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/search": + json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{{ + "slug": "pr-review", + "displayName": "PR Review", + "summary": "ClawHub PR review skill", + "version": "1.2.3", + }}, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + setClawHubBaseURL(cfg, server.URL) + githubRegistry, _ := cfg.Tools.Skills.Registries.Get("github") + githubRegistry.Enabled = false + cfg.Tools.Skills.Registries.Set("github", githubRegistry) + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillSearchResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Results) != 1 { + t.Fatalf("results count = %d, want 1", len(resp.Results)) + } + if !resp.Results[0].Installed || resp.Results[0].InstalledName != "pr-review" { + t.Fatalf("search result should be treated as installed when directory is occupied, got %#v", resp.Results[0]) + } +} + +func TestHandleInstallSkillRollsBackOnOriginMetadataWriteFailure(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + zipContent := buildSkillZip(t, map[string]string{ + "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n", + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/skills/github": + json.NewEncoder(w).Encode(map[string]any{ + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub registry skill", + "latestVersion": map[string]any{ + "version": "1.2.3", + }, + "moderation": map[string]any{ + "isMalwareBlocked": false, + "isSuspicious": false, + }, + }) + case "/api/v1/download": + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + setClawHubBaseURL(cfg, server.URL) + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + previousPersist := persistSkillOriginMeta + persistSkillOriginMeta = func(targetDir string, meta installedSkillOriginMeta) error { + return errors.New("forced metadata failure") + } + defer func() { + persistSkillOriginMeta = previousPersist + }() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + body, err := json.Marshal(installSkillRequest{ + Slug: "github", + Registry: "clawhub", + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String()) + } + + skillDir := filepath.Join(workspace, "skills", "github") + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Fatalf("skill directory should be removed after metadata write failure, stat err=%v", err) + } +} + +func TestHandleInstallSkillSerializesConcurrentRequests(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + zipContent := buildSkillZip(t, map[string]string{ + "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n", + }) + + downloadStarted := make(chan struct{}, 2) + releaseFirstDownload := make(chan struct{}) + downloadCount := 0 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/skills/github": + json.NewEncoder(w).Encode(map[string]any{ + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub registry skill", + "latestVersion": map[string]any{ + "version": "1.2.3", + }, + "moderation": map[string]any{ + "isMalwareBlocked": false, + "isSuspicious": false, + }, + }) + case "/api/v1/download": + downloadCount++ + downloadStarted <- struct{}{} + if downloadCount == 1 { + <-releaseFirstDownload + } + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + setClawHubBaseURL(cfg, server.URL) + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + body, err := json.Marshal(installSkillRequest{ + Slug: "github", + Registry: "clawhub", + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + type installResult struct { + code int + body string + } + results := make(chan installResult, 2) + startInstall := func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + results <- installResult{ + code: rec.Code, + body: rec.Body.String(), + } + } + + go startInstall() + + select { + case <-downloadStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for first install download to start") + } + + go startInstall() + + select { + case <-downloadStarted: + t.Fatal("second install should not reach registry download before the first request completes") + case <-time.After(200 * time.Millisecond): + } + + close(releaseFirstDownload) + + firstResult := <-results + secondResult := <-results + + codes := map[int]int{ + firstResult.code: 1, + secondResult.code: 1, + } + if codes[http.StatusOK] != 1 || codes[http.StatusConflict] != 1 { + t.Fatalf( + "unexpected install results: first=(%d, %q) second=(%d, %q)", + firstResult.code, + firstResult.body, + secondResult.code, + secondResult.body, + ) + } +} + +func TestHandleImportSkillWaitsForConcurrentInstall(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + zipContent := buildSkillZip(t, map[string]string{ + "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n", + }) + + downloadStarted := make(chan struct{}, 1) + releaseDownload := make(chan struct{}) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/skills/github": + json.NewEncoder(w).Encode(map[string]any{ + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub registry skill", + "latestVersion": map[string]any{ + "version": "1.2.3", + }, + "moderation": map[string]any{ + "isMalwareBlocked": false, + "isSuspicious": false, + }, + }) + case "/api/v1/download": + downloadStarted <- struct{}{} + <-releaseDownload + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + setClawHubBaseURL(cfg, server.URL) + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + installBody, err := json.Marshal(installSkillRequest{ + Slug: "github", + Registry: "clawhub", + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + type result struct { + code int + body string + } + installResults := make(chan result, 1) + importResults := make(chan result, 1) + + go func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(installBody)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + installResults <- result{code: rec.Code, body: rec.Body.String()} + }() + + select { + case <-downloadStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for install download to start") + } + + var importBody bytes.Buffer + writer := multipart.NewWriter(&importBody) + part, err := writer.CreateFormFile("file", "github.md") + if err != nil { + t.Fatalf("CreateFormFile() error = %v", err) + } + if _, err := io.WriteString(part, "# GitHub\n"); err != nil { + t.Fatalf("WriteString() error = %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + go func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &importBody) + req.Header.Set("Content-Type", writer.FormDataContentType()) + mux.ServeHTTP(rec, req) + importResults <- result{code: rec.Code, body: rec.Body.String()} + }() + + select { + case got := <-importResults: + t.Fatalf("import should wait for the install lock, got early response (%d, %q)", got.code, got.body) + case <-time.After(200 * time.Millisecond): + } + + close(releaseDownload) + + installResult := <-installResults + importResult := <-importResults + + if installResult.code != http.StatusOK { + t.Fatalf("install status = %d, want %d, body=%s", installResult.code, http.StatusOK, installResult.body) + } + if importResult.code != http.StatusConflict { + t.Fatalf("import status = %d, want %d, body=%s", importResult.code, http.StatusConflict, importResult.body) + } +} + +func TestHandleInstallSkillRejectsInvalidArchive(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + zipContent := buildSkillZip(t, map[string]string{ + "README.md": "# Not a skill\n", + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/skills/github": + json.NewEncoder(w).Encode(map[string]any{ + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub registry skill", + "latestVersion": map[string]any{ + "version": "1.2.3", + }, + "moderation": map[string]any{ + "isMalwareBlocked": false, + "isSuspicious": false, + }, + }) + case "/api/v1/download": + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + setClawHubBaseURL(cfg, server.URL) + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + body, err := json.Marshal(installSkillRequest{ + Slug: "github", + Registry: "clawhub", + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadGateway, rec.Body.String()) + } + + skillDir := filepath.Join(workspace, "skills", "github") + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Fatalf("invalid installed archive should be removed, stat err=%v", err) + } +} + +func buildSkillZip(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + for name, content := range files { + writer, err := zipWriter.Create(name) + if err != nil { + t.Fatalf("Create(%q) error = %v", name, err) + } + if _, err := io.WriteString(writer, content); err != nil { + t.Fatalf("WriteString(%q) error = %v", name, err) + } + } + if err := zipWriter.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + return buf.Bytes() +} diff --git a/web/backend/api/startup.go b/web/backend/api/startup.go new file mode 100644 index 000000000..8a3b8e8ff --- /dev/null +++ b/web/backend/api/startup.go @@ -0,0 +1,308 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +const ( + autoStartEntryName = "PicoClawLauncher" + launchAgentLabel = "io.picoclaw.launcher" +) + +type autoStartRequest struct { + Enabled bool `json:"enabled"` +} + +type autoStartResponse struct { + Enabled bool `json:"enabled"` + Supported bool `json:"supported"` + Platform string `json:"platform"` + Message string `json:"message,omitempty"` +} + +var errAutoStartUnsupported = errors.New("autostart is not supported on this platform") + +func (h *Handler) registerStartupRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/system/autostart", h.handleGetAutoStart) + mux.HandleFunc("PUT /api/system/autostart", h.handleSetAutoStart) +} + +func (h *Handler) handleGetAutoStart(w http.ResponseWriter, r *http.Request) { + enabled, supported, message, err := h.getAutoStartStatus() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to read startup setting: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(autoStartResponse{ + Enabled: enabled, + Supported: supported, + Platform: runtime.GOOS, + Message: message, + }) +} + +func (h *Handler) handleSetAutoStart(w http.ResponseWriter, r *http.Request) { + var req autoStartRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if err := h.setAutoStart(req.Enabled); err != nil { + if errors.Is(err, errAutoStartUnsupported) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + http.Error(w, fmt.Sprintf("Failed to update startup setting: %v", err), http.StatusInternalServerError) + return + } + + enabled, supported, message, err := h.getAutoStartStatus() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to verify startup setting: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(autoStartResponse{ + Enabled: enabled, + Supported: supported, + Platform: runtime.GOOS, + Message: message, + }) +} + +func (h *Handler) resolveLaunchCommand() (string, []string, error) { + exePath, err := os.Executable() + if err != nil { + return "", nil, err + } + + args := []string{"-no-browser"} + if h.debug { + args = append(args, "-d") + } + if h.configPath != "" { + args = append(args, h.configPath) + } + + return exePath, args, nil +} + +func (h *Handler) getAutoStartStatus() (enabled bool, supported bool, message string, err error) { + switch runtime.GOOS { + case "darwin": + exists, err := fileExists(macLaunchAgentPath()) + return exists, true, "Changes apply on next login.", err + case "linux": + exists, err := fileExists(linuxAutoStartPath()) + return exists, true, "Changes apply on next login.", err + case "windows": + exists, err := windowsRunKeyExists() + return exists, true, "Changes apply on next login.", err + default: + return false, false, "Current platform does not support launch at login.", nil + } +} + +func (h *Handler) setAutoStart(enabled bool) error { + exePath, args, err := h.resolveLaunchCommand() + if err != nil { + return err + } + + switch runtime.GOOS { + case "darwin": + return setDarwinAutoStart(enabled, exePath, args) + case "linux": + return setLinuxAutoStart(enabled, exePath, args) + case "windows": + return setWindowsAutoStart(enabled, exePath, args) + default: + return errAutoStartUnsupported + } +} + +func fileExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func macLaunchAgentPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, "Library", "LaunchAgents", launchAgentLabel+".plist") +} + +func setDarwinAutoStart(enabled bool, exePath string, args []string) error { + plistPath := macLaunchAgentPath() + if enabled { + if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil { + return err + } + content := buildDarwinPlist(exePath, args) + return os.WriteFile(plistPath, []byte(content), 0o644) + } + + if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func xmlEscape(s string) string { + var b bytes.Buffer + for _, r := range s { + switch r { + case '&': + b.WriteString("&") + case '<': + b.WriteString("<") + case '>': + b.WriteString(">") + case '"': + b.WriteString(""") + case '\'': + b.WriteString("'") + default: + b.WriteRune(r) + } + } + return b.String() +} + +func buildDarwinPlist(exePath string, args []string) string { + programArgs := make([]string, 0, len(args)+1) + programArgs = append(programArgs, exePath) + programArgs = append(programArgs, args...) + + var b strings.Builder + b.WriteString(`` + "\n") + b.WriteString( + `` + "\n", + ) + b.WriteString(`` + "\n") + b.WriteString(`` + "\n") + b.WriteString(` Label` + "\n") + b.WriteString(` ` + launchAgentLabel + `` + "\n") + b.WriteString(` ProgramArguments` + "\n") + b.WriteString(` ` + "\n") + for _, arg := range programArgs { + b.WriteString(` ` + xmlEscape(arg) + `` + "\n") + } + b.WriteString(` ` + "\n") + b.WriteString(` RunAtLoad` + "\n") + b.WriteString(` ` + "\n") + b.WriteString(` ProcessType` + "\n") + b.WriteString(` Background` + "\n") + b.WriteString(`` + "\n") + b.WriteString(`` + "\n") + return b.String() +} + +func linuxAutoStartPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "autostart", "picoclaw-web.desktop") +} + +func shellQuote(s string) string { + if s == "" { + return "''" + } + if !strings.ContainsAny(s, " \t\n'\"\\$`") { + return s + } + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} + +func buildLinuxExecLine(exePath string, args []string) string { + parts := make([]string, 0, len(args)+1) + parts = append(parts, shellQuote(exePath)) + for _, arg := range args { + parts = append(parts, shellQuote(arg)) + } + return strings.Join(parts, " ") +} + +func setLinuxAutoStart(enabled bool, exePath string, args []string) error { + desktopPath := linuxAutoStartPath() + if enabled { + if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil { + return err + } + content := strings.Join([]string{ + "[Desktop Entry]", + "Type=Application", + "Version=1.0", + "Name=PicoClaw Web", + "Comment=Start PicoClaw Web on login", + "Exec=" + buildLinuxExecLine(exePath, args), + "Terminal=false", + "X-GNOME-Autostart-enabled=true", + "NoDisplay=true", + "", + }, "\n") + return os.WriteFile(desktopPath, []byte(content), 0o644) + } + + if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func windowsCommandLine(exePath string, args []string) string { + parts := make([]string, 0, len(args)+1) + parts = append(parts, fmt.Sprintf("%q", exePath)) + for _, arg := range args { + parts = append(parts, fmt.Sprintf("%q", arg)) + } + return strings.Join(parts, " ") +} + +func windowsRunKeyExists() (bool, error) { + cmd := exec.Command("reg", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autoStartEntryName) + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return false, nil + } + return false, err + } + return true, nil +} + +func setWindowsAutoStart(enabled bool, exePath string, args []string) error { + key := `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` + if enabled { + commandLine := windowsCommandLine(exePath, args) + cmd := exec.Command("reg", "add", key, "/v", autoStartEntryName, "/t", "REG_SZ", "/d", commandLine, "/f") + return cmd.Run() + } + + cmd := exec.Command("reg", "delete", key, "/v", autoStartEntryName, "/f") + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return nil + } + return err + } + return nil +} diff --git a/web/backend/api/startup_test.go b/web/backend/api/startup_test.go new file mode 100644 index 000000000..c224d36e2 --- /dev/null +++ b/web/backend/api/startup_test.go @@ -0,0 +1,79 @@ +package api + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/web/backend/launcherconfig" +) + +func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + // Persist non-default launcher options to ensure resolveLaunchCommand does not + // pin them into autostart args. + launcherPath := launcherconfig.PathForAppConfig(configPath) + if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ + Port: 19999, + Public: true, + }); err != nil { + t.Fatalf("launcherconfig.Save() error = %v", err) + } + + exePath, args, err := h.resolveLaunchCommand() + if err != nil { + t.Fatalf("resolveLaunchCommand() error = %v", err) + } + if exePath == "" { + t.Fatal("resolveLaunchCommand() returned empty executable path") + } + if len(args) != 2 { + t.Fatalf("args len = %d, want 2 (got %v)", len(args), args) + } + if args[0] != "-no-browser" { + t.Fatalf("args[0] = %q, want %q", args[0], "-no-browser") + } + if args[1] != configPath { + t.Fatalf("args[1] = %q, want %q", args[1], configPath) + } + for _, arg := range args { + if arg == "-port" || arg == "-public" { + t.Fatalf("autostart args should not pin network flags, got %v", args) + } + } +} + +func TestResolveLaunchCommandIncludesDebugFlagWhenEnabled(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + h.SetDebug(true) + + _, args, err := h.resolveLaunchCommand() + if err != nil { + t.Fatalf("resolveLaunchCommand() error = %v", err) + } + if len(args) != 3 { + t.Fatalf("args len = %d, want 3 (got %v)", len(args), args) + } + if args[0] != "-no-browser" { + t.Fatalf("args[0] = %q, want %q", args[0], "-no-browser") + } + if args[1] != "-d" { + t.Fatalf("args[1] = %q, want %q", args[1], "-d") + } + if args[2] != configPath { + t.Fatalf("args[2] = %q, want %q", args[2], configPath) + } +} + +func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) { + plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"}) + if !strings.Contains(plist, "RunAtLoad") { + t.Fatalf("plist missing RunAtLoad key:\n%s", plist) + } + if !strings.Contains(plist, "") { + t.Fatalf("plist missing RunAtLoad true value:\n%s", plist) + } +} diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go new file mode 100644 index 000000000..3476e3c53 --- /dev/null +++ b/web/backend/api/tools.go @@ -0,0 +1,674 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "runtime" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + picotools "github.com/sipeed/picoclaw/pkg/tools" +) + +type toolCatalogEntry struct { + Name string + Description string + Category string + ConfigKey string +} + +type toolSupportItem struct { + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + ConfigKey string `json:"config_key"` + Status string `json:"status"` + ReasonCode string `json:"reason_code,omitempty"` +} + +type toolSupportResponse struct { + Tools []toolSupportItem `json:"tools"` +} + +type toolStateRequest struct { + Enabled bool `json:"enabled"` +} + +type webSearchProviderOption struct { + ID string `json:"id"` + Label string `json:"label"` + Configured bool `json:"configured"` + Current bool `json:"current"` + RequiresAuth bool `json:"requires_auth"` +} + +type webSearchProviderConfig struct { + Enabled bool `json:"enabled"` + MaxResults int `json:"max_results"` + BaseURL string `json:"base_url,omitempty"` + APIKey string `json:"api_key,omitempty"` + APIKeys []string `json:"api_keys,omitempty"` + APIKeySet bool `json:"api_key_set,omitempty"` +} + +type webSearchConfigResponse struct { + Provider string `json:"provider"` + CurrentService string `json:"current_service"` + PreferNative bool `json:"prefer_native"` + Proxy string `json:"proxy,omitempty"` + Providers []webSearchProviderOption `json:"providers"` + Settings map[string]webSearchProviderConfig `json:"settings"` +} + +type webSearchConfigRequest struct { + Provider string `json:"provider"` + PreferNative bool `json:"prefer_native"` + Proxy string `json:"proxy"` + Settings map[string]webSearchProviderConfig `json:"settings"` +} + +var toolCatalog = []toolCatalogEntry{ + { + Name: "read_file", + Description: "Read file content from the workspace or explicitly allowed paths.", + Category: "filesystem", + ConfigKey: "read_file", + }, + { + Name: "write_file", + Description: "Create or overwrite files within the writable workspace scope.", + Category: "filesystem", + ConfigKey: "write_file", + }, + { + Name: "list_dir", + Description: "Inspect directories and enumerate files available to the agent.", + Category: "filesystem", + ConfigKey: "list_dir", + }, + { + Name: "edit_file", + Description: "Apply targeted edits to existing files without rewriting everything.", + Category: "filesystem", + ConfigKey: "edit_file", + }, + { + Name: "append_file", + Description: "Append content to the end of an existing file.", + Category: "filesystem", + ConfigKey: "append_file", + }, + { + Name: "exec", + Description: "Run shell commands inside the configured workspace sandbox.", + Category: "filesystem", + ConfigKey: "exec", + }, + { + Name: "cron", + Description: "Schedule one-time or recurring reminders, jobs, and shell commands.", + Category: "automation", + ConfigKey: "cron", + }, + { + Name: "web_search", + Description: "Search the web using the configured providers.", + Category: "web", + ConfigKey: "web", + }, + { + Name: "web_fetch", + Description: "Fetch and summarize the contents of a webpage.", + Category: "web", + ConfigKey: "web_fetch", + }, + { + Name: "message", + Description: "Send a follow-up message back to the active user or chat.", + Category: "communication", + ConfigKey: "message", + }, + { + Name: "send_file", + Description: "Send an outbound file or media attachment to the active chat.", + Category: "communication", + ConfigKey: "send_file", + }, + { + Name: "find_skills", + Description: "Search external skill registries for installable skills.", + Category: "skills", + ConfigKey: "find_skills", + }, + { + Name: "install_skill", + Description: "Install a skill into the current workspace from a registry.", + Category: "skills", + ConfigKey: "install_skill", + }, + { + Name: "spawn", + Description: "Launch a background subagent for long-running or delegated work.", + Category: "agents", + ConfigKey: "spawn", + }, + { + Name: "spawn_status", + Description: "Query the status of spawned subagents.", + Category: "agents", + ConfigKey: "spawn_status", + }, + { + Name: "i2c", + Description: "Interact with I2C hardware devices exposed on the host.", + Category: "hardware", + ConfigKey: "i2c", + }, + { + Name: "spi", + Description: "Interact with SPI hardware devices exposed on the host.", + Category: "hardware", + ConfigKey: "spi", + }, + { + Name: "serial", + Description: "Interact with serial ports exposed on the host.", + Category: "hardware", + ConfigKey: "serial", + }, + { + Name: "tool_search_tool_regex", + Description: "Discover hidden MCP tools by regex search when tool discovery is enabled.", + Category: "discovery", + ConfigKey: "mcp.discovery.use_regex", + }, + { + Name: "tool_search_tool_bm25", + Description: "Discover hidden MCP tools by semantic ranking when tool discovery is enabled.", + Category: "discovery", + ConfigKey: "mcp.discovery.use_bm25", + }, +} + +func (h *Handler) registerToolRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/tools", h.handleListTools) + mux.HandleFunc("PUT /api/tools/{name}/state", h.handleUpdateToolState) + mux.HandleFunc("GET /api/tools/web-search-config", h.handleGetWebSearchConfig) + mux.HandleFunc("PUT /api/tools/web-search-config", h.handleUpdateWebSearchConfig) +} + +func (h *Handler) handleListTools(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(toolSupportResponse{ + Tools: buildToolSupport(cfg), + }) +} + +func (h *Handler) handleUpdateToolState(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + var req toolStateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if err := applyToolState(cfg, r.PathValue("name"), req.Enabled); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func buildToolSupport(cfg *config.Config) []toolSupportItem { + items := make([]toolSupportItem, 0, len(toolCatalog)) + for _, entry := range toolCatalog { + status := "disabled" + reasonCode := "" + + switch entry.Name { + case "find_skills", "install_skill": + if cfg.Tools.IsToolEnabled(entry.ConfigKey) { + if cfg.Tools.IsToolEnabled("skills") { + status = "enabled" + } else { + status = "blocked" + reasonCode = "requires_skills" + } + } + case "spawn", "spawn_status": + if cfg.Tools.IsToolEnabled(entry.ConfigKey) { + if cfg.Tools.IsToolEnabled("subagent") { + status = "enabled" + } else { + status = "blocked" + reasonCode = "requires_subagent" + } + } + case "tool_search_tool_regex": + status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseRegex) + case "tool_search_tool_bm25": + status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseBM25) + case "web_search": + status, reasonCode = resolveWebSearchToolSupport(cfg) + case "i2c", "spi": + status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey)) + case "serial": + status, reasonCode = resolveSerialToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey)) + default: + if cfg.Tools.IsToolEnabled(entry.ConfigKey) { + status = "enabled" + } + } + + items = append(items, toolSupportItem{ + Name: entry.Name, + Description: entry.Description, + Category: entry.Category, + ConfigKey: entry.ConfigKey, + Status: status, + ReasonCode: reasonCode, + }) + } + return items +} + +func resolveHardwareToolSupport(enabled bool) (string, string) { + if !enabled { + return "disabled", "" + } + if runtime.GOOS != "linux" { + return "blocked", "requires_linux" + } + return "enabled", "" +} + +func resolveSerialToolSupport(enabled bool) (string, string) { + if !enabled { + return "disabled", "" + } + switch runtime.GOOS { + case "linux", "darwin", "windows": + return "enabled", "" + default: + return "blocked", "requires_serial_platform" + } +} + +func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string, string) { + if !cfg.Tools.IsToolEnabled("mcp") { + return "disabled", "" + } + if !cfg.Tools.MCP.Discovery.Enabled { + return "blocked", "requires_mcp_discovery" + } + if !methodEnabled { + return "disabled", "" + } + return "enabled", "" +} + +func resolveWebSearchToolSupport(cfg *config.Config) (string, string) { + if !cfg.Tools.IsToolEnabled("web") { + return "disabled", "" + } + return "enabled", "" +} + +func applyToolState(cfg *config.Config, toolName string, enabled bool) error { + switch toolName { + case "read_file": + cfg.Tools.ReadFile.Enabled = enabled + case "write_file": + cfg.Tools.WriteFile.Enabled = enabled + case "list_dir": + cfg.Tools.ListDir.Enabled = enabled + case "edit_file": + cfg.Tools.EditFile.Enabled = enabled + case "append_file": + cfg.Tools.AppendFile.Enabled = enabled + case "exec": + cfg.Tools.Exec.Enabled = enabled + case "cron": + cfg.Tools.Cron.Enabled = enabled + case "web_search": + cfg.Tools.Web.Enabled = enabled + case "web_fetch": + cfg.Tools.WebFetch.Enabled = enabled + case "message": + cfg.Tools.Message.Enabled = enabled + case "send_file": + cfg.Tools.SendFile.Enabled = enabled + case "find_skills": + cfg.Tools.FindSkills.Enabled = enabled + if enabled { + cfg.Tools.Skills.Enabled = true + } + case "install_skill": + cfg.Tools.InstallSkill.Enabled = enabled + if enabled { + cfg.Tools.Skills.Enabled = true + } + case "spawn": + cfg.Tools.Spawn.Enabled = enabled + if enabled { + cfg.Tools.Subagent.Enabled = true + } + case "spawn_status": + cfg.Tools.SpawnStatus.Enabled = enabled + if enabled { + cfg.Tools.Spawn.Enabled = true + cfg.Tools.Subagent.Enabled = true + } + case "i2c": + cfg.Tools.I2C.Enabled = enabled + case "spi": + cfg.Tools.SPI.Enabled = enabled + case "serial": + cfg.Tools.Serial.Enabled = enabled + case "tool_search_tool_regex": + cfg.Tools.MCP.Discovery.UseRegex = enabled + if enabled { + cfg.Tools.MCP.Enabled = true + cfg.Tools.MCP.Discovery.Enabled = true + } + case "tool_search_tool_bm25": + cfg.Tools.MCP.Discovery.UseBM25 = enabled + if enabled { + cfg.Tools.MCP.Enabled = true + cfg.Tools.MCP.Discovery.Enabled = true + } + default: + return fmt.Errorf("tool %q cannot be updated", toolName) + } + return nil +} + +func (h *Handler) handleGetWebSearchConfig(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(buildWebSearchConfigResponse(cfg)); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +func (h *Handler) handleUpdateWebSearchConfig(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + var req webSearchConfigRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + provider := normalizeWebSearchProvider(req.Provider) + if provider == "" { + http.Error(w, "invalid web search provider", http.StatusBadRequest) + return + } + + cfg.Tools.Web.Provider = provider + cfg.Tools.Web.PreferNative = req.PreferNative + cfg.Tools.Web.Proxy = strings.TrimSpace(req.Proxy) + + if settings, ok := req.Settings["sogou"]; ok { + cfg.Tools.Web.Sogou.Enabled = settings.Enabled + cfg.Tools.Web.Sogou.MaxResults = settings.MaxResults + } + if settings, ok := req.Settings["duckduckgo"]; ok { + cfg.Tools.Web.DuckDuckGo.Enabled = settings.Enabled + cfg.Tools.Web.DuckDuckGo.MaxResults = settings.MaxResults + } + if settings, ok := req.Settings["brave"]; ok { + cfg.Tools.Web.Brave.Enabled = settings.Enabled + cfg.Tools.Web.Brave.MaxResults = settings.MaxResults + if keys, ok := normalizeWebSearchAPIKeys(settings.APIKeys, settings.APIKey); ok { + cfg.Tools.Web.Brave.SetAPIKeys(keys) + } + } + if settings, ok := req.Settings["tavily"]; ok { + cfg.Tools.Web.Tavily.Enabled = settings.Enabled + cfg.Tools.Web.Tavily.MaxResults = settings.MaxResults + cfg.Tools.Web.Tavily.BaseURL = strings.TrimSpace(settings.BaseURL) + if keys, ok := normalizeWebSearchAPIKeys(settings.APIKeys, settings.APIKey); ok { + cfg.Tools.Web.Tavily.SetAPIKeys(keys) + } + } + if settings, ok := req.Settings["perplexity"]; ok { + cfg.Tools.Web.Perplexity.Enabled = settings.Enabled + cfg.Tools.Web.Perplexity.MaxResults = settings.MaxResults + if keys, ok := normalizeWebSearchAPIKeys(settings.APIKeys, settings.APIKey); ok { + cfg.Tools.Web.Perplexity.APIKeys = config.SimpleSecureStrings(keys...) + } + } + if settings, ok := req.Settings["searxng"]; ok { + cfg.Tools.Web.SearXNG.Enabled = settings.Enabled + cfg.Tools.Web.SearXNG.MaxResults = settings.MaxResults + cfg.Tools.Web.SearXNG.BaseURL = strings.TrimSpace(settings.BaseURL) + } + if settings, ok := req.Settings["glm_search"]; ok { + cfg.Tools.Web.GLMSearch.Enabled = settings.Enabled + cfg.Tools.Web.GLMSearch.MaxResults = settings.MaxResults + cfg.Tools.Web.GLMSearch.BaseURL = strings.TrimSpace(settings.BaseURL) + if key := strings.TrimSpace(settings.APIKey); key != "" { + cfg.Tools.Web.GLMSearch.APIKey = *config.NewSecureString(key) + } + } + if settings, ok := req.Settings["baidu_search"]; ok { + cfg.Tools.Web.BaiduSearch.Enabled = settings.Enabled + cfg.Tools.Web.BaiduSearch.MaxResults = settings.MaxResults + cfg.Tools.Web.BaiduSearch.BaseURL = strings.TrimSpace(settings.BaseURL) + if key := strings.TrimSpace(settings.APIKey); key != "" { + cfg.Tools.Web.BaiduSearch.APIKey = *config.NewSecureString(key) + } + } + + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(buildWebSearchConfigResponse(cfg)); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +func normalizeWebSearchProvider(provider string) string { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "", "auto": + return "auto" + case "sogou", "brave", "tavily", "duckduckgo", "perplexity", "searxng", "glm_search", "baidu_search": + return strings.ToLower(strings.TrimSpace(provider)) + default: + return "" + } +} + +func normalizeWebSearchAPIKeys(apiKeys []string, apiKey string) ([]string, bool) { + if apiKeys != nil { + keys := make([]string, 0, len(apiKeys)) + seen := make(map[string]struct{}, len(apiKeys)) + for _, key := range apiKeys { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + keys = append(keys, trimmed) + } + return keys, true + } + + if trimmed := strings.TrimSpace(apiKey); trimmed != "" { + return []string{trimmed}, true + } + + return nil, false +} + +func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse { + opts := picotools.WebSearchToolOptionsFromConfig(cfg) + current := resolveCurrentWebSearchProvider(cfg) + settings := map[string]webSearchProviderConfig{ + "sogou": { + Enabled: cfg.Tools.Web.Sogou.Enabled, + MaxResults: cfg.Tools.Web.Sogou.MaxResults, + }, + "duckduckgo": { + Enabled: cfg.Tools.Web.DuckDuckGo.Enabled, + MaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, + }, + "brave": { + Enabled: cfg.Tools.Web.Brave.Enabled, + MaxResults: cfg.Tools.Web.Brave.MaxResults, + APIKeySet: len(cfg.Tools.Web.Brave.APIKeys.Values()) > 0, + }, + "tavily": { + Enabled: cfg.Tools.Web.Tavily.Enabled, + MaxResults: cfg.Tools.Web.Tavily.MaxResults, + BaseURL: cfg.Tools.Web.Tavily.BaseURL, + APIKeySet: len(cfg.Tools.Web.Tavily.APIKeys.Values()) > 0, + }, + "perplexity": { + Enabled: cfg.Tools.Web.Perplexity.Enabled, + MaxResults: cfg.Tools.Web.Perplexity.MaxResults, + APIKeySet: len(cfg.Tools.Web.Perplexity.APIKeys.Values()) > 0, + }, + "searxng": { + Enabled: cfg.Tools.Web.SearXNG.Enabled, + MaxResults: cfg.Tools.Web.SearXNG.MaxResults, + BaseURL: cfg.Tools.Web.SearXNG.BaseURL, + }, + "glm_search": { + Enabled: cfg.Tools.Web.GLMSearch.Enabled, + MaxResults: cfg.Tools.Web.GLMSearch.MaxResults, + BaseURL: cfg.Tools.Web.GLMSearch.BaseURL, + APIKeySet: cfg.Tools.Web.GLMSearch.APIKey.String() != "", + }, + "baidu_search": { + Enabled: cfg.Tools.Web.BaiduSearch.Enabled, + MaxResults: cfg.Tools.Web.BaiduSearch.MaxResults, + BaseURL: cfg.Tools.Web.BaiduSearch.BaseURL, + APIKeySet: cfg.Tools.Web.BaiduSearch.APIKey.String() != "", + }, + } + + providers := []webSearchProviderOption{ + { + ID: "auto", + Label: "Auto", + Configured: current != "", + Current: cfg.Tools.Web.Provider == "" || + cfg.Tools.Web.Provider == "auto", + }, + { + ID: "sogou", + Label: "Sogou", + Configured: picotools.WebSearchProviderReady(opts, "sogou"), + Current: current == "sogou", + }, + { + ID: "duckduckgo", + Label: "DuckDuckGo", + Configured: picotools.WebSearchProviderReady(opts, "duckduckgo"), + Current: current == "duckduckgo", + }, + { + ID: "brave", + Label: "Brave Search", + Configured: picotools.WebSearchProviderReady(opts, "brave"), + Current: current == "brave", + RequiresAuth: true, + }, + { + ID: "tavily", + Label: "Tavily", + Configured: picotools.WebSearchProviderReady(opts, "tavily"), + Current: current == "tavily", + RequiresAuth: true, + }, + { + ID: "perplexity", + Label: "Perplexity", + Configured: picotools.WebSearchProviderReady(opts, "perplexity"), + Current: current == "perplexity", + RequiresAuth: true, + }, + { + ID: "searxng", + Label: "SearXNG", + Configured: picotools.WebSearchProviderReady(opts, "searxng"), + Current: current == "searxng", + }, + { + ID: "glm_search", + Label: "GLM Search", + Configured: picotools.WebSearchProviderReady(opts, "glm_search"), + Current: current == "glm_search", + RequiresAuth: true, + }, + { + ID: "baidu_search", + Label: "Baidu Search", + Configured: picotools.WebSearchProviderReady(opts, "baidu_search"), + Current: current == "baidu_search", + RequiresAuth: true, + }, + } + + provider := cfg.Tools.Web.Provider + if provider == "" { + provider = "auto" + } + + return webSearchConfigResponse{ + Provider: provider, + CurrentService: current, + PreferNative: cfg.Tools.Web.PreferNative, + Proxy: cfg.Tools.Web.Proxy, + Providers: providers, + Settings: settings, + } +} + +func resolveCurrentWebSearchProvider(cfg *config.Config) string { + if cfg == nil || !cfg.Tools.IsToolEnabled("web") { + return "" + } + selected, err := picotools.ResolveWebSearchProviderName(picotools.WebSearchToolOptionsFromConfig(cfg), "") + if err != nil { + return "" + } + return selected +} diff --git a/web/backend/api/tools_test.go b/web/backend/api/tools_test.go new file mode 100644 index 000000000..a09a49fd6 --- /dev/null +++ b/web/backend/api/tools_test.go @@ -0,0 +1,604 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "runtime" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestHandleListTools(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.ReadFile.Enabled = true + cfg.Tools.WriteFile.Enabled = false + cfg.Tools.Cron.Enabled = true + cfg.Tools.FindSkills.Enabled = true + cfg.Tools.Skills.Enabled = true + cfg.Tools.Spawn.Enabled = true + cfg.Tools.Subagent.Enabled = false + cfg.Tools.MCP.Enabled = true + cfg.Tools.MCP.Discovery.Enabled = true + cfg.Tools.MCP.Discovery.UseRegex = true + cfg.Tools.MCP.Discovery.UseBM25 = false + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/tools", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp toolSupportResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + gotTools := make(map[string]toolSupportItem, len(resp.Tools)) + for _, tool := range resp.Tools { + gotTools[tool.Name] = tool + } + if gotTools["read_file"].Status != "enabled" { + t.Fatalf("read_file status = %q, want enabled", gotTools["read_file"].Status) + } + if gotTools["write_file"].Status != "disabled" { + t.Fatalf("write_file status = %q, want disabled", gotTools["write_file"].Status) + } + if gotTools["cron"].Status != "enabled" { + t.Fatalf("cron status = %q, want enabled", gotTools["cron"].Status) + } + if gotTools["spawn"].Status != "blocked" || gotTools["spawn"].ReasonCode != "requires_subagent" { + t.Fatalf("spawn = %#v, want blocked/requires_subagent", gotTools["spawn"]) + } + if gotTools["find_skills"].Status != "enabled" { + t.Fatalf("find_skills status = %q, want enabled", gotTools["find_skills"].Status) + } + if gotTools["tool_search_tool_regex"].Status != "enabled" { + t.Fatalf("tool_search_tool_regex status = %q, want enabled", gotTools["tool_search_tool_regex"].Status) + } + if gotTools["tool_search_tool_regex"].ConfigKey != "mcp.discovery.use_regex" { + t.Fatalf( + "tool_search_tool_regex config_key = %q, want mcp.discovery.use_regex", + gotTools["tool_search_tool_regex"].ConfigKey, + ) + } + if gotTools["tool_search_tool_bm25"].Status != "disabled" { + t.Fatalf("tool_search_tool_bm25 status = %q, want disabled", gotTools["tool_search_tool_bm25"].Status) + } + if gotTools["tool_search_tool_bm25"].ConfigKey != "mcp.discovery.use_bm25" { + t.Fatalf( + "tool_search_tool_bm25 config_key = %q, want mcp.discovery.use_bm25", + gotTools["tool_search_tool_bm25"].ConfigKey, + ) + } + if runtime.GOOS == "linux" { + if gotTools["i2c"].Status != "disabled" { + t.Fatalf("i2c status = %q, want disabled on linux when config is off", gotTools["i2c"].Status) + } + if gotTools["serial"].Status != "disabled" { + t.Fatalf("serial status = %q, want disabled when config is off", gotTools["serial"].Status) + } + + cfg.Tools.Serial.Enabled = true + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/tools", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + gotTools = make(map[string]toolSupportItem, len(resp.Tools)) + for _, tool := range resp.Tools { + gotTools[tool.Name] = tool + } + if gotTools["serial"].Status != "enabled" { + t.Fatalf("serial = %#v, want enabled on linux when config is on", gotTools["serial"]) + } + } else { + cfg.Tools.I2C.Enabled = true + cfg.Tools.SPI.Enabled = true + cfg.Tools.Serial.Enabled = true + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/tools", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + gotTools = make(map[string]toolSupportItem, len(resp.Tools)) + for _, tool := range resp.Tools { + gotTools[tool.Name] = tool + } + + if gotTools["i2c"].Status != "blocked" || gotTools["i2c"].ReasonCode != "requires_linux" { + t.Fatalf("i2c = %#v, want blocked/requires_linux", gotTools["i2c"]) + } + if gotTools["spi"].Status != "blocked" || gotTools["spi"].ReasonCode != "requires_linux" { + t.Fatalf("spi = %#v, want blocked/requires_linux", gotTools["spi"]) + } + switch runtime.GOOS { + case "darwin", "windows": + if gotTools["serial"].Status != "enabled" { + t.Fatalf("serial = %#v, want enabled on supported host", gotTools["serial"]) + } + default: + if gotTools["serial"].Status != "blocked" || gotTools["serial"].ReasonCode != "requires_serial_platform" { + t.Fatalf("serial = %#v, want blocked/requires_serial_platform", gotTools["serial"]) + } + } + } +} + +func TestHandleUpdateToolState(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.Spawn.Enabled = false + cfg.Tools.Subagent.Enabled = false + cfg.Tools.Cron.Enabled = false + cfg.Tools.MCP.Enabled = false + cfg.Tools.MCP.Discovery.Enabled = false + cfg.Tools.MCP.Discovery.UseRegex = false + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/tools/spawn/state", + bytes.NewBufferString(`{"enabled":true}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("spawn status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest( + http.MethodPut, + "/api/tools/tool_search_tool_regex/state", + bytes.NewBufferString(`{"enabled":true}`), + ) + req2.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("regex status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String()) + } + + rec3 := httptest.NewRecorder() + req3 := httptest.NewRequest( + http.MethodPut, + "/api/tools/cron/state", + bytes.NewBufferString(`{"enabled":true}`), + ) + req3.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec3, req3) + if rec3.Code != http.StatusOK { + t.Fatalf("cron status = %d, want %d, body=%s", rec3.Code, http.StatusOK, rec3.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig(updated) error = %v", err) + } + if !updated.Tools.Spawn.Enabled || !updated.Tools.Subagent.Enabled { + t.Fatalf("spawn/subagent should both be enabled: %#v", updated.Tools) + } + if !updated.Tools.MCP.Enabled || !updated.Tools.MCP.Discovery.Enabled || !updated.Tools.MCP.Discovery.UseRegex { + t.Fatalf("mcp regex discovery should be enabled: %#v", updated.Tools.MCP) + } + if !updated.Tools.Cron.Enabled { + t.Fatalf("cron should be enabled: %#v", updated.Tools.Cron) + } + + rec4 := httptest.NewRecorder() + req4 := httptest.NewRequest( + http.MethodPut, + "/api/tools/serial/state", + bytes.NewBufferString(`{"enabled":true}`), + ) + req4.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec4, req4) + if rec4.Code != http.StatusOK { + t.Fatalf("serial status = %d, want %d, body=%s", rec4.Code, http.StatusOK, rec4.Body.String()) + } + + updated, err = config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig(updated serial) error = %v", err) + } + if !updated.Tools.Serial.Enabled { + t.Fatalf("serial should be enabled: %#v", updated.Tools.Serial) + } +} + +func TestHandleListTools_ReportsWebSearchEnabledWhenToolIsOn(t *testing.T) { + tests := []struct { + name string + preferNative bool + }{ + {name: "without prefer_native", preferNative: false}, + {name: "with prefer_native", preferNative: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.Web.PreferNative = tt.preferNative + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Sogou.Enabled = false + cfg.Tools.Web.DuckDuckGo.Enabled = false + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Brave.SetAPIKeys(nil) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/tools", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp toolSupportResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + for _, tool := range resp.Tools { + if tool.Name != "web_search" { + continue + } + if tool.Status != "enabled" || tool.ReasonCode != "" { + t.Fatalf("web_search = %#v, want enabled with no reason code", tool) + } + return + } + + t.Fatal("expected web_search in response") + }) + } +} + +func TestHandleGetWebSearchConfig(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.Web.PreferNative = false + cfg.Tools.Web.Provider = "sogou" + cfg.Tools.Web.Sogou.Enabled = true + cfg.Tools.Web.Sogou.MaxResults = 6 + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Brave.SetAPIKey("brave-test-key") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/tools/web-search-config", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp webSearchConfigResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Provider != "sogou" { + t.Fatalf("provider = %q, want sogou", resp.Provider) + } + if resp.CurrentService != "sogou" { + t.Fatalf("current_service = %q, want sogou", resp.CurrentService) + } + if !resp.Settings["brave"].APIKeySet { + t.Fatalf("brave api_key_set should be true: %#v", resp.Settings["brave"]) + } +} + +func TestHandleGetWebSearchConfig_DoesNotExposeNativeAsCurrentService(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.Web.PreferNative = true + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Sogou.Enabled = false + cfg.Tools.Web.DuckDuckGo.Enabled = false + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Brave.SetAPIKeys(nil) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/tools/web-search-config", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp webSearchConfigResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if !resp.PreferNative { + t.Fatal("prefer_native should remain true in response") + } + if resp.CurrentService != "" { + t.Fatalf("current_service = %q, want empty when no external provider is ready", resp.CurrentService) + } +} + +func TestHandleUpdateWebSearchConfig(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.Web.Brave.SetAPIKeys([]string{"brave-old-1", "brave-old-2"}) + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/tools/web-search-config", + bytes.NewBufferString(`{ + "provider":"brave", + "prefer_native":false, + "proxy":"http://127.0.0.1:7890", + "settings":{ + "sogou":{"enabled":true,"max_results":4}, + "brave":{"enabled":true,"max_results":7,"api_key":"brave-new-key"}, + "duckduckgo":{"enabled":false,"max_results":3} + } + }`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if updated.Tools.Web.Provider != "brave" { + t.Fatalf("provider = %q, want brave", updated.Tools.Web.Provider) + } + if updated.Tools.Web.PreferNative { + t.Fatal("prefer_native should be false after update") + } + if updated.Tools.Web.Proxy != "http://127.0.0.1:7890" { + t.Fatalf("proxy = %q", updated.Tools.Web.Proxy) + } + if !updated.Tools.Web.Sogou.Enabled || updated.Tools.Web.Sogou.MaxResults != 4 { + t.Fatalf("sogou config not updated: %#v", updated.Tools.Web.Sogou) + } + if !updated.Tools.Web.Brave.Enabled || updated.Tools.Web.Brave.MaxResults != 7 { + t.Fatalf("brave config not updated: %#v", updated.Tools.Web.Brave) + } + if updated.Tools.Web.Brave.APIKey() != "brave-new-key" { + t.Fatalf("brave api key not updated") + } +} + +func TestHandleUpdateWebSearchConfig_PreservesAndReplacesMultiKeys(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.Web.Brave.SetAPIKeys([]string{"brave-old-1", "brave-old-2"}) + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/tools/web-search-config", + bytes.NewBufferString(`{ + "provider":"auto", + "prefer_native":true, + "proxy":"", + "settings":{ + "brave":{"enabled":true,"max_results":7} + } + }`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.Tools.Web.Brave.APIKeys.Values(); len(got) != 2 || + got[0] != "brave-old-1" || got[1] != "brave-old-2" { + t.Fatalf("brave api keys should be preserved, got %#v", got) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest( + http.MethodPut, + "/api/tools/web-search-config", + bytes.NewBufferString(`{ + "provider":"auto", + "prefer_native":true, + "proxy":"", + "settings":{ + "brave":{"enabled":true,"max_results":7,"api_keys":["brave-new-1","brave-new-2","brave-new-1"]} + } + }`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err = config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.Tools.Web.Brave.APIKeys.Values(); len(got) != 2 || + got[0] != "brave-new-1" || got[1] != "brave-new-2" { + t.Fatalf("brave api keys should be replaced by api_keys, got %#v", got) + } +} + +func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersBeforeSogou(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Web.Provider = "auto" + cfg.Tools.Web.Sogou.Enabled = true + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Brave.SetAPIKey("brave-test-key") + + if got := resolveCurrentWebSearchProvider(cfg); got != "brave" { + t.Fatalf("resolveCurrentWebSearchProvider() = %q, want brave", got) + } +} + +func TestResolveCurrentWebSearchProvider_FallsBackWhenExplicitProviderUnavailable(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Sogou.Enabled = true + + if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" { + t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got) + } +} + +func TestResolveCurrentWebSearchProvider_FallsBackWhenProviderIsUnknown(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Web.Provider = "totally_unknown" + cfg.Tools.Web.Sogou.Enabled = true + + if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" { + t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got) + } +} + +func TestResolveCurrentWebSearchProvider_PrefersStableDefaultForSogouAndDuckDuckGo(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Web.Provider = "auto" + cfg.Tools.Web.Sogou.Enabled = true + cfg.Tools.Web.DuckDuckGo.Enabled = true + + if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" { + t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got) + } +} + +func TestResolveCurrentWebSearchProvider_IgnoresPreferNativeInConfigView(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "custom-default", + Model: "openai/gpt-4o", + APIKeys: config.SimpleSecureStrings("sk-default"), + }} + cfg.Agents.Defaults.ModelName = "custom-default" + cfg.Tools.Web.PreferNative = true + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Sogou.Enabled = false + cfg.Tools.Web.DuckDuckGo.Enabled = false + cfg.Tools.Web.Brave.Enabled = true + + if got := resolveCurrentWebSearchProvider(cfg); got != "" { + t.Fatalf("resolveCurrentWebSearchProvider() = %q, want empty when only native search would be available", got) + } +} diff --git a/web/backend/api/update.go b/web/backend/api/update.go new file mode 100644 index 000000000..2ba862631 --- /dev/null +++ b/web/backend/api/update.go @@ -0,0 +1,52 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/sipeed/picoclaw/pkg/updater" +) + +// registerUpdateRoutes registers the self-update endpoint. +func (h *Handler) registerUpdateRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/update", h.handleUpdate) +} + +type updateRequest struct { + URL string `json:"url,omitempty"` + Binary string `json:"binary,omitempty"` +} + +type updateResponse struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "method not allowed"}) + return + } + + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + var req updateRequest + if err := dec.Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "invalid request body"}) + return + } + + binary := req.Binary + if binary == "" { + binary = "picoclaw-launcher" + } + + if err := updater.UpdateSelfFromRelease(req.URL, "", "", binary); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: err.Error()}) + return + } + + _ = json.NewEncoder(w).Encode(updateResponse{Status: "ok", Message: "update applied; restart to use new version"}) +} diff --git a/web/backend/api/version.go b/web/backend/api/version.go new file mode 100644 index 000000000..6232b989b --- /dev/null +++ b/web/backend/api/version.go @@ -0,0 +1,345 @@ +package api + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "os/exec" + "regexp" + "runtime" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/web/backend/utils" +) + +type systemVersionResponse struct { + Version string `json:"version"` + GitCommit string `json:"git_commit,omitempty"` + BuildTime string `json:"build_time,omitempty"` + GoVersion string `json:"go_version"` +} + +type cachedSystemVersion struct { + value systemVersionResponse + gatewayPID int +} + +type systemVersionCache struct { + mu sync.Mutex + current cachedSystemVersion + hasCurrent bool + inflightCh chan struct{} +} + +func newSystemVersionCache() *systemVersionCache { + return &systemVersionCache{} +} + +var ( + // 15 seconds matches the gateway startup window used elsewhere in launcher flow, + // giving slow/embedded hosts enough time for first command invocation while + // staying independent from cross-file init ordering. + versionCmdTimeout = 15 * time.Second + maxVersionResolveAttempts = 3 + findPicoclawBinaryForInfo = resolveGatewayBinaryForVersionInfo + runPicoclawVersionOutput = executePicoclawVersion + currentGatewayVersionState = gatewayVersionState + launcherBuildInfoForVersion = fallbackSystemVersionInfoFromConfig + versionInfoCache = newSystemVersionCache() + ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + versionLinePattern = regexp.MustCompile( + `^(?:[^A-Za-z0-9]*\s*)?picoclaw(?:\.exe)?\s+([^\s(]+)` + + `(?:\s+\(git:\s*([^)]+)\))?\s*$`, + ) +) + +func (h *Handler) registerVersionRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/system/version", h.handleGetVersion) +} + +// handleGetVersion returns runtime version information for web clients. +func (h *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) { + versionInfo := h.resolveSystemVersionInfo(r.Context()) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(versionInfo); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// resolveSystemVersionInfo prefers the actual picoclaw binary version output, +// and falls back to launcher build metadata when command execution fails. +func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse { + for range maxVersionResolveAttempts { + gatewayPID, gatewayAlive := currentGatewayVersionState() + if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok { + return cached + } + + leader, ok := versionInfoCache.waitOrStart(ctx) + if !ok { + return fallbackSystemVersionInfo() + } + if !leader { + continue + } + + resolved := h.resolveSystemVersionInfoUncached(ctx) + gatewayPID, gatewayAlive = currentGatewayVersionState() + versionInfoCache.finishResolve(resolved, gatewayPID, gatewayAlive) + return resolved + } + + return fallbackSystemVersionInfo() +} + +func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVersionResponse { + if ctx == nil { + ctx = context.Background() + } + + fallback := fallbackSystemVersionInfo() + + execPath := strings.TrimSpace(findPicoclawBinaryForInfo()) + if execPath == "" { + return fallback + } + + cmdCtx, cancel := context.WithTimeout(ctx, versionCmdTimeout) + defer cancel() + + output, err := runPicoclawVersionOutput(cmdCtx, execPath) + if err != nil { + return fallback + } + + parsed, ok := parsePicoclawVersionOutput(output) + if !ok { + return fallback + } + + if parsed.GoVersion == "" { + parsed.GoVersion = fallback.GoVersion + if parsed.GoVersion == "" { + parsed.GoVersion = runtime.Version() + } + } + + return parsed +} + +func fallbackSystemVersionInfo() systemVersionResponse { + return launcherBuildInfoForVersion() +} + +func fallbackSystemVersionInfoFromConfig() systemVersionResponse { + buildTime, goVer := config.FormatBuildInfo() + return systemVersionResponse{ + Version: config.GetVersion(), + GitCommit: config.GitCommit, + BuildTime: buildTime, + GoVersion: goVer, + } +} + +// resolveGatewayBinaryForVersionInfo uses the same executable as the launcher +// gateway start path when available, then falls back to launcher binary lookup. +// This keeps version probing aligned with the actual gateway startup behavior, +// so web and gateway do not drift onto different binaries. +func resolveGatewayBinaryForVersionInfo() string { + gateway.mu.Lock() + cmd := gateway.cmd + gateway.mu.Unlock() + + if cmd != nil { + if execPath := strings.TrimSpace(cmd.Path); execPath != "" { + return execPath + } + } + + return utils.FindPicoclawBinary() +} + +func gatewayVersionState() (int, bool) { + gateway.mu.Lock() + defer gateway.mu.Unlock() + + if gateway.cmd == nil || gateway.cmd.Process == nil { + return 0, false + } + pid := gateway.cmd.Process.Pid + if pid <= 0 { + return 0, false + } + + return pid, isCmdProcessAliveLocked(gateway.cmd) +} + +func (c *systemVersionCache) get(gatewayPID int, gatewayAlive bool) (systemVersionResponse, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.hasCurrent && (!gatewayAlive || gatewayPID <= 0 || gatewayPID != c.current.gatewayPID) { + c.clearCurrentLocked() + } + + if c.hasCurrent { + return c.current.value, true + } + + return systemVersionResponse{}, false +} + +func (c *systemVersionCache) waitOrStart(ctx context.Context) (bool, bool) { + if ctx == nil { + ctx = context.Background() + } + if ctx.Err() != nil { + return false, false + } + + c.mu.Lock() + if c.inflightCh == nil { + c.inflightCh = make(chan struct{}) + c.mu.Unlock() + return true, true + } + waitCh := c.inflightCh + c.mu.Unlock() + + select { + case <-waitCh: + return false, true + case <-ctx.Done(): + return false, false + } +} + +func (c *systemVersionCache) finishResolve(value systemVersionResponse, gatewayPID int, gatewayAlive bool) { + c.mu.Lock() + if gatewayAlive && gatewayPID > 0 { + c.current = cachedSystemVersion{value: value, gatewayPID: gatewayPID} + c.hasCurrent = true + } else { + c.clearCurrentLocked() + } + + inflightCh := c.inflightCh + c.inflightCh = nil + c.mu.Unlock() + + if inflightCh != nil { + close(inflightCh) + } +} + +func (c *systemVersionCache) clearCurrentLocked() { + c.hasCurrent = false + c.current = cachedSystemVersion{} +} + +func (c *systemVersionCache) resetForTest() { + c.mu.Lock() + defer c.mu.Unlock() + + c.current = cachedSystemVersion{} + c.hasCurrent = false + if c.inflightCh != nil { + close(c.inflightCh) + c.inflightCh = nil + } +} + +// executePicoclawVersion runs the version subcommand against the +// discovered picoclaw executable. +func executePicoclawVersion(ctx context.Context, execPath string) (string, error) { + out, err := exec.CommandContext(ctx, execPath, "version").CombinedOutput() + if err == nil { + return string(out), nil + } + + return string(out), fmt.Errorf("failed to execute version command: %w", err) +} + +// parsePicoclawVersionOutput extracts version/build/go fields from CLI output. +// It accepts banner/ANSI-decorated output and only requires the version line. +func parsePicoclawVersionOutput(raw string) (systemVersionResponse, bool) { + var result systemVersionResponse + + scanner := bufio.NewScanner(strings.NewReader(raw)) + for scanner.Scan() { + line := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(scanner.Text(), "")) + if line == "" { + continue + } + + if match := versionLinePattern.FindStringSubmatch(line); len(match) > 0 { + candidateVersion := strings.TrimSpace(match[1]) + if !isLikelyVersionValue(candidateVersion) { + continue + } + result.Version = candidateVersion + if len(match) > 2 { + result.GitCommit = strings.TrimSpace(match[2]) + } + continue + } + + if buildValue, ok := strings.CutPrefix(line, "Build:"); ok { + result.BuildTime = strings.TrimSpace(buildValue) + continue + } + + if goValue, ok := strings.CutPrefix(line, "Go:"); ok { + result.GoVersion = strings.TrimSpace(goValue) + } + } + + if err := scanner.Err(); err != nil { + return systemVersionResponse{}, false + } + + if result.Version == "" { + return systemVersionResponse{}, false + } + + return result, true +} + +func isLikelyVersionValue(value string) bool { + v := strings.TrimSpace(strings.ToLower(value)) + if v == "" { + return false + } + if v == "dev" { + return true + } + + // Accept git-like short/long hashes even when they contain only letters (a-f). + if len(v) >= 7 && len(v) <= 40 { + allHex := true + for _, ch := range v { + if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') { + continue + } + allHex = false + break + } + if allHex { + return true + } + } + + for _, ch := range v { + if ch >= '0' && ch <= '9' { + return true + } + } + return false +} diff --git a/web/backend/api/version_test.go b/web/backend/api/version_test.go new file mode 100644 index 000000000..31c5366ab --- /dev/null +++ b/web/backend/api/version_test.go @@ -0,0 +1,317 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os/exec" + "runtime" + "testing" +) + +func setupVersionTestIsolation(t *testing.T) { + t.Helper() + + originalGatewayState := currentGatewayVersionState + originalFinder := findPicoclawBinaryForInfo + originalRunner := runPicoclawVersionOutput + originalFallback := launcherBuildInfoForVersion + t.Cleanup(func() { + currentGatewayVersionState = originalGatewayState + findPicoclawBinaryForInfo = originalFinder + runPicoclawVersionOutput = originalRunner + launcherBuildInfoForVersion = originalFallback + versionInfoCache.resetForTest() + }) + + currentGatewayVersionState = func() (int, bool) { return 0, false } + versionInfoCache.resetForTest() +} + +func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) { + setupVersionTestIsolation(t) + + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "fallback", GoVersion: "go-fallback"} + } + + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + return "🦞 picoclaw v1.2.3 (git: deadbeef)\n Build: 2026-03-27T12:34:56Z\n Go: go1.25.8\n", nil + } + + h := NewHandler("") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var got systemVersionResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got.Version != "v1.2.3" { + t.Fatalf("version = %q, want %q", got.Version, "v1.2.3") + } + if got.GitCommit != "deadbeef" { + t.Fatalf("git_commit = %q, want %q", got.GitCommit, "deadbeef") + } + if got.BuildTime != "2026-03-27T12:34:56Z" { + t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T12:34:56Z") + } + if got.GoVersion != "go1.25.8" { + t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8") + } +} + +func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) { + setupVersionTestIsolation(t) + + expected := systemVersionResponse{ + Version: "v9.9.9", + GitCommit: "cafebabe", + BuildTime: "2026-03-27T10:43:34+0000", + GoVersion: "go1.25.8", + } + launcherBuildInfoForVersion = func() systemVersionResponse { return expected } + + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + return "", errors.New("binary unavailable") + } + + h := NewHandler("") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var got systemVersionResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got.Version != expected.Version { + t.Fatalf("version = %q, want %q", got.Version, expected.Version) + } + if got.GitCommit != expected.GitCommit { + t.Fatalf("git_commit = %q, want %q", got.GitCommit, expected.GitCommit) + } + if got.BuildTime != expected.BuildTime { + t.Fatalf("build_time = %q, want %q", got.BuildTime, expected.BuildTime) + } + if got.GoVersion != expected.GoVersion { + t.Fatalf("go_version = %q, want %q", got.GoVersion, expected.GoVersion) + } +} + +func TestParsePicoclawVersionOutput(t *testing.T) { + setupVersionTestIsolation(t) + + raw := "\u001b[1;31m████\u001b[0m\n🦞 picoclaw 18ec263 (git: 18ec2631)\n Build: 2026-03-27T10:43:34+0000\n Go: go1.25.8\n" + got, ok := parsePicoclawVersionOutput(raw) + if !ok { + t.Fatal("parsePicoclawVersionOutput() should parse valid output") + } + if got.Version != "18ec263" { + t.Fatalf("version = %q, want %q", got.Version, "18ec263") + } + if got.GitCommit != "18ec2631" { + t.Fatalf("git_commit = %q, want %q", got.GitCommit, "18ec2631") + } + if got.BuildTime != "2026-03-27T10:43:34+0000" { + t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T10:43:34+0000") + } + if got.GoVersion != "go1.25.8" { + t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8") + } +} + +func TestParsePicoclawVersionOutputIgnoresUsageLine(t *testing.T) { + setupVersionTestIsolation(t) + + raw := "Usage: picoclaw version [flags]\n" + got, ok := parsePicoclawVersionOutput(raw) + if ok { + t.Fatalf("parsePicoclawVersionOutput() parsed usage line unexpectedly: %#v", got) + } +} + +func TestParsePicoclawVersionOutputAcceptsLetterOnlyHashVersion(t *testing.T) { + setupVersionTestIsolation(t) + + raw := "picoclaw abcdefa (git: abcdefabcdefabcdefabcdefabcdefabcdefabcd)\n" + got, ok := parsePicoclawVersionOutput(raw) + if !ok { + t.Fatal("parsePicoclawVersionOutput() should parse letter-only hash version") + } + if got.Version != "abcdefa" { + t.Fatalf("version = %q, want %q", got.Version, "abcdefa") + } + if got.GitCommit != "abcdefabcdefabcdefabcdefabcdefabcdefabcd" { + t.Fatalf("git_commit = %q, want %q", got.GitCommit, "abcdefabcdefabcdefabcdefabcdefabcdefabcd") + } +} + +func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) { + setupVersionTestIsolation(t) + + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "dev", GoVersion: ""} + } + + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + return "picoclaw v1.0.0\n", nil + } + + h := NewHandler("") + got := h.resolveSystemVersionInfo(context.Background()) + if got.GoVersion != runtime.Version() { + t.Fatalf("go_version = %q, want runtime version %q", got.GoVersion, runtime.Version()) + } +} + +func TestResolveSystemVersionInfoCachesWhileGatewayAlive(t *testing.T) { + setupVersionTestIsolation(t) + + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"} + } + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + + pid := 4321 + currentGatewayVersionState = func() (int, bool) { return pid, true } + + runCount := 0 + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + runCount++ + return fmt.Sprintf("picoclaw v1.2.%d\n", runCount), nil + } + + h := NewHandler("") + first := h.resolveSystemVersionInfo(context.Background()) + second := h.resolveSystemVersionInfo(context.Background()) + + if first.Version != "v1.2.1" { + t.Fatalf("first version = %q, want %q", first.Version, "v1.2.1") + } + if second.Version != "v1.2.1" { + t.Fatalf("second version = %q, want cached %q", second.Version, "v1.2.1") + } + if runCount != 1 { + t.Fatalf("run count = %d, want %d", runCount, 1) + } +} + +func TestResolveSystemVersionInfoInvalidatesCacheWhenGatewayStops(t *testing.T) { + setupVersionTestIsolation(t) + + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"} + } + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + + alive := true + pid := 9876 + currentGatewayVersionState = func() (int, bool) { + if !alive { + return 0, false + } + return pid, true + } + + runCount := 0 + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + runCount++ + return fmt.Sprintf("picoclaw v2.0.%d\n", runCount), nil + } + + h := NewHandler("") + first := h.resolveSystemVersionInfo(context.Background()) + second := h.resolveSystemVersionInfo(context.Background()) + + if first.Version != "v2.0.1" || second.Version != "v2.0.1" { + t.Fatalf("expected cached version v2.0.1, got first=%q second=%q", first.Version, second.Version) + } + if runCount != 1 { + t.Fatalf("run count after cache hit = %d, want %d", runCount, 1) + } + + alive = false + third := h.resolveSystemVersionInfo(context.Background()) + if third.Version != "v2.0.2" { + t.Fatalf("third version = %q, want refreshed %q", third.Version, "v2.0.2") + } + if runCount != 2 { + t.Fatalf("run count after invalidation = %d, want %d", runCount, 2) + } +} + +func TestResolveSystemVersionInfoSkipsCommandWhenContextCanceled(t *testing.T) { + setupVersionTestIsolation(t) + + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "v3.0.0", GoVersion: "go-fallback"} + } + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + + runCount := 0 + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + runCount++ + return "picoclaw v9.9.9\n", nil + } + + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + h := NewHandler("") + got := h.resolveSystemVersionInfo(canceledCtx) + + if runCount != 0 { + t.Fatalf("run count = %d, want %d", runCount, 0) + } + if got.Version != "v3.0.0" { + t.Fatalf("version = %q, want fallback %q", got.Version, "v3.0.0") + } +} + +func TestResolveGatewayBinaryForVersionInfoPrefersGatewayCommandPath(t *testing.T) { + setupVersionTestIsolation(t) + + originalFinder := findPicoclawBinaryForInfo + t.Cleanup(func() { + findPicoclawBinaryForInfo = originalFinder + }) + + gateway.mu.Lock() + originalCmd := gateway.cmd + gateway.cmd = &exec.Cmd{Path: "/tmp/picoclaw-from-gateway"} + gateway.mu.Unlock() + t.Cleanup(func() { + gateway.mu.Lock() + gateway.cmd = originalCmd + gateway.mu.Unlock() + }) + + got := resolveGatewayBinaryForVersionInfo() + if got != "/tmp/picoclaw-from-gateway" { + t.Fatalf("exec path = %q, want %q", got, "/tmp/picoclaw-from-gateway") + } +} diff --git a/web/backend/api/wecom.go b/web/backend/api/wecom.go new file mode 100644 index 000000000..74e5d8e83 --- /dev/null +++ b/web/backend/api/wecom.go @@ -0,0 +1,432 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "runtime" + "strconv" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const ( + wecomFlowTTL = 5 * time.Minute + wecomFlowGCAge = 30 * time.Minute + wecomQRSourceID = "picoclaw" + wecomQRGenerateEndpoint = "https://work.weixin.qq.com/ai/qc/generate" + wecomQRQueryEndpoint = "https://work.weixin.qq.com/ai/qc/query_result" + wecomQRHTTPTimeout = 15 * time.Second + wecomDefaultWebSocketURL = "wss://openws.work.weixin.qq.com" + wecomPollStartTimeout = 15 * time.Second + wecomPollStatusTimeout = 10 * time.Second +) + +const ( + wecomStatusWait = "wait" + wecomStatusScanned = "scaned" + wecomStatusConfirmed = "confirmed" + wecomStatusExpired = "expired" + wecomStatusError = "error" +) + +type wecomFlow struct { + ID string + SCode string + QRDataURI string + BotID string + Status string + Error string + CreatedAt time.Time + UpdatedAt time.Time + ExpiresAt time.Time +} + +type wecomFlowResponse struct { + FlowID string `json:"flow_id"` + Status string `json:"status"` + QRDataURI string `json:"qr_data_uri,omitempty"` + BotID string `json:"bot_id,omitempty"` + Error string `json:"error,omitempty"` +} + +type wecomQRGenerateResponse struct { + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` + Data struct { + SCode string `json:"scode"` + AuthURL string `json:"auth_url"` + } `json:"data"` +} + +type wecomQRQueryResponse struct { + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` + Data struct { + Status string `json:"status"` + BotInfo struct { + BotID string `json:"botid"` + Secret string `json:"secret"` + } `json:"bot_info"` + } `json:"data"` +} + +// registerWecomRoutes binds WeCom QR login endpoints to the ServeMux. +func (h *Handler) registerWecomRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/wecom/flows", h.handleStartWecomFlow) + mux.HandleFunc("GET /api/wecom/flows/{id}", h.handlePollWecomFlow) +} + +// handleStartWecomFlow starts a new WeCom QR login flow. +// +// POST /api/wecom/flows +func (h *Handler) handleStartWecomFlow(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), wecomPollStartTimeout) + defer cancel() + + session, err := fetchWecomQRCode(ctx) + if err != nil { + http.Error(w, fmt.Sprintf("failed to get QR code: %v", err), http.StatusInternalServerError) + return + } + + dataURI, err := generateQRDataURI(session.Data.AuthURL) + if err != nil { + http.Error(w, fmt.Sprintf("failed to generate QR image: %v", err), http.StatusInternalServerError) + return + } + + now := time.Now() + flow := &wecomFlow{ + ID: newWecomFlowID(), + SCode: session.Data.SCode, + QRDataURI: dataURI, + Status: wecomStatusWait, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: now.Add(wecomFlowTTL), + } + h.storeWecomFlow(flow) + + logger.InfoCF("wecom", "QR flow started", map[string]any{"flow_id": flow.ID}) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(wecomFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + QRDataURI: flow.QRDataURI, + }) +} + +// handlePollWecomFlow polls the WeCom API for QR code status and updates the flow. +// +// GET /api/wecom/flows/{id} +func (h *Handler) handlePollWecomFlow(w http.ResponseWriter, r *http.Request) { + flowID := strings.TrimSpace(r.PathValue("id")) + if flowID == "" { + http.Error(w, "missing flow id", http.StatusBadRequest) + return + } + + flow, ok := h.getWecomFlow(flowID) + if !ok { + http.Error(w, "flow not found", http.StatusNotFound) + return + } + + if flow.Status == wecomStatusConfirmed || + flow.Status == wecomStatusExpired || + flow.Status == wecomStatusError { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(wecomFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + BotID: flow.BotID, + Error: flow.Error, + }) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), wecomPollStatusTimeout) + defer cancel() + + statusResp, err := queryWecomQRCodeStatus(ctx, flow.SCode) + if err != nil { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(wecomFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + QRDataURI: flow.QRDataURI, + }) + return + } + + switch strings.ToLower(statusResp.Data.Status) { + case wecomStatusWait: + // no-op + case wecomStatusScanned, "scanned": + h.updateWecomFlowStatus(flowID, wecomStatusScanned) + case "success": + if statusResp.Data.BotInfo.BotID == "" || statusResp.Data.BotInfo.Secret == "" { + h.setWecomFlowError(flowID, "login confirmed but missing bot credentials") + break + } + if saveErr := h.saveWecomBinding( + statusResp.Data.BotInfo.BotID, + statusResp.Data.BotInfo.Secret, + ); saveErr != nil { + h.setWecomFlowError(flowID, fmt.Sprintf("failed to save credentials: %v", saveErr)) + logger.ErrorCF("wecom", "failed to save credentials", map[string]any{"error": saveErr.Error()}) + break + } + h.setWecomFlowConfirmed(flowID, statusResp.Data.BotInfo.BotID) + logger.InfoCF("wecom", "QR login confirmed, credentials saved", map[string]any{ + "flow_id": flowID, + "bot_id": statusResp.Data.BotInfo.BotID, + }) + case wecomStatusExpired: + h.updateWecomFlowStatus(flowID, wecomStatusExpired) + } + + flow, _ = h.getWecomFlow(flowID) + w.Header().Set("Content-Type", "application/json") + resp := wecomFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + BotID: flow.BotID, + Error: flow.Error, + } + if flow.Status == wecomStatusWait || flow.Status == wecomStatusScanned { + resp.QRDataURI = flow.QRDataURI + } + _ = json.NewEncoder(w).Encode(resp) +} + +func (h *Handler) saveWecomBinding(botID, secret string) error { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + bc := cfg.Channels.Get(config.ChannelWeCom) + if bc == nil { + bc = &config.Channel{Type: config.ChannelWeCom} + cfg.Channels["wecom"] = bc + } + bc.Enabled = true + + var wecomCfg config.WeComSettings + bc.Decode(&wecomCfg) + wecomCfg.BotID = botID + wecomCfg.Secret = *config.NewSecureString(secret) + if strings.TrimSpace(wecomCfg.WebSocketURL) == "" { + wecomCfg.WebSocketURL = wecomDefaultWebSocketURL + } + if err := config.SaveConfig(h.configPath, cfg); err != nil { + return err + } + + status := h.gatewayStatusData() + gatewayStatus, _ := status["gateway_status"].(string) + if gatewayStatus != "running" { + return nil + } + + if _, err := h.RestartGateway(); err != nil { + logger.ErrorCF("wecom", "failed to restart gateway after saving binding", map[string]any{ + "error": err.Error(), + }) + } + return nil +} + +func fetchWecomQRCode(ctx context.Context) (wecomQRGenerateResponse, error) { + targetURL, err := buildWecomQRGenerateURL(wecomQRGenerateEndpoint, wecomQRSourceID, wecomPlatformCode()) + if err != nil { + return wecomQRGenerateResponse{}, err + } + + var resp wecomQRGenerateResponse + if err := doWecomJSONGet(ctx, targetURL, &resp); err != nil { + return wecomQRGenerateResponse{}, err + } + if resp.ErrCode != 0 { + return wecomQRGenerateResponse{}, fmt.Errorf( + "errcode=%d errmsg=%s", + resp.ErrCode, + resp.ErrMsg, + ) + } + if resp.Data.SCode == "" || resp.Data.AuthURL == "" { + return wecomQRGenerateResponse{}, fmt.Errorf("response missing scode or auth_url") + } + return resp, nil +} + +func queryWecomQRCodeStatus(ctx context.Context, scode string) (wecomQRQueryResponse, error) { + targetURL, err := buildWecomQRQueryURL(wecomQRQueryEndpoint, scode) + if err != nil { + return wecomQRQueryResponse{}, err + } + + var resp wecomQRQueryResponse + if err := doWecomJSONGet(ctx, targetURL, &resp); err != nil { + return wecomQRQueryResponse{}, err + } + if resp.ErrCode != 0 { + return wecomQRQueryResponse{}, fmt.Errorf( + "errcode=%d errmsg=%s", + resp.ErrCode, + resp.ErrMsg, + ) + } + return resp, nil +} + +func buildWecomQRGenerateURL(baseURL, sourceID string, platformCode int) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("invalid WeCom QR generate URL: %w", err) + } + + query := u.Query() + query.Set("source", sourceID) + query.Set("sourceID", sourceID) + query.Set("plat", strconv.Itoa(platformCode)) + u.RawQuery = query.Encode() + + return u.String(), nil +} + +func buildWecomQRQueryURL(baseURL, scode string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("invalid WeCom QR query URL: %w", err) + } + + query := u.Query() + query.Set("scode", scode) + u.RawQuery = query.Encode() + + return u.String(), nil +} + +func doWecomJSONGet(ctx context.Context, targetURL string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) + if err != nil { + return err + } + + client := &http.Client{Timeout: wecomQRHTTPTimeout} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if readErr != nil { + return fmt.Errorf("unexpected status %s", resp.Status) + } + return fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decode JSON response: %w", err) + } + return nil +} + +func wecomPlatformCode() int { + switch runtime.GOOS { + case "darwin": + return 1 + case "windows": + return 2 + case "linux": + return 3 + default: + return 0 + } +} + +func newWecomFlowID() string { + buf := make([]byte, 12) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("wc_%d", time.Now().UnixNano()) + } + return "wc_" + hex.EncodeToString(buf) +} + +func (h *Handler) storeWecomFlow(flow *wecomFlow) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + h.gcWecomFlowsLocked(time.Now()) + h.wecomFlows[flow.ID] = flow +} + +func (h *Handler) getWecomFlow(flowID string) (*wecomFlow, bool) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + h.gcWecomFlowsLocked(time.Now()) + flow, ok := h.wecomFlows[flowID] + if !ok { + return nil, false + } + cp := *flow + return &cp, true +} + +func (h *Handler) updateWecomFlowStatus(flowID, status string) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + if flow, ok := h.wecomFlows[flowID]; ok { + flow.Status = status + flow.UpdatedAt = time.Now() + } +} + +func (h *Handler) setWecomFlowConfirmed(flowID, botID string) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + if flow, ok := h.wecomFlows[flowID]; ok { + flow.Status = wecomStatusConfirmed + flow.BotID = botID + flow.UpdatedAt = time.Now() + } +} + +func (h *Handler) setWecomFlowError(flowID, errMsg string) { + h.wecomMu.Lock() + defer h.wecomMu.Unlock() + if flow, ok := h.wecomFlows[flowID]; ok { + flow.Status = wecomStatusError + flow.Error = errMsg + flow.UpdatedAt = time.Now() + } +} + +func (h *Handler) gcWecomFlowsLocked(now time.Time) { + for id, flow := range h.wecomFlows { + if flow.Status == wecomStatusWait || flow.Status == wecomStatusScanned { + if !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) { + flow.Status = wecomStatusExpired + flow.UpdatedAt = now + } + } + if flow.Status != wecomStatusWait && + flow.Status != wecomStatusScanned && + now.Sub(flow.UpdatedAt) > wecomFlowGCAge { + delete(h.wecomFlows, id) + } + } +} diff --git a/web/backend/api/weixin.go b/web/backend/api/weixin.go new file mode 100644 index 000000000..888789f86 --- /dev/null +++ b/web/backend/api/weixin.go @@ -0,0 +1,332 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "rsc.io/qr" + + "github.com/sipeed/picoclaw/pkg/channels/weixin" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const ( + weixinFlowTTL = 5 * time.Minute + weixinFlowGCAge = 30 * time.Minute + weixinBaseURL = "https://ilinkai.weixin.qq.com/" + weixinBotType = "3" +) + +const ( + weixinStatusWait = "wait" + weixinStatusScanned = "scaned" + weixinStatusConfirmed = "confirmed" + weixinStatusExpired = "expired" + weixinStatusError = "error" +) + +type weixinFlow struct { + ID string + Qrcode string // qrcode token from WeChat API (used for status polling) + QRDataURI string // base64 PNG data URI for display + AccountID string // IlinkBotID returned on confirmed + Status string // wait / scaned / confirmed / expired / error + Error string + CreatedAt time.Time + UpdatedAt time.Time + ExpiresAt time.Time +} + +type weixinFlowResponse struct { + FlowID string `json:"flow_id"` + Status string `json:"status"` + QRDataURI string `json:"qr_data_uri,omitempty"` + AccountID string `json:"account_id,omitempty"` + Error string `json:"error,omitempty"` +} + +// registerWeixinRoutes binds WeChat QR login endpoints to the ServeMux. +func (h *Handler) registerWeixinRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/weixin/flows", h.handleStartWeixinFlow) + mux.HandleFunc("GET /api/weixin/flows/{id}", h.handlePollWeixinFlow) +} + +// handleStartWeixinFlow starts a new WeChat QR login flow. +// +// POST /api/weixin/flows +func (h *Handler) handleStartWeixinFlow(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + api, err := weixin.NewApiClient(weixinBaseURL, "", "") + if err != nil { + http.Error(w, fmt.Sprintf("failed to create weixin client: %v", err), http.StatusInternalServerError) + return + } + + qrResp, err := api.GetQRCode(ctx, weixinBotType) + if err != nil { + http.Error(w, fmt.Sprintf("failed to get QR code: %v", err), http.StatusInternalServerError) + return + } + + dataURI, err := generateQRDataURI(qrResp.QrcodeImgContent) + if err != nil { + http.Error(w, fmt.Sprintf("failed to generate QR image: %v", err), http.StatusInternalServerError) + return + } + + now := time.Now() + flow := &weixinFlow{ + ID: newWeixinFlowID(), + Qrcode: qrResp.Qrcode, + QRDataURI: dataURI, + Status: weixinStatusWait, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: now.Add(weixinFlowTTL), + } + h.storeWeixinFlow(flow) + + logger.InfoCF("weixin", "QR flow started", map[string]any{"flow_id": flow.ID}) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(weixinFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + QRDataURI: flow.QRDataURI, + }) +} + +// handlePollWeixinFlow polls the WeChat API for QR code status and updates the flow. +// +// GET /api/weixin/flows/{id} +func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) { + flowID := strings.TrimSpace(r.PathValue("id")) + if flowID == "" { + http.Error(w, "missing flow id", http.StatusBadRequest) + return + } + + flow, ok := h.getWeixinFlow(flowID) + if !ok { + http.Error(w, "flow not found", http.StatusNotFound) + return + } + + // Return terminal states directly without polling WeChat again + if flow.Status == weixinStatusConfirmed || + flow.Status == weixinStatusExpired || + flow.Status == weixinStatusError { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(weixinFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + Error: flow.Error, + }) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + api, err := weixin.NewApiClient(weixinBaseURL, "", "") + if err != nil { + h.setWeixinFlowError(flowID, fmt.Sprintf("client error: %v", err)) + flow, _ = h.getWeixinFlow(flowID) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(weixinFlowResponse{FlowID: flow.ID, Status: flow.Status, Error: flow.Error}) + return + } + + statusResp, err := api.GetQRCodeStatus(ctx, flow.Qrcode) + if err != nil { + // Transient error — keep current status, return it + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(weixinFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + QRDataURI: flow.QRDataURI, + }) + return + } + + switch statusResp.Status { + case weixinStatusWait: + // no change + + case weixinStatusScanned: + h.updateWeixinFlowStatus(flowID, weixinStatusScanned) + + case weixinStatusConfirmed: + if statusResp.BotToken == "" { + h.setWeixinFlowError(flowID, "login confirmed but missing bot_token") + break + } + if saveErr := h.saveWeixinBinding(statusResp.BotToken, statusResp.IlinkBotID); saveErr != nil { + h.setWeixinFlowError(flowID, fmt.Sprintf("failed to save token: %v", saveErr)) + logger.ErrorCF("weixin", "failed to save token", map[string]any{"error": saveErr.Error()}) + break + } + h.setWeixinFlowConfirmed(flowID, statusResp.IlinkBotID) + logger.InfoCF("weixin", "QR login confirmed, token saved", map[string]any{ + "flow_id": flowID, + "account_id": statusResp.IlinkBotID, + }) + + case weixinStatusExpired: + h.updateWeixinFlowStatus(flowID, weixinStatusExpired) + + default: + // unknown status, keep as-is + } + + flow, _ = h.getWeixinFlow(flowID) + w.Header().Set("Content-Type", "application/json") + resp := weixinFlowResponse{ + FlowID: flow.ID, + Status: flow.Status, + AccountID: flow.AccountID, + Error: flow.Error, + } + if flow.Status == weixinStatusWait || flow.Status == weixinStatusScanned { + resp.QRDataURI = flow.QRDataURI + } + _ = json.NewEncoder(w).Encode(resp) +} + +// saveWeixinBinding writes the token/account ID, enables the Weixin channel, +// and best-effort restarts the gateway when it is currently running. +func (h *Handler) saveWeixinBinding(token, accountID string) error { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + bc := cfg.Channels.Get(config.ChannelWeixin) + if bc == nil { + bc = &config.Channel{Type: config.ChannelWeixin} + cfg.Channels[config.ChannelWeixin] = bc + } + bc.Enabled = true + + var weixinCfg config.WeixinSettings + if err := bc.Decode(&weixinCfg); err != nil { + logger.ErrorCF("weixin", "failed to decode weixin settings", map[string]any{ + "error": err.Error(), + }) + return fmt.Errorf("decode weixin settings: %w", err) + } + weixinCfg.Token = *config.NewSecureString(token) + if accountID != "" { + weixinCfg.AccountID = accountID + } + + if err := config.SaveConfig(h.configPath, cfg); err != nil { + return err + } + + status := h.gatewayStatusData() + gatewayStatus, _ := status["gateway_status"].(string) + if gatewayStatus != "running" { + return nil + } + + if _, err := h.RestartGateway(); err != nil { + logger.ErrorCF("weixin", "failed to restart gateway after saving binding", map[string]any{ + "error": err.Error(), + }) + } + return nil +} + +// generateQRDataURI encodes content as a QR code PNG and returns a data URI. +func generateQRDataURI(content string) (string, error) { + code, err := qr.Encode(content, qr.L) + if err != nil { + return "", fmt.Errorf("qr encode: %w", err) + } + pngBytes := code.PNG() + encoded := base64.StdEncoding.EncodeToString(pngBytes) + return "data:image/png;base64," + encoded, nil +} + +func newWeixinFlowID() string { + buf := make([]byte, 12) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("wx_%d", time.Now().UnixNano()) + } + return "wx_" + hex.EncodeToString(buf) +} + +func (h *Handler) storeWeixinFlow(flow *weixinFlow) { + h.weixinMu.Lock() + defer h.weixinMu.Unlock() + h.gcWeixinFlowsLocked(time.Now()) + h.weixinFlows[flow.ID] = flow +} + +func (h *Handler) getWeixinFlow(flowID string) (*weixinFlow, bool) { + h.weixinMu.Lock() + defer h.weixinMu.Unlock() + h.gcWeixinFlowsLocked(time.Now()) + flow, ok := h.weixinFlows[flowID] + if !ok { + return nil, false + } + cp := *flow + return &cp, true +} + +func (h *Handler) updateWeixinFlowStatus(flowID, status string) { + h.weixinMu.Lock() + defer h.weixinMu.Unlock() + if flow, ok := h.weixinFlows[flowID]; ok { + flow.Status = status + flow.UpdatedAt = time.Now() + } +} + +func (h *Handler) setWeixinFlowConfirmed(flowID, accountID string) { + h.weixinMu.Lock() + defer h.weixinMu.Unlock() + if flow, ok := h.weixinFlows[flowID]; ok { + flow.Status = weixinStatusConfirmed + flow.AccountID = accountID + flow.UpdatedAt = time.Now() + } +} + +func (h *Handler) setWeixinFlowError(flowID, errMsg string) { + h.weixinMu.Lock() + defer h.weixinMu.Unlock() + if flow, ok := h.weixinFlows[flowID]; ok { + flow.Status = weixinStatusError + flow.Error = errMsg + flow.UpdatedAt = time.Now() + } +} + +func (h *Handler) gcWeixinFlowsLocked(now time.Time) { + for id, flow := range h.weixinFlows { + if flow.Status == weixinStatusWait || flow.Status == weixinStatusScanned { + if !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) { + flow.Status = weixinStatusExpired + flow.UpdatedAt = now + } + } + if flow.Status != weixinStatusWait && + flow.Status != weixinStatusScanned && + now.Sub(flow.UpdatedAt) > weixinFlowGCAge { + delete(h.weixinFlows, id) + } + } +} diff --git a/web/backend/api/weixin_test.go b/web/backend/api/weixin_test.go new file mode 100644 index 000000000..575de7b9c --- /dev/null +++ b/web/backend/api/weixin_test.go @@ -0,0 +1,62 @@ +package api + +import ( + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestSaveWeixinBindingReturnsSuccessWhenRestartFails(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + originalHealthGet := gatewayHealthGet + gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader( + `{"status":"ok","uptime":"1s","pid":` + strconv.Itoa(os.Getpid()) + `}`, + )), + }, nil + } + t.Cleanup(func() { + gatewayHealthGet = originalHealthGet + }) + + h := NewHandler(configPath) + if err := h.saveWeixinBinding("bot-token", "bot-account"); err != nil { + t.Fatalf("saveWeixinBinding() error = %v, want nil after config save succeeds", err) + } + + savedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + bc := savedCfg.Channels["weixin"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + wxCfg := decoded.(*config.WeixinSettings) + if got := wxCfg.Token.String(); got != "bot-token" { + t.Fatalf("Weixin.Token() = %q, want %q", got, "bot-token") + } + if got := wxCfg.AccountID; got != "bot-account" { + t.Fatalf("Weixin.AccountID = %q, want %q", got, "bot-account") + } + if !bc.Enabled { + t.Fatalf("Weixin.Enabled = false, want true") + } +} diff --git a/web/backend/app_runtime.go b/web/backend/app_runtime.go new file mode 100644 index 000000000..a06396526 --- /dev/null +++ b/web/backend/app_runtime.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/web/backend/utils" +) + +const ( + browserDelay = 500 * time.Millisecond + shutdownTimeout = 15 * time.Second +) + +// shutdownApp gracefully shuts down all server components and resources. +// It performs the following shutdown sequence: +// - Shuts down the API handler to close all active SSE (Server-Sent Events) connections +// - Disables HTTP keep-alive to prevent new connections during shutdown +// - Attempts graceful HTTP server shutdown with timeout +// - Logs shutdown status at appropriate log levels +// +// The function handles timeout errors gracefully by logging them at info level +// since context.DeadlineExceeded is expected when there are active long-running +// connections (such as SSE streams). +// +// This function should be called during application termination to ensure +// clean resource cleanup and proper connection closure. +func shutdownApp() { + // First, shutdown API handler to close all SSE connections + if apiHandler != nil { + apiHandler.Shutdown() + } + + if len(servers) > 0 { + for _, srv := range servers { + if srv == nil { + continue + } + + // Disable keep-alive to allow graceful shutdown + srv.SetKeepAlivesEnabled(false) + + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + err := srv.Shutdown(ctx) + cancel() + + if err != nil { + // Context deadline exceeded is expected if there are active connections + // This is not necessarily an error, so log it at info level + if errors.Is(err, context.DeadlineExceeded) { + logger.Infof("Server shutdown timeout after %v, forcing close", shutdownTimeout) + } else { + logger.Errorf("Server shutdown error: %v", err) + } + } else { + logger.Infof("Server shutdown completed successfully") + } + } + } +} + +func openBrowser() error { + target := browserLaunchURL + if target == "" { + target = serverAddr + } + if target == "" { + return fmt.Errorf("server address not set") + } + return utils.OpenBrowser(target) +} diff --git a/web/backend/dashboardauth/platform.go b/web/backend/dashboardauth/platform.go new file mode 100644 index 000000000..25ba5da08 --- /dev/null +++ b/web/backend/dashboardauth/platform.go @@ -0,0 +1,7 @@ +package dashboardauth + +import "errors" + +// ErrUnsupportedPlatform reports that the SQLite-backed password store is not +// available for the current target platform. +var ErrUnsupportedPlatform = errors.New("dashboard password store is unavailable on this platform") diff --git a/web/backend/dashboardauth/sql.go b/web/backend/dashboardauth/sql.go new file mode 100644 index 000000000..94886072b --- /dev/null +++ b/web/backend/dashboardauth/sql.go @@ -0,0 +1,24 @@ +package dashboardauth + +const ( + // DBFilename is the SQLite database file stored under the PicoClaw home directory. + DBFilename = "launcher-auth.db" + + sqliteDriver = "sqlite" + // bcryptCost is deliberately high enough to slow brute-force attempts. + bcryptCost = 12 + + sqlCreateTable = ` + CREATE TABLE IF NOT EXISTS dashboard_credentials ( + id INTEGER PRIMARY KEY CHECK (id = 1), + bcrypt_hash TEXT NOT NULL + )` + + sqlCountCredentials = `SELECT COUNT(*) FROM dashboard_credentials WHERE id = 1` + + sqlUpsertHash = ` + INSERT INTO dashboard_credentials (id, bcrypt_hash) VALUES (1, ?) + ON CONFLICT(id) DO UPDATE SET bcrypt_hash = excluded.bcrypt_hash` + + sqlSelectHash = `SELECT bcrypt_hash FROM dashboard_credentials WHERE id = 1` +) diff --git a/web/backend/dashboardauth/store.go b/web/backend/dashboardauth/store.go new file mode 100644 index 000000000..870796bba --- /dev/null +++ b/web/backend/dashboardauth/store.go @@ -0,0 +1,96 @@ +//go:build !mipsle && !netbsd && !(freebsd && arm) + +// Package dashboardauth provides a bcrypt-backed SQLite store for the +// launcher dashboard password. The database contains a single row (id=1) +// with the bcrypt hash; no plaintext is ever persisted. +package dashboardauth + +import ( + "context" + "database/sql" + "errors" + "fmt" + "path/filepath" + + "golang.org/x/crypto/bcrypt" + _ "modernc.org/sqlite" // register "sqlite" driver +) + +// Store holds a handle to the SQLite database that stores the bcrypt hash. +type Store struct { + db *sql.DB + path string // absolute path to the SQLite file +} + +// New opens (or creates) the database inside dir, using the package's +// canonical filename. This is the preferred constructor for most callers. +// Any error is wrapped with the resolved path so callers get actionable output. +func New(dir string) (*Store, error) { + path := filepath.Join(dir, DBFilename) + s, err := Open(path) + if err != nil { + return nil, fmt.Errorf("open %q: %w", path, err) + } + return s, nil +} + +// Open opens (or creates) the SQLite database at path and migrates the schema. +func Open(path string) (*Store, error) { + db, err := sql.Open(sqliteDriver, path) + if err != nil { + return nil, err + } + if _, err = db.Exec(sqlCreateTable); err != nil { + _ = db.Close() + return nil, err + } + return &Store{db: db, path: path}, nil +} + +// Close releases the database handle. +func (s *Store) Close() error { return s.db.Close() } + +// DBPath returns the absolute path to the SQLite database file. +func (s *Store) DBPath() string { return s.path } + +// IsInitialized reports whether a password hash has been stored. +func (s *Store) IsInitialized(ctx context.Context) (bool, error) { + var n int + err := s.db.QueryRowContext(ctx, sqlCountCredentials).Scan(&n) + if err != nil { + return false, err + } + return n > 0, nil +} + +// SetPassword hashes plain with bcrypt (cost 12) and stores (or replaces) it. +// The plaintext is never written to disk. +func (s *Store) SetPassword(ctx context.Context, plain string) error { + if len([]rune(plain)) == 0 { + return errors.New("password must not be empty") + } + hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, sqlUpsertHash, string(hash)) + return err +} + +// VerifyPassword returns true iff plain matches the stored bcrypt hash. +// Returns (false, nil) when no password has been set yet. +func (s *Store) VerifyPassword(ctx context.Context, plain string) (bool, error) { + var hash string + err := s.db.QueryRowContext(ctx, sqlSelectHash).Scan(&hash) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return false, nil + } + return err == nil, err +} diff --git a/web/backend/dashboardauth/store_unsupported.go b/web/backend/dashboardauth/store_unsupported.go new file mode 100644 index 000000000..204682020 --- /dev/null +++ b/web/backend/dashboardauth/store_unsupported.go @@ -0,0 +1,60 @@ +//go:build mipsle || netbsd || (freebsd && arm) + +package dashboardauth + +import ( + "context" + "fmt" + "path/filepath" + "runtime" +) + +// Store is unavailable on platforms where modernc sqlite/libc does not build. +type Store struct { + path string +} + +// New reports that the password store is unavailable on this platform. +func New(dir string) (*Store, error) { + path := filepath.Join(dir, DBFilename) + s, err := Open(path) + if err != nil { + return nil, fmt.Errorf("open %q: %w", path, err) + } + return s, nil +} + +// Open reports that the password store is unavailable on this platform. +func Open(path string) (*Store, error) { + return nil, unsupportedPlatformError() +} + +// Close is a no-op for unsupported platforms. +func (s *Store) Close() error { return nil } + +// DBPath returns the configured path, if any. +func (s *Store) DBPath() string { + if s == nil { + return "" + } + return s.path +} + +// IsInitialized reports that the store is unavailable on this platform. +func (s *Store) IsInitialized(context.Context) (bool, error) { + return false, unsupportedPlatformError() +} + +// SetPassword reports that the store is unavailable on this platform. +func (s *Store) SetPassword(context.Context, string) error { + return unsupportedPlatformError() +} + +// VerifyPassword reports that the store is unavailable on this platform. +func (s *Store) VerifyPassword(context.Context, string) (bool, error) { + return false, unsupportedPlatformError() +} + +func unsupportedPlatformError() error { + return fmt.Errorf("%w (%s/%s)", ErrUnsupportedPlatform, runtime.GOOS, runtime.GOARCH) +} diff --git a/web/backend/dist/.gitkeep b/web/backend/dist/.gitkeep new file mode 100644 index 000000000..4b533f03a --- /dev/null +++ b/web/backend/dist/.gitkeep @@ -0,0 +1 @@ +# Keep the embedded web backend dist directory in version control. diff --git a/web/backend/embed.go b/web/backend/embed.go new file mode 100644 index 000000000..cf0c76bce --- /dev/null +++ b/web/backend/embed.go @@ -0,0 +1,79 @@ +package main + +import ( + "embed" + "fmt" + "io/fs" + "mime" + "net/http" + "path" + "strings" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +//go:embed all:dist +var frontendFS embed.FS + +// registerEmbedRoutes sets up the HTTP handler to serve the embedded frontend files +func registerEmbedRoutes(mux *http.ServeMux) { + // Register correct MIME type for SVG files + // Go's built-in mime.TypeByExtension returns "image/svg" which is incorrect + // The correct MIME type per RFC 6838 is "image/svg+xml" + if err := mime.AddExtensionType(".svg", "image/svg+xml"); err != nil { + logger.ErrorC("web", fmt.Sprintf("Warning: failed to register SVG MIME type: %v", err)) + } + + // Attempt to get the subdirectory 'dist' where Vite usually builds + subFS, err := fs.Sub(frontendFS, "dist") + if err != nil { + // Log a warning if dist doesn't exist yet (e.g., during development before a frontend build) + logger.WarnC("web", + "Warning: no 'dist' folder found in embedded frontend. "+ + "Ensure you run `pnpm build:backend` in the frontend directory "+ + "before building the Go backend.", + ) + return + } + + fileServer := http.FileServer(http.FS(subFS)) + + // Serve static assets and fallback to index.html for SPA routes. + mux.Handle( + "/", + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.NotFound(w, r) + return + } + + // Keep unknown API paths as 404 instead of falling back to SPA entry. + if r.URL.Path == "/api" || strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + + cleanPath := path.Clean(strings.TrimPrefix(r.URL.Path, "/")) + if cleanPath == "." { + cleanPath = "" + } + + // Existing static files/directories should be served directly. + if cleanPath != "" { + if _, statErr := fs.Stat(subFS, cleanPath); statErr == nil { + fileServer.ServeHTTP(w, r) + return + } + // Missing asset-like paths should remain 404. + if strings.Contains(path.Base(cleanPath), ".") { + fileServer.ServeHTTP(w, r) + return + } + } + + indexReq := r.Clone(r.Context()) + indexReq.URL.Path = "/" + fileServer.ServeHTTP(w, indexReq) + }), + ) +} diff --git a/web/backend/embed_test.go b/web/backend/embed_test.go new file mode 100644 index 000000000..c0365488e --- /dev/null +++ b/web/backend/embed_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestUnknownAPIPathStays404(t *testing.T) { + mux := http.NewServeMux() + registerEmbedRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/not-found", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound) + } +} + +func TestMissingAssetStays404(t *testing.T) { + mux := http.NewServeMux() + registerEmbedRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/assets/not-found.js", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound) + } +} diff --git a/web/backend/i18n.go b/web/backend/i18n.go new file mode 100644 index 000000000..9cda9e5d5 --- /dev/null +++ b/web/backend/i18n.go @@ -0,0 +1,120 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +// Language represents the supported languages +type Language string + +const ( + LanguageEnglish Language = "en" + LanguageChinese Language = "zh" +) + +// current language (default: English) +var currentLang Language = LanguageEnglish + +// TranslationKey represents a translation key used for i18n +type TranslationKey string + +const ( + AppTooltip TranslationKey = "AppTooltip" + MenuOpen TranslationKey = "MenuOpen" + MenuOpenTooltip TranslationKey = "MenuOpenTooltip" + MenuAbout TranslationKey = "MenuAbout" + MenuAboutTooltip TranslationKey = "MenuAboutTooltip" + MenuVersion TranslationKey = "MenuVersion" + MenuVersionTooltip TranslationKey = "MenuVersionTooltip" + MenuGitHub TranslationKey = "MenuGitHub" + MenuDocs TranslationKey = "MenuDocs" + MenuRestart TranslationKey = "MenuRestart" + MenuRestartTooltip TranslationKey = "MenuRestartTooltip" + MenuQuit TranslationKey = "MenuQuit" + MenuQuitTooltip TranslationKey = "MenuQuitTooltip" + Exiting TranslationKey = "Exiting" + DocUrl TranslationKey = "DocUrl" +) + +// Translation tables +// Chinese translations intentionally contain Han script +// +//nolint:gosmopolitan +var translations = map[Language]map[TranslationKey]string{ + LanguageEnglish: { + AppTooltip: "%s - Web Console", + MenuOpen: "Open Console", + MenuOpenTooltip: "Open PicoClaw console in browser", + MenuAbout: "About", + MenuAboutTooltip: "About PicoClaw", + MenuVersion: "Version: %s", + MenuVersionTooltip: "Current version number", + MenuGitHub: "GitHub", + MenuDocs: "Documentation", + MenuRestart: "Restart Service", + MenuRestartTooltip: "Restart Gateway service", + MenuQuit: "Quit", + MenuQuitTooltip: "Exit PicoClaw", + Exiting: "Exiting PicoClaw...", + DocUrl: "https://docs.picoclaw.io/docs/", + }, + LanguageChinese: { + AppTooltip: "%s - Web Console", + MenuOpen: "打开控制台", + MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台", + MenuAbout: "关于", + MenuAboutTooltip: "关于 PicoClaw", + MenuVersion: "版本: %s", + MenuVersionTooltip: "当前版本号", + MenuGitHub: "GitHub", + MenuDocs: "文档", + MenuRestart: "重启服务", + MenuRestartTooltip: "重启核心服务", + MenuQuit: "退出", + MenuQuitTooltip: "退出 PicoClaw", + Exiting: "正在退出 PicoClaw...", + DocUrl: "https://docs.picoclaw.io/zh-Hans/docs/", + }, +} + +// SetLanguage sets the current language +func SetLanguage(lang string) { + lang = strings.ToLower(strings.TrimSpace(lang)) + + // Extract language code before first underscore or dot + // e.g., "en_US.UTF-8" -> "en", "zh_CN" -> "zh" + if idx := strings.IndexAny(lang, "_."); idx > 0 { + lang = lang[:idx] + } + + if lang == "zh" || lang == "zh-cn" || lang == "chinese" { + currentLang = LanguageChinese + } else { + currentLang = LanguageEnglish + } +} + +// GetLanguage returns the current language +func GetLanguage() Language { + return currentLang +} + +// T translates a key to the current language +func T(key TranslationKey, args ...any) string { + if trans, ok := translations[currentLang][key]; ok { + if len(args) > 0 { + return fmt.Sprintf(trans, args...) + } + return trans + } + return string(key) +} + +// Initialize i18n from environment variable +func init() { + if lang := os.Getenv("LANG"); lang != "" { + SetLanguage(lang) + } +} diff --git a/cmd/picoclaw-launcher/icon.ico b/web/backend/icon.ico similarity index 100% rename from cmd/picoclaw-launcher/icon.ico rename to web/backend/icon.ico diff --git a/web/backend/icon.png b/web/backend/icon.png new file mode 100644 index 000000000..e0b4aab9c Binary files /dev/null and b/web/backend/icon.png differ diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go new file mode 100644 index 000000000..e3595738f --- /dev/null +++ b/web/backend/launcherconfig/config.go @@ -0,0 +1,123 @@ +package launcherconfig + +import ( + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strings" +) + +const ( + // FileName is the launcher-specific settings file name. + FileName = "launcher-config.json" + // DefaultPort is the default port for the web launcher. + DefaultPort = 18800 + // EnvLauncherHost overrides launcher listen host. + EnvLauncherHost = "PICOCLAW_LAUNCHER_HOST" +) + +// Config stores launch parameters for the web backend service. +type Config struct { + Port int `json:"port"` + Public bool `json:"public"` + AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` + DashboardPasswordHash string `json:"dashboard_password_hash,omitempty"` + // LegacyLauncherToken is read only for one-time migration from the removed + // token login flow. Save always clears it so new configs do not persist it. + LegacyLauncherToken string `json:"launcher_token,omitempty"` +} + +// Default returns default launcher settings. +func Default() Config { + return Config{Port: DefaultPort, Public: false} +} + +// Validate checks if launcher settings are valid. +func Validate(cfg Config) error { + if cfg.Port < 1 || cfg.Port > 65535 { + return fmt.Errorf("port %d is out of range (1-65535)", cfg.Port) + } + for _, cidr := range cfg.AllowedCIDRs { + if _, _, err := net.ParseCIDR(cidr); err != nil { + return fmt.Errorf("invalid CIDR %q", cidr) + } + } + return nil +} + +// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs. +func NormalizeCIDRs(cidrs []string) []string { + if len(cidrs) == 0 { + return nil + } + out := make([]string, 0, len(cidrs)) + seen := make(map[string]struct{}, len(cidrs)) + for _, raw := range cidrs { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + if len(out) == 0 { + return nil + } + return out +} + +// PathForAppConfig returns launcher-config path near the app config file. +func PathForAppConfig(appConfigPath string) string { + dir := filepath.Dir(appConfigPath) + if dir == "" || dir == "." { + dir = "." + } + return filepath.Join(dir, FileName) +} + +// Load reads launcher settings; fallback is returned when file does not exist. +func Load(path string, fallback Config) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return fallback, nil + } + return Config{}, err + } + + cfg := fallback + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, err + } + cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) + cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash) + cfg.LegacyLauncherToken = strings.TrimSpace(cfg.LegacyLauncherToken) + if err := Validate(cfg); err != nil { + return Config{}, err + } + return cfg, nil +} + +// Save writes launcher settings to disk. +func Save(path string, cfg Config) error { + cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) + cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash) + cfg.LegacyLauncherToken = "" + if err := Validate(cfg); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o600) +} diff --git a/web/backend/launcherconfig/config_test.go b/web/backend/launcherconfig/config_test.go new file mode 100644 index 000000000..bb13ea115 --- /dev/null +++ b/web/backend/launcherconfig/config_test.go @@ -0,0 +1,152 @@ +package launcherconfig + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestLoadReturnsFallbackWhenMissing(t *testing.T) { + path := filepath.Join(t.TempDir(), "launcher-config.json") + fallback := Config{Port: 19999, Public: true} + + got, err := Load(path, fallback) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.Port != fallback.Port || got.Public != fallback.Public { + t.Fatalf("Load() = %+v, want %+v", got, fallback) + } +} + +func TestSaveAndLoadRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "launcher-config.json") + want := Config{ + Port: 18080, + Public: true, + AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"}, + DashboardPasswordHash: "$2a$12$saved-dashboard-password-hash", + LegacyLauncherToken: "legacy-token-should-not-persist", + } + + if err := Save(path, want); err != nil { + t.Fatalf("Save() error = %v", err) + } + got, err := Load(path, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.Port != want.Port || got.Public != want.Public { + t.Fatalf("Load() = %+v, want %+v", got, want) + } + if got.DashboardPasswordHash != want.DashboardPasswordHash { + t.Fatalf("dashboard_password_hash = %q, want %q", got.DashboardPasswordHash, want.DashboardPasswordHash) + } + if got.LegacyLauncherToken != "" { + t.Fatalf("legacy launcher_token = %q, want empty after Save", got.LegacyLauncherToken) + } + if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) { + t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs)) + } + for i := range want.AllowedCIDRs { + if got.AllowedCIDRs[i] != want.AllowedCIDRs[i] { + t.Fatalf("allowed_cidrs[%d] = %q, want %q", i, got.AllowedCIDRs[i], want.AllowedCIDRs[i]) + } + } + + stat, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat() error = %v", err) + } + if perm := stat.Mode().Perm(); perm != 0o600 { + t.Fatalf("file perm = %o, want 600", perm) + } +} + +func TestLoadReadsLegacyLauncherTokenForMigration(t *testing.T) { + path := filepath.Join(t.TempDir(), "launcher-config.json") + if err := os.WriteFile(path, []byte(`{"port":18800,"launcher_token":"legacy-token"}`), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + got, err := Load(path, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.LegacyLauncherToken != "legacy-token" { + t.Fatalf("legacy launcher_token = %q, want legacy-token", got.LegacyLauncherToken) + } +} + +func TestValidateRejectsInvalidPort(t *testing.T) { + if err := Validate(Config{Port: 0, Public: false}); err == nil { + t.Fatal("Validate() expected error for port 0") + } + if err := Validate(Config{Port: 65536, Public: false}); err == nil { + t.Fatal("Validate() expected error for port 65536") + } +} + +func TestValidateRejectsInvalidCIDR(t *testing.T) { + err := Validate(Config{ + Port: 18800, + AllowedCIDRs: []string{"192.168.1.0/24", "not-a-cidr"}, + }) + if err == nil { + t.Fatal("Validate() expected error for invalid CIDR") + } +} + +func TestNormalizeCIDRs(t *testing.T) { + got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"}) + want := []string{"192.168.1.0/24", "10.0.0.0/8"} + if len(got) != len(want) { + t.Fatalf("len(got) = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestPasswordStoreSetAndVerify(t *testing.T) { + path := filepath.Join(t.TempDir(), "launcher-config.json") + store := NewPasswordStore(path, Default()) + ctx := context.Background() + + initialized, err := store.IsInitialized(ctx) + if err != nil { + t.Fatalf("IsInitialized() error = %v", err) + } + if initialized { + t.Fatal("IsInitialized() = true, want false before SetPassword") + } + + if err = store.SetPassword(ctx, "dashboard-password"); err != nil { + t.Fatalf("SetPassword() error = %v", err) + } + initialized, err = store.IsInitialized(ctx) + if err != nil { + t.Fatalf("IsInitialized() after SetPassword error = %v", err) + } + if !initialized { + t.Fatal("IsInitialized() = false, want true after SetPassword") + } + ok, err := store.VerifyPassword(ctx, "dashboard-password") + if err != nil { + t.Fatalf("VerifyPassword() error = %v", err) + } + if !ok { + t.Fatal("VerifyPassword(correct) = false, want true") + } + ok, err = store.VerifyPassword(ctx, "wrong-password") + if err != nil { + t.Fatalf("VerifyPassword(wrong) error = %v", err) + } + if ok { + t.Fatal("VerifyPassword(wrong) = true, want false") + } +} diff --git a/web/backend/launcherconfig/migration.go b/web/backend/launcherconfig/migration.go new file mode 100644 index 000000000..66caa73ae --- /dev/null +++ b/web/backend/launcherconfig/migration.go @@ -0,0 +1,62 @@ +package launcherconfig + +import ( + "context" + "strings" +) + +var ( + loadConfigForMigration = Load + saveConfigForMigration = Save +) + +type dashboardPasswordStore interface { + IsInitialized(ctx context.Context) (bool, error) + SetPassword(ctx context.Context, plain string) error +} + +// LegacyLauncherTokenMigrationResult reports the outcome of converting a +// removed launcher_token value into the current password-based auth flow. +type LegacyLauncherTokenMigrationResult struct { + Migrated bool + // CleanupErr is non-nil when password migration succeeded (or was already in + // place) but removing launcher_token from launcher-config.json failed. + CleanupErr error +} + +// MigrateLegacyLauncherToken converts the removed launcher_token setting into +// the current password-login store, then removes launcher_token from config. +func MigrateLegacyLauncherToken( + ctx context.Context, + store dashboardPasswordStore, + launcherPath string, + fallback Config, +) (LegacyLauncherTokenMigrationResult, error) { + legacyToken := strings.TrimSpace(fallback.LegacyLauncherToken) + if legacyToken == "" || store == nil { + return LegacyLauncherTokenMigrationResult{}, nil + } + + result := LegacyLauncherTokenMigrationResult{} + initialized, err := store.IsInitialized(ctx) + if err != nil { + return result, err + } + if !initialized { + if err = store.SetPassword(ctx, legacyToken); err != nil { + return result, err + } + result.Migrated = true + } + result.CleanupErr = cleanupLegacyLauncherTokenConfig(launcherPath, fallback) + return result, nil +} + +func cleanupLegacyLauncherTokenConfig(launcherPath string, fallback Config) error { + cfg, err := loadConfigForMigration(launcherPath, fallback) + if err != nil { + return err + } + cfg.LegacyLauncherToken = "" + return saveConfigForMigration(launcherPath, cfg) +} diff --git a/web/backend/launcherconfig/migration_test.go b/web/backend/launcherconfig/migration_test.go new file mode 100644 index 000000000..c5c5fa2c9 --- /dev/null +++ b/web/backend/launcherconfig/migration_test.go @@ -0,0 +1,135 @@ +package launcherconfig + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +type stubMigrationPasswordStore struct { + initialized bool + password string +} + +func (s *stubMigrationPasswordStore) IsInitialized(context.Context) (bool, error) { + return s.initialized, nil +} + +func (s *stubMigrationPasswordStore) SetPassword(_ context.Context, plain string) error { + s.password = plain + s.initialized = true + return nil +} + +func TestMigrateLegacyLauncherToken(t *testing.T) { + dir := t.TempDir() + launcherPath := filepath.Join(dir, FileName) + cfg := Config{ + Port: DefaultPort, + LegacyLauncherToken: "legacy-password", + } + if err := os.WriteFile( + launcherPath, + []byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + store := NewPasswordStore(launcherPath, Default()) + result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg) + if err != nil { + t.Fatalf("MigrateLegacyLauncherToken() error = %v", err) + } + if !result.Migrated { + t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true") + } + if result.CleanupErr != nil { + t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr) + } + + loaded, err := Load(launcherPath, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.LegacyLauncherToken != "" { + t.Fatalf("legacy launcher token = %q, want empty", loaded.LegacyLauncherToken) + } + if loaded.DashboardPasswordHash == "" { + t.Fatal("dashboard password hash should be set after migration") + } + ok, err := store.VerifyPassword(context.Background(), "legacy-password") + if err != nil { + t.Fatalf("VerifyPassword() error = %v", err) + } + if !ok { + t.Fatal("VerifyPassword() = false, want true") + } +} + +func TestMigrateLegacyLauncherTokenCleanupFailureIsNonFatal(t *testing.T) { + dir := t.TempDir() + launcherPath := filepath.Join(dir, FileName) + cfg := Config{ + Port: DefaultPort, + LegacyLauncherToken: "legacy-password", + } + if err := os.WriteFile( + launcherPath, + []byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + store := &stubMigrationPasswordStore{} + origSave := saveConfigForMigration + saveConfigForMigration = func(string, Config) error { + return errors.New("write launcher config") + } + t.Cleanup(func() { + saveConfigForMigration = origSave + }) + + result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg) + if err != nil { + t.Fatalf("MigrateLegacyLauncherToken() error = %v, want nil", err) + } + if !result.Migrated { + t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true") + } + if result.CleanupErr == nil { + t.Fatal("MigrateLegacyLauncherToken().CleanupErr = nil, want non-nil") + } + if store.password != "legacy-password" { + t.Fatalf("password = %q, want legacy-password", store.password) + } + + loaded, err := Load(launcherPath, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.LegacyLauncherToken != "legacy-password" { + t.Fatalf( + "legacy launcher token = %q, want legacy-password after cleanup failure", + loaded.LegacyLauncherToken, + ) + } +} + +func TestMigrateLegacyLauncherTokenNoopWithoutToken(t *testing.T) { + launcherPath := filepath.Join(t.TempDir(), FileName) + store := NewPasswordStore(launcherPath, Default()) + result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, Default()) + if err != nil { + t.Fatalf("MigrateLegacyLauncherToken() error = %v", err) + } + if result.Migrated { + t.Fatal("MigrateLegacyLauncherToken().Migrated = true, want false") + } + if result.CleanupErr != nil { + t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr) + } +} diff --git a/web/backend/launcherconfig/password_store.go b/web/backend/launcherconfig/password_store.go new file mode 100644 index 000000000..3813384bb --- /dev/null +++ b/web/backend/launcherconfig/password_store.go @@ -0,0 +1,92 @@ +package launcherconfig + +import ( + "context" + "errors" + "strings" + "sync" + + "golang.org/x/crypto/bcrypt" +) + +const passwordBcryptCost = 12 + +// PasswordStore keeps the dashboard bcrypt hash in launcher-config.json. +// It is used on platforms where the SQLite-backed dashboard auth store is not +// available. +type PasswordStore struct { + path string + fallback Config + mu sync.Mutex +} + +// NewPasswordStore returns a config-backed password store. +func NewPasswordStore(path string, fallback Config) *PasswordStore { + return &PasswordStore{ + path: path, + fallback: fallback, + } +} + +// IsInitialized reports whether a dashboard password hash exists in config. +func (s *PasswordStore) IsInitialized(ctx context.Context) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + cfg, err := s.load() + if err != nil { + return false, err + } + return strings.TrimSpace(cfg.DashboardPasswordHash) != "", nil +} + +// SetPassword hashes plain with bcrypt and writes it to launcher-config.json. +func (s *PasswordStore) SetPassword(ctx context.Context, plain string) error { + if err := ctx.Err(); err != nil { + return err + } + if len([]rune(plain)) == 0 { + return errors.New("password must not be empty") + } + hash, err := bcrypt.GenerateFromPassword([]byte(plain), passwordBcryptCost) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + cfg, err := Load(s.path, s.fallback) + if err != nil { + return err + } + cfg.DashboardPasswordHash = string(hash) + cfg.LegacyLauncherToken = "" + return Save(s.path, cfg) +} + +// VerifyPassword returns true iff plain matches the stored bcrypt hash. +func (s *PasswordStore) VerifyPassword(ctx context.Context, plain string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + cfg, err := s.load() + if err != nil { + return false, err + } + hash := strings.TrimSpace(cfg.DashboardPasswordHash) + if hash == "" { + return false, nil + } + err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return false, nil + } + return err == nil, err +} + +func (s *PasswordStore) load() (Config, error) { + s.mu.Lock() + defer s.mu.Unlock() + return Load(s.path, s.fallback) +} diff --git a/web/backend/main.go b/web/backend/main.go new file mode 100644 index 000000000..fa2448d5c --- /dev/null +++ b/web/backend/main.go @@ -0,0 +1,699 @@ +// PicoClaw Web Console - Web-based chat and management interface +// +// Provides a web UI for chatting with PicoClaw via the Pico Channel WebSocket, +// with configuration management and gateway process control. +// +// Usage: +// +// go build -o picoclaw-web ./web/backend/ +// ./picoclaw-web [config.json] +// ./picoclaw-web -public config.json + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/netbind" + "github.com/sipeed/picoclaw/web/backend/api" + "github.com/sipeed/picoclaw/web/backend/dashboardauth" + "github.com/sipeed/picoclaw/web/backend/launcherconfig" + "github.com/sipeed/picoclaw/web/backend/middleware" + "github.com/sipeed/picoclaw/web/backend/utils" +) + +const ( + appName = "PicoClaw" + + logPath = "logs" + panicFile = "launcher_panic.log" + logFile = "launcher.log" +) + +var ( + appVersion = config.Version + + servers []*http.Server + serverAddr string + // browserLaunchURL is opened by openBrowser() (auto-open + tray "open console"). + browserLaunchURL string + apiHandler *api.Handler + + noBrowser *bool +) + +func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool { + return !enableConsole || debug +} + +func shouldEnableLocalAutoLogin(noBrowser bool, probeHost string) bool { + return !noBrowser && isLoopbackLaunchHost(probeHost) +} + +func isLoopbackLaunchHost(host string) bool { + host = strings.TrimSpace(host) + if strings.EqualFold(host, "localhost") { + return true + } + host = strings.Trim(host, "[]") + if i := strings.LastIndex(host, "%"); i >= 0 { + host = host[:i] + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func launcherBrowserLaunchSuffix( + needsSetup bool, + localAutoLogin *middleware.LauncherDashboardLocalAutoLogin, +) string { + if needsSetup { + return middleware.LauncherDashboardSetupPath + } + if localAutoLogin != nil { + return localAutoLogin.URLPath() + } + return "" +} + +func resolveLauncherHostInput(flagHost string, explicitFlag bool, envHost string) (string, bool, error) { + if explicitFlag { + normalized, err := netbind.NormalizeHostInput(flagHost) + if err != nil { + return "", false, err + } + return normalized, true, nil + } + + envHost = strings.TrimSpace(envHost) + if envHost == "" { + return "", false, nil + } + + normalized, err := netbind.NormalizeHostInput(envHost) + if err != nil { + return "", false, err + } + return normalized, true, nil +} + +func openLauncherListeners(hostInput string, public bool, port string) (netbind.OpenResult, error) { + defaultMode := netbind.DefaultLoopback + if strings.TrimSpace(hostInput) == "" && public { + defaultMode = netbind.DefaultAny + } + + plan, err := netbind.BuildPlan(hostInput, defaultMode) + if err != nil { + return netbind.OpenResult{}, err + } + return netbind.OpenPlan(plan, port) +} + +func appendUniqueHost(hosts []string, seen map[string]struct{}, host string) []string { + host = strings.TrimSpace(host) + if host == "" { + return hosts + } + key := strings.ToLower(host) + if _, ok := seen[key]; ok { + return hosts + } + seen[key] = struct{}{} + return append(hosts, host) +} + +func hasWildcardBindHosts(bindHosts []string) bool { + for _, bindHost := range bindHosts { + if netbind.IsUnspecifiedHost(bindHost) { + return true + } + } + return false +} + +func wildcardBindHostFamilies(bindHosts []string) (hasIPv4, hasIPv6 bool) { + for _, bindHost := range bindHosts { + host := strings.TrimSpace(bindHost) + if host == "" { + continue + } + + if !netbind.IsUnspecifiedHost(host) { + continue + } + + ip := net.ParseIP(strings.Trim(host, "[]")) + if ip == nil { + continue + } + if ip.To4() != nil { + hasIPv4 = true + continue + } + hasIPv6 = true + } + + return hasIPv4, hasIPv6 +} + +func wildcardAdvertiseIP(bindHosts []string, ipv4, ipv6 string) string { + hasIPv4Wildcard, hasIPv6Wildcard := wildcardBindHostFamilies(bindHosts) + v4 := strings.TrimSpace(ipv4) + v6 := strings.TrimSpace(ipv6) + + switch { + case hasIPv4Wildcard && hasIPv6Wildcard: + if v6 != "" { + return v6 + } + return v4 + case hasIPv6Wildcard: + return v6 + case hasIPv4Wildcard: + return v4 + default: + return "" + } +} + +func advertiseIPForWildcardBindHosts(bindHosts []string) string { + return wildcardAdvertiseIP(bindHosts, utils.GetLocalIPv4(), utils.GetLocalIPv6()) +} + +func appendLauncherConsoleHostList(hosts []string, seen map[string]struct{}, values []string) []string { + for _, value := range values { + hosts = appendUniqueHost(hosts, seen, value) + } + return hosts +} + +func shouldShowLocalhostConsoleEntry(hostInput string) bool { + normalizedHostInput := strings.TrimSpace(hostInput) + if normalizedHostInput == "" { + return true + } + + for token := range strings.SplitSeq(normalizedHostInput, ",") { + token = strings.TrimSpace(token) + if token == "" { + continue + } + if token == "*" || strings.EqualFold(token, "localhost") { + return true + } + + ip := net.ParseIP(strings.Trim(token, "[]")) + if ip == nil { + continue + } + if ip4 := ip.To4(); ip4 != nil { + if ip4.String() == "127.0.0.1" || ip4.String() == "0.0.0.0" { + return true + } + continue + } + if ip.String() == "::1" || ip.String() == "::" { + return true + } + } + + return false +} + +func isConsoleDisplayGlobalIPv6(ip net.IP) bool { + if ip == nil || ip.IsLoopback() || ip.To4() != nil { + return false + } + ip = ip.To16() + if ip == nil { + return false + } + return ip[0]&0xe0 == 0x20 +} + +func launcherConsoleHostsWithLocalAddrs( + hostInput string, + public bool, + ipv4s []string, + globalIPv6s []string, +) []string { + hosts := make([]string, 0, 8) + seen := make(map[string]struct{}, 8) + + if shouldShowLocalhostConsoleEntry(hostInput) { + hosts = appendUniqueHost(hosts, seen, "localhost") + } + + normalizedHostInput := strings.TrimSpace(hostInput) + if normalizedHostInput == "" { + if public { + hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s) + hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s) + } + return hosts + } + + hasStar := false + hasIPv4Any := false + hasIPv6Any := false + for _, token := range strings.Split(normalizedHostInput, ",") { + switch strings.TrimSpace(token) { + case "*": + hasStar = true + case "0.0.0.0": + hasIPv4Any = true + case "::": + hasIPv6Any = true + } + } + + if hasStar { + hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s) + hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s) + return hosts + } + + for _, token := range strings.Split(normalizedHostInput, ",") { + token = strings.TrimSpace(token) + if token == "" || strings.EqualFold(token, "localhost") || netbind.IsLoopbackHost(token) { + continue + } + + ip := net.ParseIP(strings.Trim(token, "[]")) + switch { + case token == "::": + hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s) + case token == "0.0.0.0": + hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s) + case ip != nil && ip.To4() != nil: + if hasIPv4Any { + continue + } + hosts = appendUniqueHost(hosts, seen, ip.String()) + case ip != nil: + if hasIPv6Any { + continue + } + if isConsoleDisplayGlobalIPv6(ip) { + hosts = appendUniqueHost(hosts, seen, ip.String()) + } + default: + hosts = appendUniqueHost(hosts, seen, token) + } + } + + return hosts +} + +func launcherConsoleHosts(hostInput string, public bool) []string { + return launcherConsoleHostsWithLocalAddrs( + hostInput, + public, + utils.GetLocalIPv4s(), + utils.GetGlobalIPv6s(), + ) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + return "" +} + +func main() { + port := flag.String("port", "18800", "Port to listen on") + host := flag.String("host", "", "Host to listen on (overrides -public when set)") + public := flag.Bool("public", false, "Listen on all interfaces (dual-stack) instead of localhost only") + noBrowser = flag.Bool("no-browser", false, "Do not auto-open browser on startup") + lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale") + console := flag.Bool("console", false, "Console mode, no GUI") + + var debug bool + flag.BoolVar(&debug, "d", false, "Enable debug logging") + flag.BoolVar(&debug, "debug", false, "Enable debug logging") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "%s Launcher - Web console and gateway manager\n\n", appName) + fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Arguments:\n") + fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n") + fmt.Fprintf(os.Stderr, "Options:\n") + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nExamples:\n") + fmt.Fprintf(os.Stderr, " %s\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " Use default config path in GUI mode\n") + fmt.Fprintf(os.Stderr, " %s ./config.json\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " Specify a config file\n") + fmt.Fprintf( + os.Stderr, + " %s -public ./config.json\n", + os.Args[0], + ) + fmt.Fprintf(os.Stderr, " Allow access from other devices on the local network\n") + fmt.Fprintf(os.Stderr, " %s -host :: ./config.json\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " Bind launcher host explicitly with exact host semantics\n") + fmt.Fprintf(os.Stderr, " %s -console -d ./config.json\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " Run in the terminal with debug logs enabled\n") + } + flag.Parse() + + // Initialize logger + picoHome := utils.GetPicoclawHome() + + f := filepath.Join(picoHome, logPath, panicFile) + panicFunc, err := logger.InitPanic(f) + if err != nil { + panic(fmt.Sprintf("error initializing panic log: %v", err)) + } + defer panicFunc() + + enableConsole := *console + fileLoggingEnabled := shouldEnableLauncherFileLogging(enableConsole, debug) + if fileLoggingEnabled { + // GUI mode writes launcher logs to file. Debug mode keeps file logging enabled in console mode too. + if !debug { + logger.DisableConsole() + } + + f := filepath.Join(picoHome, logPath, logFile) + if err = logger.EnableFileLogging(f); err != nil { + panic(fmt.Sprintf("error enabling file logging: %v", err)) + } + defer logger.DisableFileLogging() + } + if debug { + logger.SetLevel(logger.DEBUG) + } + + // Set language from command line or auto-detect + if *lang != "" { + SetLanguage(*lang) + } + + // Resolve config path + configPath := utils.GetDefaultConfigPath() + if flag.NArg() > 0 { + configPath = flag.Arg(0) + } + + absPath, err := filepath.Abs(configPath) + if err != nil { + logger.Fatalf("Failed to resolve config path: %v", err) + } + err = utils.EnsureOnboarded(absPath) + if err != nil { + logger.Errorf("Warning: Failed to initialize %s config automatically: %v", appName, err) + } + if !debug { + logger.SetLevelFromString(config.ResolveGatewayLogLevel(absPath)) + } + + logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion)) + logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome)) + if debug { + logger.InfoC("web", "Debug mode enabled") + logger.DebugC( + "web", + fmt.Sprintf( + "Launcher flags: console=%t host=%q public=%t no_browser=%t config=%s", + enableConsole, + *host, + *public, + *noBrowser, + absPath, + ), + ) + } + + var explicitPort bool + var explicitPublic bool + var explicitHost bool + flag.Visit(func(f *flag.Flag) { + switch f.Name { + case "port": + explicitPort = true + case "host": + explicitHost = true + case "public": + explicitPublic = true + } + }) + + launcherPath := launcherconfig.PathForAppConfig(absPath) + launcherCfg, err := launcherconfig.Load(launcherPath, launcherconfig.Default()) + if err != nil { + logger.ErrorC("web", fmt.Sprintf("Warning: Failed to load %s: %v", launcherPath, err)) + launcherCfg = launcherconfig.Default() + } + + effectivePort := *port + effectivePublic := *public + if !explicitPort { + effectivePort = strconv.Itoa(launcherCfg.Port) + } + if !explicitPublic { + effectivePublic = launcherCfg.Public + } + envHost := strings.TrimSpace(os.Getenv(launcherconfig.EnvLauncherHost)) + + hostInput, hostOverrideActive, err := resolveLauncherHostInput(*host, explicitHost, envHost) + if err != nil { + logger.Fatalf("Invalid host %q: %v", firstNonEmpty(strings.TrimSpace(*host), envHost), err) + } + if hostOverrideActive { + effectivePublic = false + } + + if !explicitHost && hostOverrideActive { + logger.InfoC("web", "Using launcher host from environment PICOCLAW_LAUNCHER_HOST") + } + + if hostOverrideActive && explicitPublic { + logger.InfoC("web", "Ignoring -public because launcher host was explicitly set") + } + + portNum, err := strconv.Atoi(effectivePort) + if err != nil || portNum < 1 || portNum > 65535 { + if err == nil { + err = errors.New("must be in range 1-65535") + } + logger.Fatalf("Invalid port %q: %v", effectivePort, err) + } + + openResult, err := openLauncherListeners(hostInput, effectivePublic, effectivePort) + if err != nil { + logger.Fatalf("Failed to open launcher listener(s): %v", err) + } + listeners := openResult.Listeners + + dashboardSessionCookie, dashErr := middleware.NewLauncherDashboardSessionCookie() + if dashErr != nil { + logger.Fatalf("Dashboard auth setup failed: %v", dashErr) + } + + // Open the bcrypt password store (creates the DB file on first run). + authStore, authStoreErr := dashboardauth.New(picoHome) + var passwordStore api.PasswordStore + if authStoreErr == nil { + passwordStore = authStore + defer authStore.Close() + } else if errors.Is(authStoreErr, dashboardauth.ErrUnsupportedPlatform) { + logger.InfoC( + "web", + fmt.Sprintf( + "Dashboard SQLite password store unavailable on this platform; using launcher-config password storage: %v", + authStoreErr, + ), + ) + passwordStore = launcherconfig.NewPasswordStore(launcherPath, launcherCfg) + authStoreErr = nil + } else { + logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr)) + } + + migrationResult, migrationErr := launcherconfig.MigrateLegacyLauncherToken( + context.Background(), + passwordStore, + launcherPath, + launcherCfg, + ) + if migrationErr != nil { + logger.Fatalf("Failed to migrate legacy launcher token to password login: %v", migrationErr) + } + if migrationResult.Migrated { + logger.InfoC("web", "Migrated legacy launcher token to dashboard password login") + } + if migrationResult.CleanupErr != nil { + logger.WarnC( + "web", + fmt.Sprintf( + "Legacy launcher token password migration succeeded, but failed to remove launcher_token from %s: %v", + launcherPath, + migrationResult.CleanupErr, + ), + ) + } + + var localAutoLogin *middleware.LauncherDashboardLocalAutoLogin + needsInitialSetup := false + if passwordStore != nil { + initialized, initErr := passwordStore.IsInitialized(context.Background()) + if initErr != nil { + logger.ErrorC("web", fmt.Sprintf("Warning: could not check dashboard password state: %v", initErr)) + } else if !initialized { + needsInitialSetup = true + } else if shouldEnableLocalAutoLogin(*noBrowser, openResult.ProbeHost) { + localAutoLogin, err = middleware.NewLauncherDashboardLocalAutoLogin(5 * time.Minute) + if err != nil { + logger.Fatalf("Failed to create local auto-login grant: %v", err) + } + } + } + + // Initialize Server components + mux := http.NewServeMux() + + api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{ + SessionCookie: dashboardSessionCookie, + PasswordStore: passwordStore, + StoreError: authStoreErr, + }) + + // API Routes (e.g. /api/status) + apiHandler = api.NewHandler(absPath) + apiHandler.SetDebug(debug) + if _, err = apiHandler.EnsurePicoChannel(); err != nil { + logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err)) + } + apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) + apiHandler.SetServerBindHost(hostInput, hostOverrideActive) + apiHandler.RegisterRoutes(mux) + + // Frontend Embedded Assets + registerEmbedRoutes(mux) + + accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux) + if err != nil { + logger.Fatalf("Invalid allowed CIDR configuration: %v", err) + } + + dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{ + ExpectedCookie: dashboardSessionCookie, + LocalAutoLogin: localAutoLogin, + }, accessControlledMux) + + // Apply middleware stack + handler := middleware.Recoverer( + middleware.Logger( + middleware.ReferrerPolicyNoReferrer( + middleware.JSONContentType(dashAuth), + ), + ), + ) + + // Print startup banner (console mode only). + if enableConsole || debug { + consoleHosts := launcherConsoleHosts(hostInput, effectivePublic) + + fmt.Print(utils.Banner) + fmt.Println() + if needsInitialSetup { + if *noBrowser { + fmt.Println(" First-time setup: open /launcher-setup to create the dashboard password.") + } else { + fmt.Println(" Launcher will open /launcher-setup automatically.") + } + fmt.Println() + } + fmt.Println(" Dashboard address:") + fmt.Println() + for _, host := range consoleHosts { + fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort)) + } + fmt.Println() + } + + // Log startup info to file + for _, ln := range listeners { + logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", ln.Addr().String())) + } + if hasWildcardBindHosts(openResult.BindHosts) { + if ip := advertiseIPForWildcardBindHosts(openResult.BindHosts); ip != "" { + logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s", net.JoinHostPort(ip, effectivePort))) + } + } + + // Share the local URL with the launcher runtime. + serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(openResult.ProbeHost, effectivePort)) + browserLaunchURL = serverAddr + launcherBrowserLaunchSuffix(needsInitialSetup, localAutoLogin) + + // Auto-open browser will be handled by the launcher runtime. + + // Auto-start gateway after backend starts listening. + go func() { + time.Sleep(1 * time.Second) + apiHandler.TryAutoStartGateway() + }() + + // Start the server(s) in goroutines. + servers = make([]*http.Server, 0, len(listeners)) + for _, ln := range listeners { + srv := &http.Server{Handler: handler} + servers = append(servers, srv) + + go func(s *http.Server, l net.Listener) { + logger.InfoC("web", fmt.Sprintf("Server listening on %s", l.Addr().String())) + if serveErr := s.Serve(l); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + logger.Fatalf("Server failed to start on %s: %v", l.Addr().String(), serveErr) + } + }(srv, ln) + } + + defer shutdownApp() + + // Start system tray or run in console mode + if enableConsole { + if !*noBrowser { + // Auto-open browser after systray is ready (if not disabled) + // Check no-browser flag via environment or pass as parameter if needed + if err := openBrowser(); err != nil { + logger.Errorf("Warning: Failed to auto-open browser: %v", err) + } + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Main event loop - wait for signals or config changes + for { + select { + case <-sigChan: + logger.Info("Shutting down...") + + return + } + } + } else { + // GUI mode: start system tray + runTray() + } +} diff --git a/web/backend/main_test.go b/web/backend/main_test.go new file mode 100644 index 000000000..aea02927e --- /dev/null +++ b/web/backend/main_test.go @@ -0,0 +1,422 @@ +package main + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/netbind" + "github.com/sipeed/picoclaw/web/backend/middleware" +) + +func TestShouldEnableLauncherFileLogging(t *testing.T) { + tests := []struct { + name string + enableConsole bool + debug bool + want bool + }{ + {name: "gui mode", enableConsole: false, debug: false, want: true}, + {name: "console mode", enableConsole: true, debug: false, want: false}, + {name: "debug gui mode", enableConsole: false, debug: true, want: true}, + {name: "debug console mode", enableConsole: true, debug: true, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldEnableLauncherFileLogging(tt.enableConsole, tt.debug); got != tt.want { + t.Fatalf( + "shouldEnableLauncherFileLogging(%t, %t) = %t, want %t", + tt.enableConsole, + tt.debug, + got, + tt.want, + ) + } + }) + } +} + +func TestShouldEnableLocalAutoLogin(t *testing.T) { + tests := []struct { + name string + noBrowser bool + probeHost string + wantEnable bool + }{ + {name: "loopback localhost", probeHost: "localhost", wantEnable: true}, + {name: "loopback ipv4", probeHost: "127.0.0.1", wantEnable: true}, + {name: "loopback ipv6", probeHost: "::1", wantEnable: true}, + {name: "browser disabled", noBrowser: true, probeHost: "localhost", wantEnable: false}, + {name: "non-loopback host", probeHost: "192.168.1.50", wantEnable: false}, + {name: "non-loopback hostname", probeHost: "example.com", wantEnable: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldEnableLocalAutoLogin(tt.noBrowser, tt.probeHost); got != tt.wantEnable { + t.Fatalf( + "shouldEnableLocalAutoLogin(%t, %q) = %t, want %t", + tt.noBrowser, + tt.probeHost, + got, + tt.wantEnable, + ) + } + }) + } +} + +func TestLauncherBrowserLaunchSuffix(t *testing.T) { + autoLogin, err := middleware.NewLauncherDashboardLocalAutoLogin(time.Minute) + if err != nil { + t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err) + } + + if got := launcherBrowserLaunchSuffix(true, autoLogin); got != middleware.LauncherDashboardSetupPath { + t.Fatalf("setup suffix = %q", got) + } + if got := launcherBrowserLaunchSuffix(false, autoLogin); !strings.HasPrefix(got, "/launcher-auto-login?nonce=") { + t.Fatalf("auto-login suffix = %q", got) + } + if got := launcherBrowserLaunchSuffix(false, nil); got != "" { + t.Fatalf("empty suffix = %q, want empty", got) + } +} + +func TestResolveLauncherHostInput(t *testing.T) { + tests := []struct { + name string + flagHost string + explicitFlag bool + envHost string + wantHost string + wantActive bool + wantErr bool + }{ + { + name: "flag host wins", + flagHost: "127.0.0.1", + explicitFlag: true, + envHost: "::", + wantHost: "127.0.0.1", + wantActive: true, + }, + {name: "env host used when flag absent", envHost: "127.0.0.1,::1", wantHost: "127.0.0.1,::1", wantActive: true}, + {name: "blank env ignored", envHost: " ", wantHost: "", wantActive: false}, + {name: "invalid flag rejected", flagHost: "127.0.0.1, ", explicitFlag: true, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotHost, gotActive, err := resolveLauncherHostInput(tt.flagHost, tt.explicitFlag, tt.envHost) + if (err != nil) != tt.wantErr { + t.Fatalf("resolveLauncherHostInput() err = %v, wantErr %t", err, tt.wantErr) + } + if tt.wantErr { + return + } + if gotHost != tt.wantHost { + t.Fatalf("resolveLauncherHostInput() host = %q, want %q", gotHost, tt.wantHost) + } + if gotActive != tt.wantActive { + t.Fatalf("resolveLauncherHostInput() active = %t, want %t", gotActive, tt.wantActive) + } + }) + } +} + +func TestLauncherConsoleHosts(t *testing.T) { + t.Run("default loopback shows localhost only", func(t *testing.T) { + hosts := launcherConsoleHostsWithLocalAddrs( + "", + false, + []string{"192.168.1.2", "10.0.0.8"}, + []string{"2001:db8::1", "2001:db8::2"}, + ) + want := []string{"localhost"} + if strings.Join(hosts, ",") != strings.Join(want, ",") { + t.Fatalf("hosts = %#v, want %#v", hosts, want) + } + }) + + t.Run("explicit loopback hosts collapse to localhost", func(t *testing.T) { + tests := []struct { + name string + hostInput string + }{ + {name: "ipv6 loopback", hostInput: "::1"}, + {name: "ipv4 loopback", hostInput: "127.0.0.1"}, + {name: "localhost", hostInput: "localhost"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hosts := launcherConsoleHostsWithLocalAddrs( + tt.hostInput, + false, + []string{"192.168.1.2", "10.0.0.8"}, + []string{"2001:db8::1", "2001:db8::2"}, + ) + want := []string{"localhost"} + if strings.Join(hosts, ",") != strings.Join(want, ",") { + t.Fatalf("hosts = %#v, want %#v", hosts, want) + } + }) + } + }) + + t.Run("public wildcard shows localhost then ipv6 and ipv4", func(t *testing.T) { + hosts := launcherConsoleHostsWithLocalAddrs( + "", + true, + []string{"192.168.1.2", "10.0.0.8"}, + []string{"2001:db8::1", "2001:db8::2"}, + ) + want := []string{"localhost", "2001:db8::1", "2001:db8::2", "192.168.1.2", "10.0.0.8"} + if strings.Join(hosts, ",") != strings.Join(want, ",") { + t.Fatalf("hosts = %#v, want %#v", hosts, want) + } + }) + + t.Run("explicit ipv6 any shows localhost then ipv6 variants", func(t *testing.T) { + hosts := launcherConsoleHostsWithLocalAddrs( + "::", + false, + []string{"192.168.1.2", "10.0.0.8"}, + []string{"2001:db8::1", "2001:db8::2"}, + ) + want := []string{"localhost", "2001:db8::1", "2001:db8::2"} + if strings.Join(hosts, ",") != strings.Join(want, ",") { + t.Fatalf("hosts = %#v, want %#v", hosts, want) + } + + for _, host := range hosts { + if host == "::1" || host == "127.0.0.1" || strings.HasPrefix(strings.ToLower(host), "fe80:") { + t.Fatalf("hosts = %#v, loopback IPs must not be displayed", hosts) + } + } + }) + + t.Run("explicit ipv4 any shows localhost then lan ipv4", func(t *testing.T) { + hosts := launcherConsoleHostsWithLocalAddrs( + "0.0.0.0", + false, + []string{"192.168.1.2", "10.0.0.8"}, + []string{"2001:db8::1", "2001:db8::2"}, + ) + want := []string{"localhost", "192.168.1.2", "10.0.0.8"} + if strings.Join(hosts, ",") != strings.Join(want, ",") { + t.Fatalf("hosts = %#v, want %#v", hosts, want) + } + }) + + t.Run("explicit wildcard star shows localhost first", func(t *testing.T) { + hosts := launcherConsoleHostsWithLocalAddrs( + "*", + false, + []string{"192.168.1.2", "10.0.0.8"}, + []string{"2001:db8::1", "2001:db8::2"}, + ) + want := []string{"localhost", "2001:db8::1", "2001:db8::2", "192.168.1.2", "10.0.0.8"} + if strings.Join(hosts, ",") != strings.Join(want, ",") { + t.Fatalf("hosts = %#v, want %#v", hosts, want) + } + }) + + t.Run("explicit multi-address binding without local tokens hides localhost", func(t *testing.T) { + hosts := launcherConsoleHostsWithLocalAddrs( + "192.168.1.2,10.0.0.8,2001:db8::1,2001:db8::2,fe80::1", + false, + []string{"192.168.1.2", "10.0.0.8"}, + []string{"2001:db8::1", "2001:db8::2"}, + ) + want := []string{"192.168.1.2", "10.0.0.8", "2001:db8::1", "2001:db8::2"} + if strings.Join(hosts, ",") != strings.Join(want, ",") { + t.Fatalf("hosts = %#v, want %#v", hosts, want) + } + }) +} + +func TestWildcardAdvertiseIP(t *testing.T) { + tests := []struct { + name string + bindHosts []string + ipv4 string + ipv6 string + want string + }{ + { + name: "ipv4 wildcard uses ipv4", + bindHosts: []string{"0.0.0.0"}, + ipv4: "192.168.1.2", + ipv6: "2001:db8::1", + want: "192.168.1.2", + }, + { + name: "dual wildcard prefers ipv6", + bindHosts: []string{"0.0.0.0", "::"}, + ipv4: "192.168.1.2", + ipv6: "2001:db8::1", + want: "2001:db8::1", + }, + { + name: "ipv6 wildcard uses ipv6", + bindHosts: []string{"::"}, + ipv4: "192.168.1.2", + ipv6: "2001:db8::1", + want: "2001:db8::1", + }, + { + name: "dual wildcard falls back to ipv4 when ipv6 missing", + bindHosts: []string{"0.0.0.0", "::"}, + ipv4: "192.168.1.2", + ipv6: "", + want: "192.168.1.2", + }, + { + name: "ipv6 wildcard without ipv6 does not advertise ipv4", + bindHosts: []string{"::"}, + ipv4: "192.168.1.2", + ipv6: "", + want: "", + }, + { + name: "non wildcard does not advertise", + bindHosts: []string{"127.0.0.1"}, + ipv4: "192.168.1.2", + ipv6: "2001:db8::1", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := wildcardAdvertiseIP(tt.bindHosts, tt.ipv4, tt.ipv6); got != tt.want { + t.Fatalf("wildcardAdvertiseIP(%#v, %q, %q) = %q, want %q", tt.bindHosts, tt.ipv4, tt.ipv6, got, tt.want) + } + }) + } +} + +func TestOpenLauncherListeners_HonorsIPv6OnlyHost(t *testing.T) { + hasIPv4, hasIPv6 := netbind.DetectIPFamilies() + if !hasIPv6 { + t.Skip("IPv6 is unavailable in this environment") + } + + result, err := openLauncherListeners("::", false, "0") + if err != nil { + t.Fatalf("openLauncherListeners() error = %v", err) + } + startLauncherTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + requireLauncherHTTPReachable(t, "::1", port) + if hasIPv4 { + requireLauncherHTTPUnreachable(t, "127.0.0.1", port) + } +} + +func TestOpenLauncherListeners_SupportsExplicitMultiHost(t *testing.T) { + hasIPv4, hasIPv6 := netbind.DetectIPFamilies() + if !hasIPv4 || !hasIPv6 { + t.Skip("dual-stack loopback is unavailable in this environment") + } + + result, err := openLauncherListeners("127.0.0.1,::1", false, "0") + if err != nil { + t.Fatalf("openLauncherListeners() error = %v", err) + } + startLauncherTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + requireLauncherHTTPReachable(t, "127.0.0.1", port) + requireLauncherHTTPReachable(t, "::1", port) +} + +func startLauncherTestHTTPServer(t *testing.T, listeners []net.Listener) { + t.Helper() + + server := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + }), + } + + errCh := make(chan error, len(listeners)) + for _, listener := range listeners { + ln := listener + go func() { + errCh <- server.Serve(ln) + }() + } + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + for range listeners { + err := <-errCh + if err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("server.Serve() error = %v", err) + } + } + }) +} + +func requireLauncherHTTPReachable(t *testing.T, host string, port int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + err := launcherHTTPGet(host, port) + if err == nil { + return + } + if time.Now().After(deadline) { + t.Fatalf("expected %s:%d to be reachable: %v", host, port, err) + } + time.Sleep(50 * time.Millisecond) + } +} + +func requireLauncherHTTPUnreachable(t *testing.T, host string, port int) { + t.Helper() + if err := launcherHTTPGet(host, port); err == nil { + t.Fatalf("expected %s:%d to be unreachable", host, port) + } +} + +func launcherHTTPGet(host string, port int) error { + client := &http.Client{ + Timeout: 300 * time.Millisecond, + Transport: &http.Transport{ + Proxy: nil, + }, + } + + resp, err := client.Get("http://" + net.JoinHostPort(host, strconv.Itoa(port))) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New(resp.Status) + } + return nil +} + +func mustAtoi(t *testing.T, value string) int { + t.Helper() + n, err := strconv.Atoi(value) + if err != nil { + t.Fatalf("Atoi(%q) error = %v", value, err) + } + return n +} diff --git a/web/backend/middleware/access_control.go b/web/backend/middleware/access_control.go new file mode 100644 index 000000000..159d60c3e --- /dev/null +++ b/web/backend/middleware/access_control.go @@ -0,0 +1,64 @@ +package middleware + +import ( + "fmt" + "net" + "net/http" + "strings" +) + +// IPAllowlist restricts access to requests from configured CIDR ranges. +// Loopback addresses are always allowed for local administration. +// Empty CIDR list means no restriction. +func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) { + if len(allowedCIDRs) == 0 { + return next, nil + } + + nets := make([]*net.IPNet, 0, len(allowedCIDRs)) + for _, cidr := range allowedCIDRs { + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err) + } + nets = append(nets, ipNet) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := clientIPFromRemoteAddr(r.RemoteAddr) + if ip == nil { + rejectByPolicy(w, r) + return + } + if ip.IsLoopback() { + next.ServeHTTP(w, r) + return + } + for _, ipNet := range nets { + if ipNet.Contains(ip) { + next.ServeHTTP(w, r) + return + } + } + + rejectByPolicy(w, r) + }), nil +} + +func clientIPFromRemoteAddr(remoteAddr string) net.IP { + host := remoteAddr + if h, _, err := net.SplitHostPort(remoteAddr); err == nil { + host = h + } + return net.ParseIP(host) +} + +func rejectByPolicy(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"access denied by network policy"}`)) + return + } + http.Error(w, "Forbidden", http.StatusForbidden) +} diff --git a/web/backend/middleware/access_control_test.go b/web/backend/middleware/access_control_test.go new file mode 100644 index 000000000..259fd4a4c --- /dev/null +++ b/web/backend/middleware/access_control_test.go @@ -0,0 +1,86 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestIPAllowlist_EmptyCIDRsAllowsAll(t *testing.T) { + h, err := IPAllowlist(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + if err != nil { + t.Fatalf("IPAllowlist() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "203.0.113.5:1234" + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestIPAllowlist_RejectsOutsideCIDR(t *testing.T) { + h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + if err != nil { + t.Fatalf("IPAllowlist() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/config", nil) + req.RemoteAddr = "10.0.0.8:1234" + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } +} + +func TestIPAllowlist_AllowsInsideCIDR(t *testing.T) { + h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + if err != nil { + t.Fatalf("IPAllowlist() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.168.1.88:1234" + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestIPAllowlist_AlwaysAllowsLoopback(t *testing.T) { + h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + if err != nil { + t.Fatalf("IPAllowlist() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "127.0.0.1:1234" + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestIPAllowlist_InvalidCIDR(t *testing.T) { + _, err := IPAllowlist([]string{"bad-cidr"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + if err == nil { + t.Fatal("IPAllowlist() expected error for invalid CIDR") + } +} diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go new file mode 100644 index 000000000..fd59958a9 --- /dev/null +++ b/web/backend/middleware/launcher_dashboard_auth.go @@ -0,0 +1,311 @@ +package middleware + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "net/http" + "net/url" + "path" + "strings" + "sync" + "time" +) + +// LauncherDashboardCookieName is the HttpOnly cookie set after a successful password login. +const LauncherDashboardCookieName = "picoclaw_launcher_auth" + +// launcherDashboardSessionMaxAgeSec is the dashboard session cookie lifetime (31 days). +const launcherDashboardSessionMaxAgeSec = 31 * 24 * 3600 + +const ( + launcherSessionCookieBytes = 32 + launcherGrantNonceBytes = 32 + // LauncherDashboardLocalAutoLoginPath is the one-shot local browser + // bootstrap endpoint used by the launcher-managed auto-open flow. + LauncherDashboardLocalAutoLoginPath = "/launcher-auto-login" + // LauncherDashboardSetupPath is the setup page used before the dashboard + // password is initialized. + LauncherDashboardSetupPath = "/launcher-setup" +) + +// NewLauncherDashboardSessionCookie creates the per-process session cookie value. +func NewLauncherDashboardSessionCookie() (string, error) { + return randomURLToken(launcherSessionCookieBytes) +} + +func randomURLToken(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// LauncherDashboardAuthConfig holds runtime material for dashboard access checks. +type LauncherDashboardAuthConfig struct { + ExpectedCookie string + // LocalAutoLogin enables one-shot startup auto-login. + LocalAutoLogin *LauncherDashboardLocalAutoLogin + // SecureCookie sets the session cookie's Secure flag. If nil, DefaultLauncherDashboardSecureCookie is used. + SecureCookie func(*http.Request) bool +} + +// LauncherDashboardLocalAutoLogin is an in-memory, one-shot startup grant. +// It is not a reusable credential; it only lets the launcher-opened browser +// receive the current process session cookie. +type LauncherDashboardLocalAutoLogin struct { + grant *launcherDashboardOneTimeGrant +} + +type launcherDashboardOneTimeGrant struct { + mu sync.Mutex + expires time.Time + consumed bool + nonce string + now func() time.Time +} + +// NewLauncherDashboardLocalAutoLogin creates a one-shot local auto-login grant. +func NewLauncherDashboardLocalAutoLogin(ttl time.Duration) (*LauncherDashboardLocalAutoLogin, error) { + grant, err := newLauncherDashboardOneTimeGrant(ttl) + if err != nil { + return nil, err + } + return &LauncherDashboardLocalAutoLogin{ + grant: grant, + }, nil +} + +// URLPath returns the one-shot local auto-login URL path including its nonce. +func (a *LauncherDashboardLocalAutoLogin) URLPath() string { + return launcherGrantQueryPath(LauncherDashboardLocalAutoLoginPath, a.grant) +} + +// DefaultLauncherDashboardSecureCookie mirrors typical production HTTPS detection (TLS or X-Forwarded-Proto). +func DefaultLauncherDashboardSecureCookie(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard password login. +func SetLauncherDashboardSessionCookie( + w http.ResponseWriter, + r *http.Request, + sessionValue string, + secure func(*http.Request) bool, +) { + if secure == nil { + secure = DefaultLauncherDashboardSecureCookie + } + http.SetCookie(w, &http.Cookie{ + Name: LauncherDashboardCookieName, + Value: sessionValue, + Path: "/", + MaxAge: launcherDashboardSessionMaxAgeSec, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: secure(r), + }) +} + +// ClearLauncherDashboardSessionCookie clears the dashboard session (e.g. logout). +func ClearLauncherDashboardSessionCookie(w http.ResponseWriter, r *http.Request, secure func(*http.Request) bool) { + if secure == nil { + secure = DefaultLauncherDashboardSecureCookie + } + http.SetCookie(w, &http.Cookie{ + Name: LauncherDashboardCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: secure(r), + Expires: time.Unix(0, 0), + }) +} + +// LauncherDashboardAuth requires a valid session cookie before calling next. +// Public paths are login/setup pages and /api/auth/* handlers. +func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := canonicalAuthPath(r.URL.Path) + if p == LauncherDashboardLocalAutoLoginPath { + handleLauncherLocalAutoLogin(w, r, cfg) + return + } + if isPublicLauncherDashboardPath(r.Method, p) { + next.ServeHTTP(w, r) + return + } + if validLauncherDashboardAuth(r, cfg) { + next.ServeHTTP(w, r) + return + } + rejectLauncherDashboardAuth(w, r, p) + }) +} + +// canonicalAuthPath matches path cleaning used for routing decisions so +// prefixes like /assets/../ cannot bypass auth (CVE-class traversal). + +func handleLauncherLocalAutoLogin(w http.ResponseWriter, r *http.Request, cfg LauncherDashboardAuthConfig) { + if validLauncherDashboardAuth(r, cfg) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte("method not allowed")) + return + } + if r.Method == http.MethodHead { + rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath) + return + } + if cfg.LocalAutoLogin != nil && cfg.LocalAutoLogin.consume(r.URL.Query().Get("nonce")) { + SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie) + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath) +} + +func (a *LauncherDashboardLocalAutoLogin) consume(nonce string) bool { + if a == nil || a.grant == nil { + return false + } + return a.grant.use(nonce, nil) == nil +} + +func newLauncherDashboardOneTimeGrant(ttl time.Duration) (*launcherDashboardOneTimeGrant, error) { + nonce, err := randomURLToken(launcherGrantNonceBytes) + if err != nil { + return nil, err + } + return &launcherDashboardOneTimeGrant{ + expires: time.Now().Add(ttl), + nonce: nonce, + now: time.Now, + }, nil +} + +func launcherGrantQueryPath(basePath string, grant *launcherDashboardOneTimeGrant) string { + if grant == nil { + return basePath + } + return basePath + "?nonce=" + url.QueryEscape(grant.nonce) +} + +// ErrInvalidLauncherDashboardGrant reports that an auto-login grant is missing, +// expired, already consumed, or otherwise invalid. +var ErrInvalidLauncherDashboardGrant = errors.New("invalid launcher dashboard grant") + +func (g *launcherDashboardOneTimeGrant) use(nonce string, fn func() error) error { + if g == nil { + return ErrInvalidLauncherDashboardGrant + } + if len(nonce) != len(g.nonce) || + subtle.ConstantTimeCompare([]byte(nonce), []byte(g.nonce)) != 1 { + return ErrInvalidLauncherDashboardGrant + } + + g.mu.Lock() + defer g.mu.Unlock() + + now := time.Now + if g.now != nil { + now = g.now + } + if g.consumed || !now().Before(g.expires) { + return ErrInvalidLauncherDashboardGrant + } + if fn != nil { + if err := fn(); err != nil { + return err + } + } + g.consumed = true + return nil +} + +func canonicalAuthPath(raw string) string { + if raw == "" { + return "/" + } + c := path.Clean(raw) + switch c { + case ".", "": + return "/" + default: + if c[0] != '/' { + return "/" + c + } + return c + } +} + +func isPublicLauncherDashboardPath(method, p string) bool { + if isPublicLauncherDashboardStatic(method, p) { + return true + } + switch p { + case "/api/auth/login": + return method == http.MethodPost + case "/api/auth/logout": + return method == http.MethodPost + case "/api/auth/status": + return method == http.MethodGet + case "/api/auth/setup": + return method == http.MethodPost + } + return false +} + +// isPublicLauncherDashboardStatic allows the SPA login route and embedded +// frontend assets without a session (GET/HEAD only). +func isPublicLauncherDashboardStatic(method, p string) bool { + if method != http.MethodGet && method != http.MethodHead { + return false + } + if p == "/launcher-login" || p == "/launcher-setup" { + return true + } + if strings.HasPrefix(p, "/assets/") { + return true + } + switch p { + case "/favicon.ico", "/favicon.svg", "/favicon-96x96.png", + "/apple-touch-icon.png", "/site.webmanifest", "/robots.txt": + return true + default: + return false + } +} + +func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig) bool { + if c, err := r.Cookie(LauncherDashboardCookieName); err == nil { + if subtle.ConstantTimeCompare([]byte(c.Value), []byte(cfg.ExpectedCookie)) == 1 { + return true + } + } + return false +} + +func rejectLauncherDashboardAuth(w http.ResponseWriter, r *http.Request, canonicalPath string) { + if canonicalPath == "/pico/ws" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if strings.HasPrefix(canonicalPath, "/api/") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + return + } + http.Redirect(w, r, "/launcher-login", http.StatusFound) +} diff --git a/web/backend/middleware/launcher_dashboard_auth_test.go b/web/backend/middleware/launcher_dashboard_auth_test.go new file mode 100644 index 000000000..871b6f607 --- /dev/null +++ b/web/backend/middleware/launcher_dashboard_auth_test.go @@ -0,0 +1,265 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestNewLauncherDashboardSessionCookie(t *testing.T) { + a, err := NewLauncherDashboardSessionCookie() + if err != nil { + t.Fatalf("NewLauncherDashboardSessionCookie() error = %v", err) + } + b, err := NewLauncherDashboardSessionCookie() + if err != nil { + t.Fatalf("NewLauncherDashboardSessionCookie() second error = %v", err) + } + if a == "" || b == "" { + t.Fatalf("session cookie values should be non-empty: %q %q", a, b) + } + if a == b { + t.Fatal("session cookie values should be random") + } +} + +func mustLocalAutoLogin(t *testing.T, ttl time.Duration) *LauncherDashboardLocalAutoLogin { + t.Helper() + autoLogin, err := NewLauncherDashboardLocalAutoLogin(ttl) + if err != nil { + t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err) + } + return autoLogin +} + +func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + h := LauncherDashboardAuth(cfg, next) + + for _, tc := range []struct { + method, path string + want int + }{ + {http.MethodGet, "/launcher-login", http.StatusTeapot}, + {http.MethodGet, "/launcher-setup", http.StatusTeapot}, + {http.MethodGet, "/assets/index.js", http.StatusTeapot}, + {http.MethodPost, "/api/auth/login", http.StatusTeapot}, + {http.MethodGet, "/api/auth/status", http.StatusTeapot}, + {http.MethodPost, "/api/auth/setup", http.StatusTeapot}, + {http.MethodPost, "/api/auth/logout", http.StatusTeapot}, + {http.MethodGet, "/api/auth/logout", http.StatusUnauthorized}, + {http.MethodGet, "/api/config", http.StatusUnauthorized}, + {http.MethodGet, "/pico/ws", http.StatusUnauthorized}, + } { + rec := httptest.NewRecorder() + req := httptest.NewRequest(tc.method, tc.path, nil) + h.ServeHTTP(rec, req) + if rec.Code != tc.want { + t.Fatalf("%s %s: status = %d, want %d", tc.method, tc.path, rec.Code, tc.want) + } + } +} + +func TestLauncherDashboardAuth_QueryTokenDoesNotAuthenticate(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("next handler should not run without session cookie") + }) + h := LauncherDashboardAuth(cfg, next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/?token=secret", nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" { + t.Fatalf("GET /?token=secret: code=%d loc=%q", rec.Code, rec.Header().Get("Location")) + } +} + +func TestLauncherDashboardAuth_LocalAutoLogin(t *testing.T) { + const cookieVal = "session-cookie-value" + autoLogin := mustLocalAutoLogin(t, time.Minute) + cfg := LauncherDashboardAuthConfig{ + ExpectedCookie: cookieVal, + LocalAutoLogin: autoLogin, + } + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := LauncherDashboardAuth(cfg, next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath, nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" || + len(rec.Result().Cookies()) != 0 { + t.Fatalf( + "auto-login without nonce code=%d loc=%q cookies=%#v", + rec.Code, + rec.Header().Get("Location"), + rec.Result().Cookies(), + ) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath+"?nonce=wrong", nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" || + len(rec.Result().Cookies()) != 0 { + t.Fatalf( + "auto-login with wrong nonce code=%d loc=%q cookies=%#v", + rec.Code, + rec.Header().Get("Location"), + rec.Result().Cookies(), + ) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodHead, autoLogin.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" || + len(rec.Result().Cookies()) != 0 { + t.Fatalf( + "auto-login HEAD code=%d loc=%q cookies=%#v", + rec.Code, + rec.Header().Get("Location"), + rec.Result().Cookies(), + ) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { + t.Fatalf("local auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location")) + } + cookies := rec.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != LauncherDashboardCookieName || cookies[0].Value != cookieVal { + t.Fatalf("cookies = %#v", cookies) + } + if cookies[0].MaxAge != 31*24*3600 { + t.Fatalf("session cookie MaxAge = %d, want 31 days", cookies[0].MaxAge) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal}) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("cookie auth after auto-login status = %d", rec.Code) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal}) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { + t.Fatalf("auto-login path with existing session code=%d loc=%q", rec.Code, rec.Header().Get("Location")) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" { + t.Fatalf("consumed auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location")) + } +} + +func TestLauncherDashboardAuth_LocalAutoLoginRequiresValidNonceAndUnexpired(t *testing.T) { + const cookieVal = "session-cookie-value" + newHandler := func(autoLogin *LauncherDashboardLocalAutoLogin) http.Handler { + return LauncherDashboardAuth(LauncherDashboardAuthConfig{ + ExpectedCookie: cookieVal, + LocalAutoLogin: autoLogin, + }, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + } + + autoLogin := mustLocalAutoLogin(t, time.Minute) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + req.RemoteAddr = "192.168.1.50:12345" + req.Host = "192.168.1.50:18800" + newHandler(autoLogin).ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || len(rec.Result().Cookies()) != 1 { + t.Fatalf("capability auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies()) + } + + expired := mustLocalAutoLogin(t, -time.Second) + h := newHandler(expired) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, expired.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || len(rec.Result().Cookies()) != 0 { + t.Fatalf("expired auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies()) + } +} + +func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("next handler should not run without auth") + }) + h := LauncherDashboardAuth(cfg, next) + + for _, p := range []string{ + "/assets/../api/config", + "/launcher-login/../api/config", + "/./api/config", + } { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, p, nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("%q: status = %d, want %d", p, rec.Code, http.StatusUnauthorized) + } + } +} + +func TestLauncherDashboardAuth_CookieOnly(t *testing.T) { + cookieVal := "session-cookie-value" + cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal} + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := LauncherDashboardAuth(cfg, next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal}) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("cookie auth: status = %d", rec.Code) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/config", nil) + req2.Header.Set("Authorization", "Bearer dashboard-secret-9") + h.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("bearer auth should not be accepted: status = %d", rec2.Code) + } +} + +func TestLauncherDashboardAuth_WebSocketUnauthorizedDoesNotRedirect(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("next handler should not run without auth") + }) + h := LauncherDashboardAuth(cfg, next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } + if got := rec.Header().Get("Location"); got != "" { + t.Fatalf("Location = %q, want empty", got) + } +} diff --git a/web/backend/middleware/middleware.go b/web/backend/middleware/middleware.go new file mode 100644 index 000000000..f9eb3149d --- /dev/null +++ b/web/backend/middleware/middleware.go @@ -0,0 +1,81 @@ +package middleware + +import ( + "bufio" + "fmt" + "net" + "net/http" + "runtime/debug" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// JSONContentType sets the Content-Type header to application/json for +// API requests handled by the wrapped handler. +func JSONContentType(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Path) >= 5 && r.URL.Path[:5] == "/api/" { + w.Header().Set("Content-Type", "application/json") + } + next.ServeHTTP(w, r) + }) +} + +// responseRecorder wraps http.ResponseWriter to capture the status code. +type responseRecorder struct { + http.ResponseWriter + statusCode int +} + +func (rr *responseRecorder) WriteHeader(code int) { + rr.statusCode = code + rr.ResponseWriter.WriteHeader(code) +} + +// Flush delegates to the underlying ResponseWriter if it implements http.Flusher. +func (rr *responseRecorder) Flush() { + if f, ok := rr.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Unwrap returns the underlying ResponseWriter so that http.ResponseController +// and interface checks (like http.Flusher) can see through the wrapper. +func (rr *responseRecorder) Unwrap() http.ResponseWriter { + return rr.ResponseWriter +} + +// Hijack implements http.Hijacker so that WebSocket upgrades work through +// the middleware layer. +func (rr *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hj, ok := rr.ResponseWriter.(http.Hijacker); ok { + return hj.Hijack() + } + return nil, nil, http.ErrNotSupported +} + +// Logger logs each HTTP request with method, path, status code, and duration. +func Logger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK} + next.ServeHTTP(rec, r) + logger.DebugC("http", fmt.Sprintf("%s %s %d %s", r.Method, r.URL.Path, rec.statusCode, time.Since(start))) + }) +} + +// Recoverer recovers from panics in downstream handlers and returns a 500 +// Internal Server Error response. +func Recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + logger.RecoverPanicNoExit(err) + logger.ErrorC("http", fmt.Sprintf("panic recovered: %v\n%s", err, debug.Stack())) + http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} diff --git a/web/backend/middleware/referrer_policy.go b/web/backend/middleware/referrer_policy.go new file mode 100644 index 000000000..6cb14669d --- /dev/null +++ b/web/backend/middleware/referrer_policy.go @@ -0,0 +1,12 @@ +package middleware + +import "net/http" + +// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response +// so sensitive paths and query parameters are not leaked via the Referer header. +func ReferrerPolicyNoReferrer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Referrer-Policy", "no-referrer") + next.ServeHTTP(w, r) + }) +} diff --git a/web/backend/model/status.go b/web/backend/model/status.go new file mode 100644 index 000000000..325981502 --- /dev/null +++ b/web/backend/model/status.go @@ -0,0 +1,8 @@ +package model + +// StatusResponse represents the response payload for the GET /api/status endpoint. +type StatusResponse struct { + Status string `json:"status"` + Version string `json:"version"` + Uptime string `json:"uptime"` +} diff --git a/web/backend/systray.go b/web/backend/systray.go new file mode 100644 index 000000000..41fea1fbe --- /dev/null +++ b/web/backend/systray.go @@ -0,0 +1,94 @@ +//go:build !android && ((!darwin && !freebsd) || cgo) + +package main + +import ( + "fmt" + + "fyne.io/systray" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/web/backend/utils" +) + +func runTray() { + systray.Run(onReady, onExit) +} + +// onReady is called when the system tray is ready +func onReady() { + // Set icon and tooltip + systray.SetIcon(getIcon()) + systray.SetTooltip(fmt.Sprintf(T(AppTooltip), appName)) + + // Create menu items + mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip)) + mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip)) + + // Add version info under About menu + mVersion := mAbout.AddSubMenuItem(fmt.Sprintf(T(MenuVersion), appVersion), T(MenuVersionTooltip)) + mVersion.Disable() + mRepo := mAbout.AddSubMenuItem(T(MenuGitHub), "") + mDocs := mAbout.AddSubMenuItem(T(MenuDocs), "") + + systray.AddSeparator() + + // Add restart option + mRestart := systray.AddMenuItem(T(MenuRestart), T(MenuRestartTooltip)) + + systray.AddSeparator() + + // Quit option + mQuit := systray.AddMenuItem(T(MenuQuit), T(MenuQuitTooltip)) + + // Handle menu clicks + go func() { + for { + select { + case <-mOpen.ClickedCh: + if err := openBrowser(); err != nil { + logger.Errorf("Failed to open browser: %v", err) + } + + case <-mVersion.ClickedCh: + // Version info - do nothing, just shows current version + + case <-mRepo.ClickedCh: + if err := utils.OpenBrowser("https://github.com/sipeed/picoclaw"); err != nil { + logger.Errorf("Failed to open GitHub: %v", err) + } + + case <-mDocs.ClickedCh: + if err := utils.OpenBrowser(T(DocUrl)); err != nil { + logger.Errorf("Failed to open docs: %v", err) + } + + case <-mRestart.ClickedCh: + fmt.Println("Restart request received...") + if apiHandler != nil { + if pid, err := apiHandler.RestartGateway(); err != nil { + logger.Errorf("Failed to restart gateway: %v", err) + } else { + logger.Infof("Gateway restarted (PID: %d)", pid) + } + } + + case <-mQuit.ClickedCh: + systray.Quit() + } + } + }() + + if !*noBrowser { + // Auto-open browser after systray is ready (if not disabled) + // Check no-browser flag via environment or pass as parameter if needed + if err := openBrowser(); err != nil { + logger.Errorf("Warning: Failed to auto-open browser: %v", err) + } + } +} + +// onExit is called when the system tray is exiting +func onExit() { + logger.Info(T(Exiting)) +} diff --git a/web/backend/systray_icon_nonwindows.go b/web/backend/systray_icon_nonwindows.go new file mode 100644 index 000000000..0117a9ae8 --- /dev/null +++ b/web/backend/systray_icon_nonwindows.go @@ -0,0 +1,12 @@ +//go:build !windows && ((!darwin && !freebsd) || cgo) + +package main + +import _ "embed" + +//go:embed icon.png +var iconPNG []byte + +func getIcon() []byte { + return iconPNG +} diff --git a/web/backend/systray_icon_windows.go b/web/backend/systray_icon_windows.go new file mode 100644 index 000000000..c265e2f9c --- /dev/null +++ b/web/backend/systray_icon_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package main + +import _ "embed" + +//go:embed icon.ico +var iconICO []byte + +func getIcon() []byte { + return iconICO +} diff --git a/web/backend/systray_stub_nocgo.go b/web/backend/systray_stub_nocgo.go new file mode 100644 index 000000000..41514feef --- /dev/null +++ b/web/backend/systray_stub_nocgo.go @@ -0,0 +1,34 @@ +//go:build (darwin || freebsd || android) && !cgo + +package main + +import ( + "context" + "os" + "os/signal" + "runtime" + "syscall" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// runTray falls back to a headless mode on platforms where systray requires cgo. +func runTray() { + logger.Infof("System tray is unavailable in %s builds without cgo; running without tray", runtime.GOOS) + + if !*noBrowser { + go func() { + time.Sleep(browserDelay) + if err := openBrowser(); err != nil { + logger.Errorf("Warning: Failed to auto-open browser: %v", err) + } + }() + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + <-ctx.Done() + shutdownApp() +} diff --git a/web/backend/utils/banner.go b/web/backend/utils/banner.go new file mode 100644 index 000000000..a64ea6390 --- /dev/null +++ b/web/backend/utils/banner.go @@ -0,0 +1,15 @@ +package utils + +const ( + colorBlue = "\x1b[38;2;62;93;185m" + colorRed = "\x1b[38;2;213;70;70m" + colorReset = "\x1b[0m" + Banner = "\r\n" + + colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" + + colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" + + colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" + + colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" + + colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + + colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n" + + colorReset +) diff --git a/web/backend/utils/onboard.go b/web/backend/utils/onboard.go new file mode 100644 index 000000000..81475ac80 --- /dev/null +++ b/web/backend/utils/onboard.go @@ -0,0 +1,44 @@ +package utils + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" +) + +var execCommand = exec.Command + +func EnsureOnboarded(configPath string) error { + _, err := os.Stat(configPath) + if err == nil { + return nil + } + if !os.IsNotExist(err) { + return fmt.Errorf("stat config: %w", err) + } + + cmd := execCommand(FindPicoclawBinary(), "onboard") + cmd.Env = append(os.Environ(), config.EnvConfig+"="+configPath) + cmd.Stdin = strings.NewReader("n\n") + + output, err := cmd.CombinedOutput() + if err != nil { + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return fmt.Errorf("run onboard: %w", err) + } + return fmt.Errorf("run onboard: %w: %s", err, trimmed) + } + + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("onboard completed but did not create config %s", configPath) + } + return fmt.Errorf("verify config after onboard: %w", err) + } + + return nil +} diff --git a/web/backend/utils/onboard_test.go b/web/backend/utils/onboard_test.go new file mode 100644 index 000000000..06f967e76 --- /dev/null +++ b/web/backend/utils/onboard_test.go @@ -0,0 +1,101 @@ +package utils + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureOnboardedSkipsWhenConfigExists(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + origExecCommand := execCommand + defer func() { execCommand = origExecCommand }() + + called := false + execCommand = func(name string, args ...string) *exec.Cmd { + called = true + return exec.Command("sh", "-c", "exit 1") + } + + if err := EnsureOnboarded(configPath); err != nil { + t.Fatalf("EnsureOnboarded() error = %v", err) + } + if called { + t.Fatal("expected onboard command not to run when config already exists") + } +} + +func TestEnsureOnboardedRunsOnboardWhenConfigMissing(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv("EXPECTED_CONFIG_PATH", configPath) + + origExecCommand := execCommand + defer func() { execCommand = origExecCommand }() + + var gotName string + var gotArgs []string + execCommand = func(name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = append([]string(nil), args...) + return exec.Command( + "sh", + "-c", + `test "$PICOCLAW_CONFIG" = "$EXPECTED_CONFIG_PATH" && +mkdir -p "$(dirname "$PICOCLAW_CONFIG")" && +printf '{}' > "$PICOCLAW_CONFIG"`, + ) + } + + if err := EnsureOnboarded(configPath); err != nil { + t.Fatalf("EnsureOnboarded() error = %v", err) + } + if gotName == "" { + t.Fatal("expected onboard command to run") + } + if len(gotArgs) != 1 || gotArgs[0] != "onboard" { + t.Fatalf("command args = %#v, want []string{\"onboard\"}", gotArgs) + } + if _, err := os.Stat(configPath); err != nil { + t.Fatalf("expected config to be created: %v", err) + } +} + +func TestEnsureOnboardedFailsWhenOnboardDoesNotCreateConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + + origExecCommand := execCommand + defer func() { execCommand = origExecCommand }() + + execCommand = func(name string, args ...string) *exec.Cmd { + return exec.Command("sh", "-c", "exit 0") + } + + if err := EnsureOnboarded(configPath); err == nil { + t.Fatal("EnsureOnboarded() error = nil, want failure when onboard does not create config") + } +} + +func TestEnsureOnboardedIncludesOnboardOutputOnFailure(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + + origExecCommand := execCommand + defer func() { execCommand = origExecCommand }() + + execCommand = func(name string, args ...string) *exec.Cmd { + return exec.Command("sh", "-c", "echo onboarding failed >&2; exit 2") + } + + err := EnsureOnboarded(configPath) + if err == nil { + t.Fatal("EnsureOnboarded() error = nil, want failure") + } + if !strings.Contains(err.Error(), "onboarding failed") { + t.Fatalf("error = %q, want onboard output included", err) + } +} diff --git a/web/backend/utils/runtime.go b/web/backend/utils/runtime.go new file mode 100644 index 000000000..8899a664b --- /dev/null +++ b/web/backend/utils/runtime.go @@ -0,0 +1,159 @@ +package utils + +import ( + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// GetPicoclawHome returns the picoclaw home directory. +// Priority: $PICOCLAW_HOME > ~/.picoclaw +func GetPicoclawHome() string { + return config.GetHome() +} + +// GetDefaultConfigPath returns the default path to the picoclaw config file. +func GetDefaultConfigPath() string { + if configPath := os.Getenv(config.EnvConfig); configPath != "" { + return configPath + } + return filepath.Join(GetPicoclawHome(), "config.json") +} + +// FindPicoclawBinary locates the picoclaw executable. +// Search order: +// 1. PICOCLAW_BINARY environment variable (explicit override) +// 2. Same directory as the current executable +// 3. Falls back to "picoclaw" and relies on $PATH +func FindPicoclawBinary() string { + binaryName := "picoclaw" + if runtime.GOOS == "windows" { + binaryName = "picoclaw.exe" + } + + if p := os.Getenv(config.EnvBinary); p != "" { + if info, _ := os.Stat(p); info != nil && !info.IsDir() { + return p + } + } + + if exe, err := os.Executable(); err == nil { + logger.Debugf("Trying to find picoclaw binary in %s", exe) + candidate := filepath.Join(filepath.Dir(exe), binaryName) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate + } + } + + return "picoclaw" +} + +func appendUniqueIP(addrs []string, seen map[string]struct{}, value string) []string { + value = strings.TrimSpace(value) + if value == "" { + return addrs + } + if _, ok := seen[value]; ok { + return addrs + } + seen[value] = struct{}{} + return append(addrs, value) +} + +// GetLocalIPv4s returns all non-loopback local IPv4 addresses. +func GetLocalIPv4s() []string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return nil + } + results := make([]string, 0, 4) + seen := make(map[string]struct{}, 4) + for _, a := range addrs { + ipnet, ok := a.(*net.IPNet) + if !ok || ipnet.IP == nil || ipnet.IP.IsLoopback() { + continue + } + if ip4 := ipnet.IP.To4(); ip4 != nil { + results = appendUniqueIP(results, seen, ip4.String()) + } + } + return results +} + +func isDisplayGlobalIPv6(ip net.IP) bool { + if ip == nil || ip.IsLoopback() || ip.To4() != nil { + return false + } + ip = ip.To16() + if ip == nil { + return false + } + // Only show IPv6 global unicast addresses in 2000::/3. + return ip[0]&0xe0 == 0x20 +} + +// GetGlobalIPv6s returns all IPv6 global unicast addresses. +func GetGlobalIPv6s() []string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return nil + } + results := make([]string, 0, 4) + seen := make(map[string]struct{}, 4) + for _, a := range addrs { + ipnet, ok := a.(*net.IPNet) + if !ok || ipnet.IP == nil { + continue + } + ip := ipnet.IP + if !isDisplayGlobalIPv6(ip) { + continue + } + results = appendUniqueIP(results, seen, ip.String()) + } + return results +} + +// GetLocalIPv4 returns the first non-loopback local IPv4 address. +func GetLocalIPv4() string { + addrs := GetLocalIPv4s() + if len(addrs) == 0 { + return "" + } + return addrs[0] +} + +// GetLocalIPv6 returns the first IPv6 global unicast address. +func GetLocalIPv6() string { + addrs := GetGlobalIPv6s() + if len(addrs) == 0 { + return "" + } + return addrs[0] +} + +// GetLocalIP returns a non-loopback local IPv4 address for backward compatibility. +func GetLocalIP() string { + return GetLocalIPv4() +} + +// OpenBrowser automatically opens the given URL in the default browser. +func OpenBrowser(url string) error { + switch runtime.GOOS { + case "linux": + return exec.Command("xdg-open", url).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + return exec.Command("open", url).Start() + default: + return fmt.Errorf("unsupported platform") + } +} diff --git a/cmd/picoclaw-launcher/winres/winres.json b/web/backend/winres/winres.json similarity index 100% rename from cmd/picoclaw-launcher/winres/winres.json rename to web/backend/winres/winres.json diff --git a/web/frontend/.editorconfig b/web/frontend/.editorconfig new file mode 100644 index 000000000..a8c0f1ecf --- /dev/null +++ b/web/frontend/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf \ No newline at end of file diff --git a/web/frontend/.gitignore b/web/frontend/.gitignore new file mode 100644 index 000000000..72e68ffba --- /dev/null +++ b/web/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.tanstack diff --git a/web/frontend/.prettierignore b/web/frontend/.prettierignore new file mode 100644 index 000000000..7040bf59e --- /dev/null +++ b/web/frontend/.prettierignore @@ -0,0 +1,5 @@ +package-lock.json +pnpm-lock.yaml +yarn.lock +routeTree.gen.ts +src/components/ui \ No newline at end of file diff --git a/web/frontend/components.json b/web/frontend/components.json new file mode 100644 index 000000000..9d5329694 --- /dev/null +++ b/web/frontend/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-vega", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "tabler", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/web/frontend/eslint.config.js b/web/frontend/eslint.config.js new file mode 100644 index 000000000..884649e41 --- /dev/null +++ b/web/frontend/eslint.config.js @@ -0,0 +1,40 @@ +import js from "@eslint/js" +import eslintConfigPrettier from "eslint-config-prettier" +import reactHooks from "eslint-plugin-react-hooks" +import reactRefresh from "eslint-plugin-react-refresh" +import { defineConfig, globalIgnores } from "eslint/config" +import globals from "globals" +import tseslint from "typescript-eslint" + +export default defineConfig([ + globalIgnores(["dist", "src/components/ui", "src/routeTree.gen.ts"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + eslintConfigPrettier, + ], + languageOptions: { + ecmaVersion: "latest", + globals: globals.browser, + }, + rules: { + "react-hooks/set-state-in-effect": "off", + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, + { + files: ["src/routes/**/*.{ts,tsx}"], + rules: { + // TanStack Router route modules must export Route objects, so this rule + // produces false positives for framework-managed files. + "react-refresh/only-export-components": "off", + }, + }, +]) diff --git a/web/frontend/index.html b/web/frontend/index.html new file mode 100644 index 000000000..d3bdd90f8 --- /dev/null +++ b/web/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + PicoClaw + + + +
+ + + diff --git a/web/frontend/package.json b/web/frontend/package.json new file mode 100644 index 000000000..bf3e7921b --- /dev/null +++ b/web/frontend/package.json @@ -0,0 +1,71 @@ +{ + "name": "picoclaw-web", + "private": true, + "version": "0.0.0", + "type": "module", + "packageManager": "pnpm@10.33.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "build:backend": "tsc -b && vite build --outDir ../backend/dist --emptyOutDir && node ./scripts/ensure-backend-gitkeep.cjs", + "lint": "eslint .", + "preview": "vite preview", + "format": "prettier --check .", + "check": "prettier --write . && eslint --fix" + }, + "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@tabler/icons-react": "^3.40.0", + "@tailwindcss/vite": "^4.2.4", + "@tanstack/react-query": "^5.99.0", + "@tanstack/react-router": "^1.169.2", + "@tanstack/react-router-devtools": "^1.166.13", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.20", + "highlight.js": "^11.11.1", + "i18next": "^26.0.8", + "i18next-browser-languagedetector": "^8.2.1", + "jotai": "^2.19.1", + "radix-ui": "^1.4.3", + "react": "19.2.5", + "react-dom": "19.2.5", + "react-i18next": "^17.0.4", + "react-markdown": "^10.1.0", + "react-textarea-autosize": "^8.5.9", + "rehype-highlight": "^7.0.2", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "shadcn": "^4.3.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.4", + "tw-animate-css": "^1.4.0", + "wrap-ansi": "^10.0.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/typography": "^0.5.19", + "@tanstack/router-plugin": "^1.164.0", + "@trivago/prettier-plugin-sort-imports": "^6.0.2", + "@types/node": "^25.6.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.58.2", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.2.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "prettier": "^3.8.3", + "prettier-plugin-tailwindcss": "^0.7.2", + "typescript": "~5.9.3", + "typescript-eslint": "^8.59.1", + "vite": "^8.0.10" + } +} diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml new file mode 100644 index 000000000..78639de19 --- /dev/null +++ b/web/frontend/pnpm-lock.yaml @@ -0,0 +1,8194 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fontsource-variable/inter': + specifier: ^5.2.8 + version: 5.2.8 + '@tabler/icons-react': + specifier: ^3.40.0 + version: 3.41.1(react@19.2.5) + '@tailwindcss/vite': + specifier: ^4.2.4 + version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)) + '@tanstack/react-query': + specifier: ^5.99.0 + version: 5.99.0(react@19.2.5) + '@tanstack/react-router': + specifier: ^1.169.2 + version: 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/react-router-devtools': + specifier: ^1.166.13 + version: 1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + dayjs: + specifier: ^1.11.20 + version: 1.11.20 + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 + i18next: + specifier: ^26.0.8 + version: 26.0.8(typescript@5.9.3) + i18next-browser-languagedetector: + specifier: ^8.2.1 + version: 8.2.1 + jotai: + specifier: ^2.19.1 + version: 2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5) + radix-ui: + specifier: ^1.4.3 + version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: + specifier: 19.2.5 + version: 19.2.5 + react-dom: + specifier: 19.2.5 + version: 19.2.5(react@19.2.5) + react-i18next: + specifier: ^17.0.4 + version: 17.0.4(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.14)(react@19.2.5) + react-textarea-autosize: + specifier: ^8.5.9 + version: 8.5.9(@types/react@19.2.14)(react@19.2.5) + rehype-highlight: + specifier: ^7.0.2 + version: 7.0.2 + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + shadcn: + specifier: ^4.3.0 + version: 4.3.0(@types/node@25.6.0)(typescript@5.9.3) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + tailwindcss: + specifier: ^4.2.4 + version: 4.2.4 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + wrap-ansi: + specifier: ^10.0.0 + version: 10.0.0 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.2.1(jiti@2.7.0)) + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@4.2.4) + '@tanstack/router-plugin': + specifier: ^1.164.0 + version: 1.167.9(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)) + '@trivago/prettier-plugin-sort-imports': + specifier: ^6.0.2 + version: 6.0.2(prettier@3.8.3) + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + '@types/react': + specifier: ^19.2.7 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@typescript-eslint/eslint-plugin': + specifier: ^8.58.2 + version: 8.58.2(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)) + eslint: + specifier: ^10.2.1 + version: 10.2.1(jiti@2.7.0) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.2.1(jiti@2.7.0)) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.2.1(jiti@2.7.0)) + eslint-plugin-react-refresh: + specifier: ^0.5.2 + version: 0.5.2(eslint@10.2.1(jiti@2.7.0)) + globals: + specifier: ^17.5.0 + version: 17.5.0 + prettier: + specifier: ^3.8.3 + version: 3.8.3 + prettier-plugin-tailwindcss: + specifier: ^0.7.2 + version: 0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.59.1 + version: 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + vite: + specifier: ^8.0.10 + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@dotenvx/dotenvx@1.61.0': + resolution: {integrity: sha512-utL3cpZoFzflyqUkjYbxYujI6STBTmO5LFn4bbin/NZnRWN6wQ7eErhr3/Vpa5h/jicPFC6kTa42r940mQftJQ==} + hasBin: true + + '@ecies/ciphers@0.2.6': + resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==} + engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'} + peerDependencies: + '@noble/ciphers': ^1.0.0 + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@fontsource-variable/inter@5.2.8': + resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@inquirer/ansi@2.0.5': + resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/confirm@6.0.11': + resolution: {integrity: sha512-pTpHjg0iEIRMYV/7oCZUMf27/383E6Wyhfc/MY+AVQGEoUobffIYWOK9YLP2XFRGz/9i6WlTQh1CkFVIo2Y7XA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.1.8': + resolution: {integrity: sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.5': + resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/type@4.0.5': + resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@mswjs/interceptors@0.41.3': + resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} + engines: {node: '>=18'} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-accessible-icon@1.1.7': + resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.7': + resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.1.8': + resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.15': + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.16': + resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.1.8': + resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.3': + resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.15': + resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.1.11': + resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@tabler/icons-react@3.41.1': + resolution: {integrity: sha512-kUgweE+DJtAlMZVIns1FTDdcbpRVnkK7ZpUOXmoxy3JAF0rSHj0TcP4VHF14+gMJGnF+psH2Zt26BLT6owetBA==} + peerDependencies: + react: '>= 16' + + '@tabler/icons@3.41.1': + resolution: {integrity: sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==} + + '@tailwindcss/node@4.2.4': + resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} + + '@tailwindcss/oxide-android-arm64@4.2.4': + resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.4': + resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.4': + resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.4': + resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.4': + resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} + engines: {node: '>= 20'} + + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + + '@tailwindcss/vite@4.2.4': + resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/history@1.161.6': + resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} + engines: {node: '>=20.19'} + + '@tanstack/query-core@5.99.0': + resolution: {integrity: sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==} + + '@tanstack/react-query@5.99.0': + resolution: {integrity: sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-router-devtools@1.166.13': + resolution: {integrity: sha512-6yKRFFJrEEOiGp5RAAuGCYsl81M4XAhJmLcu9PKj+HZle4A3dsP60lwHoqQYWHMK9nKKFkdXR+D8qxzxqtQbEA==} + engines: {node: '>=20.19'} + peerDependencies: + '@tanstack/react-router': ^1.168.15 + '@tanstack/router-core': ^1.168.11 + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + peerDependenciesMeta: + '@tanstack/router-core': + optional: true + + '@tanstack/react-router@1.169.2': + resolution: {integrity: sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.168.7': + resolution: {integrity: sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ==} + engines: {node: '>=20.19'} + hasBin: true + + '@tanstack/router-core@1.169.2': + resolution: {integrity: sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw==} + engines: {node: '>=20.19'} + + '@tanstack/router-devtools-core@1.167.3': + resolution: {integrity: sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg==} + engines: {node: '>=20.19'} + peerDependencies: + '@tanstack/router-core': ^1.168.11 + csstype: ^3.0.10 + peerDependenciesMeta: + csstype: + optional: true + + '@tanstack/router-generator@1.166.22': + resolution: {integrity: sha512-wQ7H8/Q2rmSPuaxWnurJ3DATNnqWV2tajxri9TSiW4QHsG7cWPD34+goeIinKG+GajJyEdfVpz6w/gRJXfbAPw==} + engines: {node: '>=20.19'} + + '@tanstack/router-plugin@1.167.9': + resolution: {integrity: sha512-h/VV05FEHd4PVyc5Zy8B3trWLcdLt/Pmp+mfifmBKGRw+MUtvdQKbBHhmy4ouOf67s5zDJMc+n8R3xgU7bDwFA==} + engines: {node: '>=20.19'} + hasBin: true + peerDependencies: + '@rsbuild/core': '>=1.0.2' + '@tanstack/react-router': ^1.168.8 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' + vite-plugin-solid: ^2.11.10 + webpack: '>=5.92.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-utils@1.161.6': + resolution: {integrity: sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@tanstack/virtual-file-routes@1.161.7': + resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==} + engines: {node: '>=20.19'} + hasBin: true + + '@trivago/prettier-plugin-sort-imports@6.0.2': + resolution: {integrity: sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA==} + engines: {node: '>= 20'} + peerDependencies: + '@vue/compiler-sfc': 3.x + prettier: 2.x - 3.x + prettier-plugin-ember-template-tag: '>= 2.0.0' + prettier-plugin-svelte: 3.x + svelte: 4.x || 5.x + peerDependenciesMeta: + '@vue/compiler-sfc': + optional: true + prettier-plugin-ember-template-tag: + optional: true + prettier-plugin-svelte: + optional: true + svelte: + optional: true + + '@ts-morph/common@0.27.0': + resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/validate-npm-package-name@4.0.2': + resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + + '@typescript-eslint/eslint-plugin@8.58.2': + resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.58.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/eslint-plugin@8.59.1': + resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.1': + resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.58.2': + resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.1': + resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.58.2': + resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.59.1': + resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.58.2': + resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/tsconfig-utils@8.59.1': + resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.58.2': + resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.1': + resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.58.2': + resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.59.1': + resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.58.2': + resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/typescript-estree@8.59.1': + resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.58.2': + resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.1': + resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.58.2': + resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.59.1': + resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher + + '@vitejs/plugin-react@6.0.1': + resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.17: + resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001787: + resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@2.0.0: + resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eciesjs@0.4.18: + resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.334: + resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.21.0: + resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} + engines: {node: '>=10.13.0'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.2: + resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.2.1: + resolution: {integrity: sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + express-rate-limit@8.3.2: + resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + engines: {node: '>=14.14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + fuzzysort@3.1.0: + resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-own-enumerable-keys@1.0.0: + resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} + engines: {node: '>=14.16'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.5.0: + resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==} + engines: {node: '>=18'} + + goober@2.1.18: + resolution: {integrity: sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==} + peerDependencies: + csstype: ^3.0.10 + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphql@16.13.2: + resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + hono@4.12.14: + resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} + engines: {node: '>=16.9.0'} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + i18next-browser-languagedetector@8.2.1: + resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} + + i18next@26.0.8: + resolution: {integrity: sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@3.0.0: + resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regexp@3.1.0: + resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} + engines: {node: '>=12'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isbot@5.1.40: + resolution: {integrity: sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + javascript-natural-sort@0.7.1: + resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + + jotai@2.19.1: + resolution: {integrity: sha512-sqm9lVZiqBHZH8aSRk32DSiZDHY3yUIlulXYn9GQj7/LvoUdYXSMti7ZPJGo+6zjzKFt5a25k/I6iBCi43PJcw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.13.4: + resolution: {integrity: sha512-fPlKBeFe+8rpcyR3umUmmHuNwu6gc6T3STvkgEa9WDX/HEgal9wDeflpCUAIRtmvaLZM2igfI5y1bZ9G5J26KA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-treeify@1.1.33: + resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} + engines: {node: '>= 10'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-tailwindcss@0.7.2: + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + radix-ui@1.4.3: + resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + + react-i18next@17.0.4: + resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==} + peerDependencies: + i18next: '>= 26.0.1' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-textarea-autosize@8.5.9: + resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==} + engines: {node: '>=10'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + engines: {node: '>=0.10.0'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + recast@0.23.11: + resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + engines: {node: '>= 4'} + + rehype-highlight@7.0.2: + resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + rettime@0.11.7: + resolution: {integrity: sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + seroval-plugins@1.5.1: + resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.1: + resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} + engines: {node: '>=10'} + + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + engines: {node: '>=10'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shadcn@4.3.0: + resolution: {integrity: sha512-7vhnBh2LVLyxOd1ZQWwXv7OATCnQcxdqc8FbZdNigZriNOwDsHklQmPpvPt1jcrFK5mzMI+cyuAYv8WzERx2Og==} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + stringify-object@5.0.0: + resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} + engines: {node: '>=14.16'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@4.2.4: + resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tldts-core@7.0.28: + resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} + + tldts@7.0.28: + resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-morph@26.0.0: + resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@5.5.0: + resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} + engines: {node: '>=20'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript-eslint@8.59.1: + resolution: {integrity: sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-composed-ref@1.4.0: + resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-isomorphic-layout-effect@1.2.1: + resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest@1.3.0: + resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} + engines: {node: ^20.17.0 || >=22.9.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@10.0.0: + resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + engines: {node: '>=20'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-spinner@1.1.0: + resolution: {integrity: sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==} + engines: {node: '>=18.19'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@dotenvx/dotenvx@1.61.0': + dependencies: + commander: 11.1.0 + dotenv: 17.4.2 + eciesjs: 0.4.18 + execa: 5.1.1 + fdir: 6.5.0(picomatch@4.0.4) + ignore: 5.3.2 + object-treeify: 1.1.33 + picomatch: 4.0.4 + which: 4.0.0 + yocto-spinner: 1.1.0 + + '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': + dependencies: + '@noble/ciphers': 1.3.0 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.7.0))': + dependencies: + eslint: 10.2.1(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.2.1(jiti@2.7.0))': + optionalDependencies: + eslint: 10.2.1(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + '@floating-ui/utils@0.2.11': {} + + '@fontsource-variable/inter@5.2.8': {} + + '@hono/node-server@1.19.14(hono@4.12.14)': + dependencies: + hono: 4.12.14 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@inquirer/ansi@2.0.5': {} + + '@inquirer/confirm@6.0.11(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.1.8(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/core@11.1.8(@types/node@25.6.0)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@25.6.0) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/figures@2.0.5': {} + + '@inquirer/type@4.0.5(@types/node@25.6.0)': + optionalDependencies: + '@types/node': 25.6.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.14) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.2(express@5.2.1) + hono: 4.12.14 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@mswjs/interceptors@0.41.3': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/deferred-promise@3.0.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@oxc-project/types@0.127.0': {} + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + aria-hidden: 1.2.6 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + aria-hidden: 1.2.6 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + aria-hidden: 1.2.6 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/rect': 1.1.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + aria-hidden: 1.2.6 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/rect@1.1.1': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@rolldown/pluginutils@1.0.0-rc.7': {} + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@tabler/icons-react@3.41.1(react@19.2.5)': + dependencies: + '@tabler/icons': 3.41.1 + react: 19.2.5 + + '@tabler/icons@3.41.1': {} + + '@tailwindcss/node@4.2.4': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.0 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.4 + + '@tailwindcss/oxide-android-arm64@4.2.4': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.4': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.4': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + optional: true + + '@tailwindcss/oxide@4.2.4': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-x64': 4.2.4 + '@tailwindcss/oxide-freebsd-x64': 4.2.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-x64-musl': 4.2.4 + '@tailwindcss/oxide-wasm32-wasi': 4.2.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 + + '@tailwindcss/typography@0.5.19(tailwindcss@4.2.4)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 4.2.4 + + '@tailwindcss/vite@4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))': + dependencies: + '@tailwindcss/node': 4.2.4 + '@tailwindcss/oxide': 4.2.4 + tailwindcss: 4.2.4 + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) + + '@tanstack/history@1.161.6': {} + + '@tanstack/query-core@5.99.0': {} + + '@tanstack/react-query@5.99.0(react@19.2.5)': + dependencies: + '@tanstack/query-core': 5.99.0 + react: 19.2.5 + + '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/react-router': 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/router-devtools-core': 1.167.3(@tanstack/router-core@1.169.2)(csstype@3.2.3) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@tanstack/router-core': 1.169.2 + transitivePeerDependencies: + - csstype + + '@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/history': 1.161.6 + '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/router-core': 1.169.2 + isbot: 5.1.40 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) + + '@tanstack/router-core@1.168.7': + dependencies: + '@tanstack/history': 1.161.6 + cookie-es: 2.0.0 + seroval: 1.5.1 + seroval-plugins: 1.5.1(seroval@1.5.1) + + '@tanstack/router-core@1.169.2': + dependencies: + '@tanstack/history': 1.161.6 + cookie-es: 3.1.1 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + + '@tanstack/router-devtools-core@1.167.3(@tanstack/router-core@1.169.2)(csstype@3.2.3)': + dependencies: + '@tanstack/router-core': 1.169.2 + clsx: 2.1.1 + goober: 2.1.18(csstype@3.2.3) + optionalDependencies: + csstype: 3.2.3 + + '@tanstack/router-generator@1.166.22': + dependencies: + '@tanstack/router-core': 1.168.7 + '@tanstack/router-utils': 1.161.6 + '@tanstack/virtual-file-routes': 1.161.7 + prettier: 3.8.3 + recast: 0.23.11 + source-map: 0.7.6 + tsx: 4.21.0 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.168.7 + '@tanstack/router-generator': 1.166.22 + '@tanstack/router-utils': 1.161.6 + '@tanstack/virtual-file-routes': 1.161.7 + chokidar: 3.6.0 + unplugin: 2.3.11 + zod: 3.25.76 + optionalDependencies: + '@tanstack/react-router': 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) + transitivePeerDependencies: + - supports-color + + '@tanstack/router-utils@1.161.6': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + ansis: 4.2.0 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.4 + pathe: 2.0.3 + tinyglobby: 0.2.16 + transitivePeerDependencies: + - supports-color + + '@tanstack/store@0.9.3': {} + + '@tanstack/virtual-file-routes@1.161.7': {} + + '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3)': + dependencies: + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + javascript-natural-sort: 0.7.1 + lodash-es: 4.17.23 + minimatch: 9.0.9 + parse-imports-exports: 0.2.4 + prettier: 3.8.3 + transitivePeerDependencies: + - supports-color + + '@ts-morph/common@0.27.0': + dependencies: + fast-glob: 3.3.3 + minimatch: 10.2.5 + path-browserify: 1.0.1 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/esrecurse@4.3.1': {} + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + + '@types/estree@1.0.8': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/json-schema@7.0.15': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 25.6.0 + + '@types/statuses@2.0.6': {} + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@types/validate-npm-package-name@4.0.2': {} + + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.2 + eslint: 10.2.1(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/type-utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + eslint: 10.2.1(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + eslint: 10.2.1(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.58.2': + dependencies: + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 + + '@typescript-eslint/scope-manager@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + + '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/tsconfig-utils@8.59.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.2.1(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.2.1(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.58.2': {} + + '@typescript-eslint/types@8.59.1': {} + + '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.58.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + eslint: 10.2.1(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + eslint: 10.2.1(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.58.2': + dependencies: + '@typescript-eslint/types': 8.58.2 + eslint-visitor-keys: 5.0.1 + + '@typescript-eslint/visitor-keys@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.3.0': {} + + '@vitejs/plugin-react@6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + ansis@4.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.17: {} + + binary-extensions@2.3.0: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@2.0.3: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.17 + caniuse-lite: 1.0.30001787 + electron-to-chromium: 1.5.334 + node-releases: 2.0.37 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001787: {} + + ccount@2.0.1: {} + + chalk@5.6.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + code-block-writer@13.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + commander@14.0.3: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-es@2.0.0: {} + + cookie-es@3.1.1: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig@9.0.1(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + data-uri-to-buffer@4.0.1: {} + + dayjs@1.11.20: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + dedent@1.7.2: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@8.0.4: {} + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eciesjs@0.4.18: + dependencies: + '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.334: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.21.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@6.0.1: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.2.1(jiti@2.7.0)): + dependencies: + eslint: 10.2.1(jiti@2.7.0) + + eslint-plugin-react-hooks@7.1.1(eslint@10.2.1(jiti@2.7.0)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + eslint: 10.2.1(jiti@2.7.0) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.7.0)): + dependencies: + eslint: 10.2.1(jiti@2.7.0) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.2.1(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.5.5 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-util-is-identifier-name@3.0.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + + express-rate-limit@8.3.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.0: {} + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@11.3.4: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + fuzzysort@3.1.0: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-own-enumerable-keys@1.0.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.5.0: {} + + goober@2.1.18(csstype@3.2.3): + dependencies: + csstype: 3.2.3 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphql@16.13.2: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.0 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + highlight.js@11.11.1: {} + + hono@4.12.14: {} + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + html-url-attributes@3.0.1: {} + + html-void-elements@3.0.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + human-signals@8.0.1: {} + + i18next-browser-languagedetector@8.2.1: + dependencies: + '@babel/runtime': 7.29.2 + + i18next@26.0.8(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + inline-style-parser@0.2.7: {} + + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-decimal@2.0.1: {} + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hexadecimal@2.0.1: {} + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + + is-node-process@1.2.0: {} + + is-number@7.0.0: {} + + is-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-promise@4.0.0: {} + + is-regexp@3.1.0: {} + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isbot@5.1.40: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + javascript-natural-sort@0.7.1: {} + + jiti@2.7.0: {} + + jose@6.2.2: {} + + jotai@2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5): + optionalDependencies: + '@babel/core': 7.29.0 + '@babel/template': 7.28.6 + '@types/react': 19.2.14 + react: 19.2.5 + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + kleur@4.1.5: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.17.23: {} + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + longest-streak@3.1.0: {} + + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + markdown-table@3.0.4: {} + + math-intrinsics@1.1.0: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.3 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + msw@2.13.4(@types/node@25.6.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 6.0.11(@types/node@25.6.0) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.13.2 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.7 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.5.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + mute-stream@3.0.0: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-releases@2.0.37: {} + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-treeify@1.1.33: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + outvariant@1.4.3: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-ms@4.0.0: {} + + parse-statements@1.0.11: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-to-regexp@6.3.0: {} + + path-to-regexp@8.4.2: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pkce-challenge@5.0.1: {} + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + powershell-utils@0.1.0: {} + + prelude-ls@1.2.1: {} + + prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3): + dependencies: + prettier: 3.8.3 + optionalDependencies: + '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.3) + + prettier@3.8.3: {} + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + property-information@7.1.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + react-dom@19.2.5(react@19.2.5): + dependencies: + react: 19.2.5 + scheduler: 0.27.0 + + react-i18next@17.0.4(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.2 + html-parse-stringify: 3.0.1 + i18next: 26.0.8(typescript@5.9.3) + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) + optionalDependencies: + react-dom: 19.2.5(react@19.2.5) + typescript: 5.9.3 + + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.14 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.5 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): + dependencies: + react: 19.2.5 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.5): + dependencies: + react: 19.2.5 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.5) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.5) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5): + dependencies: + get-nonce: 1.0.1 + react: 19.2.5 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.5): + dependencies: + '@babel/runtime': 7.29.2 + react: 19.2.5 + use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.5) + use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.5) + transitivePeerDependencies: + - '@types/react' + + react@19.2.5: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + recast@0.23.11: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + + rehype-highlight@7.0.2: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-text: 4.0.2 + lowlight: 3.3.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + rettime@0.11.7: {} + + reusify@1.1.0: {} + + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + seroval-plugins@1.5.1(seroval@1.5.1): + dependencies: + seroval: 1.5.1 + + seroval-plugins@1.5.4(seroval@1.5.4): + dependencies: + seroval: 1.5.4 + + seroval@1.5.1: {} + + seroval@1.5.4: {} + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@3.1.0: {} + + setprototypeof@1.2.0: {} + + shadcn@4.3.0(@types/node@25.6.0)(typescript@5.9.3): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@dotenvx/dotenvx': 1.61.0 + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) + '@types/validate-npm-package-name': 4.0.2 + browserslist: 4.28.2 + commander: 14.0.3 + cosmiconfig: 9.0.1(typescript@5.9.3) + dedent: 1.7.2 + deepmerge: 4.3.1 + diff: 8.0.4 + execa: 9.6.1 + fast-glob: 3.3.3 + fs-extra: 11.3.4 + fuzzysort: 3.1.0 + https-proxy-agent: 7.0.6 + kleur: 4.1.5 + msw: 2.13.4(@types/node@25.6.0)(typescript@5.9.3) + node-fetch: 3.3.2 + open: 11.0.0 + ora: 8.2.0 + postcss: 8.5.10 + postcss-selector-parser: 7.1.1 + prompts: 2.4.2 + recast: 0.23.11 + stringify-object: 5.0.0 + tailwind-merge: 3.5.0 + ts-morph: 26.0.0 + tsconfig-paths: 4.2.0 + validate-npm-package-name: 7.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@types/node' + - babel-plugin-macros + - supports-color + - typescript + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + source-map@0.7.6: {} + + space-separated-tokens@2.0.2: {} + + statuses@2.0.2: {} + + stdin-discarder@0.2.2: {} + + strict-event-emitter@0.5.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + stringify-object@5.0.0: + dependencies: + get-own-enumerable-keys: 1.0.0 + is-obj: 3.0.0 + is-regexp: 3.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@4.0.0: {} + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + tagged-tag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tailwindcss@4.2.4: {} + + tapable@2.3.3: {} + + tiny-invariant@1.3.3: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tldts-core@7.0.28: {} + + tldts@7.0.28: + dependencies: + tldts-core: 7.0.28 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.28 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-morph@26.0.0: + dependencies: + '@ts-morph/common': 0.27.0 + code-block-writer: 13.0.3 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.13.7 + optionalDependencies: + fsevents: 2.3.3 + + tw-animate-css@1.4.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@5.5.0: + dependencies: + tagged-tag: 1.0.0 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript-eslint@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + eslint: 10.2.1(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@7.19.2: {} + + unicorn-magic@0.3.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.16.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + until-async@3.0.2: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5): + dependencies: + react: 19.2.5 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.5): + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.5): + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + use-latest@1.3.0(@types/react@19.2.14)(react@19.2.5): + dependencies: + react: 19.2.5 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.5 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.5): + dependencies: + react: 19.2.5 + + util-deprecate@1.0.2: {} + + validate-npm-package-name@7.0.2: {} + + vary@1.1.2: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.10 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.0 + esbuild: 0.27.4 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.21.0 + + void-elements@3.1.0: {} + + web-namespaces@2.0.1: {} + + web-streams-polyfill@3.3.3: {} + + webpack-virtual-modules@0.6.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + word-wrap@1.2.5: {} + + wrap-ansi@10.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.0 + strip-ansi: 7.2.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + yocto-spinner@1.1.0: + dependencies: + yoctocolors: 2.1.2 + + yoctocolors@2.1.2: {} + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@3.25.76: {} + + zod@4.3.6: {} + + zwitch@2.0.4: {} diff --git a/web/frontend/prettier.config.js b/web/frontend/prettier.config.js new file mode 100644 index 000000000..492ef1dd7 --- /dev/null +++ b/web/frontend/prettier.config.js @@ -0,0 +1,17 @@ +// @ts-check + +/** @type {import('prettier').Config} */ +const config = { + semi: false, + printWidth: 80, + tabWidth: 2, + importOrder: ["", "", "^@/", "^[./]"], + importOrderSeparation: true, + importOrderSortSpecifiers: true, + plugins: [ + "@trivago/prettier-plugin-sort-imports", + "prettier-plugin-tailwindcss", + ], +} + +export default config diff --git a/web/frontend/public/apple-touch-icon.png b/web/frontend/public/apple-touch-icon.png new file mode 100644 index 000000000..d881c64af Binary files /dev/null and b/web/frontend/public/apple-touch-icon.png differ diff --git a/web/frontend/public/favicon-96x96.png b/web/frontend/public/favicon-96x96.png new file mode 100644 index 000000000..5bdeccea5 Binary files /dev/null and b/web/frontend/public/favicon-96x96.png differ diff --git a/web/frontend/public/favicon.ico b/web/frontend/public/favicon.ico new file mode 100644 index 000000000..8b46b4b26 Binary files /dev/null and b/web/frontend/public/favicon.ico differ diff --git a/web/frontend/public/favicon.svg b/web/frontend/public/favicon.svg new file mode 100644 index 000000000..e2f412b70 --- /dev/null +++ b/web/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/frontend/public/lark.svg b/web/frontend/public/lark.svg new file mode 100644 index 000000000..0761f278f --- /dev/null +++ b/web/frontend/public/lark.svg @@ -0,0 +1 @@ + diff --git a/web/frontend/public/logo_with_text.png b/web/frontend/public/logo_with_text.png new file mode 100644 index 000000000..70f26788c Binary files /dev/null and b/web/frontend/public/logo_with_text.png differ diff --git a/web/frontend/public/site.webmanifest b/web/frontend/public/site.webmanifest new file mode 100644 index 000000000..981d97f15 --- /dev/null +++ b/web/frontend/public/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "MyWebSite", + "short_name": "MySite", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/web/frontend/public/web-app-manifest-192x192.png b/web/frontend/public/web-app-manifest-192x192.png new file mode 100644 index 000000000..01933339b Binary files /dev/null and b/web/frontend/public/web-app-manifest-192x192.png differ diff --git a/web/frontend/public/web-app-manifest-512x512.png b/web/frontend/public/web-app-manifest-512x512.png new file mode 100644 index 000000000..e0b4aab9c Binary files /dev/null and b/web/frontend/public/web-app-manifest-512x512.png differ diff --git a/web/frontend/scripts/ensure-backend-gitkeep.cjs b/web/frontend/scripts/ensure-backend-gitkeep.cjs new file mode 100644 index 000000000..db9782ab4 --- /dev/null +++ b/web/frontend/scripts/ensure-backend-gitkeep.cjs @@ -0,0 +1,9 @@ +const fs = require("node:fs") +const path = require("node:path") + +const gitkeepPath = path.resolve(__dirname, "../../backend/dist/.gitkeep") +const gitkeepContents = + "# Keep the embedded web backend dist directory in version control.\n" + +fs.mkdirSync(path.dirname(gitkeepPath), { recursive: true }) +fs.writeFileSync(gitkeepPath, gitkeepContents) diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts new file mode 100644 index 000000000..42a3a0606 --- /dev/null +++ b/web/frontend/src/api/channels.ts @@ -0,0 +1,122 @@ +import { launcherFetch } from "@/api/http" + +export type ChannelConfig = Record +export type AppConfig = Record + +export interface SupportedChannel { + name: string + display_name?: string + config_key: string + variant?: string +} + +export interface ChannelConfigResponse { + config: ChannelConfig + configured_secrets: string[] + config_key: string + variant?: string +} + +interface ChannelsCatalogResponse { + channels: SupportedChannel[] +} + +interface ConfigActionResponse { + status: string + errors?: string[] +} + +const BASE_URL = "" + +async function request(path: string, options?: RequestInit): Promise { + const res = await launcherFetch(`${BASE_URL}${path}`, options) + if (!res.ok) { + let message = `API error: ${res.status} ${res.statusText}` + try { + const body = (await res.json()) as { + error?: string + errors?: string[] + status?: string + } + if (Array.isArray(body.errors) && body.errors.length > 0) { + message = body.errors.join("; ") + } else if (typeof body.error === "string" && body.error.trim() !== "") { + message = body.error + } + } catch { + // Keep default fallback message if response body is not JSON. + } + throw new Error(message) + } + return res.json() as Promise +} + +export async function getChannelsCatalog(): Promise { + return request("/api/channels/catalog") +} + +export async function getAppConfig(): Promise { + return request("/api/config") +} + +export async function getChannelConfig( + channelName: string, +): Promise { + return request( + `/api/channels/${encodeURIComponent(channelName)}/config`, + ) +} + +export async function patchAppConfig( + patch: Record, +): Promise { + return request("/api/config", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }) +} + +// WeChat QR login flow API + +export interface WeixinFlowResponse { + flow_id: string + status: "wait" | "scaned" | "confirmed" | "expired" | "error" + qr_data_uri?: string + account_id?: string + error?: string +} + +export interface WecomFlowResponse { + flow_id: string + status: "wait" | "scaned" | "confirmed" | "expired" | "error" + qr_data_uri?: string + bot_id?: string + error?: string +} + +export async function startWeixinFlow(): Promise { + return request("/api/weixin/flows", { method: "POST" }) +} + +export async function pollWeixinFlow( + flowID: string, +): Promise { + return request( + `/api/weixin/flows/${encodeURIComponent(flowID)}`, + ) +} + +export async function startWecomFlow(): Promise { + return request("/api/wecom/flows", { method: "POST" }) +} + +export async function pollWecomFlow( + flowID: string, +): Promise { + return request( + `/api/wecom/flows/${encodeURIComponent(flowID)}`, + ) +} + +export type { ChannelsCatalogResponse, ConfigActionResponse } diff --git a/web/frontend/src/api/gateway.ts b/web/frontend/src/api/gateway.ts new file mode 100644 index 000000000..2742a0a37 --- /dev/null +++ b/web/frontend/src/api/gateway.ts @@ -0,0 +1,86 @@ +import { launcherFetch } from "@/api/http" + +// API client for gateway process management. + +interface GatewayStatusResponse { + gateway_status: "running" | "starting" | "restarting" | "stopped" | "error" + gateway_start_allowed?: boolean + gateway_start_reason?: string + gateway_restart_required?: boolean + pid?: number + boot_default_model?: string + config_default_model?: string + [key: string]: unknown +} + +interface GatewayLogsResponse { + logs?: string[] + log_total?: number + log_run_id?: number +} + +interface GatewayActionResponse { + status: string + pid?: number + log_total?: number + log_run_id?: number +} + +const BASE_URL = "" + +async function request(path: string, options?: RequestInit): Promise { + const res = await launcherFetch(`${BASE_URL}${path}`, options) + if (!res.ok) { + throw new Error(`API error: ${res.status} ${res.statusText}`) + } + return res.json() as Promise +} + +export async function getGatewayStatus(): Promise { + return request("/api/gateway/status") +} + +export async function getGatewayLogs(options?: { + log_offset?: number + log_run_id?: number +}): Promise { + const params = new URLSearchParams() + if (options?.log_offset !== undefined) { + params.set("log_offset", options.log_offset.toString()) + } + if (options?.log_run_id !== undefined) { + params.set("log_run_id", options.log_run_id.toString()) + } + const queryString = params.toString() ? `?${params.toString()}` : "" + return request(`/api/gateway/logs${queryString}`) +} + +export async function startGateway(): Promise { + return request("/api/gateway/start", { + method: "POST", + }) +} + +export async function stopGateway(): Promise { + return request("/api/gateway/stop", { + method: "POST", + }) +} + +export async function restartGateway(): Promise { + return request("/api/gateway/restart", { + method: "POST", + }) +} + +export async function clearGatewayLogs(): Promise { + return request("/api/gateway/logs/clear", { + method: "POST", + }) +} + +export type { + GatewayStatusResponse, + GatewayLogsResponse, + GatewayActionResponse, +} diff --git a/web/frontend/src/api/http.ts b/web/frontend/src/api/http.ts new file mode 100644 index 000000000..347dd9373 --- /dev/null +++ b/web/frontend/src/api/http.ts @@ -0,0 +1,42 @@ +import { isLauncherAuthPathname } from "@/lib/launcher-login-path" + +function isLauncherAuthPath(): boolean { + if (typeof globalThis.location === "undefined") { + return false + } + if (isLauncherAuthPathname(globalThis.location.pathname || "/")) { + return true + } + try { + return isLauncherAuthPathname( + new URL(globalThis.location.href).pathname || "/", + ) + } catch { + return false + } +} + +/** + * Same-origin fetch that sends cookies; redirects to launcher login on 401 JSON responses. + * Skips redirect while already on an auth page (login or setup) to avoid reload loops. + */ +export async function launcherFetch( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + const res = await fetch(input, { + credentials: "same-origin", + ...init, + }) + if (res.status === 401) { + const ct = res.headers.get("content-type") || "" + if ( + ct.includes("application/json") && + typeof globalThis.location !== "undefined" && + !isLauncherAuthPath() + ) { + globalThis.location.assign("/launcher-login") + } + } + return res +} diff --git a/web/frontend/src/api/launcher-auth.ts b/web/frontend/src/api/launcher-auth.ts new file mode 100644 index 000000000..c7318d962 --- /dev/null +++ b/web/frontend/src/api/launcher-auth.ts @@ -0,0 +1,82 @@ +/** + * Dashboard launcher auth API. + * Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages. + */ +export type LoginResult = + | { ok: true } + | { ok: false; status: number; error: string } + +export async function postLauncherDashboardLogin( + password: string, +): Promise { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ password: password.trim() }), + }) + if (res.ok) return { ok: true } + + return { + ok: false, + status: res.status, + error: await readLauncherAuthError(res), + } +} + +export type LauncherAuthStatus = { + authenticated: boolean + /** true when a bcrypt password has been stored in the DB */ + initialized: boolean +} + +export async function getLauncherAuthStatus(): Promise { + const res = await fetch("/api/auth/status", { + method: "GET", + credentials: "same-origin", + }) + if (!res.ok) { + throw new Error(`status ${res.status}`) + } + return (await res.json()) as LauncherAuthStatus +} + +export async function postLauncherDashboardLogout(): Promise { + const res = await fetch("/api/auth/logout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: "{}", + }) + return res.ok +} + +export type SetupResult = { ok: true } | { ok: false; error: string } + +export async function postLauncherDashboardSetup( + password: string, + confirm: string, +): Promise { + const res = await fetch("/api/auth/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ + password: password.trim(), + confirm: confirm.trim(), + }), + }) + if (res.ok) return { ok: true } + return { ok: false, error: await readLauncherAuthError(res) } +} + +async function readLauncherAuthError(res: Response): Promise { + let msg = `Request failed with status ${res.status}` + try { + const j = (await res.json()) as { error?: string } + if (j.error) msg = j.error + } catch { + /* ignore */ + } + return msg +} diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts new file mode 100644 index 000000000..5bb275fde --- /dev/null +++ b/web/frontend/src/api/models.ts @@ -0,0 +1,110 @@ +import { launcherFetch } from "@/api/http" +import { refreshGatewayState } from "@/store/gateway" + +// API client for model list management. + +export interface ModelInfo { + index: number + model_name: string + provider?: string + model: string + api_base?: string + api_key: string + proxy?: string + auth_method?: string + // Advanced fields + connect_mode?: string + workspace?: string + rpm?: number + max_tokens_field?: string + request_timeout?: number + thinking_level?: string + tool_schema_transform?: string + extra_body?: Record + custom_headers?: Record + // Meta + available: boolean + status: "available" | "unconfigured" | "unreachable" + is_default: boolean + is_virtual: boolean + default_model_allowed?: boolean +} + +export interface ModelProviderOption { + id: string + default_api_base: string + empty_api_key_allowed: boolean + create_allowed: boolean + default_model_allowed: boolean + default_auth_method?: string + auth_method_locked?: boolean +} + +interface ModelsListResponse { + models: ModelInfo[] + total: number + default_model: string + provider_options: ModelProviderOption[] +} + +interface ModelActionResponse { + status: string + index?: number + default_model?: string +} + +const BASE_URL = "" + +async function request(path: string, options?: RequestInit): Promise { + const res = await launcherFetch(`${BASE_URL}${path}`, options) + if (!res.ok) { + throw new Error(`API error: ${res.status} ${res.statusText}`) + } + return res.json() as Promise +} + +export async function getModels(): Promise { + return request("/api/models") +} + +export async function addModel( + model: Partial, +): Promise { + return request("/api/models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(model), + }) +} + +export async function updateModel( + index: number, + model: Partial, +): Promise { + return request(`/api/models/${index}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(model), + }) +} + +export async function deleteModel(index: number): Promise { + return request(`/api/models/${index}`, { + method: "DELETE", + }) +} + +export async function setDefaultModel( + modelName: string, +): Promise { + const response = await request("/api/models/default", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model_name: modelName }), + }) + + await refreshGatewayState() + return response +} + +export type { ModelsListResponse, ModelActionResponse } diff --git a/web/frontend/src/api/oauth.ts b/web/frontend/src/api/oauth.ts new file mode 100644 index 000000000..689a2bcd1 --- /dev/null +++ b/web/frontend/src/api/oauth.ts @@ -0,0 +1,104 @@ +import { launcherFetch } from "@/api/http" + +export type OAuthProvider = "openai" | "anthropic" | "google-antigravity" +export type OAuthMethod = "browser" | "device_code" | "token" + +export interface OAuthProviderStatus { + provider: OAuthProvider + display_name: string + methods: OAuthMethod[] + logged_in: boolean + status: "connected" | "expired" | "needs_refresh" | "not_logged_in" + auth_method?: string + expires_at?: string + account_id?: string + email?: string + project_id?: string +} + +export interface OAuthFlowState { + flow_id: string + provider: OAuthProvider + method: OAuthMethod + status: "pending" | "success" | "error" | "expired" + expires_at?: string + error?: string + user_code?: string + verify_url?: string + interval?: number +} + +export interface OAuthLoginRequest { + provider: OAuthProvider + method: OAuthMethod + token?: string +} + +export interface OAuthLoginResponse { + status: string + provider: OAuthProvider + method: OAuthMethod + flow_id?: string + auth_url?: string + user_code?: string + verify_url?: string + interval?: number + expires_at?: string +} + +interface OAuthProvidersResponse { + providers: OAuthProviderStatus[] +} + +const BASE_URL = "" + +async function request(path: string, options?: RequestInit): Promise { + const res = await launcherFetch(`${BASE_URL}${path}`, options) + if (!res.ok) { + const message = await res.text() + throw new Error(message || `API error: ${res.status} ${res.statusText}`) + } + return res.json() as Promise +} + +export async function getOAuthProviders(): Promise { + return request("/api/oauth/providers") +} + +export async function loginOAuth( + payload: OAuthLoginRequest, +): Promise { + return request("/api/oauth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) +} + +export async function getOAuthFlow(flowID: string): Promise { + return request( + `/api/oauth/flows/${encodeURIComponent(flowID)}`, + ) +} + +export async function pollOAuthFlow(flowID: string): Promise { + return request( + `/api/oauth/flows/${encodeURIComponent(flowID)}/poll`, + { + method: "POST", + }, + ) +} + +export async function logoutOAuth( + provider: OAuthProvider, +): Promise<{ status: string; provider: OAuthProvider }> { + return request<{ status: string; provider: OAuthProvider }>( + "/api/oauth/logout", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider }), + }, + ) +} diff --git a/web/frontend/src/api/pico.ts b/web/frontend/src/api/pico.ts new file mode 100644 index 000000000..ca98a06da --- /dev/null +++ b/web/frontend/src/api/pico.ts @@ -0,0 +1,40 @@ +import { launcherFetch } from "@/api/http" + +// API client for Pico Channel configuration. + +interface PicoInfoResponse { + ws_url: string + enabled: boolean + configured?: boolean +} + +interface PicoSetupResponse { + ws_url: string + enabled: boolean + configured?: boolean + changed: boolean +} + +const BASE_URL = "" + +async function request(path: string, options?: RequestInit): Promise { + const res = await launcherFetch(`${BASE_URL}${path}`, options) + if (!res.ok) { + throw new Error(`API error: ${res.status} ${res.statusText}`) + } + return res.json() as Promise +} + +export async function getPicoInfo(): Promise { + return request("/api/pico/info") +} + +export async function regenPicoToken(): Promise { + return request("/api/pico/token", { method: "POST" }) +} + +export async function setupPico(): Promise { + return request("/api/pico/setup", { method: "POST" }) +} + +export type { PicoInfoResponse, PicoSetupResponse } diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts new file mode 100644 index 000000000..edd7d7c27 --- /dev/null +++ b/web/frontend/src/api/sessions.ts @@ -0,0 +1,73 @@ +import { launcherFetch } from "@/api/http" + +export interface SessionSummary { + id: string + title: string + preview: string + message_count: number + created: string + updated: string +} + +export interface SessionDetail { + id: string + messages: { + role: "user" | "assistant" + content: string + kind?: "normal" | "thought" | "tool_calls" + media?: string[] + attachments?: { + type?: "image" | "audio" | "video" | "file" + url: string + filename?: string + content_type?: string + }[] + tool_calls?: { + id?: string + type?: string + function?: { + name?: string + arguments?: string + } + extra_content?: { + tool_feedback_explanation?: string + } + }[] + }[] + summary: string + created: string + updated: string +} + +export async function getSessions( + offset: number = 0, + limit: number = 20, +): Promise { + const params = new URLSearchParams({ + offset: offset.toString(), + limit: limit.toString(), + }) + + const res = await launcherFetch(`/api/sessions?${params.toString()}`) + if (!res.ok) { + throw new Error(`Failed to fetch sessions: ${res.status}`) + } + return res.json() +} + +export async function getSessionHistory(id: string): Promise { + const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`) + if (!res.ok) { + throw new Error(`Failed to fetch session ${id}: ${res.status}`) + } + return res.json() +} + +export async function deleteSession(id: string): Promise { + const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`, { + method: "DELETE", + }) + if (!res.ok) { + throw new Error(`Failed to delete session ${id}: ${res.status}`) + } +} diff --git a/web/frontend/src/api/skills.ts b/web/frontend/src/api/skills.ts new file mode 100644 index 000000000..958808afd --- /dev/null +++ b/web/frontend/src/api/skills.ts @@ -0,0 +1,150 @@ +import { launcherFetch } from "@/api/http" + +export interface SkillSupportItem { + name: string + path: string + source: "workspace" | "global" | "builtin" | string + description: string + origin_kind: "builtin" | "third_party" | "manual" | string + registry_name?: string + registry_url?: string + installed_version?: string + installed_at?: number +} + +export interface SkillDetailResponse extends SkillSupportItem { + content: string +} + +export interface SkillRegistrySearchResult { + score: number + slug: string + display_name: string + summary: string + version: string + registry_name: string + url?: string + installed: boolean + installed_name?: string +} + +interface SkillsResponse { + skills: SkillSupportItem[] +} + +export interface SkillSearchResponse { + results: SkillRegistrySearchResult[] + limit: number + offset: number + next_offset?: number + has_more: boolean +} + +type SkillActionResponse = Partial & { + status?: string +} + +export interface InstallSkillRequest { + slug: string + registry: string + version?: string + force?: boolean +} + +export interface InstallSkillResponse { + status: string + slug: string + registry: string + version: string + summary?: string + is_suspicious?: boolean + skill?: SkillSupportItem +} + +async function request(path: string, options?: RequestInit): Promise { + const res = await launcherFetch(path, options) + if (!res.ok) { + throw new Error(await extractErrorMessage(res)) + } + return res.json() as Promise +} + +export async function getSkills(): Promise { + return request("/api/skills") +} + +export async function getSkill(name: string): Promise { + return request(`/api/skills/${encodeURIComponent(name)}`) +} + +export async function searchSkills( + query: string, + limit = 20, + offset = 0, +): Promise { + const params = new URLSearchParams({ + q: query, + limit: String(limit), + offset: String(offset), + }) + return request(`/api/skills/search?${params.toString()}`) +} + +export async function installSkill( + input: InstallSkillRequest, +): Promise { + return request("/api/skills/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }) +} + +export async function importSkill(file: File): Promise { + const formData = new FormData() + formData.set("file", file) + + const res = await launcherFetch("/api/skills/import", { + method: "POST", + body: formData, + }) + if (!res.ok) { + throw new Error(await extractErrorMessage(res)) + } + return res.json() as Promise +} + +export async function deleteSkill(name: string): Promise { + return request( + `/api/skills/${encodeURIComponent(name)}`, + { + method: "DELETE", + }, + ) +} + +async function extractErrorMessage(res: Response): Promise { + try { + const raw = await res.text() + if (raw.trim() === "") { + return `API error: ${res.status} ${res.statusText}` + } + try { + const body = JSON.parse(raw) as { + error?: string + errors?: string[] + } + if (Array.isArray(body.errors) && body.errors.length > 0) { + return body.errors.join("; ") + } + if (typeof body.error === "string" && body.error.trim() !== "") { + return body.error + } + } catch { + return raw.trim() + } + } catch { + // ignore invalid body + } + return `API error: ${res.status} ${res.statusText}` +} diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts new file mode 100644 index 000000000..dfc48b6b8 --- /dev/null +++ b/web/frontend/src/api/system.ts @@ -0,0 +1,75 @@ +import { launcherFetch } from "@/api/http" + +export interface AutoStartStatus { + enabled: boolean + supported: boolean + platform: string + message?: string +} + +export interface LauncherConfig { + port: number + public: boolean + allowed_cidrs: string[] +} + +export interface SystemVersionInfo { + version: string + git_commit?: string + build_time?: string + go_version: string +} + +async function request(path: string, options?: RequestInit): Promise { + const res = await launcherFetch(path, options) + if (!res.ok) { + let message = `API error: ${res.status} ${res.statusText}` + try { + const body = (await res.json()) as { + error?: string + errors?: string[] + } + if (Array.isArray(body.errors) && body.errors.length > 0) { + message = body.errors.join("; ") + } else if (typeof body.error === "string" && body.error.trim() !== "") { + message = body.error + } + } catch { + // Keep fallback error message when response body is not JSON. + } + throw new Error(message) + } + return res.json() as Promise +} + +export async function getAutoStartStatus(): Promise { + return request("/api/system/autostart") +} + +export async function setAutoStartEnabled( + enabled: boolean, +): Promise { + return request("/api/system/autostart", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }) +} + +export async function getLauncherConfig(): Promise { + return request("/api/system/launcher-config") +} + +export async function setLauncherConfig( + payload: LauncherConfig, +): Promise { + return request("/api/system/launcher-config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) +} + +export async function getSystemVersionInfo(): Promise { + return request("/api/system/version") +} diff --git a/web/frontend/src/api/tools.ts b/web/frontend/src/api/tools.ts new file mode 100644 index 000000000..a77f3ba80 --- /dev/null +++ b/web/frontend/src/api/tools.ts @@ -0,0 +1,97 @@ +import { launcherFetch } from "@/api/http" + +export interface ToolSupportItem { + name: string + description: string + category: string + config_key: string + status: "enabled" | "disabled" | "blocked" + reason_code?: string +} + +interface ToolsResponse { + tools: ToolSupportItem[] +} + +interface ToolActionResponse { + status: string +} + +export interface WebSearchProviderOption { + id: string + label: string + configured: boolean + current: boolean + requires_auth: boolean +} + +export interface WebSearchProviderConfig { + enabled: boolean + max_results: number + base_url?: string + api_key?: string + api_key_set?: boolean +} + +export interface WebSearchConfigResponse { + provider: string + current_service: string + prefer_native: boolean + proxy?: string + providers: WebSearchProviderOption[] + settings: Record +} + +async function request(path: string, options?: RequestInit): Promise { + const res = await launcherFetch(path, options) + if (!res.ok) { + let message = `API error: ${res.status} ${res.statusText}` + try { + const body = (await res.json()) as { + error?: string + errors?: string[] + } + if (Array.isArray(body.errors) && body.errors.length > 0) { + message = body.errors.join("; ") + } else if (typeof body.error === "string" && body.error.trim() !== "") { + message = body.error + } + } catch { + // ignore invalid body + } + throw new Error(message) + } + return res.json() as Promise +} + +export async function getTools(): Promise { + return request("/api/tools") +} + +export async function setToolEnabled( + name: string, + enabled: boolean, +): Promise { + return request( + `/api/tools/${encodeURIComponent(name)}/state`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }, + ) +} + +export async function getWebSearchConfig(): Promise { + return request("/api/tools/web-search-config") +} + +export async function updateWebSearchConfig( + payload: WebSearchConfigResponse, +): Promise { + return request("/api/tools/web-search-config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) +} diff --git a/web/frontend/src/app-providers.tsx b/web/frontend/src/app-providers.tsx new file mode 100644 index 000000000..bfb5dfb38 --- /dev/null +++ b/web/frontend/src/app-providers.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react" + +import { useHighlightTheme } from "./hooks/use-highlight-theme" + +interface AppProvidersProps { + children: ReactNode +} + +export function AppProviders({ children }: AppProvidersProps) { + useHighlightTheme() + + return <>{children} +} diff --git a/web/frontend/src/components/agent/hub/hub-page.tsx b/web/frontend/src/components/agent/hub/hub-page.tsx new file mode 100644 index 000000000..69f0be638 --- /dev/null +++ b/web/frontend/src/components/agent/hub/hub-page.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from "react-i18next" + +import { PageHeader } from "@/components/page-header" + +import { ResultsPanel } from "./results-panel" +import { SearchPanel } from "./search-panel" +import { useHubMarketplace } from "./use-hub-marketplace" + +export function HubPage() { + const { t } = useTranslation() + const hub = useHubMarketplace() + + return ( +
+ + +
+
+
+ + + +
+
+
+
+ ) +} diff --git a/web/frontend/src/components/agent/hub/market-skill-card.tsx b/web/frontend/src/components/agent/hub/market-skill-card.tsx new file mode 100644 index 000000000..99b00db92 --- /dev/null +++ b/web/frontend/src/components/agent/hub/market-skill-card.tsx @@ -0,0 +1,158 @@ +import { + IconCheck, + IconFileInfo, + IconLoader2, + IconPlus, +} from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import { + type SkillRegistrySearchResult, + type SkillSupportItem, +} from "@/api/skills" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +export function MarketSkillCard({ + result, + canInstall, + installPending, + installedSkill, + onInstall, + onViewInstalled, +}: { + result: SkillRegistrySearchResult + canInstall: boolean + installPending: boolean + installedSkill: SkillSupportItem | null + onInstall: () => void + onViewInstalled: () => void +}) { + const { t } = useTranslation() + + const installDisabledReason = (() => { + if (installPending) + return t("pages.agent.skills.marketplace_installDisabled.installing") + if (result.installed) + return t("pages.agent.skills.marketplace_installDisabled.installed") + if (!canInstall) + return t("pages.agent.skills.marketplace_installDisabled.cannotInstall") + return t("pages.agent.skills.marketplace_install_action") + })() + const installDisabled = !canInstall || result.installed || installPending + + return ( + + {result.installed && ( +
+ )} + +
+
+
+ + {result.display_name || result.slug} + + + {result.registry_name} + + {result.installed ? ( + + {t("pages.agent.skills.marketplace_installed")} + + ) : null} +
+
+ {result.slug} + {result.version ? ( + + {" "} + · v{result.version} + + ) : null} +
+ + {result.summary} + + {result.url ? ( + + ) : null} +
+
+ + + + + + + {installDisabledReason} + + {result.installed && installedSkill ? ( + + ) : null} +
+
+
+ {result.installed_name ? ( + +
+ {t("pages.agent.skills.marketplace_installed_hint", { + name: result.installed_name, + })} +
+
+ ) : null} + + ) +} diff --git a/web/frontend/src/components/agent/hub/results-panel.tsx b/web/frontend/src/components/agent/hub/results-panel.tsx new file mode 100644 index 000000000..e2a351955 --- /dev/null +++ b/web/frontend/src/components/agent/hub/results-panel.tsx @@ -0,0 +1,135 @@ +import { IconLoader2, IconSearch, IconX } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import { + type SkillRegistrySearchResult, + type SkillSupportItem, +} from "@/api/skills" + +import { MarketSkillCard } from "./market-skill-card" + +export function ResultsPanel({ + canSearchMarketplace, + hasSubmittedQuery, + submittedQuery, + marketResults, + marketSearchError, + isMarketSearchInitialLoading, + isMarketSearchLoadingMore, + canInstallFromMarketplace, + getInstalledSkill, + isInstallPending, + onInstall, + onViewInstalled, +}: { + canSearchMarketplace: boolean + hasSubmittedQuery: boolean + submittedQuery: string + marketResults: SkillRegistrySearchResult[] + marketSearchError: unknown + isMarketSearchInitialLoading: boolean + isMarketSearchLoadingMore: boolean + canInstallFromMarketplace: boolean + getInstalledSkill: (installedName?: string) => SkillSupportItem | null + isInstallPending: (result: SkillRegistrySearchResult) => boolean + onInstall: (result: SkillRegistrySearchResult) => void + onViewInstalled: () => void +}) { + const { t } = useTranslation() + + return ( +
+
+ {canSearchMarketplace && hasSubmittedQuery ? ( +
+
+
+ {t("pages.agent.skills.marketplace_notice_title")} +
+
+ {t("pages.agent.skills.marketplace_notice_body")} +
+
+ + {isMarketSearchInitialLoading ? ( +
+ + + {t("pages.agent.skills.marketplace_loading_results")} + +
+ ) : marketSearchError ? ( +
+
+ + + {marketSearchError instanceof Error + ? marketSearchError.message + : t("pages.agent.skills.marketplace_search_error")} + +
+
+ ) : marketResults.length ? ( +
+
+

+ {t("pages.agent.skills.marketplace_results_title", { + query: submittedQuery, + count: marketResults.length, + })} +

+ + {t("pages.agent.skills.marketplace_results_hint")} + +
+
+ {marketResults.map((result) => ( + onInstall(result)} + onViewInstalled={onViewInstalled} + /> + ))} +
+ {isMarketSearchLoadingMore ? ( +
+ + + {t("pages.agent.skills.marketplace_loading_more")} + +
+ ) : null} +
+ ) : ( +
+ + + {t("pages.agent.skills.marketplace_empty_results", { + query: submittedQuery, + })} + +
+ )} +
+ ) : !canSearchMarketplace ? ( +
+ + {t("pages.agent.skills.marketplace_unavailable")} + +
+ ) : ( +
+ + + {t("pages.agent.skills.marketplace_idle")} + +
+ )} +
+
+ ) +} diff --git a/web/frontend/src/components/agent/hub/search-panel.tsx b/web/frontend/src/components/agent/hub/search-panel.tsx new file mode 100644 index 000000000..875aaad6b --- /dev/null +++ b/web/frontend/src/components/agent/hub/search-panel.tsx @@ -0,0 +1,91 @@ +import { IconLoader2 } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +import type { UnavailableToolMessage } from "./tool-support" + +export function SearchPanel({ + marketQuery, + canSearchMarketplace, + isMarketSearchInitialLoading, + unavailableToolMessages, + onMarketQueryChange, + onSearchSubmit, +}: { + marketQuery: string + canSearchMarketplace: boolean + isMarketSearchInitialLoading: boolean + unavailableToolMessages: UnavailableToolMessage[] + onMarketQueryChange: (value: string) => void + onSearchSubmit: () => void +}) { + const { t } = useTranslation() + + return ( +
+
+

+ {t("pages.agent.skills.marketplace_title", { + defaultValue: "Discover Skills", + })} +

+

+ {t("pages.agent.skills.marketplace_description")} +

+
+ +
{ + event.preventDefault() + onSearchSubmit() + }} + > +
+ onMarketQueryChange(event.target.value)} + placeholder={t("pages.agent.skills.marketplace_search_placeholder")} + className="border-border/60 bg-background/50 hover:bg-background focus-visible:ring-primary/20 h-12 w-full rounded-full pr-20 pl-5 text-sm shadow-sm backdrop-blur-sm transition-all focus-visible:ring-2 md:min-w-[520px]" + disabled={!canSearchMarketplace} + /> + +
+
+ + {unavailableToolMessages.length ? ( +
+ {unavailableToolMessages.map((item) => ( +
+
{item.label}
+
{item.message}
+
+ ))} +
+ ) : null} +
+ ) +} diff --git a/web/frontend/src/components/agent/hub/tool-support.ts b/web/frontend/src/components/agent/hub/tool-support.ts new file mode 100644 index 000000000..1553b156a --- /dev/null +++ b/web/frontend/src/components/agent/hub/tool-support.ts @@ -0,0 +1,56 @@ +import type { TFunction } from "i18next" + +import type { ToolSupportItem } from "@/api/tools" + +type MarketplaceTool = + | Pick + | undefined + +export interface UnavailableToolMessage { + key: "search" | "install" + label: string + message: string +} + +export function buildUnavailableToolMessages({ + searchTool, + installTool, + t, +}: { + searchTool: MarketplaceTool + installTool: MarketplaceTool + t: TFunction +}): UnavailableToolMessage[] { + const searchMessage = getToolSupportMessage(searchTool, t) + const installMessage = getToolSupportMessage(installTool, t) + + return [ + searchMessage + ? { + key: "search", + label: t("pages.agent.skills.marketplace_search_status"), + message: searchMessage, + } + : null, + installMessage + ? { + key: "install", + label: t("pages.agent.skills.marketplace_install_status"), + message: installMessage, + } + : null, + ].filter((item): item is UnavailableToolMessage => Boolean(item)) +} + +function getToolSupportMessage( + tool: MarketplaceTool, + t: TFunction, +): string | null { + if (!tool || tool.status === "enabled") { + return null + } + if (tool.reason_code) { + return `${t(`pages.agent.tools.reasons.${tool.reason_code}`)} ${t("pages.agent.skills.marketplace_status_enable_hint")}` + } + return t("pages.agent.skills.marketplace_status_disabled") +} diff --git a/web/frontend/src/components/agent/hub/use-hub-marketplace.ts b/web/frontend/src/components/agent/hub/use-hub-marketplace.ts new file mode 100644 index 000000000..2777aa376 --- /dev/null +++ b/web/frontend/src/components/agent/hub/use-hub-marketplace.ts @@ -0,0 +1,211 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" +import { type UIEvent, useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { + type SkillRegistrySearchResult, + type SkillSearchResponse, + type SkillSupportItem, + getSkills, + installSkill, + searchSkills, +} from "@/api/skills" +import { getTools } from "@/api/tools" + +import { buildUnavailableToolMessages } from "./tool-support" + +const MARKET_SEARCH_LIMIT = 20 + +export function useHubMarketplace() { + const { t } = useTranslation() + const navigate = useNavigate() + const queryClient = useQueryClient() + const isLoadMoreLockedRef = useRef(false) + + const [marketQuery, setMarketQuery] = useState("") + const [submittedMarketQuery, setSubmittedMarketQuery] = useState("") + + const { data: skillsData } = useQuery({ + queryKey: ["skills"], + queryFn: getSkills, + }) + const { data: toolsData } = useQuery({ + queryKey: ["tools"], + queryFn: getTools, + }) + + const findSkillsTool = toolsData?.tools.find( + (tool) => tool.name === "find_skills", + ) + const installSkillTool = toolsData?.tools.find( + (tool) => tool.name === "install_skill", + ) + const canSearchMarketplace = findSkillsTool?.status === "enabled" + const canInstallFromMarketplace = installSkillTool?.status === "enabled" + const hasSubmittedQuery = submittedMarketQuery.trim() !== "" + const isMarketSearchActive = canSearchMarketplace && hasSubmittedQuery + + const { + data: marketSearchData, + isPending: isMarketSearchPending, + isFetching: isMarketSearchFetching, + isFetchingNextPage, + error: marketSearchError, + hasNextPage, + fetchNextPage, + refetch: refetchMarketSearch, + } = useInfiniteQuery({ + queryKey: ["skills-marketplace", submittedMarketQuery], + initialPageParam: 0, + queryFn: ({ pageParam }) => + searchSkills( + submittedMarketQuery, + MARKET_SEARCH_LIMIT, + Number(pageParam) || 0, + ), + getNextPageParam: (lastPage: SkillSearchResponse) => + lastPage.has_more ? (lastPage.next_offset ?? undefined) : undefined, + enabled: isMarketSearchActive, + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }) + + const installMutation = useMutation({ + mutationFn: installSkill, + onSuccess: (response) => { + toast.success( + t("pages.agent.skills.install_success", { + name: response.skill?.name ?? response.slug, + }), + ) + void queryClient.invalidateQueries({ queryKey: ["skills"] }) + void queryClient.invalidateQueries({ queryKey: ["skills-marketplace"] }) + }, + onError: (err) => { + toast.error( + err instanceof Error + ? err.message + : t("pages.agent.skills.install_error"), + ) + }, + }) + + const allSkills = skillsData?.skills ?? [] + const workspaceSkillsByName = new Map( + allSkills + .filter((skill) => skill.source === "workspace") + .map((skill) => [skill.name, skill] as const), + ) + const marketResults = + marketSearchData?.pages.flatMap((page) => page.results) ?? [] + const hasMoreMarketResults = hasNextPage ?? false + const isMarketSearchInitialLoading = + isMarketSearchActive && + !marketSearchData && + (isMarketSearchPending || isMarketSearchFetching) + const isMarketSearchLoadingMore = + isMarketSearchActive && Boolean(marketSearchData) && isFetchingNextPage + const installPendingKey = + installMutation.isPending && installMutation.variables + ? `${installMutation.variables.registry}:${installMutation.variables.slug}` + : null + + const unavailableToolMessages = buildUnavailableToolMessages({ + searchTool: findSkillsTool, + installTool: installSkillTool, + t, + }) + + useEffect(() => { + if (!isFetchingNextPage) { + isLoadMoreLockedRef.current = false + } + }, [isFetchingNextPage]) + + const handleSearchSubmit = () => { + const nextQuery = marketQuery.trim() + if (!canSearchMarketplace || nextQuery === "") { + return + } + + isLoadMoreLockedRef.current = false + if (nextQuery === submittedMarketQuery) { + void refetchMarketSearch() + return + } + + setSubmittedMarketQuery(nextQuery) + } + + const handleInstall = (result: SkillRegistrySearchResult) => { + installMutation.mutate({ + slug: result.slug, + registry: result.registry_name, + version: result.version || undefined, + }) + } + + const handleViewInstalled = () => { + void navigate({ to: "/agent/skills" }) + } + + const handleScroll = (event: UIEvent) => { + if ( + !isMarketSearchActive || + !hasMoreMarketResults || + isFetchingNextPage || + isLoadMoreLockedRef.current + ) { + return + } + + const node = event.currentTarget + const remaining = node.scrollHeight - node.scrollTop - node.clientHeight + if (remaining > 240) { + return + } + + isLoadMoreLockedRef.current = true + void fetchNextPage() + } + + const getInstalledSkill = ( + installedName?: string, + ): SkillSupportItem | null => { + if (!installedName) { + return null + } + return workspaceSkillsByName.get(installedName) ?? null + } + + const isInstallPending = (result: SkillRegistrySearchResult) => + installPendingKey === `${result.registry_name}:${result.slug}` + + return { + marketQuery, + submittedMarketQuery, + canSearchMarketplace, + canInstallFromMarketplace, + marketResults, + marketSearchError, + unavailableToolMessages, + hasSubmittedQuery, + isMarketSearchInitialLoading, + isMarketSearchLoadingMore, + setMarketQuery, + handleSearchSubmit, + handleInstall, + handleViewInstalled, + handleScroll, + getInstalledSkill, + isInstallPending, + } +} diff --git a/web/frontend/src/components/agent/skills/delete-dialog.tsx b/web/frontend/src/components/agent/skills/delete-dialog.tsx new file mode 100644 index 000000000..1f4eba4c3 --- /dev/null +++ b/web/frontend/src/components/agent/skills/delete-dialog.tsx @@ -0,0 +1,66 @@ +import { IconLoader2, IconTrash } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import type { SkillSupportItem } from "@/api/skills" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" + +interface DeleteDialogProps { + open: boolean + skillPendingDelete: SkillSupportItem | null + isDeletePending: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void +} + +export function DeleteDialog({ + open, + skillPendingDelete, + isDeletePending, + onOpenChange, + onConfirm, +}: DeleteDialogProps) { + const { t } = useTranslation() + + return ( + + + + + {t("pages.agent.skills.delete_title")} + + + {t("pages.agent.skills.delete_description", { + name: skillPendingDelete?.name, + })} + + + + + {t("common.cancel")} + + + {isDeletePending ? ( + + ) : ( + + )} + {t("pages.agent.skills.delete_confirm")} + + + + + ) +} diff --git a/web/frontend/src/components/agent/skills/detail-sheet.tsx b/web/frontend/src/components/agent/skills/detail-sheet.tsx new file mode 100644 index 000000000..4579926d8 --- /dev/null +++ b/web/frontend/src/components/agent/skills/detail-sheet.tsx @@ -0,0 +1,248 @@ +import { + IconFileCode, + IconSparkles, + IconWorld, + IconX, +} from "@tabler/icons-react" +import type { ReactNode } from "react" +import { useTranslation } from "react-i18next" +import ReactMarkdown from "react-markdown" +import rehypeHighlight from "rehype-highlight" +import rehypeRaw from "rehype-raw" +import rehypeSanitize from "rehype-sanitize" +import remarkGfm from "remark-gfm" + +import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { cn } from "@/lib/utils" + +import { OriginBadge } from "./origin-badge" +import { getOriginLabel, getSkillOriginKind } from "./origin-utils" +import type { SkillDetailView } from "./types" + +const DETAIL_VIEWS = [ + "preview", + "raw", + "meta", +] as const satisfies SkillDetailView[] + +interface DetailSheetProps { + open: boolean + selectedSkill: SkillSupportItem | null + selectedSkillDetail?: SkillDetailResponse + isLoading: boolean + error: unknown + detailView: SkillDetailView + onDetailViewChange: (view: SkillDetailView) => void + onOpenChange: (open: boolean) => void +} + +export function DetailSheet({ + open, + selectedSkill, + selectedSkillDetail, + isLoading, + error, + detailView, + onDetailViewChange, + onOpenChange, +}: DetailSheetProps) { + const { t } = useTranslation() + + const activeSkillDetail = selectedSkillDetail ?? selectedSkill + const activeSkillOrigin = activeSkillDetail + ? getSkillOriginKind(activeSkillDetail) + : null + const detailLineCount = selectedSkillDetail + ? selectedSkillDetail.content.split("\n").length + : 0 + const detailCharacterCount = selectedSkillDetail?.content.length ?? 0 + + return ( + + + +
+
+ {activeSkillDetail?.origin_kind === "builtin" ? ( + + ) : activeSkillDetail?.registry_name ? ( + + ) : ( + + )} +
+
+ + {activeSkillDetail?.name || + t("pages.agent.skills.viewer_title")} + + + {activeSkillDetail?.description || + t("pages.agent.skills.viewer_description")} + +
+
+
+ +
+ {isLoading ? ( +
+ + + +
+ ) : error ? ( +
+ + + {t("pages.agent.skills.load_detail_error")} + +
+ ) : selectedSkillDetail ? ( +
+ {activeSkillOrigin === "third_party" ? ( +
+
+ +
+ +
+ {selectedSkillDetail.registry_name ? ( + + ) : null} + {selectedSkillDetail.installed_version ? ( + + ) : null} + {selectedSkillDetail.registry_url ? ( + + {selectedSkillDetail.registry_url} + + } + mono + /> + ) : null} +
+
+ ) : null} + +
+ {DETAIL_VIEWS.map((view) => ( + + ))} +
+ + {detailView === "preview" ? ( +
+ + {selectedSkillDetail.content} + +
+ ) : null} + + {detailView === "raw" ? ( +
+
+                    {selectedSkillDetail.content}
+                  
+
+ ) : null} + + {detailView === "meta" ? ( +
+ + + + +
+ ) : null} +
+ ) : null} +
+
+
+ ) +} + +function MetadataItem({ + label, + value, + mono = false, +}: { + label: string + value: ReactNode + mono?: boolean +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} diff --git a/web/frontend/src/components/agent/skills/filter-bar.tsx b/web/frontend/src/components/agent/skills/filter-bar.tsx new file mode 100644 index 000000000..033609ea6 --- /dev/null +++ b/web/frontend/src/components/agent/skills/filter-bar.tsx @@ -0,0 +1,132 @@ +import { IconLayoutGrid, IconLayoutList, IconSearch } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" + +import { getOriginLabel } from "./origin-utils" +import type { SkillLayoutMode, SkillSortOption } from "./types" + +interface FilterBarProps { + searchQuery: string + sourceFilter: string + availableOrigins: string[] + sortOrder: SkillSortOption + layoutMode: SkillLayoutMode + onSearchQueryChange: (value: string) => void + onSourceFilterChange: (value: string) => void + onSortOrderChange: (value: SkillSortOption) => void + onLayoutModeChange: (value: SkillLayoutMode) => void +} + +export function FilterBar({ + searchQuery, + sourceFilter, + availableOrigins, + sortOrder, + layoutMode, + onSearchQueryChange, + onSourceFilterChange, + onSortOrderChange, + onLayoutModeChange, +}: FilterBarProps) { + const { t } = useTranslation() + + return ( +
+
+ + onSearchQueryChange(event.target.value)} + placeholder={t("pages.agent.skills.search_placeholder")} + className="hover:bg-background/50 focus-visible:bg-background h-9 border-transparent bg-transparent pl-9 shadow-none focus-visible:ring-1" + /> +
+ +
+ + + +
+ + + +
+ +
+ + +
+
+ ) +} diff --git a/web/frontend/src/components/agent/skills/import-dialog.tsx b/web/frontend/src/components/agent/skills/import-dialog.tsx new file mode 100644 index 000000000..21f4827e3 --- /dev/null +++ b/web/frontend/src/components/agent/skills/import-dialog.tsx @@ -0,0 +1,160 @@ +import { IconLoader2, IconUpload, IconX } from "@tabler/icons-react" +import type { DragEvent } from "react" +import { useTranslation } from "react-i18next" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { cn } from "@/lib/utils" + +interface ImportDialogProps { + open: boolean + isImportPending: boolean + isDragActive: boolean + onOpenChange: (open: boolean) => void + onImportClick: () => void + onDragEnter: (event: DragEvent) => void + onDragLeave: (event: DragEvent) => void + onDrop: (event: DragEvent) => void +} + +export function ImportDialog({ + open, + isImportPending, + isDragActive, + onOpenChange, + onImportClick, + onDragEnter, + onDragLeave, + onDrop, +}: ImportDialogProps) { + const { t } = useTranslation() + + return ( + { + if (!isImportPending) { + onOpenChange(nextOpen) + } + }} + > + +
+ + + + + {t("pages.agent.skills.dropzone_title")} + + + {t("pages.agent.skills.dropzone_description")} + + +
+ + +
+
+ ) +} + +function SkillImportPanel({ + isDragActive, + isImportPending, + onDragEnter, + onDragLeave, + onDrop, + onImportClick, +}: { + isDragActive: boolean + isImportPending: boolean + onDragEnter: (event: DragEvent) => void + onDragLeave: (event: DragEvent) => void + onDrop: (event: DragEvent) => void + onImportClick: () => void +}) { + const { t } = useTranslation() + + return ( +
+
{ + if (!isImportPending) { + onImportClick() + } + }} + onDragEnter={onDragEnter} + onDragLeave={onDragLeave} + onDragOver={(event) => event.preventDefault()} + onDrop={onDrop} + > +
+ +
+
+
+ {isDragActive + ? t("pages.agent.skills.dropzone_active") + : t("pages.agent.skills.dropzone_label")} +
+

+ {isDragActive + ? t("pages.agent.skills.dropzone_release") + : t("pages.agent.skills.import_constraints")} +

+
+ +
+
+ ) +} diff --git a/web/frontend/src/components/agent/skills/origin-badge.tsx b/web/frontend/src/components/agent/skills/origin-badge.tsx new file mode 100644 index 000000000..0b7bf4391 --- /dev/null +++ b/web/frontend/src/components/agent/skills/origin-badge.tsx @@ -0,0 +1,46 @@ +import { + IconFileCode, + IconFolder, + IconSparkles, + IconWorld, +} from "@tabler/icons-react" + +import { cn } from "@/lib/utils" + +import { getOriginBadgeClasses } from "./origin-utils" + +export function OriginBadge({ + origin, + label, +}: { + origin: string + label: string +}) { + return ( + + + {label} + + ) +} + +export function OriginIcon({ origin }: { origin: string }) { + if (origin === "builtin") { + return + } + if (origin === "third_party") { + return + } + if (origin === "manual") { + return + } + if (origin === "all") { + return + } + return +} diff --git a/web/frontend/src/components/agent/skills/origin-utils.ts b/web/frontend/src/components/agent/skills/origin-utils.ts new file mode 100644 index 000000000..6163f7bf7 --- /dev/null +++ b/web/frontend/src/components/agent/skills/origin-utils.ts @@ -0,0 +1,86 @@ +import type { TFunction } from "i18next" + +import type { SkillSupportItem } from "@/api/skills" + +import type { SkillSortOption } from "./types" + +const KNOWN_ORIGIN_ORDER = ["builtin", "third_party", "manual"] + +export function compareSkills( + left: SkillSupportItem, + right: SkillSupportItem, + sortOrder: SkillSortOption, +) { + if (sortOrder === "source") { + const sourceDelta = compareOriginOrder( + getSkillOriginKind(left), + getSkillOriginKind(right), + ) + if (sourceDelta !== 0) return sourceDelta + return left.name.localeCompare(right.name) + } + + if (sortOrder === "name-desc") { + return right.name.localeCompare(left.name) + } + + return left.name.localeCompare(right.name) +} + +export function sortOrigins(origins: string[]) { + return [...origins].sort(compareOriginOrder) +} + +export function getSkillOriginKind(skill: SkillSupportItem) { + const origin = skill.origin_kind || skill.source + return origin === "global" ? "builtin" : origin +} + +export function getOriginLabel(origin: string, t: TFunction) { + if (origin === "builtin" || origin === "third_party" || origin === "manual") { + return t(`pages.agent.skills.origin.${origin}`) + } + if (origin === "all") { + return t("pages.agent.skills.origin.all") + } + return origin +} + +export function getOriginAccentClasses(origin: string) { + if (origin === "manual") { + return "bg-emerald-100 text-emerald-700" + } + if (origin === "third_party") { + return "bg-sky-100 text-sky-700" + } + if (origin === "builtin") { + return "bg-amber-100 text-amber-700" + } + return "bg-muted text-muted-foreground" +} + +export function getOriginBadgeClasses(origin: string) { + if (origin === "manual") { + return "bg-emerald-100 text-emerald-700" + } + if (origin === "third_party") { + return "bg-sky-100 text-sky-700" + } + if (origin === "builtin") { + return "bg-amber-100 text-amber-700" + } + return "bg-muted text-muted-foreground" +} + +function compareOriginOrder(left: string, right: string) { + const leftIndex = KNOWN_ORIGIN_ORDER.indexOf(left) + const rightIndex = KNOWN_ORIGIN_ORDER.indexOf(right) + + if (leftIndex !== -1 || rightIndex !== -1) { + if (leftIndex === -1) return 1 + if (rightIndex === -1) return -1 + return leftIndex - rightIndex + } + + return left.localeCompare(right) +} diff --git a/web/frontend/src/components/agent/skills/page-skeleton.tsx b/web/frontend/src/components/agent/skills/page-skeleton.tsx new file mode 100644 index 000000000..73df6fcdf --- /dev/null +++ b/web/frontend/src/components/agent/skills/page-skeleton.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export function PageSkeleton() { + return ( +
+
+ {[1, 2, 3, 4].map((index) => ( + + ))} +
+
+
+ +
+ +
+ {[1, 2, 3, 4].map((index) => ( + + ))} +
+
+
+ ) +} diff --git a/web/frontend/src/components/agent/skills/skill-card.tsx b/web/frontend/src/components/agent/skills/skill-card.tsx new file mode 100644 index 000000000..15bdc2c63 --- /dev/null +++ b/web/frontend/src/components/agent/skills/skill-card.tsx @@ -0,0 +1,84 @@ +import { IconFileInfo, IconTrash } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import type { SkillSupportItem } from "@/api/skills" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +interface SkillCardProps { + skill: SkillSupportItem + onView: () => void + onDelete: () => void +} + +export function SkillCard({ skill, onView, onDelete }: SkillCardProps) { + const { t } = useTranslation() + + return ( + +
+ +
+
+
+ + {skill.name} + + {skill.registry_name ? ( + + {skill.registry_name} + + ) : null} +
+ + {skill.description || t("pages.agent.skills.no_description")} + +
+
+ + {skill.source === "workspace" ? ( + + ) : null} +
+
+
+ + {skill.registry_url ? ( + + {skill.registry_url} + + ) : null} + + + ) +} diff --git a/web/frontend/src/components/agent/skills/skills-list.tsx b/web/frontend/src/components/agent/skills/skills-list.tsx new file mode 100644 index 000000000..6a2bb92ed --- /dev/null +++ b/web/frontend/src/components/agent/skills/skills-list.tsx @@ -0,0 +1,86 @@ +import { IconSearch } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import type { SkillSupportItem } from "@/api/skills" + +import { OriginBadge } from "./origin-badge" +import { getOriginLabel } from "./origin-utils" +import { SkillCard } from "./skill-card" +import type { SkillGroupSection, SkillLayoutMode } from "./types" + +interface SkillsListProps { + sortedSkills: SkillSupportItem[] + groupedSkills: SkillGroupSection[] + layoutMode: SkillLayoutMode + sourceFilter: string + hasActiveFilters: boolean + onViewSkill: (skill: SkillSupportItem) => void + onDeleteSkill: (skill: SkillSupportItem) => void +} + +export function SkillsList({ + sortedSkills, + groupedSkills, + layoutMode, + sourceFilter, + hasActiveFilters, + onViewSkill, + onDeleteSkill, +}: SkillsListProps) { + const { t } = useTranslation() + + if (!sortedSkills.length) { + return ( +
+
+ +
+

+ {hasActiveFilters + ? t("pages.agent.skills.no_results") + : t("pages.agent.skills.empty")} +

+
+ ) + } + + if (layoutMode === "grouped" && sourceFilter === "all") { + return ( +
+ {groupedSkills.map((section) => ( +
+
+ +
+
+ {section.skills.map((skill) => ( + onViewSkill(skill)} + onDelete={() => onDeleteSkill(skill)} + /> + ))} +
+
+ ))} +
+ ) + } + + return ( +
+ {sortedSkills.map((skill) => ( + onViewSkill(skill)} + onDelete={() => onDeleteSkill(skill)} + /> + ))} +
+ ) +} diff --git a/web/frontend/src/components/agent/skills/skills-page.tsx b/web/frontend/src/components/agent/skills/skills-page.tsx new file mode 100644 index 000000000..d9b5a7cd1 --- /dev/null +++ b/web/frontend/src/components/agent/skills/skills-page.tsx @@ -0,0 +1,160 @@ +import { IconLoader2, IconPlus } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import { PageHeader } from "@/components/page-header" +import { Button } from "@/components/ui/button" + +import { DeleteDialog } from "./delete-dialog" +import { DetailSheet } from "./detail-sheet" +import { FilterBar } from "./filter-bar" +import { ImportDialog } from "./import-dialog" +import { PageSkeleton } from "./page-skeleton" +import { SkillsList } from "./skills-list" +import { Stats } from "./stats" +import { useSkillsPage } from "./use-skills-page" + +export function SkillsPage() { + const { t } = useTranslation() + const { + searchQuery, + sourceFilter, + sortOrder, + layoutMode, + detailView, + isDragActive, + isImportDialogOpen, + selectedSkill, + skillPendingDelete, + availableOrigins, + groupedSkills, + stats, + sortedSkills, + hasActiveFilters, + importInputRef, + selectedSkillDetail, + skillsError, + skillDetailError, + isLoading, + isSkillDetailLoading, + isImportPending, + isDeletePending, + setSearchQuery, + setSourceFilter, + setSortOrder, + setLayoutMode, + setDetailView, + openImportDialog, + handleViewSkill, + handleRequestDelete, + handleConfirmDelete, + handleImportClick, + handleImportFileChange, + handleDropZoneDragEnter, + handleDropZoneDragLeave, + handleDropZoneDrop, + handleDetailSheetOpenChange, + handleImportDialogOpenChange, + handleDeleteDialogOpenChange, + } = useSkillsPage() + + return ( +
+ + + + + } + /> + +
+
+ {isLoading ? ( + + ) : skillsError ? ( +
+ {t("pages.agent.load_error")} +
+ ) : ( +
+ + +
+ +
+ + +
+ )} +
+
+ + + + + + +
+ ) +} diff --git a/web/frontend/src/components/agent/skills/stats.tsx b/web/frontend/src/components/agent/skills/stats.tsx new file mode 100644 index 000000000..c718fc3be --- /dev/null +++ b/web/frontend/src/components/agent/skills/stats.tsx @@ -0,0 +1,39 @@ +import { Card, CardContent } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +import { OriginIcon } from "./origin-badge" +import { getOriginAccentClasses } from "./origin-utils" +import type { SkillStatItem } from "./types" + +export function Stats({ stats }: { stats: SkillStatItem[] }) { + return ( +
+ {stats.map((stat) => ( + + +
+
+ {stat.label} +
+
+ {stat.count} +
+
+
+ +
+
+
+ ))} +
+ ) +} diff --git a/web/frontend/src/components/agent/skills/types.ts b/web/frontend/src/components/agent/skills/types.ts new file mode 100644 index 000000000..44509854c --- /dev/null +++ b/web/frontend/src/components/agent/skills/types.ts @@ -0,0 +1,17 @@ +import type { SkillSupportItem } from "@/api/skills" + +export type SkillSortOption = "name-asc" | "name-desc" | "source" +export type SkillLayoutMode = "grouped" | "grid" +export type SkillDetailView = "preview" | "raw" | "meta" + +export interface SkillGroupSection { + origin: string + skills: SkillSupportItem[] +} + +export interface SkillStatItem { + key: string + origin: string + label: string + count: number +} diff --git a/web/frontend/src/components/agent/skills/use-skills-page.ts b/web/frontend/src/components/agent/skills/use-skills-page.ts new file mode 100644 index 000000000..7cf4a01ad --- /dev/null +++ b/web/frontend/src/components/agent/skills/use-skills-page.ts @@ -0,0 +1,339 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { + type ChangeEvent, + type DragEvent, + startTransition, + useDeferredValue, + useMemo, + useRef, + useState, +} from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { + type SkillSupportItem, + deleteSkill, + getSkill, + getSkills, + importSkill, +} from "@/api/skills" + +import { + compareSkills, + getOriginLabel, + getSkillOriginKind, + sortOrigins, +} from "./origin-utils" +import type { + SkillDetailView, + SkillGroupSection, + SkillLayoutMode, + SkillSortOption, + SkillStatItem, +} from "./types" + +const MAX_IMPORT_FILE_SIZE = 1 << 20 + +export function useSkillsPage() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const importInputRef = useRef(null) + const dragDepthRef = useRef(0) + + const [searchQuery, setSearchQuery] = useState("") + const deferredSearchQuery = useDeferredValue(searchQuery) + const [sourceFilter, setSourceFilter] = useState("all") + const [sortOrder, setSortOrder] = useState("name-asc") + const [layoutMode, setLayoutMode] = useState("grouped") + const [detailView, setDetailView] = useState("preview") + const [isDragActive, setIsDragActive] = useState(false) + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) + const [selectedSkill, setSelectedSkill] = useState( + null, + ) + const [skillPendingDelete, setSkillPendingDelete] = + useState(null) + + const skillsQuery = useQuery({ + queryKey: ["skills"], + queryFn: getSkills, + }) + + const skillDetailQuery = useQuery({ + queryKey: ["skills", selectedSkill?.name], + queryFn: () => getSkill(selectedSkill!.name), + enabled: selectedSkill !== null, + }) + + const importMutation = useMutation({ + mutationFn: async (file: File) => importSkill(file), + onSuccess: (importedSkill) => { + toast.success(t("pages.agent.skills.import_success")) + startTransition(() => { + setIsImportDialogOpen(false) + setDetailView("preview") + if (importedSkill.name) { + setSelectedSkill({ + name: importedSkill.name, + path: importedSkill.path ?? "", + source: importedSkill.source ?? "workspace", + description: importedSkill.description ?? "", + origin_kind: importedSkill.origin_kind ?? "manual", + registry_name: importedSkill.registry_name, + registry_url: importedSkill.registry_url, + installed_version: importedSkill.installed_version, + installed_at: importedSkill.installed_at, + }) + } + }) + void queryClient.invalidateQueries({ queryKey: ["skills"] }) + }, + onError: (err) => { + toast.error( + err instanceof Error + ? err.message + : t("pages.agent.skills.import_error"), + ) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: async (name: string) => deleteSkill(name), + onSuccess: (_, deletedName) => { + toast.success(t("pages.agent.skills.delete_success")) + setSkillPendingDelete(null) + if ( + selectedSkill?.name === deletedName && + selectedSkill.source === "workspace" + ) { + setSelectedSkill(null) + } + void queryClient.invalidateQueries({ queryKey: ["skills"] }) + }, + onError: (err) => { + toast.error( + err instanceof Error + ? err.message + : t("pages.agent.skills.delete_error"), + ) + }, + }) + + const allSkills = useMemo( + () => skillsQuery.data?.skills ?? [], + [skillsQuery.data?.skills], + ) + const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase() + + const availableOrigins = useMemo( + () => + sortOrigins([ + ...new Set(allSkills.map((skill) => getSkillOriginKind(skill))), + ]), + [allSkills], + ) + + const filteredSkills = useMemo(() => { + return allSkills.filter((skill) => { + const matchesSource = + sourceFilter === "all" + ? true + : getSkillOriginKind(skill) === sourceFilter + if (!matchesSource) return false + if (normalizedSearchQuery === "") return true + + const searchTarget = + `${skill.name} ${skill.description} ${skill.registry_name ?? ""}`.toLowerCase() + return searchTarget.includes(normalizedSearchQuery) + }) + }, [allSkills, normalizedSearchQuery, sourceFilter]) + + const sortedSkills = useMemo( + () => + [...filteredSkills].sort((left, right) => + compareSkills(left, right, sortOrder), + ), + [filteredSkills, sortOrder], + ) + + const groupedSkills = useMemo( + () => + availableOrigins + .map((origin) => ({ + origin, + skills: sortedSkills.filter( + (skill) => getSkillOriginKind(skill) === origin, + ), + })) + .filter((section) => section.skills.length > 0), + [availableOrigins, sortedSkills], + ) + + const stats = useMemo( + () => [ + { + key: "all", + origin: "all", + label: t("pages.agent.skills.summary.total"), + count: allSkills.length, + }, + ...availableOrigins.map((origin) => ({ + key: origin, + origin, + label: getOriginLabel(origin, t), + count: allSkills.filter((skill) => getSkillOriginKind(skill) === origin) + .length, + })), + ], + [allSkills, availableOrigins, t], + ) + + const hasActiveFilters = + normalizedSearchQuery !== "" || sourceFilter !== "all" + + const handleImportClick = () => { + importInputRef.current?.click() + } + + const handleViewSkill = (skill: SkillSupportItem) => { + setDetailView("preview") + setSelectedSkill(skill) + } + + const handleRequestDelete = (skill: SkillSupportItem) => { + setSkillPendingDelete(skill) + } + + const handleConfirmDelete = () => { + if (skillPendingDelete) { + deleteMutation.mutate(skillPendingDelete.name) + } + } + + const handleDetailSheetOpenChange = (open: boolean) => { + if (!open) { + setSelectedSkill(null) + } + } + + const handleImportDialogOpenChange = (open: boolean) => { + if (!importMutation.isPending) { + setIsImportDialogOpen(open) + } + } + + const handleDeleteDialogOpenChange = (open: boolean) => { + if (!open) { + setSkillPendingDelete(null) + } + } + + const validateImportFile = (file: File) => { + const fileName = file.name.toLowerCase() + const isMarkdownFile = + fileName.endsWith(".md") || + file.type === "text/markdown" || + file.type === "text/plain" || + file.type === "" + const isZipFile = + fileName.endsWith(".zip") || + file.type === "application/zip" || + file.type === "application/x-zip-compressed" + + if (!isMarkdownFile && !isZipFile) { + return t("pages.agent.skills.import_invalid_type") + } + + if (file.size > MAX_IMPORT_FILE_SIZE) { + return t("pages.agent.skills.import_invalid_size") + } + + return null + } + + const handleImportFile = (file: File) => { + const validationMessage = validateImportFile(file) + if (validationMessage) { + toast.error(validationMessage) + return + } + importMutation.mutate(file) + } + + const handleImportFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + handleImportFile(file) + event.target.value = "" + } + + const resetDragState = () => { + dragDepthRef.current = 0 + setIsDragActive(false) + } + + const handleDropZoneDragEnter = (event: DragEvent) => { + event.preventDefault() + dragDepthRef.current += 1 + setIsDragActive(true) + } + + const handleDropZoneDragLeave = (event: DragEvent) => { + event.preventDefault() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) { + setIsDragActive(false) + } + } + + const handleDropZoneDrop = (event: DragEvent) => { + event.preventDefault() + const file = event.dataTransfer.files?.[0] + resetDragState() + if (!file) return + handleImportFile(file) + } + + return { + searchQuery, + sourceFilter, + sortOrder, + layoutMode, + detailView, + isDragActive, + isImportDialogOpen, + selectedSkill, + skillPendingDelete, + availableOrigins, + groupedSkills, + stats, + sortedSkills, + hasActiveFilters, + importInputRef, + selectedSkillDetail: skillDetailQuery.data, + skillsError: skillsQuery.error, + skillDetailError: skillDetailQuery.error, + isLoading: skillsQuery.isLoading, + isSkillDetailLoading: skillDetailQuery.isLoading, + isImportPending: importMutation.isPending, + isDeletePending: deleteMutation.isPending, + setSearchQuery, + setSourceFilter, + setSortOrder, + setLayoutMode, + setDetailView, + openImportDialog: () => setIsImportDialogOpen(true), + handleViewSkill, + handleRequestDelete, + handleConfirmDelete, + handleImportClick, + handleImportFileChange, + handleDropZoneDragEnter, + handleDropZoneDragLeave, + handleDropZoneDrop, + handleDetailSheetOpenChange, + handleImportDialogOpenChange, + handleDeleteDialogOpenChange, + } +} diff --git a/web/frontend/src/components/agent/tools/tool-library-tab.tsx b/web/frontend/src/components/agent/tools/tool-library-tab.tsx new file mode 100644 index 000000000..6bbfeb091 --- /dev/null +++ b/web/frontend/src/components/agent/tools/tool-library-tab.tsx @@ -0,0 +1,270 @@ +import { IconSearch, IconSettings } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import type { ToolSupportItem } from "@/api/tools" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" + +import { ToolStatusBadge } from "./tool-status-badge" +import type { GroupedTools, ToolStatusFilter } from "./types" + +interface ToolLibraryTabProps { + allTools: ToolSupportItem[] + groupedTools: GroupedTools + totalFilteredCount: number + searchQuery: string + statusFilter: ToolStatusFilter + isLoading: boolean + hasError: boolean + pendingToolName: string | null + onSearchQueryChange: (value: string) => void + onStatusFilterChange: (value: ToolStatusFilter) => void + onOpenWebSearchSettings: () => void + onToggleTool: (name: string, enabled: boolean) => void +} + +export function ToolLibraryTab({ + allTools, + groupedTools, + totalFilteredCount, + searchQuery, + statusFilter, + isLoading, + hasError, + pendingToolName, + onSearchQueryChange, + onStatusFilterChange, + onOpenWebSearchSettings, + onToggleTool, +}: ToolLibraryTabProps) { + const { t } = useTranslation() + + return ( +
+
+
+

+ {t("pages.agent.tools.library_title", "Tool Library")} +

+

+ {t( + "pages.agent.tools.library_description", + "Browse and manage the toolset available to your AI agents.", + )} +

+
+ +
+
+ + onSearchQueryChange(event.target.value)} + /> +
+ + +
+
+ + {hasError ? ( +
+

+ {t("pages.agent.load_error", "Failed to load tools")} +

+
+ ) : isLoading ? ( + + ) : totalFilteredCount === 0 ? ( + + ) : ( +
+ {groupedTools.map(([category, items]) => ( +
+
+

+ {t(`pages.agent.tools.categories.${category}`, category)} +

+
+
+ {items.map((tool) => ( + + ))} +
+
+ ))} +
+ )} +
+ ) +} + +function ToolCard({ + tool, + isPending, + onOpenWebSearchSettings, + onToggleTool, +}: { + tool: ToolSupportItem + isPending: boolean + onOpenWebSearchSettings: () => void + onToggleTool: (name: string, enabled: boolean) => void +}) { + const { t } = useTranslation() + const reasonText = tool.reason_code + ? t(`pages.agent.tools.reasons.${tool.reason_code}`) + : "" + const isEnabled = tool.status === "enabled" + const isToggledOn = tool.status !== "disabled" + const isDisabled = tool.status === "disabled" + const isBlocked = tool.status === "blocked" + const isWebSearchTool = tool.name === "web_search" + + return ( + + +
+
+

+ {tool.name} +

+ +
+
+ {isWebSearchTool && ( + + )} + onToggleTool(tool.name, checked)} + className={cn( + "shrink-0", + isEnabled && "shadow-xs ring-1 ring-emerald-500/20", + )} + /> +
+
+ +

+ {tool.description} +

+ + {reasonText && ( +
+
+ {reasonText} +
+
+ )} +
+
+ ) +} + +function LibraryLoadingState() { + return ( +
+ {[1, 2].map((groupIndex) => ( +
+ +
+ {[1, 2].map((itemIndex) => ( + + ))} +
+
+ ))} +
+ ) +} + +function LibraryEmptyState({ allToolsCount }: { allToolsCount: number }) { + const { t } = useTranslation() + + return ( +
+
+ +
+

+ {allToolsCount === 0 + ? t("pages.agent.tools.empty", "No tools found") + : t("pages.agent.tools.no_results", "No matching tools")} +

+ {allToolsCount !== 0 && ( +

+ Try adjusting your search criteria or status filters. +

+ )} +
+ ) +} diff --git a/web/frontend/src/components/agent/tools/tool-status-badge.tsx b/web/frontend/src/components/agent/tools/tool-status-badge.tsx new file mode 100644 index 000000000..017d167b2 --- /dev/null +++ b/web/frontend/src/components/agent/tools/tool-status-badge.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "react-i18next" + +import type { ToolSupportItem } from "@/api/tools" +import { cn } from "@/lib/utils" + +interface ToolStatusBadgeProps { + status: ToolSupportItem["status"] +} + +export function ToolStatusBadge({ status }: ToolStatusBadgeProps) { + const { t } = useTranslation() + + return ( + + {t(`pages.agent.tools.status.${status}`, status)} + + ) +} diff --git a/web/frontend/src/components/agent/tools/tools-page.tsx b/web/frontend/src/components/agent/tools/tools-page.tsx new file mode 100644 index 000000000..c221f911c --- /dev/null +++ b/web/frontend/src/components/agent/tools/tools-page.tsx @@ -0,0 +1,87 @@ +import { useLayoutEffect, useRef } from "react" +import { useTranslation } from "react-i18next" + +import { PageHeader } from "@/components/page-header" + +import { ToolLibraryTab } from "./tool-library-tab" +import { ToolsTabs } from "./tools-tabs" +import { useToolsPage } from "./use-tools-page" +import { WebSearchTab } from "./web-search-tab" + +export function ToolsPage() { + const { t } = useTranslation() + const scrollContainerRef = useRef(null) + const { + activeTab, + expandedProvider, + groupedTools, + pendingToolName, + providerLabelMap, + searchQuery, + statusFilter, + tools, + totalFilteredCount, + webSearchDraft, + hasToolsError, + hasWebSearchError, + isToolsLoading, + isWebSearchLoading, + isWebSearchSaving, + isWebSearchDirty, + setActiveTab, + setSearchQuery, + setStatusFilter, + saveWebSearchConfig, + toggleExpandedProvider, + toggleTool, + updateWebSearchDraft, + } = useToolsPage() + + useLayoutEffect(() => { + scrollContainerRef.current?.scrollTo({ top: 0 }) + }, [activeTab]) + + return ( +
+ + + +
+
+ {activeTab === "library" ? ( + setActiveTab("web-search")} + onToggleTool={toggleTool} + /> + ) : ( + + )} +
+
+
+ ) +} diff --git a/web/frontend/src/components/agent/tools/tools-tabs.tsx b/web/frontend/src/components/agent/tools/tools-tabs.tsx new file mode 100644 index 000000000..a5898ccdc --- /dev/null +++ b/web/frontend/src/components/agent/tools/tools-tabs.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from "react-i18next" + +import { cn } from "@/lib/utils" + +import type { ToolsPageTab } from "./types" + +interface ToolsTabsProps { + activeTab: ToolsPageTab + onChange: (tab: ToolsPageTab) => void +} + +const tabs: Array<{ + defaultLabel: string + key: ToolsPageTab + translationKey: string +}> = [ + { + key: "library", + translationKey: "pages.agent.tools.library_title", + defaultLabel: "Tool Library", + }, + { + key: "web-search", + translationKey: "pages.agent.tools.web_search.title", + defaultLabel: "Web Search", + }, +] + +export function ToolsTabs({ activeTab, onChange }: ToolsTabsProps) { + const { t } = useTranslation() + + return ( +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ ) +} diff --git a/web/frontend/src/components/agent/tools/types.ts b/web/frontend/src/components/agent/tools/types.ts new file mode 100644 index 000000000..1aec90931 --- /dev/null +++ b/web/frontend/src/components/agent/tools/types.ts @@ -0,0 +1,9 @@ +import type { ToolSupportItem, WebSearchConfigResponse } from "@/api/tools" + +export type ToolsPageTab = "library" | "web-search" +export type ToolStatusFilter = "all" | ToolSupportItem["status"] +export type GroupedTools = Array<[string, ToolSupportItem[]]> + +export type WebSearchDraftUpdater = ( + updater: (current: WebSearchConfigResponse) => WebSearchConfigResponse, +) => void diff --git a/web/frontend/src/components/agent/tools/use-tools-page.ts b/web/frontend/src/components/agent/tools/use-tools-page.ts new file mode 100644 index 000000000..ecc433b0e --- /dev/null +++ b/web/frontend/src/components/agent/tools/use-tools-page.ts @@ -0,0 +1,209 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useDeferredValue, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { + type WebSearchConfigResponse, + getTools, + getWebSearchConfig, + setToolEnabled, + updateWebSearchConfig, +} from "@/api/tools" +import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" +import { refreshGatewayState } from "@/store/gateway" + +import type { GroupedTools, ToolStatusFilter, ToolsPageTab } from "./types" + +export function useToolsPage() { + const { t } = useTranslation() + const queryClient = useQueryClient() + + const [activeTab, setActiveTab] = useState("library") + const [searchQuery, setSearchQuery] = useState("") + const deferredSearchQuery = useDeferredValue(searchQuery) + const [statusFilter, setStatusFilter] = useState("all") + const [expandedProvider, setExpandedProvider] = useState(null) + const [webSearchDraftOverride, setWebSearchDraftOverride] = + useState(null) + + const toolsQuery = useQuery({ + queryKey: ["tools"], + queryFn: getTools, + }) + const webSearchQuery = useQuery({ + queryKey: ["tools", "web-search-config"], + queryFn: getWebSearchConfig, + }) + + const tools = useMemo( + () => toolsQuery.data?.tools ?? [], + [toolsQuery.data?.tools], + ) + const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase() + const webSearchDraft = webSearchDraftOverride ?? webSearchQuery.data ?? null + const isWebSearchDirty = useMemo(() => { + if (!webSearchDraft || !webSearchQuery.data) { + return false + } + return ( + JSON.stringify(webSearchDraft) !== JSON.stringify(webSearchQuery.data) + ) + }, [webSearchDraft, webSearchQuery.data]) + + const toggleToolMutation = useMutation({ + mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) => + setToolEnabled(name, enabled), + onSuccess: async (_, variables) => { + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, + variables.enabled + ? t("pages.agent.tools.enable_success", "Tool enabled successfully") + : t( + "pages.agent.tools.disable_success", + "Tool disabled successfully", + ), + t("navigation.tools", "Tools"), + gateway?.restartRequired === true, + ) + void queryClient.invalidateQueries({ queryKey: ["tools"] }) + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : t("pages.agent.tools.toggle_error", "Failed to toggle tool"), + ) + }, + }) + + const saveWebSearchMutation = useMutation({ + mutationFn: updateWebSearchConfig, + onSuccess: async (updatedConfig) => { + queryClient.setQueryData(["tools", "web-search-config"], updatedConfig) + setWebSearchDraftOverride(null) + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, + t( + "pages.agent.tools.web_search.save_success", + "Settings saved successfully", + ), + t("pages.agent.tools.web_search.title", "Web Search Configuration"), + gateway?.restartRequired === true, + ) + void queryClient.invalidateQueries({ + queryKey: ["tools", "web-search-config"], + }) + void queryClient.invalidateQueries({ queryKey: ["tools"] }) + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : t( + "pages.agent.tools.web_search.save_error", + "Failed to save settings", + ), + ) + }, + }) + + const groupedTools = useMemo<{ + groupedTools: GroupedTools + totalFilteredCount: number + }>(() => { + let totalFilteredCount = 0 + const grouped = new Map() + + for (const tool of tools) { + if (statusFilter !== "all" && tool.status !== statusFilter) { + continue + } + + if (normalizedSearchQuery) { + const matchesName = tool.name + .toLowerCase() + .includes(normalizedSearchQuery) + const matchesDescription = (tool.description || "") + .toLowerCase() + .includes(normalizedSearchQuery) + + if (!matchesName && !matchesDescription) { + continue + } + } + + totalFilteredCount += 1 + const items = grouped.get(tool.category) ?? [] + items.push(tool) + grouped.set(tool.category, items) + } + + return { + groupedTools: Array.from(grouped.entries()), + totalFilteredCount, + } + }, [normalizedSearchQuery, statusFilter, tools]) + + const providerLabelMap = useMemo(() => { + const providers = webSearchDraft?.providers ?? [] + return new Map(providers.map((provider) => [provider.id, provider.label])) + }, [webSearchDraft]) + + const pendingToolName = toggleToolMutation.isPending + ? (toggleToolMutation.variables?.name ?? null) + : null + + const updateWebSearchDraft = ( + updater: (current: WebSearchConfigResponse) => WebSearchConfigResponse, + ) => { + setWebSearchDraftOverride((current) => { + const draft = current ?? webSearchQuery.data + return draft ? updater(draft) : current + }) + } + + const toggleTool = (name: string, enabled: boolean) => { + toggleToolMutation.mutate({ name, enabled }) + } + + const saveWebSearchConfig = () => { + if (webSearchDraft) { + saveWebSearchMutation.mutate(webSearchDraft) + } + } + + const toggleExpandedProvider = (providerId: string) => { + setExpandedProvider((current) => + current === providerId ? null : providerId, + ) + } + + return { + activeTab, + expandedProvider, + groupedTools: groupedTools.groupedTools, + pendingToolName, + providerLabelMap, + searchQuery, + statusFilter, + tools, + totalFilteredCount: groupedTools.totalFilteredCount, + webSearchDraft, + hasToolsError: toolsQuery.error != null, + hasWebSearchError: webSearchQuery.error != null, + isToolsLoading: toolsQuery.isLoading, + isWebSearchLoading: webSearchQuery.isLoading, + isWebSearchSaving: saveWebSearchMutation.isPending, + isWebSearchDirty, + setActiveTab, + setSearchQuery, + setStatusFilter, + saveWebSearchConfig, + toggleExpandedProvider, + toggleTool, + updateWebSearchDraft, + } +} diff --git a/web/frontend/src/components/agent/tools/web-search-general-settings.tsx b/web/frontend/src/components/agent/tools/web-search-general-settings.tsx new file mode 100644 index 000000000..f3c8004b5 --- /dev/null +++ b/web/frontend/src/components/agent/tools/web-search-general-settings.tsx @@ -0,0 +1,139 @@ +import type { ReactNode } from "react" +import { useTranslation } from "react-i18next" + +import type { WebSearchConfigResponse } from "@/api/tools" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" + +import type { WebSearchDraftUpdater } from "./types" + +interface WebSearchGeneralSettingsProps { + draft: WebSearchConfigResponse + onUpdateDraft: WebSearchDraftUpdater +} + +export function WebSearchGeneralSettings({ + draft, + onUpdateDraft, +}: WebSearchGeneralSettingsProps) { + const { t } = useTranslation() + + return ( +
+

+ {t("pages.agent.tools.web_search.global_settings", "General")} +

+ +
+ + + + + + + onUpdateDraft((current) => ({ + ...current, + proxy: event.target.value, + })) + } + placeholder="http://127.0.0.1:7890" + /> + + + + + onUpdateDraft((current) => ({ + ...current, + prefer_native: checked, + })) + } + className="data-[state=checked]:shadow-xs" + /> + +
+
+ ) +} + +function SettingRow({ + label, + description, + children, +}: { + label: string + description: string + children: ReactNode +}) { + return ( +
+
+ +

+ {description} +

+
+ {children} +
+ ) +} diff --git a/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx b/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx new file mode 100644 index 000000000..9ba8d6ac6 --- /dev/null +++ b/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx @@ -0,0 +1,253 @@ +import { IconChevronDown } from "@tabler/icons-react" +import type { ReactNode } from "react" +import { useTranslation } from "react-i18next" + +import type { WebSearchProviderConfig } from "@/api/tools" +import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +import { KeyInput } from "@/components/shared-form" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" + +import type { WebSearchDraftUpdater } from "./types" + +interface WebSearchProviderSettingsProps { + providerLabelMap: Map + settings: Record + expandedProvider: string | null + onToggleProviderExpand: (providerId: string) => void + onUpdateDraft: WebSearchDraftUpdater +} + +const baseUrlProviders = new Set([ + "tavily", + "searxng", + "glm_search", + "baidu_search", +]) + +const apiKeyProviders = new Set([ + "brave", + "tavily", + "perplexity", + "glm_search", + "baidu_search", +]) + +export function WebSearchProviderSettings({ + providerLabelMap, + settings, + expandedProvider, + onToggleProviderExpand, + onUpdateDraft, +}: WebSearchProviderSettingsProps) { + const { t } = useTranslation() + + return ( +
+

+ {t("pages.agent.tools.web_search.providers_config", "Integrations")} +

+ +
+ {Object.entries(settings).map(([providerId, providerSettings]) => ( + + ))} +
+
+ ) +} + +function ProviderCard({ + providerId, + providerLabel, + settings, + isExpanded, + onToggleExpand, + onUpdateDraft, +}: { + providerId: string + providerLabel: string + settings: WebSearchProviderConfig + isExpanded: boolean + onToggleExpand: (providerId: string) => void + onUpdateDraft: WebSearchDraftUpdater +}) { + const { t } = useTranslation() + const apiKeyPlaceholder = maskedSecretPlaceholder( + settings.api_key_set ? `${providerId}-configured` : "", + t( + "pages.agent.tools.web_search.api_key_placeholder", + "Enter API key...", + ), + ) + + const updateSettings = ( + updater: (current: WebSearchProviderConfig) => WebSearchProviderConfig, + ) => { + onUpdateDraft((current) => { + const nextSettings = current.settings[providerId] ?? settings + return { + ...current, + settings: { + ...current.settings, + [providerId]: updater(nextSettings), + }, + } + }) + } + + return ( +
+
+ + +
event.stopPropagation()} + > + + updateSettings((current) => ({ + ...current, + enabled: checked, + })) + } + /> +
+
+ + {isExpanded && ( +
+
+ + + updateSettings((current) => ({ + ...current, + max_results: Number(event.target.value) || 0, + })) + } + className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent shadow-none transition-colors" + /> + + + {baseUrlProviders.has(providerId) && ( + + + updateSettings((current) => ({ + ...current, + base_url: event.target.value, + })) + } + placeholder={t( + "pages.agent.tools.web_search.base_url_placeholder", + "Optional endpoint override", + )} + className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent shadow-none transition-colors" + /> + + )} + + {apiKeyProviders.has(providerId) && ( + + + updateSettings((current) => ({ + ...current, + api_key: value, + })) + } + placeholder={apiKeyPlaceholder} + className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent transition-colors" + /> + + )} +
+
+ )} +
+ ) +} + +function ProviderField({ + label, + className, + children, +}: { + label: string + className?: string + children: ReactNode +}) { + return ( +
+ + {children} +
+ ) +} diff --git a/web/frontend/src/components/agent/tools/web-search-tab.tsx b/web/frontend/src/components/agent/tools/web-search-tab.tsx new file mode 100644 index 000000000..866e0f27f --- /dev/null +++ b/web/frontend/src/components/agent/tools/web-search-tab.tsx @@ -0,0 +1,113 @@ +import { useTranslation } from "react-i18next" + +import type { WebSearchConfigResponse } from "@/api/tools" +import { ConfigChangeNotice } from "@/components/config-change-notice" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" + +import type { WebSearchDraftUpdater } from "./types" +import { WebSearchGeneralSettings } from "./web-search-general-settings" +import { WebSearchProviderSettings } from "./web-search-provider-settings" + +interface WebSearchTabProps { + draft: WebSearchConfigResponse | null + providerLabelMap: Map + expandedProvider: string | null + isLoading: boolean + hasError: boolean + isSaving: boolean + isDirty: boolean + onSave: () => void + onToggleProviderExpand: (providerId: string) => void + onUpdateDraft: WebSearchDraftUpdater +} + +export function WebSearchTab({ + draft, + providerLabelMap, + expandedProvider, + isLoading, + hasError, + isSaving, + isDirty, + onSave, + onToggleProviderExpand, + onUpdateDraft, +}: WebSearchTabProps) { + const { t } = useTranslation() + + return ( +
+ {hasError ? ( +
+

+ {t( + "pages.agent.tools.web_search.load_error", + "Failed to load web search configuration", + )} +

+
+ ) : isLoading || !draft ? ( + + ) : ( + <> +
+
+

+ {t( + "pages.agent.tools.web_search.title", + "Web Search Configuration", + )} +

+

+ {t( + "pages.agent.tools.web_search.description", + "Configure how the web search tool behaves by default, including whether the model may use its built-in search capability.", + )} +

+
+ + +
+ + {isDirty && ( + + )} + +
+ + +
+ + )} +
+ ) +} + +function LoadingState() { + return ( +
+ + +
+ ) +} diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx new file mode 100644 index 000000000..700cc21e0 --- /dev/null +++ b/web/frontend/src/components/app-header.tsx @@ -0,0 +1,334 @@ +import { + IconBook, + IconLanguage, + IconLoader2, + IconLogout, + IconMenu2, + IconMoon, + IconPlayerPlay, + IconPower, + IconRefresh, + IconSun, +} from "@tabler/icons-react" +import { Link } from "@tanstack/react-router" +import * as React from "react" +import { useTranslation } from "react-i18next" + +import { postLauncherDashboardLogout } from "@/api/launcher-auth" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog.tsx" +import { Button } from "@/components/ui/button.tsx" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu.tsx" +import { Separator } from "@/components/ui/separator.tsx" +import { SidebarTrigger } from "@/components/ui/sidebar" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { useGateway } from "@/hooks/use-gateway.ts" +import { useTheme } from "@/hooks/use-theme.ts" + +export function AppHeader() { + const { i18n, t } = useTranslation() + const { theme, toggleTheme } = useTheme() + const { + state: gwState, + loading: gwLoading, + canStart, + startReason, + restartRequired, + start, + restart, + stop, + error: gwError, + } = useGateway() + + const isRunning = gwState === "running" + const isStarting = gwState === "starting" + const isRestarting = gwState === "restarting" + const isStopping = gwState === "stopping" + const isStopped = gwState === "stopped" || gwState === "unknown" + const showNotConnectedHint = + !isRestarting && + !isStopping && + canStart && + (gwState === "stopped" || gwState === "error") + + const [showStopDialog, setShowStopDialog] = React.useState(false) + const [showLogoutDialog, setShowLogoutDialog] = React.useState(false) + + const handleLogout = async () => { + await postLauncherDashboardLogout() + globalThis.location.assign("/launcher-login") + } + + const handleGatewayToggle = () => { + if (gwLoading || isRestarting || isStopping || (!isRunning && !canStart)) { + return + } + if (isRunning) { + setShowStopDialog(true) + } else { + void start() + } + } + + const handleGatewayRestart = () => { + if (gwLoading || isRestarting || !restartRequired || !canStart) return + void restart() + } + + const confirmStop = () => { + setShowStopDialog(false) + stop() + } + + return ( +
+
+ + + +
+ + Logo + +
+
+ + {/* Center prominent connection status */} +
+ {showNotConnectedHint && ( +
+ + + + {t("chat.notConnected")} +
+ )} +
+ + + + + + {t("header.gateway.stopDialog.title")} + + + {t("header.gateway.stopDialog.description")} + + + + {t("common.cancel")} + + {t("header.gateway.stopDialog.confirm")} + + + + + + + + + {t("header.logout.tooltip")} + + {t("header.logout.description")} + + + + {t("common.cancel")} + void handleLogout()}> + {t("header.logout.confirm")} + + + + + +
+ {restartRequired && ( + + + + + + {t("header.gateway.restartRequired")} + + + )} + + {/* Gateway Start/Stop */} + {isRunning ? ( + + + + + + {gwError ?? t("header.gateway.action.stop")} + + + ) : ( + + + {/* Wrap in span so the tooltip still fires when the button is disabled */} + + + + + {gwError || (!canStart && startReason) ? ( + {gwError ?? startReason} + ) : null} + + )} + + + + {/* Docs Link */} + + + {/* Language Switcher */} + + + + + + i18n.changeLanguage("en")}> + English + + i18n.changeLanguage("pt-BR")}> + Português (Brasil) + + i18n.changeLanguage("zh")}> + 简体中文 + + + + + {/* Theme Toggle */} + + + + + {/* Logout */} + + + + + {t("header.logout.tooltip")} + +
+
+ ) +} diff --git a/web/frontend/src/components/app-layout.tsx b/web/frontend/src/components/app-layout.tsx new file mode 100644 index 000000000..a1f8f89ae --- /dev/null +++ b/web/frontend/src/components/app-layout.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react" +import { Toaster } from "sonner" + +import { AppHeader } from "@/components/app-header" +import { AppSidebar } from "@/components/app-sidebar" +import { TourGuide } from "@/components/tour/tour-guide" +import { SidebarProvider } from "@/components/ui/sidebar" +import { TooltipProvider } from "@/components/ui/tooltip" + +export function AppLayout({ children }: { children: ReactNode }) { + return ( + + + + +
+ +
+
+ {children} +
+
+
+ + +
+
+ ) +} diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx new file mode 100644 index 000000000..1980e458c --- /dev/null +++ b/web/frontend/src/components/app-sidebar.tsx @@ -0,0 +1,260 @@ +import { IconChevronRight } from "@tabler/icons-react" +import { + IconAtom, + IconChevronsDown, + IconChevronsUp, + IconKey, + IconListDetails, + IconMessageCircle, + IconSearch, + IconSettings, + IconSparkles, + IconTools, +} from "@tabler/icons-react" +import { Link, useRouterState } from "@tanstack/react-router" +import * as React from "react" +import { useTranslation } from "react-i18next" + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail, + useSidebar, +} from "@/components/ui/sidebar" +import { useSidebarChannels } from "@/hooks/use-sidebar-channels" + +interface NavItem { + title: string + url: string + icon: React.ComponentType<{ className?: string }> + translateTitle?: boolean +} + +interface NavGroup { + label: string + defaultOpen: boolean + items: NavItem[] + isChannelsGroup?: boolean +} + +const baseNavGroups: Omit[] = [ + { + label: "navigation.chat", + defaultOpen: true, + }, + { + label: "navigation.model_group", + defaultOpen: true, + }, + { + label: "navigation.agent_group", + defaultOpen: true, + }, + { + label: "navigation.services", + defaultOpen: true, + }, +] + +export function AppSidebar({ ...props }: React.ComponentProps) { + const routerState = useRouterState() + const { i18n, t } = useTranslation() + const { isMobile, setOpenMobile } = useSidebar() + const currentPath = routerState.location.pathname + const { + channelItems, + hasMoreChannels, + showAllChannels, + toggleShowAllChannels, + } = useSidebarChannels({ + language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(), + t, + }) + + const handleNavItemClick = React.useCallback(() => { + if (isMobile) { + setOpenMobile(false) + } + }, [isMobile, setOpenMobile]) + + const navGroups: NavGroup[] = React.useMemo(() => { + return [ + { + ...baseNavGroups[0], + items: [ + { + title: "navigation.chat", + url: "/", + icon: IconMessageCircle, + translateTitle: true, + }, + ], + }, + { + ...baseNavGroups[1], + items: [ + { + title: "navigation.models", + url: "/models", + icon: IconAtom, + translateTitle: true, + }, + { + title: "navigation.credentials", + url: "/credentials", + icon: IconKey, + translateTitle: true, + }, + ], + }, + { + label: "navigation.channels_group", + defaultOpen: true, + items: channelItems.map((item) => ({ + title: item.title, + url: item.url, + icon: item.icon, + translateTitle: false, + })), + isChannelsGroup: true, + }, + { + ...baseNavGroups[2], + items: [ + { + title: "navigation.hub", + url: "/agent/hub", + icon: IconSearch, + translateTitle: true, + }, + { + title: "navigation.skills", + url: "/agent/skills", + icon: IconSparkles, + translateTitle: true, + }, + { + title: "navigation.tools", + url: "/agent/tools", + icon: IconTools, + translateTitle: true, + }, + ], + }, + { + ...baseNavGroups[3], + items: [ + { + title: "navigation.config", + url: "/config", + icon: IconSettings, + translateTitle: true, + }, + { + title: "navigation.logs", + url: "/logs", + icon: IconListDetails, + translateTitle: true, + }, + ], + }, + ] + }, [channelItems]) + + return ( + + + {navGroups.map((group) => ( + + + + + {t(group.label)} + + + + + + + {group.items.map((item) => { + const isActive = + currentPath === item.url || + (item.url !== "/" && + currentPath.startsWith(`${item.url}/`)) + return ( + + + + + + {item.translateTitle === false + ? item.title + : t(item.title)} + + + + + ) + })} + {group.isChannelsGroup && hasMoreChannels && ( + + + {showAllChannels ? ( + + ) : ( + + )} + + {showAllChannels + ? t("navigation.show_less_channels") + : t("navigation.show_more_channels")} + + + + )} + + + + + + ))} + + + + ) +} diff --git a/web/frontend/src/components/channels/channel-array-list-field.tsx b/web/frontend/src/components/channels/channel-array-list-field.tsx new file mode 100644 index 000000000..ff601c07b --- /dev/null +++ b/web/frontend/src/components/channels/channel-array-list-field.tsx @@ -0,0 +1,180 @@ +import { IconX } from "@tabler/icons-react" +import { + type KeyboardEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react" +import { useTranslation } from "react-i18next" + +import { + mergeUniqueStringItems, + parseConservativeStringListInput, +} from "@/components/channels/channel-array-utils" +import { Field } from "@/components/shared-form" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +type StringListParser = (raw: string) => string[] +export type ArrayFieldFlusher = () => string[] | null + +type RegisterArrayFieldFlusher = ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, +) => void + +function areStringArraysEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false + } + return left.every((item, index) => item === right[index]) +} + +interface ChannelArrayListFieldProps { + label: string + hint?: string + error?: string + required?: boolean + value: string[] + onChange: (value: string[]) => void + placeholder?: string + parser?: StringListParser + fieldPath?: string + registerFlusher?: RegisterArrayFieldFlusher + resetVersion?: number +} + +export function ChannelArrayListField({ + label, + hint, + error, + required, + value, + onChange, + placeholder, + parser = parseConservativeStringListInput, + fieldPath, + registerFlusher, + resetVersion, +}: ChannelArrayListFieldProps) { + const { t } = useTranslation() + const [draft, setDraft] = useState("") + const draftRef = useRef("") + const valueRef = useRef(value) + const localValueRef = useRef(value) + const parserRef = useRef(parser) + const onChangeRef = useRef(onChange) + + useEffect(() => { + valueRef.current = value + localValueRef.current = value + }, [value]) + + useEffect(() => { + draftRef.current = "" + setDraft("") + }, [resetVersion]) + + useEffect(() => { + parserRef.current = parser + }, [parser]) + + useEffect(() => { + onChangeRef.current = onChange + }, [onChange]) + + const commitDraft = useCallback(() => { + const rawDraft = draftRef.current + if (rawDraft.trim() === "") { + if (!areStringArraysEqual(localValueRef.current, valueRef.current)) { + return localValueRef.current + } + draftRef.current = "" + setDraft("") + return null + } + draftRef.current = "" + setDraft("") + const nextItems = parserRef.current(rawDraft) + if (nextItems.length === 0) { + return null + } + const mergedItems = mergeUniqueStringItems(localValueRef.current, nextItems) + localValueRef.current = mergedItems + onChangeRef.current(mergedItems) + return mergedItems + }, []) + + useEffect(() => { + if (!fieldPath || !registerFlusher) { + return + } + registerFlusher(fieldPath, commitDraft) + return () => registerFlusher(fieldPath, null) + }, [commitDraft, fieldPath, registerFlusher]) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter") { + return + } + event.preventDefault() + commitDraft() + } + + const handleRemove = (index: number) => { + const nextValue = value.filter((_, itemIndex) => itemIndex !== index) + localValueRef.current = nextValue + onChangeRef.current(nextValue) + } + + return ( + +
+ {value.length > 0 && ( +
+ {value.map((item, index) => ( + + {item} + + + ))} +
+ )} + +
+ { + const nextDraft = event.target.value + draftRef.current = nextDraft + setDraft(nextDraft) + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + /> + +
+
+
+ ) +} diff --git a/web/frontend/src/components/channels/channel-array-utils.ts b/web/frontend/src/components/channels/channel-array-utils.ts new file mode 100644 index 000000000..0f6268be8 --- /dev/null +++ b/web/frontend/src/components/channels/channel-array-utils.ts @@ -0,0 +1,72 @@ +const ALLOW_FROM_HIDDEN_CHARS_RE = + /\u200b|\u200c|\u200d|\u200e|\u200f|\u202a|\u202b|\u202c|\u202d|\u202e|\u2060|\u2061|\u2062|\u2063|\u2064|\u2066|\u2067|\u2068|\u2069|\ufeff/g + +function normalizeStringListItems( + items: string[], + options: { stripHiddenChars?: boolean } = {}, +): string[] { + const result: string[] = [] + const seen = new Set() + + for (const item of items) { + const normalized = options.stripHiddenChars + ? item.replace(ALLOW_FROM_HIDDEN_CHARS_RE, "") + : item + const trimmed = normalized.trim() + if (trimmed.length === 0 || seen.has(trimmed)) { + continue + } + seen.add(trimmed) + result.push(trimmed) + } + + return result +} + +function splitStringList( + raw: string, + separators: RegExp, + options: { stripHiddenChars?: boolean } = {}, +): string[] { + if (raw.trim() === "") { + return [] + } + return normalizeStringListItems(raw.split(separators), options) +} + +export function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter((item): item is string => typeof item === "string") +} + +export function parseAllowFromInput(raw: string): string[] { + return splitStringList(raw, /[,\uFF0C、;;\n\r\t]+/, { + stripHiddenChars: true, + }) +} + +export function parseConservativeStringListInput(raw: string): string[] { + return splitStringList(raw, /[,\uFF0C\n\r\t]+/) +} + +export function normalizeAllowFromValues(value: unknown): string[] { + return normalizeStringListItems(asStringArray(value), { + stripHiddenChars: true, + }) +} + +export function mergeUniqueStringItems( + currentItems: string[], + nextItems: string[], +): string[] { + return normalizeStringListItems([...currentItems, ...nextItems]) +} + +export function serializeStringArrayForSubmit(value: unknown): unknown { + if (!Array.isArray(value)) { + return value + } + return normalizeStringListItems(asStringArray(value)).join("\n") +} diff --git a/web/frontend/src/components/channels/channel-config-fields.ts b/web/frontend/src/components/channels/channel-config-fields.ts new file mode 100644 index 000000000..35356954b --- /dev/null +++ b/web/frontend/src/components/channels/channel-config-fields.ts @@ -0,0 +1,101 @@ +import type { ChannelConfig } from "@/api/channels" + +export const SECRET_FIELD_MAP = { + token: "_token", + app_secret: "_app_secret", + client_secret: "_client_secret", + corp_secret: "_corp_secret", + channel_secret: "_channel_secret", + channel_access_token: "_channel_access_token", + access_token: "_access_token", + bot_token: "_bot_token", + app_token: "_app_token", + encoding_aes_key: "_encoding_aes_key", + encrypt_key: "_encrypt_key", + verification_token: "_verification_token", + secret: "_secret", + password: "_password", + nickserv_password: "_nickserv_password", + sasl_password: "_sasl_password", +} as const + +const CHANNEL_SECRET_FIELDS: Record = { + weixin: ["token"], + telegram: ["token"], + discord: ["token"], + slack: ["bot_token", "app_token"], + feishu: ["app_secret", "encrypt_key", "verification_token"], + dingtalk: ["client_secret"], + line: ["channel_secret", "channel_access_token"], + qq: ["app_secret"], + onebot: ["access_token"], + wecom: ["secret"], + pico: ["token"], + matrix: ["access_token"], + irc: ["password", "nickserv_password", "sasl_password"], +} + +const SECRET_FIELD_SET = new Set(Object.keys(SECRET_FIELD_MAP)) + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +export function isSecretField(key: string): boolean { + return SECRET_FIELD_SET.has(key) +} + +export function buildEditConfig( + channelName: string, + config: ChannelConfig, +): ChannelConfig { + const edit: ChannelConfig = { ...config } + + for (const key of CHANNEL_SECRET_FIELDS[channelName] ?? []) { + if (!(key in edit)) { + edit[key] = "" + } + const editKey = SECRET_FIELD_MAP[key as keyof typeof SECRET_FIELD_MAP] + if (editKey) { + edit[editKey] = "" + } + } + + return edit +} + +export function hasConfiguredSecret( + configuredSecrets: readonly string[], + key: string, +): boolean { + return configuredSecrets.includes(key) +} + +export function getFieldValueForValidation( + config: ChannelConfig, + configuredSecrets: readonly string[], + key: string, +): unknown { + const editKey = SECRET_FIELD_MAP[key as keyof typeof SECRET_FIELD_MAP] + if (editKey) { + const incoming = asString(config[editKey]).trim() + if (incoming !== "") { + return incoming + } + if (hasConfiguredSecret(configuredSecrets, key)) { + return true + } + } + return config[key] +} + +export function getSecretInputPlaceholder( + configuredSecrets: readonly string[], + key: string, + configuredPlaceholder: string, + fallback = "", +): string { + return hasConfiguredSecret(configuredSecrets, key) + ? configuredPlaceholder + : fallback +} diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx new file mode 100644 index 000000000..d253980f8 --- /dev/null +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -0,0 +1,741 @@ +import { IconLoader2 } from "@tabler/icons-react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useTranslation } from "react-i18next" + +import { + type ChannelConfig, + type SupportedChannel, + getChannelConfig, + getChannelsCatalog, + patchAppConfig, +} from "@/api/channels" +import { type ArrayFieldFlusher } from "@/components/channels/channel-array-list-field" +import { + normalizeAllowFromValues, + serializeStringArrayForSubmit, +} from "@/components/channels/channel-array-utils" +import { + SECRET_FIELD_MAP, + buildEditConfig, + getFieldValueForValidation, + isSecretField, +} from "@/components/channels/channel-config-fields" +import { getChannelDisplayName } from "@/components/channels/channel-display-name" +import { DiscordForm } from "@/components/channels/channel-forms/discord-form" +import { FeishuForm } from "@/components/channels/channel-forms/feishu-form" +import { GenericForm } from "@/components/channels/channel-forms/generic-form" +import { SlackForm } from "@/components/channels/channel-forms/slack-form" +import { TelegramForm } from "@/components/channels/channel-forms/telegram-form" +import { WecomForm } from "@/components/channels/channel-forms/wecom-form" +import { WeixinForm } from "@/components/channels/channel-forms/weixin-form" +import { ConfigChangeNotice } from "@/components/config-change-notice" +import { PageHeader } from "@/components/page-header" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { useGateway } from "@/hooks/use-gateway" +import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" +import { refreshGatewayState } from "@/store/gateway" + +interface ChannelConfigPageProps { + channelName: string +} + +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +function asBool(value: unknown): boolean { + return value === true +} + +function setRecordValueByPath( + source: Record, + pathSegments: string[], + value: unknown, +): Record { + const [segment, ...rest] = pathSegments + if (!segment) { + return source + } + if (rest.length === 0) { + return { ...source, [segment]: value } + } + return { + ...source, + [segment]: setRecordValueByPath(asRecord(source[segment]), rest, value), + } +} + +function setConfigValueByPath( + source: ChannelConfig, + fieldPath: string, + value: unknown, +): ChannelConfig { + return setRecordValueByPath(source, fieldPath.split("."), value) +} + +function serializeGroupTriggerForSubmit(value: unknown): unknown { + const groupTrigger = asRecord(value) + if (Object.keys(groupTrigger).length === 0) { + return value + } + return { + ...groupTrigger, + prefixes: serializeStringArrayForSubmit(groupTrigger.prefixes), + } +} + +const CHANNEL_COMMON_CONFIG_KEYS = new Set([ + "allow_from", + "group_trigger", + "placeholder", + "reasoning_channel_id", + "typing", +]) + +function normalizeConfig( + channel: SupportedChannel, + rawConfig: ChannelConfig, +): ChannelConfig { + const config = { ...rawConfig } + if (channel.name === "whatsapp_native") { + config.use_native = true + } + if (channel.name === "whatsapp") { + config.use_native = false + } + return config +} + +function buildSavePayload( + channel: SupportedChannel, + editConfig: ChannelConfig, + enabled: boolean, +): ChannelConfig { + const payload: ChannelConfig = { enabled, type: channel.config_key } + const settings: ChannelConfig = {} + + for (const [key, value] of Object.entries(editConfig)) { + if (key.startsWith("_")) continue + if (key === "enabled") continue + if (CHANNEL_COMMON_CONFIG_KEYS.has(key)) { + if (key === "allow_from") { + payload[key] = serializeStringArrayForSubmit( + normalizeAllowFromValues(value), + ) + } else if (key === "group_trigger") { + payload[key] = serializeGroupTriggerForSubmit(value) + } else { + payload[key] = value + } + continue + } + if (isSecretField(key)) continue + + settings[key] = serializeStringArrayForSubmit(value) + } + + for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) { + const incoming = asString(editConfig[editKey]) + if (incoming !== "") { + settings[secretKey] = incoming + continue + } + const existing = asString(editConfig[secretKey]).trim() + if (existing !== "") { + settings[secretKey] = existing + } + } + + if (channel.name === "whatsapp_native") { + settings.use_native = true + } + if (channel.name === "whatsapp") { + settings.use_native = false + } + + if (Object.keys(settings).length > 0) { + payload.settings = settings + } + + return payload +} + +function isConfigured( + channel: SupportedChannel, + config: ChannelConfig, + configuredSecrets: readonly string[], +): boolean { + const hasValue = (key: string) => + !isMissingRequiredValue( + getFieldValueForValidation(config, configuredSecrets, key), + ) + + switch (channel.name) { + case "telegram": + return hasValue("token") + case "discord": + return hasValue("token") + case "slack": + return hasValue("bot_token") + case "feishu": + return hasValue("app_id") && hasValue("app_secret") + case "dingtalk": + return hasValue("client_id") && hasValue("client_secret") + case "line": + return hasValue("channel_secret") && hasValue("channel_access_token") + case "qq": + return hasValue("app_id") && hasValue("app_secret") + case "onebot": + return hasValue("ws_url") + case "weixin": + return hasValue("account_id") + case "wecom": + return hasValue("bot_id") + case "whatsapp": + return hasValue("bridge_url") + case "whatsapp_native": + return asBool(config.use_native) + case "pico": + return hasValue("token") + case "maixcam": + return hasValue("host") + case "matrix": + return ( + hasValue("homeserver") && + hasValue("user_id") && + hasValue("access_token") + ) + case "irc": + return hasValue("server") + default: + return false + } +} + +function getRequiredFieldKeys(channelName: string): string[] { + switch (channelName) { + case "telegram": + return ["token"] + case "discord": + return ["token"] + case "slack": + return ["bot_token"] + case "feishu": + return ["app_id", "app_secret"] + case "dingtalk": + return ["client_id", "client_secret"] + case "line": + return ["channel_secret", "channel_access_token"] + case "qq": + return ["app_id", "app_secret"] + case "onebot": + return ["ws_url"] + case "wecom": + return [] + case "whatsapp": + return ["bridge_url"] + case "pico": + return ["token"] + case "maixcam": + return ["host"] + case "matrix": + return ["homeserver", "user_id", "access_token"] + case "irc": + return ["server"] + default: + return [] + } +} + +function isMissingRequiredValue(value: unknown): boolean { + if (value === null || value === undefined) { + return true + } + if (typeof value === "string") { + return value.trim() === "" + } + if (Array.isArray(value)) { + return value.length === 0 + } + return false +} + +function getChannelDocSlug(channelName: string): string { + return channelName.replaceAll("_", "-") +} + +const CHANNELS_WITHOUT_DOCS = new Set([ + "pico", + "wecom", + "matrix", + "irc", + "whatsapp", + "whatsapp_native", +]) + +export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { + const { t, i18n } = useTranslation() + const { state: gatewayState } = useGateway() + + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [fetchError, setFetchError] = useState("") + const [serverError, setServerError] = useState("") + const [fieldErrors, setFieldErrors] = useState>({}) + + const [channel, setChannel] = useState(null) + const [baseConfig, setBaseConfig] = useState({}) + const [editConfig, setEditConfig] = useState({}) + const [configuredSecrets, setConfiguredSecrets] = useState([]) + const [enabled, setEnabled] = useState(false) + const [arrayFieldResetVersion, setArrayFieldResetVersion] = useState(0) + const arrayFieldFlushersRef = useRef(new Map()) + const loadRequestIdRef = useRef(0) + + const resetPageState = useCallback(() => { + arrayFieldFlushersRef.current.clear() + setChannel(null) + setBaseConfig({}) + setEditConfig({}) + setConfiguredSecrets([]) + setEnabled(false) + setFetchError("") + setServerError("") + setFieldErrors({}) + setArrayFieldResetVersion((version) => version + 1) + }, []) + + const loadData = useCallback( + async (silent = false) => { + const requestId = loadRequestIdRef.current + 1 + loadRequestIdRef.current = requestId + if (!silent) setLoading(true) + try { + const catalog = await getChannelsCatalog() + if (loadRequestIdRef.current !== requestId) return + const matched = + catalog.channels.find((item) => item.name === channelName) ?? null + + if (!matched) { + resetPageState() + setFetchError( + t("channels.page.notFound", { + name: channelName, + }), + ) + return + } + + const channelConfig = await getChannelConfig(channelName) + if (loadRequestIdRef.current !== requestId) return + const raw = asRecord(channelConfig.config) + const normalized = normalizeConfig(matched, raw) + + setChannel(matched) + setBaseConfig(normalized) + setEditConfig(buildEditConfig(matched.name, normalized)) + setConfiguredSecrets(channelConfig.configured_secrets ?? []) + setEnabled(asBool(normalized.enabled)) + setFetchError("") + setServerError("") + setFieldErrors({}) + } catch (e) { + if (loadRequestIdRef.current !== requestId) return + setConfiguredSecrets([]) + setFetchError(e instanceof Error ? e.message : t("channels.loadError")) + } finally { + if (!silent && loadRequestIdRef.current === requestId) { + setLoading(false) + } + } + }, + [channelName, resetPageState, t], + ) + + useEffect(() => { + resetPageState() + setLoading(true) + loadData() + }, [loadData, resetPageState]) + + const previousGatewayStatusRef = useRef(gatewayState) + useEffect(() => { + const previousStatus = previousGatewayStatusRef.current + if (previousStatus !== "running" && gatewayState === "running") { + void loadData() + } + previousGatewayStatusRef.current = gatewayState + }, [gatewayState, loadData]) + + const configured = useMemo(() => { + if (!channel) return false + return isConfigured(channel, editConfig, configuredSecrets) + }, [channel, configuredSecrets, editConfig]) + + const isDirty = useMemo(() => { + if (loading || !channel || channel.name !== channelName) return false + const basePayload = buildSavePayload( + channel, + buildEditConfig(channel.name, baseConfig), + asBool(baseConfig.enabled), + ) + const currentPayload = buildSavePayload(channel, editConfig, enabled) + return JSON.stringify(basePayload) !== JSON.stringify(currentPayload) + }, [baseConfig, channel, channelName, editConfig, enabled, loading]) + + const docsUrl = useMemo(() => { + if (!channel) return "" + if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return "" + const language = ( + i18n.resolvedLanguage ?? + i18n.language ?? + "" + ).toLowerCase() + const base = language.startsWith("zh") + ? "https://docs.picoclaw.io/zh-Hans/docs/channels" + : "https://docs.picoclaw.io/docs/channels" + return `${base}/${getChannelDocSlug(channel.name)}` + }, [channel, i18n.language, i18n.resolvedLanguage]) + + const channelDisplayName = useMemo(() => { + if (!channel) return channelName + return getChannelDisplayName(channel, t) + }, [channel, channelName, t]) + + const hidesPageLevelEnableToggle = channel?.name === "wecom" + + const hiddenKeys = useMemo(() => { + if (!channel) return [] + if (channel.name === "whatsapp") { + return ["use_native"] + } + if (channel.name === "whatsapp_native") { + return ["use_native", "bridge_url"] + } + return [] + }, [channel]) + const requiredKeys = useMemo( + () => getRequiredFieldKeys(channelName), + [channelName], + ) + + const handleChange = useCallback((key: string, value: unknown) => { + const normalizedKey = key.startsWith("_") ? key.slice(1) : key + setEditConfig((prev) => ({ ...prev, [key]: value })) + setFieldErrors((prev) => { + if (!(key in prev) && !(normalizedKey in prev)) { + return prev + } + const next = { ...prev } + delete next[key] + delete next[normalizedKey] + return next + }) + }, []) + + const registerArrayFieldFlusher = useCallback( + (fieldPath: string, flusher: ArrayFieldFlusher | null) => { + if (flusher) { + arrayFieldFlushersRef.current.set(fieldPath, flusher) + return + } + arrayFieldFlushersRef.current.delete(fieldPath) + }, + [], + ) + + const flushPendingArrayFieldDrafts = useCallback( + (sourceConfig: ChannelConfig): ChannelConfig => { + let nextConfig = sourceConfig + for (const [fieldPath, flusher] of arrayFieldFlushersRef.current) { + const flushedValue = flusher() + if (flushedValue === null) { + continue + } + nextConfig = setConfigValueByPath(nextConfig, fieldPath, flushedValue) + } + return nextConfig + }, + [], + ) + + const handleReset = () => { + if (!channel) return + setEditConfig(buildEditConfig(channel.name, baseConfig)) + setEnabled(asBool(baseConfig.enabled)) + setServerError("") + setFieldErrors({}) + setArrayFieldResetVersion((version) => version + 1) + } + + const handleSave = async () => { + if (!channel) return + + const preparedEditConfig = flushPendingArrayFieldDrafts(editConfig) + if (preparedEditConfig !== editConfig) { + setEditConfig(preparedEditConfig) + } + + const missingRequiredFields = requiredKeys.filter((key) => + isMissingRequiredValue( + getFieldValueForValidation(preparedEditConfig, configuredSecrets, key), + ), + ) + if (missingRequiredFields.length > 0) { + const requiredFieldError = t("channels.validation.requiredField") + const nextFieldErrors: Record = {} + for (const key of missingRequiredFields) { + nextFieldErrors[key] = requiredFieldError + } + setFieldErrors(nextFieldErrors) + setServerError("") + return + } + + setSaving(true) + setServerError("") + setFieldErrors({}) + try { + const savePayload = buildSavePayload(channel, preparedEditConfig, enabled) + await patchAppConfig({ + channel_list: { + [channel.config_key]: savePayload, + }, + }) + await loadData() + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, + t("channels.page.saveSuccess"), + channelDisplayName, + gateway?.restartRequired === true, + ) + } catch (e) { + const message = + e instanceof Error ? e.message : t("channels.page.saveError") + setServerError(message) + } finally { + setSaving(false) + } + } + + const handleWeixinBindSuccess = useCallback(async () => { + try { + setEnabled(true) + await Promise.all([loadData(true), refreshGatewayState({ force: true })]) + } catch (e) { + const message = + e instanceof Error ? e.message : t("channels.page.saveError") + setServerError(message) + await loadData(true) + } + }, [loadData, t]) + + const handleWecomBindSuccess = useCallback(async () => { + try { + setEnabled(true) + await Promise.all([loadData(true), refreshGatewayState({ force: true })]) + } catch (e) { + const message = + e instanceof Error ? e.message : t("channels.page.saveError") + setServerError(message) + await loadData(true) + } + }, [loadData, t]) + + const handleWecomEnabledChange = useCallback( + async (nextEnabled: boolean) => { + try { + setEnabled(nextEnabled) + await Promise.all([ + loadData(true), + refreshGatewayState({ force: true }), + ]) + } catch (e) { + const message = + e instanceof Error ? e.message : t("channels.page.saveError") + setServerError(message) + await loadData(true) + } + }, + [loadData, t], + ) + + const renderForm = () => { + if (!channel) return null + const isEdit = configured + + switch (channel.name) { + case "telegram": + return ( + + ) + case "discord": + return ( + + ) + case "slack": + return ( + + ) + case "feishu": + return ( + + ) + case "weixin": + return ( + void handleWeixinBindSuccess()} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} + /> + ) + case "wecom": + return ( + <> + void handleWecomBindSuccess()} + onEnabledChange={(nextEnabled) => + void handleWecomEnabledChange(nextEnabled) + } + /> + + + ) + default: + return ( + + ) + } + } + + return ( +
+ + {t("channels.page.docLink")} + + ) + } + /> + +
+ {loading ? ( +
+ +
+ ) : fetchError ? ( +
+ {fetchError} +
+ ) : ( +
+ {!hidesPageLevelEnableToggle && ( +
+

+ {t("channels.page.enableLabel")} +

+ +
+ )} + + {renderForm()} + + {serverError && ( +

{serverError}

+ )} + + {isDirty && ( + + )} + +
+ + +
+
+ )} +
+
+ ) +} diff --git a/web/frontend/src/components/channels/channel-display-name.ts b/web/frontend/src/components/channels/channel-display-name.ts new file mode 100644 index 000000000..fe70f5f5e --- /dev/null +++ b/web/frontend/src/components/channels/channel-display-name.ts @@ -0,0 +1,23 @@ +import type { TFunction } from "i18next" + +import type { SupportedChannel } from "@/api/channels" + +export function getChannelDisplayName( + channel: Pick, + t: TFunction, +): string { + const key = `channels.name.${channel.name}` + const translated = t(key) + if (translated !== key) { + return translated + } + + if (channel.display_name && channel.display_name.trim() !== "") { + return channel.display_name + } + + return channel.name + .split("_") + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" ") +} diff --git a/web/frontend/src/components/channels/channel-forms/discord-form.tsx b/web/frontend/src/components/channels/channel-forms/discord-form.tsx new file mode 100644 index 000000000..d2a98d325 --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/discord-form.tsx @@ -0,0 +1,121 @@ +import { useTranslation } from "react-i18next" + +import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" +import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" +import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" + +interface DiscordFormProps { + config: ChannelConfig + onChange: (key: string, value: unknown) => void + configuredSecrets: string[] + fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +function asBool(value: unknown): boolean { + return value === true +} + +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} +} + +export function DiscordForm({ + config, + onChange, + configuredSecrets, + fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, +}: DiscordFormProps) { + const { t } = useTranslation() + const groupTriggerConfig = asRecord(config.group_trigger) + + return ( +
+ + + + onChange("_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "token", + t("channels.field.secretHintSet"), + t("channels.field.tokenPlaceholder"), + )} + /> + + + + + + + + onChange("proxy", e.target.value)} + placeholder="http://127.0.0.1:7890" + /> + + onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> + +
+ { + onChange("group_trigger", { + ...groupTriggerConfig, + mention_only: checked, + }) + }} + ariaLabel={t("channels.field.mentionOnly")} + /> +
+
+
+
+ ) +} diff --git a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx new file mode 100644 index 000000000..ed49e29cf --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx @@ -0,0 +1,182 @@ +import { useTranslation } from "react-i18next" + +import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, + parseConservativeStringListInput, +} from "@/components/channels/channel-array-utils" +import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" +import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" + +interface FeishuFormProps { + config: ChannelConfig + onChange: (key: string, value: unknown) => void + configuredSecrets: string[] + fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +function asBool(value: unknown): boolean { + return typeof value === "boolean" ? value : false +} + +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} +} + +export function FeishuForm({ + config, + onChange, + configuredSecrets, + fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, +}: FeishuFormProps) { + const { t } = useTranslation() + const groupTriggerConfig = asRecord(config.group_trigger) + + return ( +
+ + + + onChange("app_id", e.target.value)} + placeholder="cli_xxxx" + /> + + + + onChange("_app_secret", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "app_secret", + t("channels.field.secretHintSet"), + t("channels.field.secretPlaceholder"), + )} + /> + + + + + + + + onChange("_verification_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "verification_token", + t("channels.field.secretHintSet"), + t("channels.field.secretPlaceholder"), + )} + /> + + + onChange("_encrypt_key", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "encrypt_key", + t("channels.field.secretHintSet"), + t("channels.field.secretPlaceholder"), + )} + /> + + + onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> + +
+ onChange("is_lark", checked)} + ariaLabel={t("channels.field.isLark")} + /> +
+
+
+ + + +
+ { + onChange("group_trigger", { + ...groupTriggerConfig, + mention_only: checked, + }) + }} + ariaLabel={t("channels.field.groupTriggerMentionOnly")} + /> +
+ + onChange("random_reaction_emoji", value)} + placeholder={t("channels.field.randomReactionEmojiPlaceholder")} + parser={parseConservativeStringListInput} + fieldPath="random_reaction_emoji" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> +
+
+
+ ) +} diff --git a/web/frontend/src/components/channels/channel-forms/generic-form.tsx b/web/frontend/src/components/channels/channel-forms/generic-form.tsx new file mode 100644 index 000000000..c8ee3f69f --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx @@ -0,0 +1,416 @@ +import { useTranslation } from "react-i18next" + +import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" +import { + getSecretInputPlaceholder, + isSecretField, +} from "@/components/channels/channel-config-fields" +import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" + +interface GenericFormProps { + config: ChannelConfig + onChange: (key: string, value: unknown) => void + configuredSecrets?: string[] + hiddenKeys?: string[] + requiredKeys?: string[] + fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number +} + +// Fields to skip in the generic form (handled by enabled toggle or internal). +const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"]) + +// Fields that are objects/nested — show as JSON or skip. +const OBJECT_FIELDS = new Set([ + "group_trigger", + "typing", + "placeholder", + "allow_token_query", + "allow_from", + "allow_origins", + "groups", +]) + +function formatLabel(key: string): string { + return key + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" ") +} + +function formatSentenceFieldName(key: string): string { + const label = formatLabel(key) + return label.charAt(0).toLowerCase() + label.slice(1) +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} +} + +function asBool(value: unknown): boolean { + return value === true +} + +export function GenericForm({ + config, + onChange, + configuredSecrets = [], + hiddenKeys = [], + requiredKeys = [], + fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, +}: GenericFormProps) { + const { t } = useTranslation() + const hiddenFieldSet = new Set(hiddenKeys) + const requiredFieldSet = new Set(requiredKeys) + const groupTriggerConfig = asRecord(config.group_trigger) + const typingConfig = asRecord(config.typing) + const placeholderConfig = asRecord(config.placeholder) + const placeholderEnabled = asBool(placeholderConfig.enabled) + + const rawFields = Object.keys(config).filter( + (k) => + !k.startsWith("_") && + !SKIP_FIELDS.has(k) && + !OBJECT_FIELDS.has(k) && + !hiddenFieldSet.has(k), + ) + + const buildHint = (key: string): string => { + const descriptions: Record = { + ws_url: t("channels.form.desc.wsUrl"), + reconnect_interval: t("channels.form.desc.reconnectInterval"), + bridge_url: t("channels.form.desc.bridgeUrl"), + session_store_path: t("channels.form.desc.sessionStorePath"), + use_native: t("channels.form.desc.useNative"), + host: t("channels.form.desc.host"), + port: t("channels.form.desc.port"), + homeserver: t("channels.form.desc.homeserver"), + user_id: t("channels.form.desc.userId"), + device_id: t("channels.form.desc.deviceId"), + join_on_invite: t("channels.form.desc.joinOnInvite"), + app_id: t("channels.form.desc.appId"), + client_id: t("channels.form.desc.clientId"), + corp_id: t("channels.form.desc.corpId"), + bot_id: t("channels.form.desc.appId"), + websocket_url: t("channels.form.desc.wsUrl"), + dm_policy: t("channels.form.desc.genericField", { field: "DM policy" }), + group_policy: t("channels.form.desc.genericField", { + field: "group policy", + }), + group_allow_from: t("channels.form.desc.allowFrom"), + send_thinking_message: t("channels.form.desc.genericField", { + field: "thinking message behavior", + }), + agent_id: t("channels.form.desc.agentId"), + webhook_url: t("channels.form.desc.webhookUrl"), + webhook_host: t("channels.form.desc.webhookHost"), + webhook_port: t("channels.form.desc.webhookPort"), + webhook_path: t("channels.form.desc.webhookPath"), + reply_timeout: t("channels.form.desc.replyTimeout"), + max_steps: t("channels.form.desc.maxSteps"), + welcome_message: t("channels.form.desc.welcomeMessage"), + allow_token_query: t("channels.form.desc.allowTokenQuery"), + ping_interval: t("channels.form.desc.pingInterval"), + read_timeout: t("channels.form.desc.readTimeout"), + write_timeout: t("channels.form.desc.writeTimeout"), + max_connections: t("channels.form.desc.maxConnections"), + server: t("channels.form.desc.server"), + tls: t("channels.form.desc.tls"), + nick: t("channels.form.desc.nick"), + user: t("channels.form.desc.user"), + real_name: t("channels.form.desc.realName"), + channels: t("channels.form.desc.channels"), + request_caps: t("channels.form.desc.requestCaps"), + max_base64_file_size_mib: t("channels.form.desc.maxBase64FileSizeMiB"), + } + return ( + descriptions[key] ?? + t("channels.form.desc.genericField", { + field: formatSentenceFieldName(key), + }) + ) + } + + const renderField = (key: string) => { + const isRequired = requiredFieldSet.has(key) + if (isSecretField(key)) { + const editKey = `_${key}` + return ( + + onChange(editKey, v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + key, + t("channels.field.secretHintSet"), + t("channels.field.secretPlaceholder"), + )} + /> + + ) + } + + const value = config[key] + if (typeof value === "boolean") { + return ( + onChange(key, checked)} + ariaLabel={formatLabel(key)} + /> + ) + } + + if (Array.isArray(value)) { + return ( + onChange(key, nextValue)} + fieldPath={key} + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> + ) + } + + return ( + + { + const v = e.target.value + if (typeof config[key] === "number") { + onChange(key, v === "" ? 0 : Number(v)) + } else { + onChange(key, v) + } + }} + /> + + ) + } + + const isBasicField = (key: string) => { + if (requiredFieldSet.has(key)) return true + if ( + key.endsWith("id") || + key.endsWith("token") || + key.endsWith("secret") || + key.endsWith("url") || + key === "server" || + key === "host" || + key === "port" + ) { + return true + } + return false + } + + const basicFields = rawFields.filter(isBasicField) + const advancedFields = rawFields.filter((key) => !isBasicField(key)) + + const hasAdvancedContent = + advancedFields.length > 0 || + (config.allow_from !== undefined && !hiddenFieldSet.has("allow_from")) || + (config.allow_origins !== undefined && + !hiddenFieldSet.has("allow_origins")) || + (config.allow_token_query !== undefined && + !hiddenFieldSet.has("allow_token_query")) || + (config.group_trigger !== undefined && + !hiddenFieldSet.has("group_trigger")) || + (config.typing !== undefined && !hiddenFieldSet.has("typing")) || + (config.placeholder !== undefined && !hiddenFieldSet.has("placeholder")) + + return ( +
+ {basicFields.length > 0 && ( + + + {basicFields.map(renderField)} + + + )} + + {hasAdvancedContent && ( + + + {advancedFields.map(renderField)} + + {config.allow_from !== undefined && + !hiddenFieldSet.has("allow_from") && ( + onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> + )} + + {config.allow_origins !== undefined && + !hiddenFieldSet.has("allow_origins") && ( + onChange("allow_origins", value)} + placeholder={t("channels.field.allowOriginsPlaceholder")} + fieldPath="allow_origins" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> + )} + + {config.allow_token_query !== undefined && + !hiddenFieldSet.has("allow_token_query") && ( +
+ + onChange("allow_token_query", checked) + } + ariaLabel={formatLabel("allow_token_query")} + /> +
+ )} + + {config.group_trigger !== undefined && + !hiddenFieldSet.has("group_trigger") && ( + <> +
+ + onChange("group_trigger", { + ...groupTriggerConfig, + mention_only: checked, + }) + } + ariaLabel={t("channels.field.groupTriggerMentionOnly")} + /> +
+ + + onChange("group_trigger", { + ...groupTriggerConfig, + prefixes: value, + }) + } + placeholder={t("channels.field.groupTriggerPrefixes")} + fieldPath="group_trigger.prefixes" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> + + )} + + {config.typing !== undefined && !hiddenFieldSet.has("typing") && ( +
+ + onChange("typing", { ...typingConfig, enabled: checked }) + } + ariaLabel={t("channels.field.typingEnabled")} + /> +
+ )} + + {config.placeholder !== undefined && + !hiddenFieldSet.has("placeholder") && ( +
+ + onChange("placeholder", { + ...placeholderConfig, + enabled: checked, + }) + } + ariaLabel={t("channels.field.placeholderEnabled")} + > + {placeholderEnabled && ( +
+ + onChange("placeholder", { + ...placeholderConfig, + text: e.target.value, + }) + } + placeholder={t("channels.field.placeholderText")} + aria-label={t("channels.field.placeholderText")} + /> +
+ )} +
+
+ )} +
+
+ )} +
+ ) +} diff --git a/web/frontend/src/components/channels/channel-forms/slack-form.tsx b/web/frontend/src/components/channels/channel-forms/slack-form.tsx new file mode 100644 index 000000000..b8184e8bc --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/slack-form.tsx @@ -0,0 +1,99 @@ +import { useTranslation } from "react-i18next" + +import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" +import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" +import { Field, KeyInput } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" + +interface SlackFormProps { + config: ChannelConfig + onChange: (key: string, value: unknown) => void + configuredSecrets: string[] + fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +export function SlackForm({ + config, + onChange, + configuredSecrets, + fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, +}: SlackFormProps) { + const { t } = useTranslation() + + return ( +
+ + + + onChange("_bot_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "bot_token", + t("channels.field.secretHintSet"), + "xoxb-xxxx", + )} + /> + + + + onChange("_app_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "app_token", + t("channels.field.secretHintSet"), + "xapp-xxxx", + )} + /> + + + + + + + onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> + + +
+ ) +} diff --git a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx new file mode 100644 index 000000000..f9c7c778a --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -0,0 +1,162 @@ +import { useTranslation } from "react-i18next" + +import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" +import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" +import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" + +interface TelegramFormProps { + config: ChannelConfig + onChange: (key: string, value: unknown) => void + configuredSecrets: string[] + fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} +} + +function asBool(value: unknown): boolean { + return value === true +} + +export function TelegramForm({ + config, + onChange, + configuredSecrets, + fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, +}: TelegramFormProps) { + const { t } = useTranslation() + const typingConfig = asRecord(config.typing) + const placeholderConfig = asRecord(config.placeholder) + const placeholderEnabled = asBool(placeholderConfig.enabled) + + return ( +
+ + + + onChange("_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "token", + t("channels.field.secretHintSet"), + t("channels.field.tokenPlaceholder"), + )} + /> + + + + onChange("base_url", e.target.value)} + placeholder="https://api.telegram.org" + /> + + + + + + + + onChange("proxy", e.target.value)} + placeholder="http://127.0.0.1:7890" + /> + + onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> + +
+ + onChange("typing", { ...typingConfig, enabled: checked }) + } + ariaLabel={t("channels.field.typingEnabled")} + /> +
+ +
+ + onChange("placeholder", { + ...placeholderConfig, + enabled: checked, + }) + } + ariaLabel={t("channels.field.placeholderEnabled")} + > + {placeholderEnabled && ( +
+ + onChange("placeholder", { + ...placeholderConfig, + text: e.target.value, + }) + } + placeholder={t("channels.field.placeholderText")} + aria-label={t("channels.field.placeholderText")} + /> +
+ )} +
+
+
+
+
+ ) +} diff --git a/web/frontend/src/components/channels/channel-forms/wecom-form.tsx b/web/frontend/src/components/channels/channel-forms/wecom-form.tsx new file mode 100644 index 000000000..c21ac318a --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/wecom-form.tsx @@ -0,0 +1,368 @@ +import { + IconCheck, + IconLoader2, + IconQrcode, + IconRefresh, + IconX, +} from "@tabler/icons-react" +import { useCallback, useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" + +import type { ChannelConfig } from "@/api/channels" +import { patchAppConfig, pollWecomFlow, startWecomFlow } from "@/api/channels" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Switch } from "@/components/ui/switch" + +type BindingState = + | "idle" + | "loading" + | "waiting" + | "scaned" + | "confirmed" + | "expired" + | "error" + +interface WecomFormProps { + config: ChannelConfig + isEdit: boolean + onBindSuccess?: () => void + onEnabledChange?: (enabled: boolean) => void +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +export function WecomForm({ + config, + isEdit, + onBindSuccess, + onEnabledChange, +}: WecomFormProps) { + const { t } = useTranslation() + + const [bindState, setBindState] = useState("idle") + const [qrDataURI, setQrDataURI] = useState(null) + const [botID, setBotID] = useState(null) + const [errorMsg, setErrorMsg] = useState("") + const [enabled, setEnabled] = useState(config.enabled === true) + const [toggleSaving, setToggleSaving] = useState(false) + const [toggleError, setToggleError] = useState("") + + const pollTimerRef = useRef | null>(null) + const pollGenerationRef = useRef(0) + const existingBotID = asString(config.bot_id) + const isBound = isEdit && existingBotID !== "" + + const stopPolling = useCallback(() => { + pollGenerationRef.current += 1 + if (pollTimerRef.current !== null) { + clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + }, []) + + useEffect(() => () => stopPolling(), [stopPolling]) + + useEffect(() => { + setEnabled(config.enabled === true) + }, [config.enabled]) + + useEffect(() => { + if (!existingBotID) return + stopPolling() + setBotID(existingBotID) + setBindState("confirmed") + setErrorMsg("") + }, [existingBotID, stopPolling]) + + const startPolling = useCallback( + (id: string) => { + stopPolling() + const generation = pollGenerationRef.current + let inFlight = false + pollTimerRef.current = setInterval(async () => { + if (inFlight) return + inFlight = true + try { + const resp = await pollWecomFlow(id) + if (generation !== pollGenerationRef.current) { + return + } + if (resp.status === "scaned") { + setBindState("scaned") + } else if (resp.status === "confirmed") { + stopPolling() + setBotID(resp.bot_id ?? existingBotID ?? null) + setBindState("confirmed") + onBindSuccess?.() + } else if (resp.status === "expired") { + stopPolling() + setBindState("expired") + } else if (resp.status === "error") { + stopPolling() + setBindState("error") + setErrorMsg(resp.error ?? t("channels.wecom.errorGeneric")) + } + } catch { + // transient network error — keep polling + } finally { + inFlight = false + } + }, 2000) + }, + [existingBotID, onBindSuccess, stopPolling, t], + ) + + const handleEnabledChange = useCallback( + async (checked: boolean) => { + if (!existingBotID || toggleSaving) { + return + } + setToggleSaving(true) + setToggleError("") + try { + await patchAppConfig({ + channel_list: { + wecom: { + enabled: checked, + type: "wecom", + }, + }, + }) + setEnabled(checked) + onEnabledChange?.(checked) + } catch (e) { + setToggleError( + e instanceof Error ? e.message : t("channels.wecom.errorGeneric"), + ) + } finally { + setToggleSaving(false) + } + }, + [existingBotID, onEnabledChange, t, toggleSaving], + ) + + const handleBind = async () => { + setBindState("loading") + setErrorMsg("") + setToggleError("") + setQrDataURI(null) + stopPolling() + try { + const resp = await startWecomFlow() + setQrDataURI(resp.qr_data_uri ?? null) + setBindState("waiting") + startPolling(resp.flow_id) + } catch (e) { + setBindState("error") + setErrorMsg( + e instanceof Error ? e.message : t("channels.wecom.errorGeneric"), + ) + } + } + + const handleRebind = () => { + stopPolling() + setBindState("idle") + setQrDataURI(null) + setBotID(null) + setErrorMsg("") + void handleBind() + } + + const renderBindSection = () => { + if (bindState === "idle") { + if (isBound) { + return ( +
+
+ + {t("channels.wecom.bound")} +
+ {existingBotID && ( +

+ {existingBotID} +

+ )} + +
+ ) + } + return ( +
+

+ {t("channels.wecom.notBound")} +

+ +
+ ) + } + + if (bindState === "loading") { + return ( +
+ +

+ {t("channels.wecom.generating")} +

+
+ ) + } + + if (bindState === "waiting" || bindState === "scaned") { + return ( +
+ {qrDataURI ? ( + WeCom QR Code + ) : ( +
+ +
+ )} + {bindState === "scaned" ? ( +
+ + {t("channels.wecom.scanned")} +
+ ) : ( +

+ {t("channels.wecom.scanHint")} +

+ )} + +
+ ) + } + + if (bindState === "confirmed") { + return ( +
+
+ +
+

+ {t("channels.wecom.bound")} +

+ {botID && ( +

{botID}

+ )} + +
+ ) + } + + if (bindState === "expired") { + return ( +
+
+ +
+

+ {t("channels.wecom.expired")} +

+ +
+ ) + } + + if (bindState === "error") { + return ( +
+
+ +
+

+ {errorMsg || t("channels.wecom.errorGeneric")} +

+ +
+ ) + } + + return null + } + + return ( +
+
+

{t("channels.page.enableLabel")}

+
+ void handleEnabledChange(checked)} + /> + {toggleError && ( +

+ {toggleError} +

+ )} +
+
+ + + + + {t("channels.wecom.bindTitle")} + + {t("channels.wecom.bindDesc")} + + {renderBindSection()} + +
+ ) +} diff --git a/web/frontend/src/components/channels/channel-forms/weixin-form.tsx b/web/frontend/src/components/channels/channel-forms/weixin-form.tsx new file mode 100644 index 000000000..29700f094 --- /dev/null +++ b/web/frontend/src/components/channels/channel-forms/weixin-form.tsx @@ -0,0 +1,360 @@ +import { + IconCheck, + IconLoader2, + IconQrcode, + IconRefresh, + IconX, +} from "@tabler/icons-react" +import { useCallback, useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" + +import type { ChannelConfig } from "@/api/channels" +import { pollWeixinFlow, startWeixinFlow } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" +import { Field } from "@/components/shared-form" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" + +type BindingState = + | "idle" + | "loading" + | "waiting" + | "scaned" + | "confirmed" + | "expired" + | "error" + +interface WeixinFormProps { + config: ChannelConfig + onChange: (key: string, value: unknown) => void + isEdit: boolean + onBindSuccess?: () => void + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +export function WeixinForm({ + config, + onChange, + isEdit, + onBindSuccess, + registerArrayFieldFlusher, + arrayFieldResetVersion, +}: WeixinFormProps) { + const { t } = useTranslation() + + const [bindState, setBindState] = useState("idle") + const [qrDataURI, setQrDataURI] = useState(null) + const [accountID, setAccountID] = useState(null) + const [errorMsg, setErrorMsg] = useState("") + + const pollTimerRef = useRef | null>(null) + const pollGenerationRef = useRef(0) + const isBound = isEdit && asString(config.account_id) !== "" + const existingAccountID = asString(config.account_id) + + const stopPolling = useCallback(() => { + pollGenerationRef.current += 1 + if (pollTimerRef.current !== null) { + clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + }, []) + + useEffect(() => () => stopPolling(), [stopPolling]) + + useEffect(() => { + if (!existingAccountID) return + stopPolling() + setAccountID(existingAccountID) + setBindState("confirmed") + setErrorMsg("") + }, [existingAccountID, stopPolling]) + + const startPolling = useCallback( + (id: string) => { + stopPolling() + const generation = pollGenerationRef.current + let inFlight = false + pollTimerRef.current = setInterval(async () => { + if (inFlight) return + inFlight = true + try { + const resp = await pollWeixinFlow(id) + if (generation !== pollGenerationRef.current) { + return + } + if (resp.status === "scaned") { + setBindState("scaned") + } else if (resp.status === "confirmed") { + stopPolling() + setAccountID(resp.account_id ?? existingAccountID ?? null) + setBindState("confirmed") + onBindSuccess?.() + } else if (resp.status === "expired") { + stopPolling() + setBindState("expired") + } else if (resp.status === "error") { + stopPolling() + setBindState("error") + setErrorMsg(resp.error ?? t("channels.weixin.errorGeneric")) + } + } catch { + // transient network error — keep polling + } finally { + inFlight = false + } + }, 2000) + }, + [existingAccountID, stopPolling, onBindSuccess, t], + ) + + const handleBind = async () => { + setBindState("loading") + setErrorMsg("") + setQrDataURI(null) + stopPolling() + try { + const resp = await startWeixinFlow() + setQrDataURI(resp.qr_data_uri ?? null) + setBindState("waiting") + startPolling(resp.flow_id) + } catch (e) { + setBindState("error") + setErrorMsg( + e instanceof Error ? e.message : t("channels.weixin.errorGeneric"), + ) + } + } + + const handleRebind = () => { + stopPolling() + setBindState("idle") + setQrDataURI(null) + setAccountID(null) + setErrorMsg("") + void handleBind() + } + + const renderBindSection = () => { + if (bindState === "idle") { + if (isBound) { + return ( +
+
+ + {t("channels.weixin.bound")} +
+ {existingAccountID && ( +

+ {existingAccountID} +

+ )} + +
+ ) + } + return ( +
+

+ {t("channels.weixin.notBound")} +

+ +
+ ) + } + + if (bindState === "loading") { + return ( +
+ +

+ {t("channels.weixin.generating")} +

+
+ ) + } + + if (bindState === "waiting" || bindState === "scaned") { + return ( +
+ {qrDataURI ? ( + WeChat QR Code + ) : ( +
+ +
+ )} + {bindState === "scaned" ? ( +
+ + {t("channels.weixin.scanned")} +
+ ) : ( +

+ {t("channels.weixin.scanHint")} +

+ )} + +
+ ) + } + + if (bindState === "confirmed") { + return ( +
+
+ +
+

+ {t("channels.weixin.bound")} +

+ {accountID && ( +

+ {accountID} +

+ )} + +
+ ) + } + + if (bindState === "expired") { + return ( +
+
+ +
+

+ {t("channels.weixin.expired")} +

+ +
+ ) + } + + if (bindState === "error") { + return ( +
+
+ +
+

+ {errorMsg || t("channels.weixin.errorGeneric")} +

+ +
+ ) + } + + return null + } + + return ( +
+ + + + {t("channels.weixin.bindTitle")} + + {t("channels.weixin.bindDesc")} + + {renderBindSection()} + + + + + onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> + + + onChange("proxy", e.target.value)} + placeholder="http://localhost:7890" + /> + + + +
+ ) +} diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx new file mode 100644 index 000000000..157ca636f --- /dev/null +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -0,0 +1,301 @@ +import { + IconBrain, + IconCheck, + IconChevronDown, + IconCopy, + IconDownload, + IconFileText, + IconTool, +} from "@tabler/icons-react" +import { useState } from "react" +import { useTranslation } from "react-i18next" +import ReactMarkdown from "react-markdown" +import rehypeHighlight from "rehype-highlight" +import rehypeRaw from "rehype-raw" +import rehypeSanitize from "rehype-sanitize" +import remarkGfm from "remark-gfm" + +import { Button } from "@/components/ui/button" +import { formatMessageTime } from "@/hooks/use-pico-chat" +import { cn } from "@/lib/utils" +import { + type AssistantMessageKind, + type ChatAttachment, + type ChatToolCall, +} from "@/store/chat" + +interface AssistantMessageProps { + content: string + attachments?: ChatAttachment[] + kind?: AssistantMessageKind + toolCalls?: ChatToolCall[] + timestamp?: string | number +} + +export function AssistantMessage({ + content, + attachments = [], + kind = "normal", + toolCalls = [], + timestamp = "", +}: AssistantMessageProps) { + const { t } = useTranslation() + const [isCopied, setIsCopied] = useState(false) + const isThought = kind === "thought" + const isToolCalls = kind === "tool_calls" + const isCollapsedBlock = isThought || isToolCalls + const hasText = content.trim().length > 0 + const hasToolCalls = toolCalls.length > 0 + const imageAttachments = attachments.filter( + (attachment) => attachment.type === "image", + ) + const fileAttachments = attachments.filter( + (attachment) => attachment.type !== "image", + ) + const [isExpanded, setIsExpanded] = useState(true) + const formattedTimestamp = + timestamp !== "" ? formatMessageTime(timestamp) : "" + + const handleCopy = async () => { + const markCopied = () => { + setIsCopied(true) + setTimeout(() => setIsCopied(false), 2000) + } + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(content) + markCopied() + return + } + } catch { + // HTTP 或受限环境下可能不支持 Clipboard API,继续走降级方案 + } + + const textArea = document.createElement("textarea") + textArea.value = content + textArea.setAttribute("readonly", "") + textArea.style.position = "fixed" + textArea.style.left = "-9999px" + document.body.appendChild(textArea) + textArea.select() + + try { + const copied = document.execCommand("copy") + if (copied) { + markCopied() + } + } finally { + document.body.removeChild(textArea) + } + } + + const collapsedLabel = isThought + ? t("chat.reasoningLabel") + : t("chat.toolCallsLabel") + + return ( +
+ {!isCollapsedBlock && ( +
+
+ PicoClaw + {formattedTimestamp && ( + <> + + {formattedTimestamp} + + )} +
+
+ )} + + {(hasText || isCollapsedBlock || hasToolCalls) && ( +
+ {isCollapsedBlock && ( +
setIsExpanded(!isExpanded)} + > +
+ {isThought ? ( + + ) : ( + + )} + {collapsedLabel} +
+ +
+ )} + {(!isCollapsedBlock || isExpanded) && isToolCalls && hasToolCalls && ( +
+ {toolCalls.map((toolCall, index) => { + const explanation = + toolCall.extraContent?.toolFeedbackExplanation?.trim() ?? "" + const toolName = toolCall.function?.name?.trim() ?? "" + const toolArguments = toolCall.function?.arguments?.trim() ?? "" + const hasFunctionSummary = toolName || toolArguments + + if (!explanation && !hasFunctionSummary) { + return null + } + + return ( +
0 && "border-border/20 border-t pt-3", + )} + > + {explanation && ( +
+
+ {t("chat.toolCallExplanationLabel")} +
+
+ + {explanation} + +
+
+ )} + + {hasFunctionSummary && ( +
+
+ {t("chat.toolCallFunctionLabel")} +
+
+ {toolName && ( +
+ {toolName} +
+ )} + {toolArguments && ( +
+                              {toolArguments}
+                            
+ )} +
+
+ )} +
+ ) + })} +
+ )} + {(!isCollapsedBlock || isExpanded) && !isToolCalls && hasText && ( +
+ + {content} + +
+ )} + + {!isCollapsedBlock && hasText && ( + + )} +
+ )} + + {imageAttachments.length > 0 && ( +
+ {imageAttachments.map((attachment, index) => ( + + {attachment.filename +
+ + ))} +
+ )} + + {fileAttachments.length > 0 && ( + + )} +
+ ) +} diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx new file mode 100644 index 000000000..b3354cc33 --- /dev/null +++ b/web/frontend/src/components/chat/chat-composer.tsx @@ -0,0 +1,162 @@ +import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react" +import type { KeyboardEvent } from "react" +import { useTranslation } from "react-i18next" +import TextareaAutosize from "react-textarea-autosize" + +import { ContextUsageRing } from "@/components/chat/context-usage-ring" +import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" +import type { ChatAttachment, ContextUsage } from "@/store/chat" + +export type ChatInputDisabledReason = + | "gatewayUnknown" + | "gatewayStarting" + | "gatewayRestarting" + | "gatewayStopping" + | "gatewayStopped" + | "gatewayError" + | "websocketConnecting" + | "websocketDisconnected" + | "websocketError" + | "noDefaultModel" + +interface ChatComposerProps { + input: string + attachments: ChatAttachment[] + onInputChange: (value: string) => void + onAddImages: () => void + onRemoveAttachment: (index: number) => void + onSend: () => void + onContextDetail?: () => void + inputDisabledReason: ChatInputDisabledReason | null + canSend: boolean + contextUsage?: ContextUsage +} + +export function ChatComposer({ + input, + attachments, + onInputChange, + onAddImages, + onRemoveAttachment, + onSend, + onContextDetail, + inputDisabledReason, + canSend, + contextUsage, +}: ChatComposerProps) { + const { t } = useTranslation() + const canInput = inputDisabledReason === null + const disabledMessage = + inputDisabledReason === null + ? null + : t(`chat.disabledPlaceholder.${inputDisabledReason}`) + const placeholder = disabledMessage ?? t("chat.placeholder") + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.nativeEvent.isComposing) return + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + onSend() + } + } + + return ( +
+
+ {attachments.length > 0 && ( +
+ {attachments.map((attachment, index) => ( +
+ {attachment.filename + +
+ ))} +
+ )} + + onInputChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={!canInput} + title={disabledMessage || undefined} + className={cn( + "placeholder:text-muted-foreground/50 max-h-[200px] min-h-[64px] 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", + !canInput && "cursor-not-allowed", + )} + minRows={1} + maxRows={8} + /> + +
+
+ +
+ +
+ {contextUsage && ( + + )} + {canInput ? ( + + + + + + + + {t("chat.sendHint")} + + + ) : null} +
+
+
+
+ ) +} diff --git a/web/frontend/src/components/chat/chat-empty-state.tsx b/web/frontend/src/components/chat/chat-empty-state.tsx new file mode 100644 index 000000000..7e1abca17 --- /dev/null +++ b/web/frontend/src/components/chat/chat-empty-state.tsx @@ -0,0 +1,87 @@ +import { + IconPlugConnectedX, + IconRobot, + IconRobotOff, + IconStar, +} from "@tabler/icons-react" +import { Link } from "@tanstack/react-router" +import { useTranslation } from "react-i18next" + +import { Button } from "@/components/ui/button" + +interface ChatEmptyStateProps { + hasAvailableModels: boolean + defaultModelName: string + isConnected: boolean +} + +export function ChatEmptyState({ + hasAvailableModels, + defaultModelName, + isConnected, +}: ChatEmptyStateProps) { + const { t } = useTranslation() + + if (!hasAvailableModels) { + return ( +
+
+ +
+

+ {t("chat.empty.noConfiguredModel")} +

+

+ {t("chat.empty.noConfiguredModelDescription")} +

+ +
+ ) + } + + if (!defaultModelName) { + return ( +
+
+ +
+

+ {t("chat.empty.noSelectedModel")} +

+

+ {t("chat.empty.noSelectedModelDescription")} +

+
+ ) + } + + if (!isConnected) { + return ( +
+
+ +
+

+ {t("chat.empty.notRunning")} +

+

+ {t("chat.empty.notRunningDescription")} +

+
+ ) + } + + return ( +
+
+ +
+

{t("chat.welcome")}

+

+ {t("chat.welcomeDesc")} +

+
+ ) +} diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx new file mode 100644 index 000000000..3ad811dae --- /dev/null +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -0,0 +1,385 @@ +import { IconPlus } from "@tabler/icons-react" +import { useAtom } from "jotai" +import { type ChangeEvent, useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { AssistantMessage } from "@/components/chat/assistant-message" +import { + ChatComposer, + type ChatInputDisabledReason, +} from "@/components/chat/chat-composer" +import { ChatEmptyState } from "@/components/chat/chat-empty-state" +import { ModelSelector } from "@/components/chat/model-selector" +import { SessionHistoryMenu } from "@/components/chat/session-history-menu" +import { TypingIndicator } from "@/components/chat/typing-indicator" +import { UserMessage } from "@/components/chat/user-message" +import { PageHeader } from "@/components/page-header" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { useChatModels } from "@/hooks/use-chat-models" +import { useGateway } from "@/hooks/use-gateway" +import { usePicoChat } from "@/hooks/use-pico-chat" +import { useSessionHistory } from "@/hooks/use-session-history" +import type { ConnectionState } from "@/store/chat" +import type { ChatAttachment } from "@/store/chat" +import { showAssistantDetailsAtom } from "@/store/chat" +import type { GatewayState } from "@/store/gateway" + +const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024 +const MAX_IMAGE_SIZE_LABEL = "7 MB" +const ALLOWED_IMAGE_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/bmp", +]) + +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result) + return + } + reject(new Error("Failed to read file")) + } + reader.onerror = () => + reject(reader.error || new Error("Failed to read file")) + reader.readAsDataURL(file) + }) +} + +function resolveChatInputDisabledReason({ + hasDefaultModel, + connectionState, + gatewayState, +}: { + hasDefaultModel: boolean + connectionState: ConnectionState + gatewayState: GatewayState +}): ChatInputDisabledReason | null { + if (gatewayState === "unknown") { + return "gatewayUnknown" + } + + if (gatewayState === "starting") { + return "gatewayStarting" + } + + if (gatewayState === "restarting") { + return "gatewayRestarting" + } + + if (gatewayState === "stopping") { + return "gatewayStopping" + } + + if (gatewayState === "stopped") { + return "gatewayStopped" + } + + if (gatewayState === "error") { + return "gatewayError" + } + + if (connectionState === "connecting") { + return "websocketConnecting" + } + + if (connectionState === "error") { + return "websocketError" + } + + if (connectionState === "disconnected") { + return "websocketDisconnected" + } + + if (!hasDefaultModel) { + return "noDefaultModel" + } + + return null +} + +export function ChatPage() { + const { t } = useTranslation() + const scrollRef = useRef(null) + const fileInputRef = useRef(null) + const [isAtBottom, setIsAtBottom] = useState(true) + const [hasScrolled, setHasScrolled] = useState(false) + const [input, setInput] = useState("") + const [attachments, setAttachments] = useState([]) + const [showAssistantDetails, setShowAssistantDetails] = useAtom( + showAssistantDetailsAtom, + ) + + const { + messages, + connectionState, + isTyping, + activeSessionId, + contextUsage, + sendMessage, + switchSession, + newChat, + } = usePicoChat() + + const { state: gwState } = useGateway() + const isGatewayRunning = gwState === "running" + + const { + defaultModelName, + hasAvailableModels, + apiKeyModels, + oauthModels, + localModels, + handleSetDefault, + } = useChatModels({ isConnected: isGatewayRunning }) + const hasDefaultModel = Boolean(defaultModelName) + const inputDisabledReason = resolveChatInputDisabledReason({ + hasDefaultModel, + connectionState, + gatewayState: gwState, + }) + const canInput = inputDisabledReason === null + + const { + sessions, + hasMore, + loadError, + loadErrorMessage, + observerRef, + loadSessions, + handleDeleteSession, + } = useSessionHistory({ + activeSessionId, + onDeletedActiveSession: newChat, + }) + + const syncScrollState = (element: HTMLDivElement) => { + const { clientHeight, scrollHeight, scrollTop } = element + setHasScrolled(scrollTop > 0) + setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) + } + + const handleScroll = (e: React.UIEvent) => { + syncScrollState(e.currentTarget) + } + + useEffect(() => { + if (scrollRef.current) { + if (isAtBottom) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + syncScrollState(scrollRef.current) + } + }, [messages, isTyping, isAtBottom]) + + const handleSend = () => { + if ((!input.trim() && attachments.length === 0) || !canInput) return + if ( + sendMessage({ + content: input, + attachments, + }) + ) { + setInput("") + setAttachments([]) + } + } + + const handleAddImages = () => { + if (!canInput) return + fileInputRef.current?.click() + } + + const handleRemoveAttachment = (index: number) => { + setAttachments((prev) => prev.filter((_, itemIndex) => itemIndex !== index)) + } + + const handleImageSelection = async (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []) + event.target.value = "" + + if (files.length === 0) { + return + } + + const nextAttachments: ChatAttachment[] = [] + for (const file of files) { + if (!ALLOWED_IMAGE_TYPES.has(file.type)) { + toast.error( + t("chat.invalidImage", { + name: file.name, + }), + ) + continue + } + + if (file.size > MAX_IMAGE_SIZE_BYTES) { + toast.error( + t("chat.imageTooLarge", { + name: file.name, + size: MAX_IMAGE_SIZE_LABEL, + }), + ) + continue + } + + try { + nextAttachments.push({ + type: "image", + filename: file.name, + url: await readFileAsDataUrl(file), + }) + } catch { + toast.error( + t("chat.imageReadFailed", { + name: file.name, + }), + ) + } + } + + if (nextAttachments.length > 0) { + setAttachments(nextAttachments.slice(0, 1)) + } + } + + const canSubmit = + canInput && (Boolean(input.trim()) || attachments.length > 0) + + return ( +
+ + ) + } + > +
+ + {t("chat.showAssistantDetails")} + + +
+ + + + { + if (open) { + void loadSessions(true) + } + }} + onSwitchSession={switchSession} + onDeleteSession={handleDeleteSession} + /> +
+ +
+
+ {messages.length === 0 && !isTyping && ( + + )} + + {messages.map((msg) => { + if ( + !showAssistantDetails && + (msg.kind === "thought" || msg.kind === "tool_calls") + ) { + return null + } + + return ( +
+ {msg.role === "assistant" ? ( + + ) : ( + + )} +
+ ) + })} + + {isTyping && } +
+
+ + + + { + if (sendMessage({ content: "/context", attachments: [] })) { + setInput("") + } + }} + inputDisabledReason={inputDisabledReason} + canSend={canSubmit} + contextUsage={contextUsage} + /> +
+ ) +} diff --git a/web/frontend/src/components/chat/context-usage-ring.tsx b/web/frontend/src/components/chat/context-usage-ring.tsx new file mode 100644 index 000000000..4a32e617b --- /dev/null +++ b/web/frontend/src/components/chat/context-usage-ring.tsx @@ -0,0 +1,161 @@ +import { IconArrowRight } from "@tabler/icons-react" +import { useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" + +import type { ContextUsage } from "@/store/chat" + +interface ContextUsageRingProps { + usage: ContextUsage + onDetailClick?: () => void +} + +function formatTokens(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k` + return String(n) +} + +export function ContextUsageRing({ + usage, + onDetailClick, +}: ContextUsageRingProps) { + const { t } = useTranslation() + const [intent, setIntent] = useState(false) // user wants open + const [visible, setVisible] = useState(false) // DOM mounted + const [animated, setAnimated] = useState(false) // CSS target state + const [cooldown, setCooldown] = useState(false) + const containerRef = useRef(null) + const timerRef = useRef>(null) + const hoverIntent = useRef>(null) + const closeTimer = useRef>(null) + + useEffect(() => { + if (intent) { + // Mount first, animate in on next frame + if (closeTimer.current) clearTimeout(closeTimer.current) + setVisible(true) + requestAnimationFrame(() => { + requestAnimationFrame(() => setAnimated(true)) + }) + } else if (visible) { + // Animate out, then unmount + setAnimated(false) + closeTimer.current = setTimeout(() => setVisible(false), 150) + } + }, [intent, visible]) + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + if (hoverIntent.current) clearTimeout(hoverIntent.current) + if (closeTimer.current) clearTimeout(closeTimer.current) + } + }, []) + + const percent = Math.min(usage.used_percent, 100) + const radius = 8 + const circumference = 2 * Math.PI * radius + const offset = circumference - (percent / 100) * circumference + const barPercent = Math.min(percent, 100) + + const handleDetail = () => { + if (cooldown || !onDetailClick) return + setCooldown(true) + onDetailClick() + setIntent(false) + timerRef.current = setTimeout(() => setCooldown(false), 1000) + } + + // Desktop: hover to open, mouse leave to close (with small delay) + const handleMouseEnter = () => { + if (hoverIntent.current) clearTimeout(hoverIntent.current) + setIntent(true) + } + + const handleMouseLeave = () => { + hoverIntent.current = setTimeout(() => setIntent(false), 150) + } + + // Mobile: tap to toggle (preventDefault suppresses synthetic mouseenter) + const handleTouchStart = (e: React.TouchEvent) => { + e.preventDefault() + setIntent((v) => !v) + } + + return ( +
+ + + {visible && ( +
+
+ +
+ + {t("chat.contextTitle")} + + + {formatTokens(usage.used_tokens)} /{" "} + {formatTokens(usage.compress_at_tokens)} + +
+
+
+
+ + +
+ )} +
+ ) +} diff --git a/web/frontend/src/components/chat/model-selector.tsx b/web/frontend/src/components/chat/model-selector.tsx new file mode 100644 index 000000000..2364f9bf2 --- /dev/null +++ b/web/frontend/src/components/chat/model-selector.tsx @@ -0,0 +1,84 @@ +import { useTranslation } from "react-i18next" + +import type { ModelInfo } from "@/api/models" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +interface ModelSelectorProps { + defaultModelName: string + apiKeyModels: ModelInfo[] + oauthModels: ModelInfo[] + localModels: ModelInfo[] + onValueChange: (modelName: string) => void +} + +export function ModelSelector({ + defaultModelName, + apiKeyModels, + oauthModels, + localModels, + onValueChange, +}: ModelSelectorProps) { + const { t } = useTranslation() + + return ( + + ) +} diff --git a/web/frontend/src/components/chat/session-history-menu.tsx b/web/frontend/src/components/chat/session-history-menu.tsx new file mode 100644 index 000000000..3ec1a5ed2 --- /dev/null +++ b/web/frontend/src/components/chat/session-history-menu.tsx @@ -0,0 +1,109 @@ +import { IconHistory, IconTrash } from "@tabler/icons-react" +import dayjs from "dayjs" +import type { RefObject } from "react" +import { useTranslation } from "react-i18next" + +import type { SessionSummary } from "@/api/sessions" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { ScrollArea } from "@/components/ui/scroll-area" + +interface SessionHistoryMenuProps { + sessions: SessionSummary[] + activeSessionId: string + hasMore: boolean + loadError: boolean + loadErrorMessage: string + observerRef: RefObject + onOpenChange: (open: boolean) => void + onSwitchSession: (sessionId: string) => void + onDeleteSession: (sessionId: string) => void +} + +export function SessionHistoryMenu({ + sessions, + activeSessionId, + hasMore, + loadError, + loadErrorMessage, + observerRef, + onOpenChange, + onSwitchSession, + onDeleteSession, +}: SessionHistoryMenuProps) { + const { t } = useTranslation() + + return ( + + + + + + + {loadError && ( + + + {loadErrorMessage} + + + )} + {sessions.length === 0 && !loadError ? ( + + + {t("chat.noHistory")} + + + ) : ( + sessions.map((session) => ( + onSwitchSession(session.id)} + > + + {session.title} + + + {t("chat.messagesCount", { + count: session.message_count, + })}{" "} + · {dayjs(session.updated).fromNow()} + + + + )) + )} + {hasMore && sessions.length > 0 && ( +
+ + {t("chat.loadingMore")} + +
+ )} +
+
+
+ ) +} diff --git a/web/frontend/src/components/chat/typing-indicator.tsx b/web/frontend/src/components/chat/typing-indicator.tsx new file mode 100644 index 000000000..df138553c --- /dev/null +++ b/web/frontend/src/components/chat/typing-indicator.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" + +export function TypingIndicator() { + const { t } = useTranslation() + const thinkingSteps = [ + t("chat.thinking.step1"), + t("chat.thinking.step2"), + t("chat.thinking.step3"), + t("chat.thinking.step4"), + ] + const [stepIndex, setStepIndex] = useState(0) + + useEffect(() => { + const stepsCount = thinkingSteps.length + const interval = setInterval(() => { + setStepIndex((prev) => (prev + 1) % stepsCount) + }, 3000) + return () => clearInterval(interval) + }, [thinkingSteps.length]) + + return ( +
+
+
+ + + +
+ +
+
+
+ +

+ {thinkingSteps[stepIndex]} +

+
+
+ ) +} diff --git a/web/frontend/src/components/chat/user-message.tsx b/web/frontend/src/components/chat/user-message.tsx new file mode 100644 index 000000000..8bfdf24c9 --- /dev/null +++ b/web/frontend/src/components/chat/user-message.tsx @@ -0,0 +1,54 @@ +import { cn } from "@/lib/utils" +import type { ChatAttachment } from "@/store/chat" + +interface UserMessageProps { + content: string + attachments?: ChatAttachment[] +} + +export function UserMessage({ content, attachments = [] }: UserMessageProps) { + const hasText = content.trim().length > 0 + const isCommand = content.trim().startsWith("/") + const imageAttachments = attachments.filter( + (attachment) => attachment.type === "image", + ) + + return ( +
+ {imageAttachments.length > 0 && ( +
+ {imageAttachments.map((attachment, index) => ( + {attachment.filename + ))} +
+ )} + + {hasText && ( +
+ {isCommand ? ( +
+ + ❯ + + {content} +
+ ) : ( + content + )} +
+ )} +
+ ) +} diff --git a/web/frontend/src/components/config-change-notice.tsx b/web/frontend/src/components/config-change-notice.tsx new file mode 100644 index 000000000..27e5eed7d --- /dev/null +++ b/web/frontend/src/components/config-change-notice.tsx @@ -0,0 +1,48 @@ +import { + IconAlertCircle, + IconDeviceFloppy, + IconRefresh, +} from "@tabler/icons-react" + +import { cn } from "@/lib/utils" + +interface ConfigChangeNoticeProps { + kind: "save" | "restart" + title: string + description?: string + className?: string +} + +export function ConfigChangeNotice({ + kind, + title, + description, + className, +}: ConfigChangeNoticeProps) { + const Icon = + kind === "restart" + ? IconRefresh + : kind === "save" + ? IconDeviceFloppy + : IconAlertCircle + + return ( +
+ +
+

{title}

+ {description && ( +

{description}

+ )} +
+
+ ) +} diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx new file mode 100644 index 000000000..0b5665640 --- /dev/null +++ b/web/frontend/src/components/config/config-page.tsx @@ -0,0 +1,456 @@ +import { IconCode, IconDeviceFloppy, IconTag } from "@tabler/icons-react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { Link } from "@tanstack/react-router" +import { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { patchAppConfig } from "@/api/channels" +import { launcherFetch } from "@/api/http" +import { postLauncherDashboardSetup } from "@/api/launcher-auth" +import { + getAutoStartStatus, + getLauncherConfig, + getSystemVersionInfo, + setAutoStartEnabled as updateAutoStartEnabled, + setLauncherConfig as updateLauncherConfig, +} from "@/api/system" +import { ConfigChangeNotice } from "@/components/config-change-notice" +import { + AgentDefaultsSection, + CronSection, + DevicesSection, + ExecSection, + LauncherSection, + RuntimeSection, +} from "@/components/config/config-sections" +import { + type CoreConfigForm, + EMPTY_FORM, + EMPTY_LAUNCHER_FORM, + type LauncherForm, + buildFormFromConfig, + parseCIDRText, + parseIntField, + parseMultilineList, +} from "@/components/config/form-model" +import { PageHeader } from "@/components/page-header" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" +import { refreshGatewayState } from "@/store/gateway" + +export function ConfigPage() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [form, setForm] = useState(EMPTY_FORM) + const [baseline, setBaseline] = useState(EMPTY_FORM) + const [launcherForm, setLauncherForm] = + useState(EMPTY_LAUNCHER_FORM) + const [launcherBaseline, setLauncherBaseline] = + useState(EMPTY_LAUNCHER_FORM) + const [autoStartEnabled, setAutoStartEnabled] = useState(false) + const [autoStartBaseline, setAutoStartBaseline] = useState(false) + const [saving, setSaving] = useState(false) + + const { data, isLoading, error } = useQuery({ + queryKey: ["config"], + queryFn: async () => { + const res = await launcherFetch("/api/config") + if (!res.ok) { + throw new Error("Failed to load config") + } + return res.json() + }, + }) + + const { data: launcherConfig, isLoading: isLauncherLoading } = useQuery({ + queryKey: ["system", "launcher-config"], + queryFn: getLauncherConfig, + }) + + const { data: versionInfo } = useQuery({ + queryKey: ["system", "version"], + queryFn: getSystemVersionInfo, + staleTime: 5 * 60 * 1000, + }) + + const { + data: autoStartStatus, + isLoading: isAutoStartLoading, + error: autoStartError, + } = useQuery({ + queryKey: ["system", "autostart"], + queryFn: getAutoStartStatus, + }) + + useEffect(() => { + if (!data) return + const parsed = buildFormFromConfig(data) + setForm(parsed) + setBaseline(parsed) + }, [data]) + + useEffect(() => { + if (!launcherConfig) return + const parsed: LauncherForm = { + port: String(launcherConfig.port), + publicAccess: launcherConfig.public, + allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"), + dashboardPassword: "", + dashboardPasswordConfirm: "", + } + setLauncherForm(parsed) + setLauncherBaseline(parsed) + }, [launcherConfig]) + + useEffect(() => { + if (!autoStartStatus) return + setAutoStartEnabled(autoStartStatus.enabled) + setAutoStartBaseline(autoStartStatus.enabled) + }, [autoStartStatus]) + + const configDirty = JSON.stringify(form) !== JSON.stringify(baseline) + const launcherSettingsDirty = + launcherForm.port !== launcherBaseline.port || + launcherForm.publicAccess !== launcherBaseline.publicAccess || + launcherForm.allowedCIDRsText !== launcherBaseline.allowedCIDRsText + const launcherPasswordDirty = + launcherForm.dashboardPassword.trim() !== "" || + launcherForm.dashboardPasswordConfirm.trim() !== "" + const launcherDirty = launcherSettingsDirty || launcherPasswordDirty + const autoStartDirty = autoStartEnabled !== autoStartBaseline + const isDirty = configDirty || launcherDirty || autoStartDirty + + const autoStartSupported = autoStartStatus?.supported !== false + const autoStartHint = autoStartError + ? t("pages.config.autostart_load_error") + : !autoStartSupported + ? t("pages.config.autostart_unsupported") + : t("pages.config.autostart_hint") + + const updateField = ( + key: K, + value: CoreConfigForm[K], + ) => { + setForm((prev) => ({ ...prev, [key]: value })) + } + + const updateLauncherField = ( + key: K, + value: LauncherForm[K], + ) => { + setLauncherForm((prev) => ({ ...prev, [key]: value })) + } + + const handleReset = () => { + setForm(baseline) + setLauncherForm(launcherBaseline) + setAutoStartEnabled(autoStartBaseline) + toast.info(t("pages.config.reset_success")) + } + + const handleSave = async () => { + try { + setSaving(true) + const password = launcherForm.dashboardPassword.trim() + const confirm = launcherForm.dashboardPasswordConfirm.trim() + if (launcherPasswordDirty) { + if (!password) { + throw new Error(t("pages.config.dashboard_password_required")) + } + if (password !== confirm) { + throw new Error(t("pages.config.dashboard_password_mismatch")) + } + if (Array.from(password).length < 8) { + throw new Error(t("pages.config.dashboard_password_min_length")) + } + } + + if (configDirty) { + const workspace = form.workspace.trim() + const dmScope = form.dmScope.trim() + + if (!workspace) { + throw new Error("Workspace path is required.") + } + if (!dmScope) { + throw new Error("Session scope is required.") + } + + const maxTokens = parseIntField(form.maxTokens, "Max tokens", { + min: 1, + }) + const contextWindow = form.contextWindow.trim() + ? parseIntField(form.contextWindow, "Context window", { min: 1 }) + : undefined + const maxToolIterations = parseIntField( + form.maxToolIterations, + "Max tool iterations", + { min: 1 }, + ) + const toolFeedbackMaxArgsLength = parseIntField( + form.toolFeedbackMaxArgsLength, + "Tool feedback max args length", + { min: 0 }, + ) + const summarizeMessageThreshold = parseIntField( + form.summarizeMessageThreshold, + "Summarize message threshold", + { min: 1 }, + ) + const summarizeTokenPercent = parseIntField( + form.summarizeTokenPercent, + "Summarize token percent", + { min: 1, max: 100 }, + ) + const heartbeatInterval = parseIntField( + form.heartbeatInterval, + "Heartbeat interval", + { min: 1 }, + ) + const cronExecTimeoutMinutes = parseIntField( + form.cronExecTimeoutMinutes, + "Cron exec timeout", + { min: 0 }, + ) + const execConfigPatch: Record = { + enabled: form.execEnabled, + } + + if (form.execEnabled) { + execConfigPatch.allow_remote = form.allowRemote + execConfigPatch.enable_deny_patterns = form.enableDenyPatterns + execConfigPatch.custom_allow_patterns = parseMultilineList( + form.customAllowPatternsText, + ) + execConfigPatch.timeout_seconds = parseIntField( + form.execTimeoutSeconds, + "Exec timeout", + { min: 0 }, + ) + + if (form.enableDenyPatterns) { + execConfigPatch.custom_deny_patterns = parseMultilineList( + form.customDenyPatternsText, + ) + } + } + + await patchAppConfig({ + agents: { + defaults: { + workspace, + restrict_to_workspace: form.restrictToWorkspace, + split_on_marker: form.splitOnMarker, + tool_feedback: { + enabled: form.toolFeedbackEnabled, + max_args_length: toolFeedbackMaxArgsLength, + separate_messages: form.toolFeedbackSeparateMessages, + }, + max_tokens: maxTokens, + context_window: contextWindow, + max_tool_iterations: maxToolIterations, + summarize_message_threshold: summarizeMessageThreshold, + summarize_token_percent: summarizeTokenPercent, + }, + }, + session: { + dm_scope: dmScope, + }, + tools: { + cron: { + allow_command: form.allowCommand, + exec_timeout_minutes: cronExecTimeoutMinutes, + }, + exec: execConfigPatch, + }, + heartbeat: { + enabled: form.heartbeatEnabled, + interval: heartbeatInterval, + }, + devices: { + enabled: form.devicesEnabled, + monitor_usb: form.monitorUSB, + }, + }) + + setBaseline(form) + queryClient.invalidateQueries({ queryKey: ["config"] }) + } + + let savedLauncherForm: LauncherForm | null = null + if (launcherSettingsDirty) { + const port = parseIntField(launcherForm.port, "Service port", { + min: 1, + max: 65535, + }) + const allowedCIDRs = parseCIDRText(launcherForm.allowedCIDRsText) + const savedLauncherConfig = await updateLauncherConfig({ + port, + public: launcherForm.publicAccess, + allowed_cidrs: allowedCIDRs, + }) + const parsedLauncher: LauncherForm = { + port: String(savedLauncherConfig.port), + publicAccess: savedLauncherConfig.public, + allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join( + "\n", + ), + dashboardPassword: "", + dashboardPasswordConfirm: "", + } + savedLauncherForm = parsedLauncher + setLauncherForm(parsedLauncher) + setLauncherBaseline(parsedLauncher) + queryClient.setQueryData( + ["system", "launcher-config"], + savedLauncherConfig, + ) + } + + if (launcherPasswordDirty) { + const result = await postLauncherDashboardSetup(password, confirm) + if (!result.ok) { + throw new Error(result.error) + } + + const clearedLauncherForm = savedLauncherForm ?? { + ...launcherForm, + dashboardPassword: "", + dashboardPasswordConfirm: "", + } + setLauncherForm(clearedLauncherForm) + if (savedLauncherForm) { + setLauncherBaseline(savedLauncherForm) + } + } + + if (autoStartDirty) { + if (!autoStartSupported) { + throw new Error(t("pages.config.autostart_unsupported")) + } + const status = await updateAutoStartEnabled(autoStartEnabled) + setAutoStartEnabled(status.enabled) + setAutoStartBaseline(status.enabled) + queryClient.setQueryData(["system", "autostart"], status) + } + + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, + t("pages.config.save_success"), + t("navigation.config"), + gateway?.restartRequired === true, + ) + } catch (err) { + toast.error( + err instanceof Error ? err.message : t("pages.config.save_error"), + ) + } finally { + setSaving(false) + } + } + + const actionButtons = ( +
+ + +
+ ) + + return ( +
+ + + {versionInfo.version} + + ) + } + children={ + + } + /> +
+
+ {isLoading ? ( +
+ {t("labels.loading")} +
+ ) : error ? ( +
+ {t("pages.config.load_error")} +
+ ) : ( +
+ + + + + + + + + + + + + {!isDirty && actionButtons} +
+ )} +
+
+ {isDirty && ( +
+
+
+ +
+ {actionButtons} +
+
+ )} +
+ ) +} diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx new file mode 100644 index 000000000..fa6b3a079 --- /dev/null +++ b/web/frontend/src/components/config/config-sections.tsx @@ -0,0 +1,665 @@ +import { useState } from "react" +import type { ReactNode } from "react" +import { useTranslation } from "react-i18next" + +import { + type CoreConfigForm, + DM_SCOPE_OPTIONS, + type LauncherForm, +} from "@/components/config/form-model" +import { Field, SwitchCardField } from "@/components/shared-form" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" + +type UpdateCoreField = ( + key: K, + value: CoreConfigForm[K], +) => void + +type UpdateLauncherField = ( + key: K, + value: LauncherForm[K], +) => void + +interface ConfigSectionCardProps { + title: string + description?: string + children: ReactNode +} + +function ConfigSectionCard({ + title, + description, + children, +}: ConfigSectionCardProps) { + return ( + + + {title} + {description && {description}} + + +
{children}
+
+
+ ) +} + +interface AgentDefaultsSectionProps { + form: CoreConfigForm + onFieldChange: UpdateCoreField +} + +export function AgentDefaultsSection({ + form, + onFieldChange, +}: AgentDefaultsSectionProps) { + const { t } = useTranslation() + + return ( + + + onFieldChange("workspace", e.target.value)} + placeholder="~/.picoclaw/workspace" + /> + + + + onFieldChange("restrictToWorkspace", checked) + } + /> + + onFieldChange("splitOnMarker", checked)} + /> + + + onFieldChange("toolFeedbackEnabled", checked) + } + /> + + {form.toolFeedbackEnabled && ( + + onFieldChange("toolFeedbackSeparateMessages", checked) + } + /> + )} + + {form.toolFeedbackEnabled && ( + + + onFieldChange("toolFeedbackMaxArgsLength", e.target.value) + } + /> + + )} + + + onFieldChange("maxTokens", e.target.value)} + /> + + + + onFieldChange("contextWindow", e.target.value)} + placeholder="131072" + /> + + + + onFieldChange("maxToolIterations", e.target.value)} + /> + + + + + onFieldChange("summarizeMessageThreshold", e.target.value) + } + /> + + + + + onFieldChange("summarizeTokenPercent", e.target.value) + } + /> + + + ) +} + +interface ExecSectionProps { + form: CoreConfigForm + onFieldChange: UpdateCoreField +} + +export function ExecSection({ form, onFieldChange }: ExecSectionProps) { + const { t } = useTranslation() + const [testCommand, setTestCommand] = useState("") + const [testResult, setTestResult] = useState<{ + allowed: boolean + blocked: boolean + matchedWhitelist: string | null + matchedBlacklist: string | null + } | null>(null) + const [isLoading, setIsLoading] = useState(false) + + const testPatterns = async () => { + if (!testCommand.trim()) { + setTestResult(null) + return + } + + const allowPatterns = form.customAllowPatternsText + .split("\n") + .map((p) => p.trim()) + .filter((p) => p.length > 0) + const denyPatterns = form.enableDenyPatterns + ? form.customDenyPatternsText + .split("\n") + .map((p) => p.trim()) + .filter((p) => p.length > 0) + : [] + + setIsLoading(true) + try { + const res = await fetch("/api/config/test-command-patterns", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + allow_patterns: allowPatterns, + deny_patterns: denyPatterns, + command: testCommand, + }), + }) + const data = await res.json() + setTestResult({ + allowed: data.allowed, + blocked: data.blocked, + matchedWhitelist: data.matched_whitelist ?? null, + matchedBlacklist: data.matched_blacklist ?? null, + }) + } catch { + setTestResult(null) + } finally { + setIsLoading(false) + } + } + + return ( + + onFieldChange("execEnabled", checked)} + /> + + {form.execEnabled && ( + <> + onFieldChange("allowRemote", checked)} + /> + + + onFieldChange("enableDenyPatterns", checked) + } + /> + + {form.enableDenyPatterns && ( + +